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/server.py ADDED
@@ -0,0 +1,760 @@
1
+ import kcl
2
+ from kittycad.models.modeling_cmd import OptionDefaultCameraLookAt, Point3d
3
+ from mcp.server.fastmcp import FastMCP
4
+ from mcp.types import ImageContent
5
+
6
+ from zoo_mcp import ZooMCPException, logger
7
+ from zoo_mcp.ai_tools import edit_kcl_project as _edit_kcl_project
8
+ from zoo_mcp.ai_tools import text_to_cad as _text_to_cad
9
+ from zoo_mcp.kcl_docs import (
10
+ get_doc_content,
11
+ list_available_docs,
12
+ search_docs,
13
+ )
14
+ from zoo_mcp.kcl_samples import (
15
+ SampleData,
16
+ get_sample_content,
17
+ list_available_samples,
18
+ search_samples,
19
+ )
20
+ from zoo_mcp.utils.image_utils import encode_image, save_image_to_disk
21
+ from zoo_mcp.zoo_tools import (
22
+ CameraView,
23
+ zoo_calculate_center_of_mass,
24
+ zoo_calculate_mass,
25
+ zoo_calculate_surface_area,
26
+ zoo_calculate_volume,
27
+ zoo_convert_cad_file,
28
+ zoo_execute_kcl,
29
+ zoo_export_kcl,
30
+ zoo_format_kcl,
31
+ zoo_lint_and_fix_kcl,
32
+ zoo_mock_execute_kcl,
33
+ zoo_multiview_snapshot_of_cad,
34
+ zoo_multiview_snapshot_of_kcl,
35
+ zoo_snapshot_of_cad,
36
+ zoo_snapshot_of_kcl,
37
+ )
38
+
39
+ mcp = FastMCP(
40
+ name="Zoo MCP Server",
41
+ log_level="INFO",
42
+ )
43
+
44
+
45
+ @mcp.tool()
46
+ async def calculate_center_of_mass(input_file: str, unit_length: str) -> dict | str:
47
+ """Calculate the center of mass of a 3d object represented by the input file.
48
+
49
+ Args:
50
+ input_file (str): The path of the file to get the mass from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
51
+ unit_length (str): The unit of length to return the result in. One of 'cm', 'ft', 'in', 'm', 'mm', 'yd'
52
+
53
+ Returns:
54
+ str: The center of mass of the file in the specified unit of length, or an error message if the operation fails.
55
+ """
56
+
57
+ logger.info("calculate_center_of_mass tool called for file: %s", input_file)
58
+
59
+ try:
60
+ com = await zoo_calculate_center_of_mass(
61
+ file_path=input_file, unit_length=unit_length
62
+ )
63
+ return com
64
+ except Exception as e:
65
+ return f"There was an error calculating the center of mass of the file: {e}"
66
+
67
+
68
+ @mcp.tool()
69
+ async def calculate_mass(
70
+ input_file: str, unit_mass: str, unit_density: str, density: float
71
+ ) -> float | str:
72
+ """Calculate the mass of a 3d object represented by the input file.
73
+
74
+ Args:
75
+ input_file (str): The path of the file to get the mass from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
76
+ unit_mass (str): The unit of mass to return the result in. One of 'g', 'kg', 'lb'.
77
+ unit_density (str): The unit of density to calculate the mass. One of 'lb:ft3', 'kg:m3'.
78
+ density (float): The density of the material.
79
+
80
+ Returns:
81
+ str: The mass of the file in the specified unit of mass, or an error message if the operation fails.
82
+ """
83
+
84
+ logger.info("calculate_mass tool called for file: %s", input_file)
85
+
86
+ try:
87
+ mass = await zoo_calculate_mass(
88
+ file_path=input_file,
89
+ unit_mass=unit_mass,
90
+ unit_density=unit_density,
91
+ density=density,
92
+ )
93
+ return mass
94
+ except Exception as e:
95
+ return f"There was an error calculating the mass of the file: {e}"
96
+
97
+
98
+ @mcp.tool()
99
+ async def calculate_surface_area(input_file: str, unit_area: str) -> float | str:
100
+ """Calculate the surface area of a 3d object represented by the input file.
101
+
102
+ Args:
103
+ input_file (str): The path of the file to get the surface area from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
104
+ unit_area (str): The unit of area to return the result in. One of 'cm2', 'dm2', 'ft2', 'in2', 'km2', 'm2', 'mm2', 'yd2'.
105
+
106
+ Returns:
107
+ str: The surface area of the file in the specified unit of area, or an error message if the operation fails.
108
+ """
109
+
110
+ logger.info("calculate_surface_area tool called for file: %s", input_file)
111
+
112
+ try:
113
+ surface_area = await zoo_calculate_surface_area(
114
+ file_path=input_file, unit_area=unit_area
115
+ )
116
+ return surface_area
117
+ except Exception as e:
118
+ return f"There was an error calculating the surface area of the file: {e}"
119
+
120
+
121
+ @mcp.tool()
122
+ async def calculate_volume(input_file: str, unit_volume: str) -> float | str:
123
+ """Calculate the volume of a 3d object represented by the input file.
124
+
125
+ Args:
126
+ input_file (str): The path of the file to get the volume from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
127
+ unit_volume (str): The unit of volume to return the result in. One of 'cm3', 'ft3', 'in3', 'm3', 'yd3', 'usfloz', 'usgal', 'l', 'ml'.
128
+
129
+ Returns:
130
+ str: The volume of the file in the specified unit of volume, or an error message if the operation fails.
131
+ """
132
+
133
+ logger.info("calculate_volume tool called for file: %s", input_file)
134
+
135
+ try:
136
+ volume = await zoo_calculate_volume(file_path=input_file, unit_vol=unit_volume)
137
+ return volume
138
+ except Exception as e:
139
+ return f"There was an error calculating the volume of the file: {e}"
140
+
141
+
142
+ @mcp.tool()
143
+ async def convert_cad_file(
144
+ input_path: str,
145
+ export_path: str | None,
146
+ export_format: str | None,
147
+ ) -> str:
148
+ """Convert a CAD file from one format to another CAD file format.
149
+
150
+ Args:
151
+ input_path (str): The input cad file to convert. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
152
+ export_path (str | None): The path to save the converted CAD file to. If the path is a directory, a temporary file will be created in the directory. If the path is a file, it will be overwritten if the extension is valid.
153
+ export_format (str | None): The format of the exported CAD file. This should be one of 'fbx', 'glb', 'gltf', 'obj', 'ply', 'step', 'stl'. If no format is provided, the default is 'step'.
154
+
155
+ Returns:
156
+ str: The path to the converted CAD file, or an error message if the operation fails.
157
+ """
158
+
159
+ logger.info("convert_cad_file tool called")
160
+
161
+ try:
162
+ step_path = await zoo_convert_cad_file(
163
+ input_path=input_path, export_path=export_path, export_format=export_format
164
+ )
165
+ return str(step_path)
166
+ except Exception as e:
167
+ return f"There was an error converting the CAD file: {e}"
168
+
169
+
170
+ @mcp.tool()
171
+ async def execute_kcl(
172
+ kcl_code: str | None = None,
173
+ kcl_path: str | None = None,
174
+ ) -> tuple[bool, str]:
175
+ """Execute KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
176
+
177
+ Args:
178
+ kcl_code (str | None): The KCL code to execute.
179
+ kcl_path (str | None): The path to a KCL file to execute. The path should point to a .kcl file or a directory containing a main.kcl file.
180
+
181
+ Returns:
182
+ tuple(bool, str): Returns True if the KCL code executed successfully and a success message, False otherwise and the error message.
183
+ """
184
+
185
+ logger.info("execute_kcl tool called")
186
+
187
+ try:
188
+ return await zoo_execute_kcl(kcl_code=kcl_code, kcl_path=kcl_path)
189
+ except Exception as e:
190
+ return False, f"Failed to execute KCL code: {e}"
191
+
192
+
193
+ @mcp.tool()
194
+ async def export_kcl(
195
+ kcl_code: str | None = None,
196
+ kcl_path: str | None = None,
197
+ export_path: str | None = None,
198
+ export_format: str | None = None,
199
+ ) -> str:
200
+ """Export KCL code to a CAD file. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
201
+
202
+ Args:
203
+ kcl_code (str | None): The KCL code to export to a CAD file.
204
+ kcl_path (str | None): The path to a KCL file to export to a CAD file. The path should point to a .kcl file or a directory containing a main.kcl file.
205
+ export_path (str | None): The path to export the CAD file. If no path is provided, a temporary file will be created.
206
+ export_format (str | None): The format to export the file as. This should be one of 'fbx', 'glb', 'gltf', 'obj', 'ply', 'step', 'stl'. If no format is provided, the default is 'step'.
207
+
208
+ Returns:
209
+ str: The path to the converted CAD file, or an error message if the operation fails.
210
+ """
211
+
212
+ logger.info("convert_kcl_to_step tool called")
213
+
214
+ try:
215
+ cad_path = await zoo_export_kcl(
216
+ kcl_code=kcl_code,
217
+ kcl_path=kcl_path,
218
+ export_path=export_path,
219
+ export_format=export_format,
220
+ )
221
+ return str(cad_path)
222
+ except Exception as e:
223
+ return f"There was an error exporting the CAD file: {e}"
224
+
225
+
226
+ @mcp.tool()
227
+ async def format_kcl(
228
+ kcl_code: str | None = None,
229
+ kcl_path: str | None = None,
230
+ ) -> str:
231
+ """Format KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing .kcl files.
232
+
233
+ Args:
234
+ kcl_code (str | None): The KCL code to format.
235
+ kcl_path (str | None): The path to a KCL file to format. The path should point to a .kcl file or a directory containing a main.kcl file.
236
+
237
+ Returns:
238
+ str | None: Returns the formatted kcl code if the kcl_code is used otherwise returns None, the KCL in the kcl_path will be formatted in place
239
+ """
240
+
241
+ logger.info("format_kcl tool called")
242
+
243
+ try:
244
+ res = zoo_format_kcl(kcl_code=kcl_code, kcl_path=kcl_path)
245
+ if isinstance(res, str):
246
+ return res
247
+ else:
248
+ return f"Successfully formatted KCL code at: {kcl_path}"
249
+ except Exception as e:
250
+ return f"There was an error formatting the KCL: {e}"
251
+
252
+
253
+ @mcp.tool()
254
+ async def lint_and_fix_kcl(
255
+ kcl_code: str | None = None,
256
+ kcl_path: str | None = None,
257
+ ) -> tuple[str, list[str]]:
258
+ """Lint and fix KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing .kcl files.
259
+
260
+ Args:
261
+ kcl_code (str | None): The KCL code to lint and fix.
262
+ kcl_path (str | None): The path to a KCL file to lint and fix. The path should point to a .kcl file or a directory containing a main.kcl file.
263
+
264
+ Returns:
265
+ tuple[str, list[str]]: If kcl_code is provided, it returns a tuple containing the fixed KCL code and a list of unfixed lints.
266
+ If kcl_path is provided, it returns a tuple containing a success message and a list of unfixed lints for each file in the project.
267
+ """
268
+
269
+ logger.info("lint_and_fix_kcl tool called")
270
+
271
+ try:
272
+ res, lints = zoo_lint_and_fix_kcl(kcl_code=kcl_code, kcl_path=kcl_path)
273
+ if isinstance(res, str):
274
+ return res, lints
275
+ else:
276
+ return f"Successfully linted and fixed KCL code at: {kcl_path}", lints
277
+ except Exception as e:
278
+ return f"There was an error linting and fixing the KCL: {e}", []
279
+
280
+
281
+ @mcp.tool()
282
+ async def mock_execute_kcl(
283
+ kcl_code: str | None = None,
284
+ kcl_path: str | None = None,
285
+ ) -> tuple[bool, str]:
286
+ """Mock execute KCL code given a string of KCL code or a path to a KCL project. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
287
+
288
+ Args:
289
+ kcl_code (str | None): The KCL code to mock execute.
290
+ kcl_path (str | None): The path to a KCL file to mock execute. The path should point to a .kcl file or a directory containing a main.kcl file.
291
+
292
+ Returns:
293
+ tuple(bool, str): Returns True if the KCL code executed successfully and a success message, False otherwise and the error message.
294
+ """
295
+
296
+ logger.info("mock_execute_kcl tool called")
297
+
298
+ try:
299
+ return await zoo_mock_execute_kcl(kcl_code=kcl_code, kcl_path=kcl_path)
300
+ except Exception as e:
301
+ return False, f"Failed to mock execute KCL code: {e}"
302
+
303
+
304
+ @mcp.tool()
305
+ async def multiview_snapshot_of_cad(
306
+ input_file: str,
307
+ ) -> ImageContent | str:
308
+ """Save a multiview snapshot of a CAD file. The input file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
309
+
310
+ This multiview image shows the render of the model from 4 different views:
311
+ The top left images is a front view.
312
+ The top right image is a right side view.
313
+ The bottom left image is a top view.
314
+ The bottom right image is an isometric view
315
+
316
+ Args:
317
+ input_file (str): The path of the file to get the mass from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
318
+
319
+ Returns:
320
+ ImageContent | str: The multiview snapshot of the CAD file as an image, or an error message if the operation fails.
321
+ """
322
+
323
+ logger.info("multiview_snapshot_of_cad tool called for file: %s", input_file)
324
+
325
+ try:
326
+ image = zoo_multiview_snapshot_of_cad(
327
+ input_path=input_file,
328
+ )
329
+ return encode_image(image)
330
+ except Exception as e:
331
+ return f"There was an error creating the multiview snapshot: {e}"
332
+
333
+
334
+ @mcp.tool()
335
+ async def multiview_snapshot_of_kcl(
336
+ kcl_code: str | None = None,
337
+ kcl_path: str | None = None,
338
+ ) -> ImageContent | str:
339
+ """Save a multiview snapshot of KCL code. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
340
+
341
+ This multiview image shows the render of the model from 4 different views:
342
+ The top left images is a front view.
343
+ The top right image is a right side view.
344
+ The bottom left image is a top view.
345
+ The bottom right image is an isometric view
346
+
347
+ Args:
348
+ kcl_code (str | None): The KCL code to export to a CAD file.
349
+ kcl_path (str | None): The path to a KCL file to export to a CAD file. The path should point to a .kcl file or a directory containing a main.kcl file.
350
+
351
+ Returns:
352
+ ImageContent | str: The multiview snapshot of the KCL code as an image, or an error message if the operation fails.
353
+ """
354
+
355
+ logger.info("multiview_snapshot_of_kcl tool called")
356
+
357
+ try:
358
+ image = await zoo_multiview_snapshot_of_kcl(
359
+ kcl_code=kcl_code,
360
+ kcl_path=kcl_path,
361
+ )
362
+ return encode_image(image)
363
+ except Exception as e:
364
+ return f"There was an error creating the multiview snapshot: {e}"
365
+
366
+
367
+ @mcp.tool()
368
+ async def snapshot_of_cad(
369
+ input_file: str,
370
+ camera_view: dict[str, list[float]] | str = "isometric",
371
+ ) -> ImageContent | str:
372
+ """Save a snapshot of a CAD file.
373
+
374
+ Args:
375
+ input_file (str): The path of the file to get the mass from. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stl
376
+ camera_view (dict | str): The camera to use for the snapshot.
377
+
378
+ 1. If a string is provided, it should be one of 'front', 'back', 'left', 'right', 'top', 'bottom', 'isometric' to set the camera to a predefined view.
379
+
380
+ 2. If a dict is provided, supply a dict with the following keys and values:
381
+ "up" (list of 3 floats) defining the up vector of the camera, "vantage" (list of 3 floats), and "center" (list of 3 floats).
382
+ For example camera = {"up": [0, 0, 1], "vantage": [0, -1, 0], "center": [0, 0, 0]} would set the camera to be looking at the origin from the front side (-y direction).
383
+
384
+ Returns:
385
+ ImageContent | str: The snapshot of the CAD file as an image, or an error message if the operation fails.
386
+ """
387
+
388
+ logger.info("snapshot_of_cad tool called for file: %s", input_file)
389
+
390
+ try:
391
+ if isinstance(camera_view, dict):
392
+ camera = OptionDefaultCameraLookAt(
393
+ up=Point3d(
394
+ x=camera_view["up"][0],
395
+ y=camera_view["up"][1],
396
+ z=camera_view["up"][2],
397
+ ),
398
+ vantage=Point3d(
399
+ x=camera_view["vantage"][0],
400
+ y=-camera_view["vantage"][1],
401
+ z=camera_view["vantage"][2],
402
+ ),
403
+ center=Point3d(
404
+ x=camera_view["center"][0],
405
+ y=camera_view["center"][1],
406
+ z=camera_view["center"][2],
407
+ ),
408
+ )
409
+ else:
410
+ if camera_view not in CameraView.views.value:
411
+ raise ZooMCPException(
412
+ f"Invalid camera view: {camera_view}. Must be one of {list(CameraView.views.value.keys())}"
413
+ )
414
+ camera = CameraView.to_kittycad_camera(CameraView.views.value[camera_view])
415
+
416
+ image = zoo_snapshot_of_cad(
417
+ input_path=input_file,
418
+ camera=camera,
419
+ )
420
+ return encode_image(image)
421
+ except Exception as e:
422
+ return f"There was an error creating the snapshot: {e}"
423
+
424
+
425
+ @mcp.tool()
426
+ async def snapshot_of_kcl(
427
+ kcl_code: str | None = None,
428
+ kcl_path: str | None = None,
429
+ camera_view: dict[str, list[float]] | str = "isometric",
430
+ ) -> ImageContent | str:
431
+ """Save a snapshot of a model represented by KCL. Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point to a .kcl file or a directory containing a main.kcl file.
432
+
433
+ Args:
434
+ kcl_code (str | None): The KCL code to export to a CAD file.
435
+ kcl_path (str | None): The path to a KCL file to export to a CAD file. The path should point to a .kcl file or a directory containing a main.kcl file.
436
+ camera_view (dict | str): The camera to use for the snapshot.
437
+
438
+ 1. If a string is provided, it should be one of 'front', 'back', 'left', 'right', 'top', 'bottom', 'isometric' to set the camera to a predefined view.
439
+
440
+ 2. If a dict is provided, supply a dict with the following keys and values:
441
+ "up" (list of 3 floats) defining the up vector of the camera, "vantage" (list of 3 floats), and "center" (list of 3 floats).
442
+ For example camera = {"up": [0, 0, 1], "vantage": [0, -1, 0], "center": [0, 0, 0]} would set the camera to be looking at the origin from the front side (-y direction).
443
+
444
+ Returns:
445
+ ImageContent | str: The snapshot of the CAD file as an image, or an error message if the operation fails.
446
+ """
447
+
448
+ logger.info("snapshot_of_kcl tool called")
449
+
450
+ try:
451
+ if isinstance(camera_view, dict):
452
+ camera = kcl.CameraLookAt(
453
+ up=kcl.Point3d(
454
+ x=camera_view["up"][0],
455
+ y=camera_view["up"][1],
456
+ z=camera_view["up"][2],
457
+ ),
458
+ vantage=kcl.Point3d(
459
+ x=camera_view["vantage"][0],
460
+ y=-camera_view["vantage"][1],
461
+ z=camera_view["vantage"][2],
462
+ ),
463
+ center=kcl.Point3d(
464
+ x=camera_view["center"][0],
465
+ y=camera_view["center"][1],
466
+ z=camera_view["center"][2],
467
+ ),
468
+ )
469
+ else:
470
+ if camera_view not in CameraView.views.value:
471
+ raise ZooMCPException(
472
+ f"Invalid camera view: {camera_view}. Must be one of {list(CameraView.views.value.keys())}"
473
+ )
474
+ camera = CameraView.to_kcl_camera(CameraView.views.value[camera_view])
475
+
476
+ image = await zoo_snapshot_of_kcl(
477
+ kcl_code=kcl_code,
478
+ kcl_path=kcl_path,
479
+ camera=camera,
480
+ )
481
+ return encode_image(image)
482
+ except Exception as e:
483
+ return f"There was an error creating the snapshot: {e}"
484
+
485
+
486
+ @mcp.tool()
487
+ async def text_to_cad(prompt: str) -> str:
488
+ """Generate a CAD model as KCL code from a text prompt.
489
+
490
+ # General Tips
491
+ - You can use verbs like "design a..." or "create a...", but those aren't needed. Prompting "A gear" works as well as "Create a gear".
492
+ - If your prompt omits important dimensions, Text-to-CAD will make its best guess to fill in missing details.
493
+ - Traditional, simple mechanical parts such as fasteners, bearings and connectors work best right now.
494
+ - Text-to-CAD returns a 422 error code if it fails to generate a valid geometry internally, even if it understands your prompt. We're working on reducing the amount of errors.
495
+ - Shorter prompts, 1-2 sentences in length, succeed more often than longer prompts.
496
+ - The maximum prompt length is approximately 6000 words. Generally, shorter prompts of one or two sentences work best. Longer prompts take longer to resolve.
497
+ - The same prompt can generate different results when submitted multiple times. Sometimes a failing prompt will succeed on the next attempt, and vice versa.
498
+
499
+ # Examples
500
+ - "A 21-tooth involute helical gear."
501
+ - "A plate with a hole in each corner for a #10 bolt. The plate is 4" wide, 6" tall."
502
+ - "A dodecahedron."
503
+ - "A camshaft."
504
+ - "A 1/2 inch gear with 21 teeth."
505
+ - "A 3x6 lego."
506
+
507
+ Args:
508
+ prompt (str): The text prompt to be realized as KCL code.
509
+
510
+ Returns:
511
+ str: The generated KCL code if Text-to-CAD is successful, otherwise the error message.
512
+ """
513
+ logger.info("text_to_cad tool called with prompt: %s", prompt)
514
+ try:
515
+ return await _text_to_cad(prompt=prompt)
516
+ except Exception as e:
517
+ return f"There was an error generating the CAD file from text: {e}"
518
+
519
+
520
+ @mcp.tool()
521
+ async def edit_kcl_project(
522
+ prompt: str,
523
+ proj_path: str,
524
+ ) -> dict | str:
525
+ """Modify an existing KCL project by sending a prompt and a KCL project path 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.
526
+
527
+ # General Tips
528
+ - You can use verbs like "add", "remove", "change", "make", "fillet", etc. to describe the modification you want to make.
529
+ - Be specific about what you want to change in the model. For example, "add a hole to the center" is more specific than "add a hole".
530
+ - If your prompt omits important dimensions, Text-to-CAD will make its best guess to fill in missing details.
531
+ - Text-to-CAD returns a 422 error code if it fails to generate a valid geometry internally, even if it understands your prompt.
532
+ - Shorter prompts, 1-2 sentences in length, succeed more often than longer prompts.
533
+ - The maximum prompt length is approximately 6000 words. Generally, shorter prompts of one or two sentences work best. Longer prompts take longer to resolve.
534
+ - The same prompt can generate different results when submitted multiple times. Sometimes a failing prompt will succeed on the next attempt, and vice versa.
535
+
536
+ # Examples
537
+ - "Add a hole to the center of the plate."
538
+ - "Make the gear twice as large."
539
+ - "Remove the top face of the box."
540
+ - "Fillet each corner"
541
+
542
+ Args:
543
+ prompt (str): The text prompt describing the modification to be made.
544
+ proj_path (str): A path to a KCL project directory containing a main.kcl file in the root. All contained files (found recursively) will be sent to the endpoint.
545
+
546
+ Returns:
547
+ dict | str: A dictionary containing the complete KCL code of the CAD model if Text-To-CAD edit KCL project was successful.
548
+ 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.
549
+ If unsuccessful, returns an error message from Text-To-CAD.
550
+ """
551
+
552
+ logger.info("edit_kcl_project tool called with prompt: %s", prompt)
553
+
554
+ try:
555
+ return await _edit_kcl_project(
556
+ proj_path=proj_path,
557
+ prompt=prompt,
558
+ )
559
+ except Exception as e:
560
+ return f"There was an error modifying the KCL project from text: {e}"
561
+
562
+
563
+ @mcp.tool()
564
+ async def save_image(
565
+ image: ImageContent,
566
+ output_path: str | None = None,
567
+ ) -> str:
568
+ """Save an ImageContent object to disk. This allows a human to review images locally that an LLM has requested.
569
+
570
+ Args:
571
+ image (ImageContent): The ImageContent object to save. This is typically returned by snapshot tools like snapshot_of_kcl, snapshot_of_cad, multiview_snapshot_of_kcl, or multiview_snapshot_of_cad.
572
+ output_path (str | None): The path where the image should be saved. Can be a file path (e.g., '/path/to/image.png') or a directory (e.g., '/path/to/dir'). If a directory is provided, the file will be named 'image.png'. If not provided, a temporary file will be created.
573
+
574
+ Returns:
575
+ str: The absolute path to the saved image file, or an error message if the operation fails.
576
+ """
577
+
578
+ logger.info("save_image tool called with output_path: %s", output_path)
579
+
580
+ try:
581
+ saved_path = save_image_to_disk(image=image, output_path=output_path)
582
+ return saved_path
583
+ except Exception as e:
584
+ return f"There was an error saving the image: {e}"
585
+
586
+
587
+ @mcp.tool()
588
+ async def list_kcl_docs() -> dict | str:
589
+ """List all available KCL documentation topics organized by category.
590
+
591
+ Returns a dictionary with the following categories:
592
+ - kcl-lang: KCL language documentation (syntax, types, functions, etc.)
593
+ - kcl-std-functions: Standard library function documentation
594
+ - kcl-std-types: Standard library type documentation
595
+ - kcl-std-consts: Standard library constants documentation
596
+ - kcl-std-modules: Standard library module documentation
597
+
598
+ Each category contains a list of documentation file paths that can be
599
+ retrieved using get_kcl_doc().
600
+
601
+ Returns:
602
+ dict | str: Categories mapped to lists of available documentation paths.
603
+ If there was an error, returns an error message string.
604
+ """
605
+ logger.info("list_kcl_docs tool called")
606
+ try:
607
+ return list_available_docs()
608
+ except Exception as e:
609
+ logger.error("list_kcl_docs tool called with error: %s", e)
610
+ return f"There was an error listing KCL documentation: {e}"
611
+
612
+
613
+ @mcp.tool()
614
+ async def search_kcl_docs(query: str, max_results: int = 5) -> list[dict] | str:
615
+ """Search KCL documentation by keyword.
616
+
617
+ Searches across all KCL language and standard library documentation
618
+ for the given query. Returns relevant excerpts with surrounding context.
619
+
620
+ Args:
621
+ query (str): The search query (case-insensitive).
622
+ max_results (int): Maximum number of results to return (default: 5).
623
+
624
+ Returns:
625
+ list[dict] | str: List of search results, each containing:
626
+ - path: The documentation file path
627
+ - title: The document title (from first heading)
628
+ - excerpt: A relevant excerpt with the match highlighted in context
629
+ - match_count: Number of times the query appears in the document
630
+ If there was an error, returns an error message string.
631
+ """
632
+ logger.info("search_kcl_docs tool called with query: %s", query)
633
+ try:
634
+ return search_docs(query, max_results)
635
+ except Exception as e:
636
+ logger.error("search_kcl_docs tool called with error: %s", e)
637
+ return f"There was an error searching KCL documentation: {e}"
638
+
639
+
640
+ @mcp.tool()
641
+ async def get_kcl_doc(doc_path: str) -> str:
642
+ """Get the full content of a specific KCL documentation file.
643
+
644
+ Use list_kcl_docs() to see available documentation paths, or
645
+ search_kcl_docs() to find relevant documentation by keyword.
646
+
647
+ Args:
648
+ doc_path (str): The path to the documentation file
649
+ (e.g., "docs/kcl-lang/functions.md" or "docs/kcl-std/functions/extrude.md")
650
+
651
+ Returns:
652
+ str: The full Markdown content of the documentation file,
653
+ or an error message if not found. If there was an error, returns an error message string.
654
+ """
655
+ logger.info("get_kcl_doc tool called for path: %s", doc_path)
656
+ try:
657
+ content = get_doc_content(doc_path)
658
+ if content is None:
659
+ return f"Documentation not found: {doc_path}. Use list_kcl_docs() to see available paths."
660
+ return content
661
+ except Exception as e:
662
+ logger.error("get_kcl_doc tool called with error: %s", e)
663
+ return f"There was an error retrieving KCL documentation: {e}"
664
+
665
+
666
+ @mcp.tool()
667
+ async def list_kcl_samples() -> list[dict] | str:
668
+ """List all available KCL sample projects.
669
+
670
+ Returns a list of all available KCL code samples from the Zoo samples
671
+ repository. Each sample demonstrates a specific CAD modeling technique
672
+ or creates a particular 3D model.
673
+
674
+ Returns:
675
+ list[dict] | str: List of sample information, each containing:
676
+ - name: The sample directory name (use with get_kcl_sample)
677
+ - title: Human-readable title
678
+ - description: Brief description of what the sample creates
679
+ - multipleFiles: Whether the sample contains multiple KCL files
680
+ If there was an error, returns an error message string.
681
+ """
682
+ logger.info("list_kcl_samples tool called")
683
+ try:
684
+ return list_available_samples()
685
+ except Exception as e:
686
+ logger.error("list_kcl_samples tool called with error: %s", e)
687
+ return f"There was an error listing KCL samples: {e}"
688
+
689
+
690
+ @mcp.tool()
691
+ async def search_kcl_samples(query: str, max_results: int = 5) -> list[dict] | str:
692
+ """Search KCL samples by keyword.
693
+
694
+ Searches across all KCL sample titles and descriptions
695
+ for the given query. Returns matching samples ranked by relevance.
696
+
697
+ Args:
698
+ query (str): The search query (case-insensitive).
699
+ max_results (int): Maximum number of results to return (default: 5).
700
+
701
+ Returns:
702
+ list[dict] | str: List of search results, each containing:
703
+ - name: The sample directory name (use with get_kcl_sample)
704
+ - title: Human-readable title
705
+ - description: Brief description of the sample
706
+ - multipleFiles: Whether the sample contains multiple KCL files
707
+ - match_count: Number of times the query appears in title/description
708
+ - excerpt: A relevant excerpt with the match in context
709
+ If there was an error, returns an error message string.
710
+ """
711
+ logger.info("search_kcl_samples tool called with query: %s", query)
712
+ try:
713
+ return search_samples(query, max_results)
714
+ except Exception as e:
715
+ logger.error("search_kcl_samples tool called with error: %s", e)
716
+ return f"There was an error searching KCL samples: {e}"
717
+
718
+
719
+ @mcp.tool()
720
+ async def get_kcl_sample(sample_name: str) -> SampleData | str:
721
+ """Get the full content of a specific KCL sample including all files.
722
+
723
+ Retrieves all KCL files that make up a sample project. Some samples
724
+ consist of a single main.kcl file, while others have multiple files
725
+ (e.g., parameters.kcl, components, etc.).
726
+
727
+ Use list_kcl_samples() to see available sample names, or
728
+ search_kcl_samples() to find samples by keyword.
729
+
730
+ Args:
731
+ sample_name (str): The sample directory name
732
+ (e.g., "ball-bearing", "axial-fan", "gear")
733
+
734
+ Returns:
735
+ SampleData | str: A SampleData dictionary containing:
736
+ - name: The sample directory name
737
+ - title: Human-readable title
738
+ - description: Brief description
739
+ - multipleFiles: Whether the sample contains multiple files
740
+ - files: List of SampleFile dictionaries, each with 'filename' and 'content'
741
+ Returns an error message string if the sample is not found. If there was an error, returns an error message string.
742
+ """
743
+ logger.info("get_kcl_sample tool called for sample: %s", sample_name)
744
+ try:
745
+ sample = await get_sample_content(sample_name)
746
+ if sample is None:
747
+ return f"Sample not found: {sample_name}. Use list_kcl_samples() to see available samples."
748
+ return sample
749
+ except Exception as e:
750
+ logger.error("get_kcl_sample tool called with error: %s", e)
751
+ return f"There was an error retrieving KCL sample: {e}"
752
+
753
+
754
+ def main():
755
+ logger.info("Starting MCP server...")
756
+ mcp.run(transport="stdio")
757
+
758
+
759
+ if __name__ == "__main__":
760
+ main()