zoo_mcp 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zoo_mcp/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Zoo Model Context Protocol (MCP) Server.
2
+
3
+ A lightweight service that enables AI assistants to execute Zoo commands through the Model Context Protocol (MCP).
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ import ssl
9
+ import sys
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ import truststore
13
+ from kittycad import KittyCAD
14
+
15
+ FORMAT = "%(asctime)s | %(levelname)-7s | %(filename)s:%(lineno)d | %(funcName)s | %(message)s"
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO, format=FORMAT, handlers=[logging.StreamHandler(sys.stderr)]
19
+ )
20
+ logger = logging.getLogger("zoo_mcp")
21
+
22
+
23
+ try:
24
+ __version__ = version("zoo_mcp")
25
+ except PackageNotFoundError:
26
+ # package is not installed
27
+ logger.error("zoo-mcp package is not installed.")
28
+
29
+
30
+ class ZooMCPException(Exception):
31
+ """Custom exception for Zoo MCP Server."""
32
+
33
+
34
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
35
+ kittycad_client = KittyCAD(verify_ssl=ctx)
36
+ # set the websocket receive timeout to 5 minutes
37
+ kittycad_client.websocket_recv_timeout = 300
38
+
39
+ httpx_logger = logging.getLogger("httpx")
40
+ httpx_logger.setLevel(logging.WARNING)
41
+
42
+
43
+ def _initialize_kcl_docs() -> None:
44
+ """Initialize KCL documentation cache at module load time."""
45
+ from zoo_mcp.kcl_docs import initialize_docs_cache
46
+
47
+ try:
48
+ try:
49
+ loop = asyncio.get_running_loop()
50
+ # Already in an event loop, schedule the task
51
+ loop.create_task(initialize_docs_cache())
52
+ except RuntimeError:
53
+ # No running loop, use asyncio.run()
54
+ asyncio.run(initialize_docs_cache())
55
+ except Exception as e:
56
+ logger.warning(f"Failed to initialize KCL docs cache: {e}")
57
+
58
+
59
+ def _initialize_kcl_samples() -> None:
60
+ """Initialize KCL samples cache at module load time."""
61
+ from zoo_mcp.kcl_samples import initialize_samples_cache
62
+
63
+ try:
64
+ try:
65
+ loop = asyncio.get_running_loop()
66
+ # Already in an event loop, schedule the task
67
+ loop.create_task(initialize_samples_cache())
68
+ except RuntimeError:
69
+ # No running loop, use asyncio.run()
70
+ asyncio.run(initialize_samples_cache())
71
+ except Exception as e:
72
+ logger.warning(f"Failed to initialize KCL samples cache: {e}")
73
+
74
+
75
+ # Initialize caches when module is imported
76
+ _initialize_kcl_docs()
77
+ _initialize_kcl_samples()
zoo_mcp/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ import sys
2
+
3
+ from zoo_mcp import logger
4
+ from zoo_mcp.server import mcp
5
+
6
+ if __name__ == "__main__":
7
+ try:
8
+ logger.info("Starting MCP server...")
9
+ mcp.run(transport="stdio")
10
+
11
+ except KeyboardInterrupt:
12
+ logger.info("Shutting down MCP server...")
13
+ except Exception as e:
14
+ logger.exception("Server encountered an error: %s", e)
15
+ sys.exit(1)
zoo_mcp/ai_tools.py ADDED
@@ -0,0 +1,256 @@
1
+ import asyncio
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from kittycad.models import (
6
+ ApiCallStatus,
7
+ FileExportFormat,
8
+ TextToCadCreateBody,
9
+ TextToCadMultiFileIterationBody,
10
+ )
11
+ from kittycad.models.ml_copilot_server_message import EndOfStream, Reasoning, ToolOutput
12
+ from kittycad.models.reasoning_message import (
13
+ OptionCreatedKclFile,
14
+ OptionDeletedKclFile,
15
+ OptionDesignPlan,
16
+ OptionFeatureTreeOutline,
17
+ OptionGeneratedKclCode,
18
+ OptionKclCodeError,
19
+ OptionKclCodeExamples,
20
+ OptionKclDocs,
21
+ OptionMarkdown,
22
+ OptionText,
23
+ OptionUpdatedKclFile,
24
+ )
25
+ from kittycad.models.text_to_cad_response import (
26
+ OptionTextToCad,
27
+ OptionTextToCadMultiFileIteration,
28
+ )
29
+ from websockets.exceptions import ConnectionClosedError
30
+
31
+ from zoo_mcp import ZooMCPException, kittycad_client, logger
32
+
33
+
34
+ def log_websocket_message(conn_id: str) -> bool:
35
+ logger.info("Connecting to Text-To-CAD websocket...")
36
+ with kittycad_client.ml.ml_reasoning_ws(id=conn_id) as ws:
37
+ logger.info(
38
+ "Successfully connected to Text-To-CAD websocket with id %s", conn_id
39
+ )
40
+ while True:
41
+ try:
42
+ message = ws.recv()
43
+ if isinstance(message.root, Reasoning):
44
+ message_option = message.root.reasoning.root
45
+ match message_option:
46
+ case OptionCreatedKclFile():
47
+ logger.info(
48
+ "Created %s: %s"
49
+ % (message_option.file_name, message_option.content),
50
+ )
51
+ case OptionDeletedKclFile():
52
+ logger.info("Deleted %s", message_option.file_name)
53
+ case OptionDesignPlan():
54
+ design_steps = " ".join(
55
+ [
56
+ f"Editing: {step.filepath_to_edit} with these instruction {step.edit_instructions}"
57
+ for step in message_option.steps
58
+ ]
59
+ )
60
+ logger.info("Design Plan: %s", design_steps)
61
+ case OptionFeatureTreeOutline():
62
+ logger.info(
63
+ "Feature Tree Outline: %s", message_option.content
64
+ )
65
+ case OptionGeneratedKclCode():
66
+ logger.info("Generated KCL code: %s", message_option.code)
67
+ case OptionKclCodeError():
68
+ logger.info("KCL Code Error: %s", message_option.error)
69
+ case OptionKclDocs():
70
+ logger.info("KCL Docs: %s", message_option.content)
71
+ case OptionKclCodeExamples():
72
+ logger.info("KCL Code Examples: %s", message_option.content)
73
+ case OptionMarkdown():
74
+ logger.info(message_option.content)
75
+ case OptionText():
76
+ logger.info(message_option.content)
77
+ case OptionUpdatedKclFile():
78
+ logger.info(
79
+ "Updated %s: %s"
80
+ % (message_option.file_name, message_option.content),
81
+ )
82
+ case _:
83
+ logger.info(
84
+ "Received unhandled reasoning message: %s",
85
+ type(message_option),
86
+ )
87
+ if isinstance(message.root, ToolOutput):
88
+ tool_result = message.root.result.root
89
+ if tool_result.error:
90
+ logger.info(
91
+ "Tool: %s, Error: %s"
92
+ % (tool_result.type, tool_result.error)
93
+ )
94
+ else:
95
+ logger.info(
96
+ "Tool: %s, Output: %s"
97
+ % (tool_result.type, tool_result.outputs)
98
+ )
99
+ if isinstance(message.root, EndOfStream):
100
+ logger.info("Text-To-CAD reasoning complete.")
101
+ return True
102
+
103
+ except ConnectionClosedError as e:
104
+ logger.info(
105
+ "Text To CAD could still be running but the websocket connection closed with error: %s",
106
+ e,
107
+ )
108
+ return False
109
+
110
+ except Exception as e:
111
+ logger.info(
112
+ "Text To CAD could still be running but an unexpected error occurred: %s",
113
+ e,
114
+ )
115
+ return False
116
+
117
+
118
+ async def text_to_cad(prompt: str) -> str:
119
+ """Send a prompt to Zoo's Text-To-CAD create endpoint
120
+
121
+ Args:
122
+ prompt (str): a description of the CAD model to be created
123
+
124
+ Returns:
125
+ A string containing the complete KCL code of the CAD model if Text-To-CAD was successful, otherwise an error
126
+ message from Text-To-CAD
127
+ """
128
+
129
+ logger.info("Sending prompt to Text-To-CAD")
130
+
131
+ # send prompt via the kittycad client
132
+ t2c = kittycad_client.ml.create_text_to_cad(
133
+ output_format=FileExportFormat.STEP,
134
+ kcl=True,
135
+ body=TextToCadCreateBody(
136
+ prompt=prompt,
137
+ ),
138
+ )
139
+
140
+ # get the response based on the request id
141
+ result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2c.id)
142
+
143
+ # check if the request has either completed or failed, otherwise sleep and try again
144
+ time_start = time.time()
145
+ ws_complete = False
146
+ while result.root.status not in [ApiCallStatus.COMPLETED, ApiCallStatus.FAILED]:
147
+ if (
148
+ result.root.status == ApiCallStatus.QUEUED
149
+ and (time.time() - time_start) % 5 == 0
150
+ ):
151
+ logger.info("Text-To-CAD queued...")
152
+ if result.root.status == ApiCallStatus.IN_PROGRESS:
153
+ logger.info("Text-To-CAD in progress...")
154
+ if not ws_complete:
155
+ ws_complete = log_websocket_message(t2c.id)
156
+ logger.info(
157
+ "Waiting for Text-To-CAD to complete... status %s", result.root.status
158
+ )
159
+ result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2c.id)
160
+ await asyncio.sleep(1)
161
+
162
+ logger.info("Received response from Text-To-CAD")
163
+
164
+ # get the data object (root) of the response
165
+ response = result.root
166
+
167
+ # check the data type of the response
168
+ if not isinstance(response, OptionTextToCad):
169
+ return "Error: Text-to-CAD response is not of type OptionTextToCad."
170
+
171
+ # if Text To CAD was successful return the KCL code, otherwise return the error
172
+ if response.status == ApiCallStatus.COMPLETED:
173
+ if response.code is None:
174
+ return "Error: Text-to-CAD response is null."
175
+ return response.code
176
+ else:
177
+ if response.error is None:
178
+ return "Error: Text-to-CAD response is null."
179
+ return response.error
180
+
181
+
182
+ async def edit_kcl_project(
183
+ prompt: str,
184
+ proj_path: Path | str,
185
+ ) -> dict | str:
186
+ """Send a prompt and a KCL project to Zoo's Text-To-CAD edit KCL project endpoint. The proj_path will upload all contained files to the endpoint. There must be a main.kcl file in the root of the project.
187
+
188
+ Args:
189
+ prompt (str): A description of the changes to be made to the KCL project associated with the provided KCL files.
190
+ proj_path (Path | str): A path to a directory containing a main.kcl file. All contained files (found recursively) will be sent to the endpoint.
191
+
192
+ Returns:
193
+ dict | str: A dictionary containing the complete KCL code of the CAD model if Text-To-CAD edit KCL project was successful.
194
+ Each key in the dict refers to a KCL file path relative to the project path, and each value is the complete KCL code for that file.
195
+ If unsuccessful, returns an error message from Text-To-CAD.
196
+ """
197
+ logger.info("Sending KCL code prompt to Text-To-CAD edit kcl project")
198
+
199
+ logger.info("Finding all files in project path")
200
+ proj_path = Path(proj_path)
201
+ file_paths = list(proj_path.rglob("*"))
202
+ file_paths = [fp for fp in file_paths if fp.is_file()]
203
+ logger.info("Found %s files in project path", len(file_paths))
204
+
205
+ if not file_paths:
206
+ logger.error("No files paths provided or found in project path")
207
+ raise ZooMCPException("No file paths provided or found in project path")
208
+
209
+ if ".kcl" not in [fp.suffix for fp in file_paths]:
210
+ logger.error("No .kcl files found in the provided project path")
211
+ raise ZooMCPException("No .kcl files found in the provided project path")
212
+
213
+ if not (proj_path / "main.kcl").is_file():
214
+ logger.error("No main.kcl file found in the root of the provided project path")
215
+ raise ZooMCPException(
216
+ "No main.kcl file found in the root of the provided project path"
217
+ )
218
+
219
+ file_attachments = {
220
+ str(fp.relative_to(proj_path)): str(fp.resolve()) for fp in file_paths
221
+ }
222
+
223
+ t2cmfi = kittycad_client.ml.create_text_to_cad_multi_file_iteration(
224
+ body=TextToCadMultiFileIterationBody(
225
+ source_ranges=[],
226
+ prompt=prompt,
227
+ ),
228
+ file_attachments=file_attachments,
229
+ )
230
+
231
+ log_websocket_message(t2cmfi.id)
232
+
233
+ # get the response based on the request id
234
+ result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2cmfi.id)
235
+
236
+ # check if the request has either completed or failed, otherwise sleep and try again
237
+ while result.root.status not in [ApiCallStatus.COMPLETED, ApiCallStatus.FAILED]:
238
+ result = kittycad_client.ml.get_text_to_cad_part_for_user(id=t2cmfi.id)
239
+ await asyncio.sleep(1)
240
+
241
+ # get the data object (root) of the response
242
+ response = result.root
243
+
244
+ # check the data type of the response
245
+ if not isinstance(response, OptionTextToCadMultiFileIteration):
246
+ return "Error: Text-to-CAD response is not of type OptionTextToCadMultiFileIteration."
247
+
248
+ # if Text To CAD iteration was successful return the KCL code, otherwise return the error
249
+ if response.status == ApiCallStatus.COMPLETED:
250
+ if response.outputs is None:
251
+ return "Error: Text-to-CAD edit kcl project response is null."
252
+ return response.outputs
253
+ else:
254
+ if response.error is None:
255
+ return "Error: Text-to-CAD edit kcl project response is null."
256
+ return response.error
zoo_mcp/kcl_docs.py ADDED
@@ -0,0 +1,268 @@
1
+ """KCL Documentation fetching and search.
2
+
3
+ This module fetches KCL documentation from the modeling-app GitHub repository
4
+ at server startup and provides search functionality for LLMs.
5
+ """
6
+
7
+ import asyncio
8
+ from dataclasses import dataclass, field
9
+ from typing import ClassVar
10
+
11
+ import httpx
12
+
13
+ from zoo_mcp import logger
14
+
15
+ GITHUB_TREE_URL = (
16
+ "https://api.github.com/repos/KittyCAD/modeling-app/git/trees/main?recursive=1"
17
+ )
18
+ RAW_CONTENT_BASE = "https://raw.githubusercontent.com/KittyCAD/modeling-app/main/"
19
+
20
+
21
+ @dataclass
22
+ class KCLDocs:
23
+ """Container for documentation data."""
24
+
25
+ docs: dict[str, str] = field(default_factory=dict)
26
+ index: dict[str, list[str]] = field(
27
+ default_factory=lambda: {
28
+ "kcl-lang": [],
29
+ "kcl-std-functions": [],
30
+ "kcl-std-types": [],
31
+ "kcl-std-consts": [],
32
+ "kcl-std-modules": [],
33
+ }
34
+ )
35
+
36
+ _instance: ClassVar["KCLDocs | None"] = None
37
+
38
+ @classmethod
39
+ def get(cls) -> "KCLDocs":
40
+ """Get the cached docs instance, or empty cache if not initialized."""
41
+ return cls._instance if cls._instance is not None else cls()
42
+
43
+ @classmethod
44
+ async def initialize(cls) -> None:
45
+ """Initialize the docs cache from GitHub."""
46
+ if cls._instance is None:
47
+ cls._instance = await _fetch_docs_from_github()
48
+
49
+
50
+ def _categorize_doc_path(path: str) -> str | None:
51
+ """Categorize a doc path into one of the index categories."""
52
+ if path.startswith("docs/kcl-lang/"):
53
+ return "kcl-lang"
54
+ elif path.startswith("docs/kcl-std/functions/"):
55
+ return "kcl-std-functions"
56
+ elif path.startswith("docs/kcl-std/types/"):
57
+ return "kcl-std-types"
58
+ elif path.startswith("docs/kcl-std/consts/"):
59
+ return "kcl-std-consts"
60
+ elif path.startswith("docs/kcl-std/modules/"):
61
+ return "kcl-std-modules"
62
+ elif path.startswith("docs/kcl-std/"):
63
+ # Other kcl-std files (index.md, README.md)
64
+ return None
65
+ return None
66
+
67
+
68
+ def _extract_title(content: str) -> str:
69
+ """Extract the title from Markdown content (first # heading)."""
70
+ for line in content.split("\n"):
71
+ line = line.strip()
72
+ if line.startswith("# "):
73
+ return line[2:].strip()
74
+ return ""
75
+
76
+
77
+ def _extract_excerpt(content: str, query: str, context_chars: int = 200) -> str:
78
+ """Extract an excerpt around the first match of query in content."""
79
+ query_lower = query.lower()
80
+ content_lower = content.lower()
81
+
82
+ pos = content_lower.find(query_lower)
83
+ if pos == -1:
84
+ # Return first context_chars of content as fallback
85
+ return content[:context_chars].strip() + "..."
86
+
87
+ # Find start and end positions for excerpt
88
+ start = max(0, pos - context_chars // 2)
89
+ end = min(len(content), pos + len(query) + context_chars // 2)
90
+
91
+ # Adjust to word boundaries
92
+ if start > 0:
93
+ # Find the start of the word
94
+ while start > 0 and content[start - 1] not in " \n\t":
95
+ start -= 1
96
+
97
+ if end < len(content):
98
+ # Find the end of the word
99
+ while end < len(content) and content[end] not in " \n\t":
100
+ end += 1
101
+
102
+ excerpt = content[start:end].strip()
103
+
104
+ # Add ellipsis
105
+ prefix = "..." if start > 0 else ""
106
+ suffix = "..." if end < len(content) else ""
107
+
108
+ return f"{prefix}{excerpt}{suffix}"
109
+
110
+
111
+ async def _fetch_doc_content(
112
+ client: httpx.AsyncClient, path: str
113
+ ) -> tuple[str, str | None]:
114
+ """Fetch a single doc file's content."""
115
+ url = f"{RAW_CONTENT_BASE}{path}"
116
+ try:
117
+ response = await client.get(url)
118
+ response.raise_for_status()
119
+ return path, response.text
120
+ except httpx.HTTPError as e:
121
+ logger.warning(f"Failed to fetch {path}: {e}")
122
+ return path, None
123
+
124
+
125
+ async def _fetch_docs_from_github() -> KCLDocs:
126
+ """Fetch all docs from GitHub and return a KCLDocs."""
127
+ docs = KCLDocs()
128
+
129
+ logger.info("Fetching KCL documentation from GitHub...")
130
+
131
+ async with httpx.AsyncClient(timeout=30.0) as client:
132
+ # 1. Get file tree from GitHub API
133
+ try:
134
+ response = await client.get(GITHUB_TREE_URL)
135
+ response.raise_for_status()
136
+ tree_data = response.json()
137
+ except httpx.HTTPError as e:
138
+ logger.warning(f"Failed to fetch GitHub tree: {e}")
139
+ return docs
140
+
141
+ # 2. Filter for docs/*.md files
142
+ doc_paths: list[str] = []
143
+ for item in tree_data.get("tree", []):
144
+ path = item.get("path", "")
145
+ if (
146
+ path.startswith("docs/")
147
+ and path.endswith(".md")
148
+ and item.get("type") == "blob"
149
+ ):
150
+ doc_paths.append(path)
151
+
152
+ logger.info(f"Found {len(doc_paths)} documentation files")
153
+
154
+ # 3. Fetch raw content in parallel
155
+ tasks = [_fetch_doc_content(client, path) for path in doc_paths]
156
+ results = await asyncio.gather(*tasks)
157
+
158
+ # 4. Populate cache and index
159
+ for path, content in results:
160
+ if content is not None:
161
+ docs.docs[path] = content
162
+
163
+ # Categorize the doc
164
+ category = _categorize_doc_path(path)
165
+ if category and category in docs.index:
166
+ docs.index[category].append(path)
167
+
168
+ # Sort the index lists
169
+ for category in docs.index:
170
+ docs.index[category].sort()
171
+
172
+ logger.info(f"KCL documentation cache initialized with {len(docs.docs)} files")
173
+ return docs
174
+
175
+
176
+ async def initialize_docs_cache() -> None:
177
+ """Initialize the docs cache from GitHub."""
178
+ await KCLDocs.initialize()
179
+
180
+
181
+ def list_available_docs() -> dict[str, list[str]]:
182
+ """Return categorized list of available documentation.
183
+
184
+ Returns a dictionary with the following categories:
185
+ - kcl-lang: KCL language documentation (syntax, types, functions, etc.)
186
+ - kcl-std-functions: Standard library function documentation
187
+ - kcl-std-types: Standard library type documentation
188
+ - kcl-std-consts: Standard library constants documentation
189
+ - kcl-std-modules: Standard library module documentation
190
+
191
+ Each category contains a list of documentation file paths that can be
192
+ retrieved using get_kcl_doc().
193
+
194
+ Returns:
195
+ dict: Categories mapped to lists of available documentation paths.
196
+ """
197
+ return KCLDocs.get().index
198
+
199
+
200
+ def search_docs(query: str, max_results: int = 5) -> list[dict]:
201
+ """Search docs by keyword.
202
+
203
+ Searches across all KCL language and standard library documentation
204
+ for the given query. Returns relevant excerpts with surrounding context.
205
+
206
+ Args:
207
+ query (str): The search query (case-insensitive).
208
+ max_results (int): Maximum number of results to return (default: 5).
209
+
210
+ Returns:
211
+ list[dict]: List of search results, each containing:
212
+ - path: The documentation file path
213
+ - title: The document title (from first heading)
214
+ - excerpt: A relevant excerpt with the match highlighted in context
215
+ - match_count: Number of times the query appears in the document
216
+ """
217
+
218
+ if not query or not query.strip():
219
+ return [{"error": "Empty search query"}]
220
+
221
+ query = query.strip()
222
+ query_lower = query.lower()
223
+ results: list[dict] = []
224
+
225
+ for path, content in KCLDocs.get().docs.items():
226
+ content_lower = content.lower()
227
+
228
+ # Count matches
229
+ match_count = content_lower.count(query_lower)
230
+ if match_count > 0:
231
+ title = _extract_title(content)
232
+ excerpt = _extract_excerpt(content, query)
233
+
234
+ results.append(
235
+ {
236
+ "path": path,
237
+ "title": title,
238
+ "excerpt": excerpt,
239
+ "match_count": match_count,
240
+ }
241
+ )
242
+
243
+ # Sort by match count (descending)
244
+ results.sort(key=lambda x: x["match_count"], reverse=True)
245
+
246
+ return results[:max_results]
247
+
248
+
249
+ def get_doc_content(doc_path: str) -> str | None:
250
+ """Get the full content of a specific KCL documentation file.
251
+
252
+ Use list_kcl_docs() to see available documentation paths, or
253
+ search_kcl_docs() to find relevant documentation by keyword.
254
+
255
+ Args:
256
+ doc_path (str): The path to the documentation file
257
+ (e.g., "docs/kcl-lang/functions.md" or "docs/kcl-std/functions/extrude.md")
258
+
259
+ Returns:
260
+ str: The full Markdown content of the documentation file,
261
+ or an error message if not found.
262
+ """
263
+
264
+ # Basic validation to prevent path traversal
265
+ if ".." in doc_path or not doc_path.startswith("docs/"):
266
+ return None
267
+
268
+ return KCLDocs.get().docs.get(doc_path)