From fe8cdd0b4944db0691108b446c77125a297d5b63 Mon Sep 17 00:00:00 2001 From: Ryan Barton Date: Thu, 15 Oct 2026 11:20:50 -0400 Subject: [PATCH 1/3] Run KCL and modeling tools on CPU engines --- README.md | 6 + pyproject.toml | 2 +- src/zoo_mcp/server.py | 2 +- src/zoo_mcp/zoo_tools.py | 97 ++++++++++++++- tests/test_credentials.py | 5 + tests/test_execution_preflight.py | 1 + tests/test_modeling_commands.py | 200 +++++++++++++++++++++++++++++- tests/test_server.py | 85 +++++++++++++ uv.lock | 30 ++--- 9 files changed, 405 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 80b65fa..01b0187 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,12 @@ session with `execute_kcl`, `exec_kcl_project`, or `import_cad_file`; pass the same `session_id` to `snapshot` and modeling tools; then call `stop_modeling_session` when finished. +KCL execution and persistent modeling sessions use the CPU engine pool. Isolated +KCL snapshots temporarily enable CPU rendering. Persistent sessions initialize +rendering on the first snapshot, selection, highlight, or camera tool and retain +it until the session is stopped, preserving visual state across calls. Exports, +measurements, and geometry queries do not initialize graphics. + As of 0.28.0, `execute_kcl` and `exec_kcl_project` run mock execution before real execution and return separate `mock_preflight` and `real_execution` objects. Each contains `status` (`succeeded`, `failed`, or `not_run`), `message`, and diff --git a/pyproject.toml b/pyproject.toml index 65277ff..ebd6c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "truststore<1.0", "typing-extensions>=4.12", "websockets>=14.1,<17.0", - "zoo-kcl>=0.3.190", + "zoo-kcl>=0.3.192", ] authors = [ { name = "Ryan Barton", email = "ryan@zoo.dev" }, diff --git a/src/zoo_mcp/server.py b/src/zoo_mcp/server.py index 39988ad..28329ac 100644 --- a/src/zoo_mcp/server.py +++ b/src/zoo_mcp/server.py @@ -569,7 +569,7 @@ async def exec_kcl_project( @mcp.tool() async def start_modeling_session() -> str: - """Open an empty modeling websocket for subsequent tools. + """Open an empty CPU modeling session for subsequent tools. Only one modeling session can be open at a time. Stop the current session before starting another. If one is already open, or is still connecting, diff --git a/src/zoo_mcp/zoo_tools.py b/src/zoo_mcp/zoo_tools.py index 31bbeef..4afc3b0 100644 --- a/src/zoo_mcp/zoo_tools.py +++ b/src/zoo_mcp/zoo_tools.py @@ -79,14 +79,18 @@ def unfixed_lints(self) -> list[kcl.Discovered]: ... OptionStl, ) from kittycad.models.modeling_cmd import ( + OptionDefaultCameraCenterToSelection, OptionDefaultCameraLookAt, OptionDefaultCameraSetOrthographic, OptionEdgeLinesVisible, OptionFaceGetCenter, OptionFaceGetGradient, OptionFaceGetPosition, + OptionHighlightSetEntities, OptionImportFiles, + OptionSelectReplace, OptionSetOrderIndependentTransparency, + OptionSetSelectionFilter, OptionTakeSnapshot, OptionViewIsometric, OptionZoomToFit, @@ -647,6 +651,7 @@ async def _open_kcl_session( if kcl_code is not None: session = await kcl.new_kcl_session_code( kcl_code, + geometry_only=True, highlight_edges=highlight_edges, video_res_width=video_res_width, video_res_height=video_res_height, @@ -657,6 +662,7 @@ async def _open_kcl_session( assert kcl_path is not None session = await kcl.new_kcl_session( str(kcl_path), + geometry_only=True, highlight_edges=highlight_edges, video_res_width=video_res_width, video_res_height=video_res_height, @@ -2929,6 +2935,7 @@ class _ModelingSession: artifact_graph_paths: set[Path] = field(default_factory=set) api_call_id: str | None = None websocket_upgrade_request_id: str | None = None + graphics_enabled: bool = False _active_modeling_session: ContextVar[_ModelingSession | None] = ContextVar( @@ -2997,6 +3004,8 @@ async def _open_modeling_websocket(client: AsyncKittyCAD) -> ClientConnection: query = urlencode( { "fps": 30, + "geometry_only": "true", + "pool": "cpu", "post_effect": PostEffectType.SSAO, "show_grid": "false", "unlocked_framerate": "false", @@ -3458,11 +3467,11 @@ def _modeling_timeout(deadline: _Deadline, description: str) -> ZooMCPTimeoutErr ) -async def _recv_modeling_frame( +async def _recv_modeling_raw_frame( ws: ClientConnection, deadline: _Deadline, description: str, -) -> WebSocketResponse: +) -> str | bytes: """Read one frame, bounded by what is left of the whole call's budget. A live session interleaves unsolicited frames every few seconds. Applying @@ -3484,6 +3493,15 @@ async def _recv_modeling_frame( pass else: _observe_modeling_frame(response) + return raw_response + + +async def _recv_modeling_frame( + ws: ClientConnection, + deadline: _Deadline, + description: str, +) -> WebSocketResponse: + raw_response = await _recv_modeling_raw_frame(ws, deadline, description) return WebSocketResponse.model_validate_json(raw_response) @@ -3601,15 +3619,83 @@ async def zoo_execute_modeling_command( ) -> _ModelingResponseT: """Run one modeling command in an existing session.""" async with _modeling_websocket(session_id) as ws: + deadline = _Deadline() + if isinstance( + command.root, + ( + OptionDefaultCameraCenterToSelection, + OptionHighlightSetEntities, + OptionSelectReplace, + OptionSetSelectionFilter, + ), + ): + await _ensure_modeling_graphics(ws, deadline) return await _send_modeling_command( ws, command, expected_response, response_description, - _Deadline(), + deadline, ) +async def _toggle_modeling_graphics( + ws: ClientConnection, enabled: bool, deadline: _Deadline +) -> None: + """Toggle CPU rendering; KittyCAD's generated SDK lacks this command.""" + command_id = str(uuid4()) + description = "enable graphics" if enabled else "disable graphics" + await _send_modeling_frame( + ws, + json.dumps( + { + "type": "modeling_cmd_req", + "cmd_id": command_id, + "cmd": {"type": "toggle_graphics", "graphics_enabled": enabled}, + } + ), + deadline, + description, + ) + while True: + raw = await _recv_modeling_raw_frame(ws, deadline, description) + response = json.loads(raw) + request_id = response.get("request_id") + if not response.get("success", False): + if request_id is None or request_id == command_id: + raise ZooMCPException( + _format_websocket_failure( + WebSocketResponse.model_validate(response).root + ) + ) + continue + if request_id != command_id: + continue + payload = response.get("resp", {}) + if ( + payload.get("type") != "modeling" + or payload.get("data", {}).get("modeling_response", {}).get("type") + != "toggle_graphics" + ): + raise ZooMCPException(f"Received an unexpected {description} response") + return + + +async def _ensure_modeling_graphics(ws: ClientConnection, deadline: _Deadline) -> None: + """Enable CPU rendering once, retaining camera and selection until session close. + + Disabling graphics resets visual state, so persistent sessions keep their + renderer after the first visual tool. Geometry-only work never initializes it. + The session lock held by _modeling_websocket serializes this transition. + """ + session = _active_modeling_session.get() + if session is not None and session.graphics_enabled: + return + await _toggle_modeling_graphics(ws, True, deadline) + if session is not None: + session.graphics_enabled = True + + @api_operation async def zoo_snapshot( session_id: str, @@ -3637,12 +3723,15 @@ async def zoo_snapshot( highlighted via highlight_set_entities stand out instead of competing with the outline drawn on every edge in the scene. Order-independent transparency is enabled so material opacity is represented in the captured image. + CPU graphics are initialized on the first visual tool and retained until the + session is stopped, preserving camera framing, selections, and highlights. """ # One budget for the whole capture. A per-command budget would multiply by - # the command count, which for a four-view capture is fifteen commands. + # the command count, which for a first four-view capture is sixteen commands. deadline = _Deadline() async with _modeling_websocket(session_id) as ws: + await _ensure_modeling_graphics(ws, deadline) await _send_modeling_command( ws, ModelingCmd(OptionDefaultCameraSetOrthographic()), diff --git a/tests/test_credentials.py b/tests/test_credentials.py index e8d714b..62d1769 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -5,6 +5,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Thread from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlsplit import pytest from kittycad.client import DEFAULT_BASE_URL @@ -146,6 +147,10 @@ async def connect(token): await asyncio.gather(connect("first-token"), connect("refreshed-token")) assert len(requests) == 2 assert all(path.startswith("/ws/modeling/commands?") for path, _ in requests) + for path, _ in requests: + query = parse_qs(urlsplit(path).query) + assert query["geometry_only"] == ["true"] + assert query["pool"] == ["cpu"] assert {authorization for _, authorization in requests} == { "Bearer first-token", "Bearer refreshed-token", diff --git a/tests/test_execution_preflight.py b/tests/test_execution_preflight.py index 71bba9a..f162324 100644 --- a/tests/test_execution_preflight.py +++ b/tests/test_execution_preflight.py @@ -180,6 +180,7 @@ def sketch_constraint_report(self): assert snapshot_options.call_args_list[1].kwargs["camera"] is camera open_session.assert_awaited_once_with( "x = 1", + geometry_only=True, highlight_edges=False, video_res_width=1024, video_res_height=1024, diff --git a/tests/test_modeling_commands.py b/tests/test_modeling_commands.py index da4196a..f6ea3ac 100644 --- a/tests/test_modeling_commands.py +++ b/tests/test_modeling_commands.py @@ -22,11 +22,15 @@ from kittycad.models.failure_web_socket_response import FailureWebSocketResponse from kittycad.models.input_format3d import OptionStep from kittycad.models.modeling_cmd import ( + OptionDefaultCameraCenterToSelection, OptionDefaultCameraLookAt, OptionDefaultCameraSetOrthographic, OptionEdgeLinesVisible, OptionEntityGetIndex, + OptionHighlightSetEntities, + OptionSelectReplace, OptionSetOrderIndependentTransparency, + OptionSetSelectionFilter, OptionTakeSnapshot, OptionViewIsometric, OptionZoomToFit, @@ -35,6 +39,9 @@ from kittycad.models.ok_modeling_cmd_response import ( OkModelingCmdResponse, ) +from kittycad.models.ok_modeling_cmd_response import ( + OptionDefaultCameraCenterToSelection as ResponseDefaultCameraCenterToSelection, +) from kittycad.models.ok_modeling_cmd_response import ( OptionEntityGetIndex as ResponseEntityGetIndex, ) @@ -147,7 +154,7 @@ async def test_modeling_websocket_uses_async_client_configuration( assert result is connection open_websocket.assert_awaited_once_with( "wss://api.example.test/ws/modeling/commands?" - "fps=30&post_effect=ssao&show_grid=false&unlocked_framerate=false&" + "fps=30&geometry_only=true&pool=cpu&post_effect=ssao&show_grid=false&unlocked_framerate=false&" "video_res_height=1024&video_res_width=1024&webrtc=false", additional_headers={"Authorization": "Bearer token"}, close_timeout=120, @@ -680,6 +687,7 @@ async def send_command( return responses[cast(Any, expected_response)] monkeypatch.setattr(zoo_tools, "_send_modeling_command", send_command) + monkeypatch.setattr(zoo_tools, "_toggle_modeling_graphics", AsyncMock()) monkeypatch.setattr( zoo_tools, "_modeling_websocket", @@ -694,6 +702,190 @@ def _camera_view(name: str) -> OptionDefaultCameraLookAt: ) +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [True, False]) +async def test_toggle_graphics_matches_its_response(enabled): + websocket = AsyncMock() + + async def recv(): + request = json.loads(websocket.send.call_args.args[0]) + return json.dumps( + { + "success": True, + "request_id": request["cmd_id"], + "resp": { + "type": "modeling", + "data": { + "modeling_response": {"type": "toggle_graphics", "data": {}} + }, + }, + } + ) + + frames = iter( + [ + _session_frame(), + _failure_frame("stale-command", "unrelated failure"), + _ok_frame( + "stale-command", + ResponseEntityGetIndex(data=EntityGetIndex(entity_index=1)), + ), + ] + ) + + async def receive_with_unsolicited_frames(): + return next(frames, None) or await recv() + + websocket.recv.side_effect = receive_with_unsolicited_frames + await zoo_tools._toggle_modeling_graphics( + cast(Any, websocket), enabled, zoo_tools._Deadline() + ) + + request = json.loads(websocket.send.call_args.args[0]) + assert request["type"] == "modeling_cmd_req" + assert request["cmd"] == { + "type": "toggle_graphics", + "graphics_enabled": enabled, + } + assert websocket.recv.await_count == 4 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("connection_failure", [False, True]) +async def test_toggle_graphics_surfaces_engine_rejection(connection_failure): + websocket = AsyncMock() + + async def recv(): + request = json.loads(websocket.send.call_args.args[0]) + return _failure_frame( + None if connection_failure else request["cmd_id"], "rendering unavailable" + ) + + websocket.recv.side_effect = recv + with pytest.raises(ZooMCPException, match="rendering unavailable"): + await zoo_tools._toggle_modeling_graphics( + cast(Any, websocket), True, zoo_tools._Deadline() + ) + + +@pytest.mark.asyncio +async def test_toggle_graphics_unsolicited_frames_consume_deadline(monkeypatch): + clock = _FakeClock() + monkeypatch.setattr(zoo_tools, "monotonic", clock) + websocket = AsyncMock() + + async def recv(): + clock.advance(100) + return _session_frame() + + websocket.recv.side_effect = recv + with pytest.raises(zoo_tools.ZooMCPTimeoutError, match="enable graphics"): + await zoo_tools._toggle_modeling_graphics( + cast(Any, websocket), True, zoo_tools._Deadline(300) + ) + assert websocket.recv.await_count == 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reject_first_snapshot", [False, True]) +async def test_snapshot_retains_cpu_graphics_across_calls( + monkeypatch, reject_first_snapshot +): + modeling_websocket = zoo_tools._modeling_websocket + _capture_snapshot_commands(monkeypatch) + send = zoo_tools._send_modeling_command + monkeypatch.setattr(zoo_tools, "_modeling_websocket", modeling_websocket) + websocket = AsyncMock() + monkeypatch.setattr( + zoo_tools, "_open_modeling_websocket", AsyncMock(return_value=websocket) + ) + toggle = AsyncMock() + monkeypatch.setattr(zoo_tools, "_toggle_modeling_graphics", toggle) + monkeypatch.setattr(zoo_tools, "resize_image", MagicMock(return_value=b"jpeg")) + session_id = await zoo_tools.zoo_start_modeling_session() + if reject_first_snapshot: + monkeypatch.setattr( + zoo_tools, + "_send_modeling_command", + AsyncMock(side_effect=ZooMCPException("snapshot rejected")), + ) + with pytest.raises(ZooMCPException, match="snapshot rejected"): + await zoo_tools.zoo_snapshot(session_id=session_id) + monkeypatch.setattr(zoo_tools, "_send_modeling_command", send) + else: + await zoo_tools.zoo_snapshot(session_id=session_id) + await zoo_tools.zoo_snapshot(session_id=session_id, zoom=False) + assert [call.args[1] for call in toggle.await_args_list] == [True] + assert isinstance(zoo_tools._modeling_session, zoo_tools._ModelingSession) + assert zoo_tools._modeling_session.graphics_enabled + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", [zoo_tools.ZooMCPTimeoutError("timeout"), asyncio.CancelledError()] +) +async def test_snapshot_discards_session_after_timeout_or_cancellation( + monkeypatch, failure +): + modeling_websocket = zoo_tools._modeling_websocket + _capture_snapshot_commands(monkeypatch) + monkeypatch.setattr(zoo_tools, "_modeling_websocket", modeling_websocket) + websocket = AsyncMock() + websocket.transport = MagicMock() + monkeypatch.setattr( + zoo_tools, "_open_modeling_websocket", AsyncMock(return_value=websocket) + ) + monkeypatch.setattr( + zoo_tools, + "_toggle_modeling_graphics", + AsyncMock(), + ) + monkeypatch.setattr( + zoo_tools, "_send_modeling_command", AsyncMock(side_effect=failure) + ) + session_id = await zoo_tools.zoo_start_modeling_session() + with pytest.raises(type(failure)): + await zoo_tools.zoo_snapshot(session_id=session_id) + websocket.transport.abort.assert_called_once_with() + assert zoo_tools._modeling_session is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "command", + [ + OptionDefaultCameraCenterToSelection(), + OptionHighlightSetEntities(entities=[]), + OptionSelectReplace(entities=[]), + OptionSetSelectionFilter(filter=[]), + ], +) +async def test_visual_commands_enable_cpu_graphics(monkeypatch, command): + websocket = AsyncMock() + monkeypatch.setattr( + zoo_tools, "_modeling_websocket", lambda _: _async_context(websocket) + ) + calls = [] + + async def toggle(_ws, enabled, deadline): + calls.append((enabled, deadline)) + + async def send(_ws, _command, _response, _description, deadline): + calls.append(("command", deadline)) + return SimpleNamespace(data=None) + + monkeypatch.setattr(zoo_tools, "_toggle_modeling_graphics", toggle) + monkeypatch.setattr(zoo_tools, "_send_modeling_command", send) + await zoo_tools.zoo_execute_modeling_command( + ModelingCmd(command), + ResponseDefaultCameraCenterToSelection, + "camera centering", + "session-id", + ) + assert [step for step, _ in calls] == [True, "command"] + assert len({id(deadline) for _, deadline in calls}) == 1 + + @pytest.mark.asyncio async def test_snapshot_frames_the_scene_before_capturing( monkeypatch: pytest.MonkeyPatch, @@ -1103,7 +1295,7 @@ async def test_connection_close_mid_command_reports_a_dead_session( async def test_snapshot_shares_one_budget_across_all_its_commands( monkeypatch: pytest.MonkeyPatch, ): - """Otherwise a four-view capture gets fifteen separate budgets.""" + """Otherwise a first four-view capture gets sixteen separate budgets.""" seen: list[object] = [] async def send_command( @@ -1119,6 +1311,8 @@ async def send_command( return SimpleNamespace(data=None) monkeypatch.setattr(zoo_tools, "_send_modeling_command", send_command) + toggle_graphics = AsyncMock() + monkeypatch.setattr(zoo_tools, "_toggle_modeling_graphics", toggle_graphics) monkeypatch.setattr( zoo_tools, "_modeling_websocket", @@ -1137,6 +1331,8 @@ async def send_command( assert len(seen) > 1 assert all(isinstance(deadline, zoo_tools._Deadline) for deadline in seen) assert len({id(deadline) for deadline in seen}) == 1 + assert [call.args[1] for call in toggle_graphics.await_args_list] == [True] + assert all(call.args[2] is seen[0] for call in toggle_graphics.await_args_list) def _stub_import_transport(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: diff --git a/tests/test_server.py b/tests/test_server.py index 1feb9b4..fc191ff 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1928,6 +1928,91 @@ async def fake_mock_execute_code(code: str): assert "warning report" in message +@pytest.mark.asyncio +@pytest.mark.parametrize("inline_code", [False, True], ids=["path", "code"]) +@pytest.mark.parametrize("highlight_edges", [False, True]) +async def test_cpu_kcl_snapshots_and_measurements( + cube_kcl, inline_code, highlight_edges +): + tools = zoo_mcp.zoo_tools + result = await tools.zoo_execute_kcl( + kcl_code=Path(cube_kcl).read_text() if inline_code else None, + kcl_path=None if inline_code else cube_kcl, + snapshot_request=tools.KclSnapshotRequest( + ("front", "right"), highlight_edges=highlight_edges + ), + physical_properties_request=tools.KclPhysicalPropertiesRequest(("volume",)), + ) + assert result.ok, result.message + assert result.inspection is not None + inspection = result.inspection + assert inspection.rendered_snapshots_status == "succeeded", ( + inspection.snapshot_errors + ) + assert inspection.completed_snapshot_views == ["front", "right"] + assert inspection.rendered_snapshot is not None + image = PILImage.open(io.BytesIO(inspection.rendered_snapshot)) + assert image.width > 0 and image.height > 0 + assert inspection.physical_analysis_status == "succeeded" + assert inspection.physical_properties is not None + volume = inspection.physical_properties["volume"] + assert isinstance(volume, dict) + assert volume["value"] > 0 + + +@pytest.mark.asyncio +async def test_cpu_session_preserves_camera_and_selection(cube_kcl): + tools = zoo_mcp.zoo_tools + session_id = await tools.zoo_start_modeling_session() + try: + execution = await tools.zoo_exec_kcl_project( + session_id=session_id, kcl_path=cube_kcl + ) + assert execution.ok, execution.message + assert isinstance(execution, tools.ResultZooExecuteKclRemote) + graph = json.loads(Path(execution.path_artifact_graph).read_text()) + face_id = next( + node["id"] for node in graph["map"].values() if node["type"] == "wall" + ) + await tools.zoo_snapshot(session_id=session_id) + for name, arguments in ( + ("set_selection_filter", {"entity_types": ["face"]}), + ("select_entities", {"entity_ids": [face_id]}), + ("highlight_set_entities", {"entity_ids": [face_id]}), + ("center_camera_on_selection", {}), + ): + await mcp.call_tool(name, arguments={"session_id": session_id, **arguments}) + + selected = await tools.zoo_snapshot(session_id=session_id, zoom=False) + repeated = await tools.zoo_snapshot(session_id=session_id, zoom=False) + assert selected == repeated, "Repeated snapshots must retain camera framing" + for name in ("select_entities", "highlight_set_entities"): + await mcp.call_tool( + name, arguments={"session_id": session_id, "entity_ids": []} + ) + cleared = await tools.zoo_snapshot(session_id=session_id, zoom=False) + assert selected != cleared, "Selections and highlights must remain visible" + await mcp.call_tool( + "entity_get_index", + arguments={"session_id": session_id, "entity_id": face_id}, + ) + finally: + await tools.zoo_stop_modeling_session(session_id) + + +@pytest.mark.asyncio +async def test_cpu_session_imports_and_snapshots_cad(cube_stp): + tools = zoo_mcp.zoo_tools + session_id = await tools.zoo_start_modeling_session() + try: + object_id = await tools.zoo_import_cad_file(session_id, cube_stp) + assert object_id + image = await tools.zoo_snapshot(session_id=session_id) + assert PILImage.open(io.BytesIO(image)).format == "JPEG" + finally: + await tools.zoo_stop_modeling_session(session_id) + + @pytest.mark.asyncio async def test_snapshot_from_a_modeling_session(populated_modeling_session: str): response = await mcp.call_tool( diff --git a/uv.lock b/uv.lock index d07380e..50be6e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1166,22 +1166,22 @@ wheels = [ [[package]] name = "zoo-kcl" -version = "0.3.190" +version = "0.3.192" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/93/4833c72b298cc5f96d16c131e669cfb05f404787a117a24a1ac380842dd0/zoo_kcl-0.3.190.tar.gz", hash = "sha256:3515bc8676d3a64ba3507c729d9b2fff50f84e67dbb84f8672cdcc4e4b53a9b0", size = 1372342, upload-time = "2026-10-02T21:59:23.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/41/cf4aeeae6d08b1f61617af772d14111f977ff63a2284cfd8ede9e9b03958/zoo_kcl-0.3.192.tar.gz", hash = "sha256:2a3ca1639086dc41c56d64892719445a5e68b0436791679c26fd2c40df8bbd69", size = 1371153, upload-time = "2026-10-14T17:30:29.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/99/0f124e4fb9342bb855659da5f13e00706261022ebfa906d516a11bc04638/zoo_kcl-0.3.190-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d3df825487a56e218a047be3e9f2de9f99c6bc1d73ce2fbd9dadd013e8987e2a", size = 9145098, upload-time = "2026-10-02T21:59:15.734Z" }, - { url = "https://files.pythonhosted.org/packages/1d/e3/8ead9dac6dc4df8797b9aebf49c52b1f43ce209e260a739a34d26edd40aa/zoo_kcl-0.3.190-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd643504170e4191a590c9c7d51f3322d15f05d4fb7af307382770e9ff1d28be", size = 9812694, upload-time = "2026-10-02T21:58:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5d/b1657462aadfaa1e2ee30ec85de2a68d9b55bf4b0269717898b3f4d28826/zoo_kcl-0.3.190-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:feaf0cf332f8610a7184b6038d999a37971593bce9e6b60e24a84d34a1c791ba", size = 10894894, upload-time = "2026-10-02T21:59:07.872Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fd/f34e762f6d0a58112f83c3a3c927b0a4e567d57da052f7c7334bd5005d70/zoo_kcl-0.3.190-cp311-cp311-win_amd64.whl", hash = "sha256:ae8b72a7609503cc4a2f5c6e7128e4aa9d1d7a3b409a7cc5dc56bcb07d93763a", size = 10533728, upload-time = "2026-10-02T21:59:24.453Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a3/5ae60d179e18805fd42396540fbdf3661fe6ffe2658f8131e48c87393e74/zoo_kcl-0.3.190-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85edcde886baeb8daa6bd61bd2c0a33367b962cc88da5ebed9d056e2491f241e", size = 9138279, upload-time = "2026-10-02T21:59:17.492Z" }, - { url = "https://files.pythonhosted.org/packages/c5/10/13edc4da52c9ffba14c90a56328c2585bba2418da7b2ff3074b0c4c8cba1/zoo_kcl-0.3.190-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b32b944f4929e2ee9b07a73285d14fc26b619af9e6ccd42daa60c9d1e8d421d", size = 9805982, upload-time = "2026-10-02T21:59:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/48/34/665ef4247b035d5afbfd2cd83521fc671533b275c146e94814985a7360b0/zoo_kcl-0.3.190-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6976c987d3cdea3c1a8de9cfea098b6e982956a415460a3625a5f463b71c860d", size = 10891262, upload-time = "2026-10-02T21:59:09.769Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e5/ee1c6e10be9a3b5fba09dd5322a393b80d93c7c017c4e97f1f5d07e0a496/zoo_kcl-0.3.190-cp312-cp312-win_amd64.whl", hash = "sha256:0fdd0cdc57e553ce63fe228f60b37ba4f5d2befff91d79a3b1b12e8e869e33a2", size = 10528152, upload-time = "2026-10-02T21:59:27.144Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/7335f4418c81b2a366a57a421f7cf6563d90713bb3762ad99eba2088c402/zoo_kcl-0.3.190-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1c7768cf9d3a2dc8f1121bee3ca6dbe57645d68cf6b68d562a97e5d9834892af", size = 9144948, upload-time = "2026-10-02T21:59:19.295Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/092e88e5bfe2e81ac48ea06bd536dc50fe9f236836e0308abec8b77734da/zoo_kcl-0.3.190-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b54498bf1fb8ae140c2690798149df0c4bedc3d144ffcd5044fa3dff7187c837", size = 9811649, upload-time = "2026-10-02T21:59:03.289Z" }, - { url = "https://files.pythonhosted.org/packages/1c/83/8b75d4cdbe653fb79fc5f05eab93b4ead21cc510785203795d5d6d43385b/zoo_kcl-0.3.190-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d40e2f12f4f299b50a16be2fdb1806878fd2de3d44d56976b8a61b8b0327ebc", size = 10892602, upload-time = "2026-10-02T21:59:11.643Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/56f9f50f02c45dac3ff05df2ab1a4cc201470316e98f2d649e830fefa470/zoo_kcl-0.3.190-cp313-cp313-win_amd64.whl", hash = "sha256:e341e52230a145bb671c166f5f9e8ce3182388842a8b999f3886c751b5da294f", size = 10523415, upload-time = "2026-10-02T21:59:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/6168ee9baf19958dba17f571b70eca1658569166294967f81fc55dda467e/zoo_kcl-0.3.192-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa243545a7191ced8f9ca93b7d9f21ff0a6f7727111f4ff81aa97f693921a93a", size = 9175300, upload-time = "2026-10-14T17:30:22.166Z" }, + { url = "https://files.pythonhosted.org/packages/ba/65/192df6b3e74dee620dc1240176ac6dcf8a6d8de55f547d4f3cac3b7c6ac8/zoo_kcl-0.3.192-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6845d2bb4ef120b6017778241fc18bdd97b978f5fe54caf6313d4d4d671398f", size = 9824172, upload-time = "2026-10-14T17:30:06.948Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/23e906f34a11916c0cf1171fc10685d18c4adf572693d0872a251f9e8151/zoo_kcl-0.3.192-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ade4c09380bd3b0f2e32416191a625f5a3e5b8bb156d6e4c9c5db0b017ce46df", size = 10922612, upload-time = "2026-10-14T17:30:14.651Z" }, + { url = "https://files.pythonhosted.org/packages/19/5a/ff7f6e5abb6365b8b7e7d42196786bc07303e338f5a2bbd071cd3aed6915/zoo_kcl-0.3.192-cp311-cp311-win_amd64.whl", hash = "sha256:b1032ccae1a32fd3d3a9817bdd654b0cd5bf9c675d894cae777a924ef2e51d76", size = 10583247, upload-time = "2026-10-14T17:30:31.032Z" }, + { url = "https://files.pythonhosted.org/packages/d3/82/1c817bea2538c6dee2184ba8d866b260c04889b8bd83b6df8800b9b5f159/zoo_kcl-0.3.192-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5a2cdaa4da03bcbefc2f53db566cce20c86686da19111ef8644f9ba859d278ef", size = 9160395, upload-time = "2026-10-14T17:30:24.137Z" }, + { url = "https://files.pythonhosted.org/packages/53/b9/65392785b06908a9cfa48f00f87a559122f9dd23bf0683034ce43acdcbde/zoo_kcl-0.3.192-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c17ac721cc6ee719cf73c6d572865ff1a06681451eefc6968d166b22188a23b0", size = 9816757, upload-time = "2026-10-14T17:30:08.93Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b9/ff6c8bbc6e580a2d2f366457b693890a60b3a0f256f46b39cbd36ee1ea4a/zoo_kcl-0.3.192-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfced75ae8a8b48511dde9c796a8108793f032aeb76a33696ea36a691c2e8ff", size = 10923274, upload-time = "2026-10-14T17:30:16.491Z" }, + { url = "https://files.pythonhosted.org/packages/05/78/9a014e82338a8cac985aca4fc9136fe513312d1c27bd02c77600516849be/zoo_kcl-0.3.192-cp312-cp312-win_amd64.whl", hash = "sha256:9c20a5ca04b2ae5695bd71e7c65f4b03f4d7cb9e0b584cf495e95a09f4728a9e", size = 10572224, upload-time = "2026-10-14T17:30:33.011Z" }, + { url = "https://files.pythonhosted.org/packages/d5/30/1db4daa6c10e877396e5f6dd7a323885d38d4d878255d55875c0986e273d/zoo_kcl-0.3.192-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b384057d1a3b1c4681ce086e7441a65452ce27f8e5c9a24012612c071bb12927", size = 9158061, upload-time = "2026-10-14T17:30:26.174Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/cd85c1e5b25c00167c1846b1eac6c539e1ea0ebeb31eeb49e3bf1ece4411/zoo_kcl-0.3.192-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b40593f19f8861552861fadc8a066c4bc87d70a4c9801d268e35cb6bd1bedd32", size = 9816440, upload-time = "2026-10-14T17:30:10.87Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/00c03a78b98fc19114e550b30b4209e20a2c0d9f3968cbc903149a6e9244/zoo_kcl-0.3.192-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:108e1e4edd9bf43f65a6cee4af65d2ebbf160b3151c50b9455ba551d09fc0740", size = 10915961, upload-time = "2026-10-14T17:30:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c7/c146c9bd1450e3398b4cb5d325a4d014636f972d7171c08e38c10004a8cd/zoo_kcl-0.3.192-cp313-cp313-win_amd64.whl", hash = "sha256:f2a91d18e2baf560f3d60b06053abb1b4e74000fd3f40f649d68f3ea1eb5546d", size = 10570902, upload-time = "2026-10-14T17:30:35.174Z" }, ] [[package]] @@ -1225,7 +1225,7 @@ requires-dist = [ { name = "truststore", specifier = "<1.0" }, { name = "typing-extensions", specifier = ">=4.12" }, { name = "websockets", specifier = ">=14.1,<17.0" }, - { name = "zoo-kcl", specifier = ">=0.3.190" }, + { name = "zoo-kcl", specifier = ">=0.3.192" }, ] [package.metadata.requires-dev] From cccbf3b4ab58a116094d41906585df7b64e30e8f Mon Sep 17 00:00:00 2001 From: Ryan Barton Date: Thu, 15 Oct 2026 13:38:37 -0400 Subject: [PATCH 2/3] Bump MCP version to 0.28.11 --- pyproject.toml | 2 +- server.json | 4 ++-- uv.lock | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b29d2f..b598342 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "zoo_mcp" -version = "0.28.10" +version = "0.28.11" requires-python = ">=3.11, <3.14" dependencies = [ "aiofiles<26.0", diff --git a/server.json b/server.json index 610f5a4..6dbde62 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/KittyCAD/mcp", "source": "github" }, - "version": "0.28.10", + "version": "0.28.11", "packages": [ { "registryType": "pypi", "identifier": "zoo_mcp", - "version": "0.28.10", + "version": "0.28.11", "transport": { "type": "stdio" }, diff --git a/uv.lock b/uv.lock index 0b44eb1..0c30b51 100644 --- a/uv.lock +++ b/uv.lock @@ -221,8 +221,8 @@ name = "httpcore2" version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } wheels = [ @@ -1115,8 +1115,8 @@ name = "uvicorn" version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, + { name = "click", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "h11", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ @@ -1186,7 +1186,7 @@ wheels = [ [[package]] name = "zoo-mcp" -version = "0.28.10" +version = "0.28.11" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 9f2964cec64531eb4254b8d2195f34e616995247 Mon Sep 17 00:00:00 2001 From: Ryan Barton Date: Thu, 15 Oct 2026 14:49:26 -0400 Subject: [PATCH 3/3] Use kittycad 1.8.0 models for CPU graphics commands --- pyproject.toml | 2 +- src/zoo_mcp/zoo_tools.py | 43 +++++++-------------------------- tests/test_modeling_commands.py | 41 +++++++++++++++++++------------ uv.lock | 8 +++--- 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b598342..662c677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.11, <3.14" dependencies = [ "aiofiles<26.0", "httpx>=0.28,<1", - "kittycad>=1.6.1,<2.0", + "kittycad>=1.8.0,<2.0", "mcp[cli]>=2.2.0,<3", "pillow<13.0", "pymongo>=4.6.0,<5.0", diff --git a/src/zoo_mcp/zoo_tools.py b/src/zoo_mcp/zoo_tools.py index 2bca28f..34daddf 100644 --- a/src/zoo_mcp/zoo_tools.py +++ b/src/zoo_mcp/zoo_tools.py @@ -92,6 +92,7 @@ def unfixed_lints(self) -> list[kcl.Discovered]: ... OptionSetOrderIndependentTransparency, OptionSetSelectionFilter, OptionTakeSnapshot, + OptionToggleGraphics, OptionViewIsometric, OptionZoomToFit, ) @@ -122,6 +123,9 @@ def unfixed_lints(self) -> list[kcl.Discovered]: ... from kittycad.models.ok_modeling_cmd_response import ( OptionTakeSnapshot as ResponseTakeSnapshot, ) +from kittycad.models.ok_modeling_cmd_response import ( + OptionToggleGraphics as ResponseToggleGraphics, +) from kittycad.models.ok_modeling_cmd_response import ( OptionViewIsometric as ResponseViewIsometric, ) @@ -3648,43 +3652,14 @@ async def zoo_execute_modeling_command( async def _toggle_modeling_graphics( ws: ClientConnection, enabled: bool, deadline: _Deadline ) -> None: - """Toggle CPU rendering; KittyCAD's generated SDK lacks this command.""" - command_id = str(uuid4()) - description = "enable graphics" if enabled else "disable graphics" - await _send_modeling_frame( + """Enable or disable CPU rendering through the typed modeling transport.""" + await _send_modeling_command( ws, - json.dumps( - { - "type": "modeling_cmd_req", - "cmd_id": command_id, - "cmd": {"type": "toggle_graphics", "graphics_enabled": enabled}, - } - ), + ModelingCmd(OptionToggleGraphics(graphics_enabled=enabled)), + ResponseToggleGraphics, + "enable graphics" if enabled else "disable graphics", deadline, - description, ) - while True: - raw = await _recv_modeling_raw_frame(ws, deadline, description) - response = json.loads(raw) - request_id = response.get("request_id") - if not response.get("success", False): - if request_id is None or request_id == command_id: - raise ZooMCPException( - _format_websocket_failure( - WebSocketResponse.model_validate(response).root - ) - ) - continue - if request_id != command_id: - continue - payload = response.get("resp", {}) - if ( - payload.get("type") != "modeling" - or payload.get("data", {}).get("modeling_response", {}).get("type") - != "toggle_graphics" - ): - raise ZooMCPException(f"Received an unexpected {description} response") - return async def _ensure_modeling_graphics(ws: ClientConnection, deadline: _Deadline) -> None: diff --git a/tests/test_modeling_commands.py b/tests/test_modeling_commands.py index 1bd5741..513d841 100644 --- a/tests/test_modeling_commands.py +++ b/tests/test_modeling_commands.py @@ -15,6 +15,7 @@ ModelingCmd, StepImportTargetRepresentation, TakeSnapshot, + ToggleGraphics, WebSocketRequest, WebSocketResponse, ) @@ -45,6 +46,9 @@ from kittycad.models.ok_modeling_cmd_response import ( OptionEntityGetIndex as ResponseEntityGetIndex, ) +from kittycad.models.ok_modeling_cmd_response import ( + OptionToggleGraphics as ResponseToggleGraphics, +) from kittycad.models.ok_web_socket_response_data import ( ModelingData, ModelingSessionDataData, @@ -54,6 +58,7 @@ ) from kittycad.models.success_web_socket_response import SuccessWebSocketResponse from kittycad.models.uuid import Uuid +from pydantic import ValidationError from websockets.exceptions import ConnectionClosedError from zoo_mcp import ZooMCPException, zoo_tools @@ -708,19 +713,8 @@ async def test_toggle_graphics_matches_its_response(enabled): websocket = AsyncMock() async def recv(): - request = json.loads(websocket.send.call_args.args[0]) - return json.dumps( - { - "success": True, - "request_id": request["cmd_id"], - "resp": { - "type": "modeling", - "data": { - "modeling_response": {"type": "toggle_graphics", "data": {}} - }, - }, - } - ) + request = _sent_request(websocket) + return _ok_frame(request.cmd_id, ResponseToggleGraphics(data=ToggleGraphics())) frames = iter( [ @@ -750,6 +744,25 @@ async def receive_with_unsolicited_frames(): assert websocket.recv.await_count == 4 +@pytest.mark.asyncio +async def test_toggle_graphics_rejects_malformed_response(): + websocket = AsyncMock() + + async def recv(): + request = _sent_request(websocket) + frame = json.loads( + _ok_frame(request.cmd_id, ResponseToggleGraphics(data=ToggleGraphics())) + ) + del frame["resp"]["data"]["modeling_response"]["data"] + return json.dumps(frame) + + websocket.recv.side_effect = recv + with pytest.raises(ValidationError, match="data"): + await zoo_tools._toggle_modeling_graphics( + cast(Any, websocket), True, zoo_tools._Deadline() + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("connection_failure", [False, True]) async def test_toggle_graphics_surfaces_engine_rejection(connection_failure): @@ -1583,8 +1596,6 @@ async def test_rejected_handshake_captures_fallback_header(monkeypatch): @pytest.mark.asyncio async def test_trace_observation_preserves_sdk_validation_error(): - from pydantic import ValidationError - websocket = AsyncMock() websocket.recv.return_value = "invalid JSON" with pytest.raises(ValidationError): diff --git a/uv.lock b/uv.lock index 0c30b51..56fa520 100644 --- a/uv.lock +++ b/uv.lock @@ -317,7 +317,7 @@ wheels = [ [[package]] name = "kittycad" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -330,9 +330,9 @@ dependencies = [ { name = "truststore" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/14/c4d2467b2c9768a457ff940ef82d4409839b644f0f49db575cd08cf64f47/kittycad-1.7.0.tar.gz", hash = "sha256:5fa18fa47861ae4beb66b26b6557d14ec9c1629248eb8a813222a089e054ece2", size = 227278, upload-time = "2026-10-06T23:46:25.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/60/ec4a4326b1215f10fbeea7cf30b1fb9233c1b3619dda206fcec28eebe5be/kittycad-1.8.0.tar.gz", hash = "sha256:0b5ea4ebe75718c7f3b7fee229009b5264d530519f524248f830110017364963", size = 227717, upload-time = "2026-10-15T18:43:39.709Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/c2/3ed651d063bd3efe43798f1c26494c5a9b6b5dca3e396e2388566412b9bb/kittycad-1.7.0-py3-none-any.whl", hash = "sha256:6ccc130322b281fc8c488b89b31e0b1aa83455d74cebf4ea8346bc5c4db429c4", size = 399238, upload-time = "2026-10-06T23:46:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dc/e02bb550bc01f071f842ed3a61f8d8ef4a28429c3fbbabe80fd48877e235/kittycad-1.8.0-py3-none-any.whl", hash = "sha256:7094eebf74b98315c1c4de7c6457e4affe40e5d9ad88e5cdd926fe2d04666def", size = 400233, upload-time = "2026-10-15T18:43:38.027Z" }, ] [[package]] @@ -1217,7 +1217,7 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = "<26.0" }, { name = "httpx", specifier = ">=0.28,<1" }, - { name = "kittycad", specifier = ">=1.6.1,<2.0" }, + { name = "kittycad", specifier = ">=1.8.0,<2.0" }, { name = "mcp", extras = ["cli"], specifier = ">=2.2.0,<3" }, { name = "pillow", specifier = "<13.0" }, { name = "pymongo", specifier = ">=4.6.0,<5.0" },