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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ 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",
"trimesh<6.0",
"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" },
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/zoo_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 68 additions & 4 deletions src/zoo_mcp/zoo_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand Down
5 changes: 5 additions & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/test_execution_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading