diff --git a/README.md b/README.md index 5c8448d..cd424b2 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. + The KCL samples tools list and search public Aquarium projects from the Zoo API. Pass the returned `name` (a public project ID) to `get_kcl_sample` to download its KCL sources. Filenames preserve project-relative paths, including subdirectories. diff --git a/pyproject.toml b/pyproject.toml index 141b57e..662c677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ 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", "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", @@ -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/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/src/zoo_mcp/server.py b/src/zoo_mcp/server.py index 356e652..98675d2 100644 --- a/src/zoo_mcp/server.py +++ b/src/zoo_mcp/server.py @@ -577,7 +577,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 013df00..34daddf 100644 --- a/src/zoo_mcp/zoo_tools.py +++ b/src/zoo_mcp/zoo_tools.py @@ -79,15 +79,20 @@ 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, + OptionToggleGraphics, OptionViewIsometric, OptionZoomToFit, ) @@ -118,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, ) @@ -649,6 +657,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, @@ -659,6 +668,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, @@ -2933,6 +2943,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( @@ -3001,6 +3012,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", @@ -3464,11 +3477,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 @@ -3490,6 +3503,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) @@ -3607,15 +3629,54 @@ 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: + """Enable or disable CPU rendering through the typed modeling transport.""" + await _send_modeling_command( + ws, + ModelingCmd(OptionToggleGraphics(graphics_enabled=enabled)), + ResponseToggleGraphics, + "enable graphics" if enabled else "disable graphics", + deadline, + ) + + +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, @@ -3643,12 +3704,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 2f13b4a..513d841 100644 --- a/tests/test_modeling_commands.py +++ b/tests/test_modeling_commands.py @@ -15,6 +15,7 @@ ModelingCmd, StepImportTargetRepresentation, TakeSnapshot, + ToggleGraphics, WebSocketRequest, WebSocketResponse, ) @@ -22,11 +23,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,9 +40,15 @@ 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, ) +from kittycad.models.ok_modeling_cmd_response import ( + OptionToggleGraphics as ResponseToggleGraphics, +) from kittycad.models.ok_web_socket_response_data import ( ModelingData, ModelingSessionDataData, @@ -47,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 @@ -147,7 +159,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 +692,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 +707,198 @@ 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 = _sent_request(websocket) + return _ok_frame(request.cmd_id, ResponseToggleGraphics(data=ToggleGraphics())) + + 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 +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): + 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 +1308,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 +1324,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 +1344,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: @@ -1387,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/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 9344248..56fa520 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 = [ @@ -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]] @@ -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" }, @@ -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" }, @@ -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]