@hybridlabor-api/bdb-hardware-pcb 0.1.0

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.
Files changed (192) hide show
  1. package/README.md +116 -0
  2. package/config/mcp/antigravity.json +22 -0
  3. package/config/mcp/claude.json +22 -0
  4. package/config/mcp/codex.toml +25 -0
  5. package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
  6. package/docs/review_documentation.md +121 -0
  7. package/installer.js +358 -0
  8. package/mcp_servers/kicad-mcp-server/.env.example +22 -0
  9. package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
  10. package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
  11. package/mcp_servers/kicad-mcp-server/README.md +316 -0
  12. package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
  13. package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
  14. package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
  15. package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
  16. package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
  17. package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
  18. package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
  19. package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
  20. package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
  21. package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
  22. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
  23. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
  24. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
  25. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
  26. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
  27. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
  28. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
  29. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
  30. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
  31. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
  32. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
  33. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
  34. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
  35. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
  36. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
  37. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
  38. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
  39. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
  40. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
  41. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
  42. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
  43. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
  44. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
  45. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
  46. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
  47. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
  48. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
  49. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
  50. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
  51. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
  52. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
  53. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
  54. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
  55. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
  56. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
  57. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
  58. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
  59. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
  60. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
  61. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
  62. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
  63. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
  64. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
  65. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
  66. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
  67. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
  68. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
  69. package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
  70. package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
  71. package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
  72. package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
  73. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
  74. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
  75. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
  76. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
  77. package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
  78. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
  79. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
  80. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
  81. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
  82. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
  83. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
  84. package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
  85. package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
  86. package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
  87. package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
  88. package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
  89. package/mcp_servers/openscad-mcp-server/README.md +154 -0
  90. package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
  91. package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
  92. package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
  93. package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
  94. package/mcp_servers/openscad-mcp-server/glama.json +4 -0
  95. package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
  96. package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
  97. package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
  98. package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
  99. package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
  100. package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
  101. package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
  102. package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
  103. package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
  104. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
  105. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
  106. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
  107. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
  108. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
  109. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
  110. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
  111. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
  112. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
  113. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
  114. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
  115. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
  116. package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
  117. package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
  118. package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
  119. package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
  120. package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
  121. package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
  122. package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
  123. package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
  124. package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
  125. package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
  126. package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
  127. package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
  128. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
  129. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
  130. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
  131. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
  132. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
  133. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
  134. package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
  135. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
  136. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
  137. package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
  138. package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
  139. package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
  140. package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
  141. package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
  142. package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
  143. package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
  144. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
  145. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
  146. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
  147. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
  148. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
  149. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
  150. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
  151. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
  152. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
  153. package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
  154. package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
  155. package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
  156. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
  157. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
  158. package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
  159. package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
  160. package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
  161. package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
  162. package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
  163. package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
  164. package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
  165. package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
  166. package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
  167. package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
  168. package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
  169. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
  170. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
  171. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
  172. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
  173. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
  174. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
  175. package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
  176. package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
  177. package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
  178. package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
  179. package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
  180. package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
  181. package/package.json +44 -0
  182. package/scripts/install_mcps.sh +86 -0
  183. package/scripts/openscad_wrapper.sh +62 -0
  184. package/scripts/run_kicad_mcp.sh +25 -0
  185. package/scripts/run_openscad_mcp.sh +41 -0
  186. package/scripts/test_mcp_connection.py +626 -0
  187. package/scripts/test_mcp_connection.sh +168 -0
  188. package/skills/code-first-hardware-design/SKILL.md +260 -0
  189. package/skills/pcb-constraint-definition/SKILL.md +236 -0
  190. package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
  191. package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
  192. package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
@@ -0,0 +1,225 @@
1
+ """Board visualization export tools.
2
+
3
+ These wrap kicad-cli's rendering and 3D/2D exporters so that MCP clients
4
+ (and the multimodal LLMs behind them) can *see* a design:
5
+
6
+ - render_pcb: 3D-rendered PNG/JPEG of the board (top/bottom/isometric views)
7
+ - export_pcb_3d: STEP model for mechanical CAD handoff
8
+ - export_schematic_svg: per-page schematic SVGs
9
+
10
+ render_pcb returns a FastMCP Image so capable clients display the picture
11
+ inline instead of just a path.
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ from fastmcp.utilities.types import Image
17
+
18
+ from ..server import mcp
19
+ from ..utils.kicad_cli import run_kicad_cli
20
+
21
+ _RENDER_SIDES = {"top", "bottom", "left", "right", "front", "back"}
22
+ _RENDER_QUALITIES = {"basic", "high", "user", "job_settings"}
23
+
24
+
25
+ @mcp.tool()
26
+ async def render_pcb(
27
+ pcb_path: str,
28
+ side: str = "top",
29
+ output_path: str = "",
30
+ width: int = 1600,
31
+ height: int = 900,
32
+ quality: str = "basic",
33
+ rotate: str = "",
34
+ ) -> Image | str:
35
+ """Render a 3D view of the PCB to a PNG image.
36
+
37
+ Produces a photorealistic render of the assembled board via kicad-cli
38
+ (footprints must have 3D models assigned; otherwise plain board geometry
39
+ is rendered). Returns the image itself, so multimodal clients show it
40
+ directly — useful for AI-assisted layout review.
41
+
42
+ Args:
43
+ pcb_path: Path to the .kicad_pcb file.
44
+ side: Camera side: top, bottom, left, right, front, back.
45
+ output_path: Output PNG (default: <pcb>_render_<side>.png next to
46
+ the PCB).
47
+ width: Image width in pixels.
48
+ height: Image height in pixels.
49
+ quality: Render quality: basic (fast) or high (slower, raytraced).
50
+ rotate: Optional board rotation 'X,Y,Z' in degrees for isometric
51
+ views, e.g. '-30,0,25'.
52
+
53
+ Returns:
54
+ The rendered image, or an error message string.
55
+ """
56
+ try:
57
+ pcb = Path(pcb_path)
58
+ if not pcb.exists():
59
+ return f"❌ PCB file not found: {pcb_path}"
60
+ if side not in _RENDER_SIDES:
61
+ return f"❌ Invalid side '{side}'. Options: {', '.join(sorted(_RENDER_SIDES))}"
62
+ if quality not in _RENDER_QUALITIES:
63
+ return f"❌ Invalid quality '{quality}'. Options: {', '.join(sorted(_RENDER_QUALITIES))}"
64
+
65
+ out = Path(output_path) if output_path else pcb.parent / f"{pcb.stem}_render_{side}.png"
66
+ out.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ args = [
69
+ "pcb",
70
+ "render",
71
+ "--output",
72
+ str(out),
73
+ "--side",
74
+ side,
75
+ "--width",
76
+ str(width),
77
+ "--height",
78
+ str(height),
79
+ "--quality",
80
+ quality,
81
+ ]
82
+ if rotate:
83
+ args += ["--rotate", rotate]
84
+ args.append(str(pcb))
85
+
86
+ result = await run_kicad_cli(args, timeout=180)
87
+
88
+ if result.returncode != 0 or not out.exists():
89
+ stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
90
+ return (
91
+ f"❌ PCB render failed (kicad-cli rc={result.returncode}):\n\n"
92
+ f"{stderr or '(no stderr, and no output file produced)'}"
93
+ )
94
+
95
+ return Image(path=str(out))
96
+ except FileNotFoundError:
97
+ return (
98
+ "❌ kicad-cli not found (PATH or KiCad install directory). "
99
+ "Rendering requires KiCad 8+."
100
+ )
101
+ except Exception as e:
102
+ import traceback
103
+
104
+ return f"❌ Error rendering PCB: {e}\n\n{traceback.format_exc()}"
105
+
106
+
107
+ @mcp.tool()
108
+ async def export_pcb_3d(
109
+ pcb_path: str,
110
+ output_path: str = "",
111
+ board_only: bool = False,
112
+ ) -> str:
113
+ """Export the PCB as a STEP 3D model for mechanical CAD handoff.
114
+
115
+ Includes board body, footprints and their assigned 3D models.
116
+
117
+ Args:
118
+ pcb_path: Path to the .kicad_pcb file.
119
+ output_path: Output .step file (default: <pcb>.step next to the PCB).
120
+ board_only: Export only the board body, without component models.
121
+
122
+ Returns:
123
+ Confirmation with the output path and file size.
124
+ """
125
+ try:
126
+ pcb = Path(pcb_path)
127
+ if not pcb.exists():
128
+ return f"❌ PCB file not found: {pcb_path}"
129
+
130
+ out = Path(output_path) if output_path else pcb.parent / f"{pcb.stem}.step"
131
+ out.parent.mkdir(parents=True, exist_ok=True)
132
+
133
+ args = ["pcb", "export", "step", "--output", str(out), "--force", str(pcb)]
134
+ if board_only:
135
+ args.insert(-1, "--board-only")
136
+
137
+ result = await run_kicad_cli(args, timeout=300)
138
+
139
+ if result.returncode != 0 or not out.exists():
140
+ stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
141
+ return (
142
+ f"❌ STEP export failed (kicad-cli rc={result.returncode}):\n\n"
143
+ f"{stderr or '(no stderr, and no output file produced)'}"
144
+ )
145
+
146
+ size_mb = out.stat().st_size / (1024 * 1024)
147
+ return f"""✅ STEP 3D model exported.
148
+
149
+ **PCB:** {pcb_path}
150
+ **Output:** {out}
151
+ **Size:** {size_mb:.1f} MB
152
+
153
+ Import into Fusion 360 / SolidWorks / FreeCAD for enclosure design."""
154
+ except FileNotFoundError:
155
+ return (
156
+ "❌ kicad-cli not found (PATH or KiCad install directory). "
157
+ "STEP export requires KiCad 7+."
158
+ )
159
+ except Exception as e:
160
+ import traceback
161
+
162
+ return f"❌ Error exporting STEP: {e}\n\n{traceback.format_exc()}"
163
+
164
+
165
+ @mcp.tool()
166
+ async def export_schematic_svg(
167
+ schematic_path: str,
168
+ output_dir: str = "",
169
+ ) -> str:
170
+ """Export schematic pages to SVG images.
171
+
172
+ Renders every page of the (hierarchical) schematic as an SVG file via
173
+ kicad-cli. SVGs are vector graphics — clients and humans can zoom into
174
+ any detail without quality loss.
175
+
176
+ Args:
177
+ schematic_path: Path to the root .kicad_sch file.
178
+ output_dir: Output directory (default: <schematic>_svg/ next to it).
179
+
180
+ Returns:
181
+ Confirmation listing the generated SVG files.
182
+ """
183
+ try:
184
+ sch = Path(schematic_path)
185
+ if not sch.exists():
186
+ return f"❌ Schematic file not found: {schematic_path}"
187
+
188
+ out_dir = Path(output_dir) if output_dir else sch.parent / f"{sch.stem}_svg"
189
+ out_dir.mkdir(parents=True, exist_ok=True)
190
+
191
+ result = await run_kicad_cli(
192
+ ["sch", "export", "svg", "--output", str(out_dir), str(sch)],
193
+ timeout=120,
194
+ )
195
+
196
+ if result.returncode != 0:
197
+ stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
198
+ return (
199
+ f"❌ Schematic SVG export failed (kicad-cli rc={result.returncode}):\n\n"
200
+ f"{stderr or '(no stderr)'}"
201
+ )
202
+
203
+ svgs = sorted(out_dir.glob("*.svg"))
204
+ if not svgs:
205
+ return (
206
+ f"⚠️ kicad-cli reported success but produced no SVG files in {out_dir}."
207
+ )
208
+
209
+ listing = "\n".join(f"- {s.name}" for s in svgs)
210
+ return f"""✅ Schematic SVG export complete.
211
+
212
+ **Schematic:** {schematic_path}
213
+ **Output directory:** {out_dir}
214
+
215
+ **Files generated ({len(svgs)}):**
216
+ {listing}"""
217
+ except FileNotFoundError:
218
+ return (
219
+ "❌ kicad-cli not found (PATH or KiCad install directory). "
220
+ "SVG export requires KiCad 7+."
221
+ )
222
+ except Exception as e:
223
+ import traceback
224
+
225
+ return f"❌ Error exporting schematic SVG: {e}\n\n{traceback.format_exc()}"
@@ -0,0 +1 @@
1
+ """Utility functions for KiCad MCP Server."""
@@ -0,0 +1,65 @@
1
+ """File handling utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def validate_kicad_file(file_path: str, expected_extension: str) -> Path:
7
+ """Validate that a file exists and has the correct extension.
8
+
9
+ Args:
10
+ file_path: Path to the file
11
+ expected_extension: Expected file extension (e.g., '.kicad_sch')
12
+
13
+ Returns:
14
+ Resolved Path object
15
+
16
+ Raises:
17
+ FileNotFoundError: If file doesn't exist
18
+ ValueError: If file has wrong extension
19
+ """
20
+ path = Path(file_path).resolve()
21
+
22
+ if not path.exists():
23
+ raise FileNotFoundError(f"File not found: {file_path}")
24
+
25
+ if path.suffix.lower() != expected_extension.lower():
26
+ raise ValueError(
27
+ f"Expected {expected_extension} file, got {path.suffix}: {file_path}"
28
+ )
29
+
30
+ return path
31
+
32
+
33
+ def resolve_project_path(file_path: str, search_paths: list[str] | None = None) -> Path:
34
+ """Resolve a file path, optionally searching project directories.
35
+
36
+ Args:
37
+ file_path: Relative or absolute file path
38
+ search_paths: List of directories to search if file_path is relative
39
+
40
+ Returns:
41
+ Resolved Path object
42
+
43
+ Raises:
44
+ FileNotFoundError: If file cannot be found
45
+ """
46
+ path = Path(file_path)
47
+
48
+ # If absolute path, just resolve it
49
+ if path.is_absolute():
50
+ if path.exists():
51
+ return path.resolve()
52
+ raise FileNotFoundError(f"File not found: {file_path}")
53
+
54
+ # If relative path, try current directory first
55
+ if path.exists():
56
+ return path.resolve()
57
+
58
+ # Search in provided paths
59
+ if search_paths:
60
+ for search_dir in search_paths:
61
+ search_path = Path(search_dir) / path
62
+ if search_path.exists():
63
+ return search_path.resolve()
64
+
65
+ raise FileNotFoundError(f"File not found: {file_path}")
@@ -0,0 +1,103 @@
1
+ """kicad-cli invocation helpers.
2
+
3
+ Centralizes two concerns that every kicad-cli call site needs:
4
+
5
+ 1. Locating the executable. On Windows, KiCad's installer does NOT add its
6
+ bin directory to PATH, so a bare "kicad-cli" only works if the user
7
+ patched PATH by hand. We fall back to the detected install location
8
+ (``find_kicad_install``), where kicad-cli sits next to the versioned
9
+ ``share/kicad`` directory.
10
+ 2. Running it off the asyncio event loop. All MCP tools are ``async def``;
11
+ calling ``subprocess.run`` inline blocks the whole server for the duration
12
+ of an ERC/DRC/export (up to minutes), so callers must use the async
13
+ ``run_kicad_cli`` wrapper instead.
14
+
15
+ The sync ``run_kicad_cli_sync`` variant exists for tests and other
16
+ non-async contexts.
17
+ """
18
+
19
+ import asyncio
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ from pathlib import Path
24
+
25
+ from .kicad_version import find_kicad_install
26
+
27
+ _cached_exe: str | None = None
28
+
29
+
30
+ def find_kicad_cli() -> str | None:
31
+ """Locate the kicad-cli executable.
32
+
33
+ Resolution order:
34
+ 1. ``KICAD_CLI`` environment variable (explicit user override).
35
+ 2. ``shutil.which("kicad-cli")`` (PATH lookup — default on Linux/macOS
36
+ and on Windows installs whose bin dir was added to PATH).
37
+ 3. Known KiCad install locations via ``find_kicad_install()``.
38
+
39
+ Returns the executable path, or None when KiCad cannot be found.
40
+ """
41
+ global _cached_exe
42
+ if _cached_exe is not None:
43
+ return _cached_exe
44
+
45
+ override = os.environ.get("KICAD_CLI")
46
+ if override and Path(override).is_file():
47
+ _cached_exe = override
48
+ return _cached_exe
49
+
50
+ found = shutil.which("kicad-cli")
51
+ if found:
52
+ _cached_exe = found
53
+ return _cached_exe
54
+
55
+ install = find_kicad_install()
56
+ if install:
57
+ install_path, _version = install
58
+ exe_name = "kicad-cli.exe" if os.name == "nt" else "kicad-cli"
59
+ # Windows: <install>/bin/kicad-cli.exe
60
+ # macOS: install_path is .../Contents/SharedSupport, the binary is
61
+ # in the sibling MacOS directory.
62
+ # Linux: distro packages put kicad-cli on PATH, which step 2 found.
63
+ candidates = [
64
+ install_path / "bin" / exe_name,
65
+ install_path.parent / "MacOS" / "kicad-cli",
66
+ ]
67
+ for candidate in candidates:
68
+ if candidate.is_file():
69
+ _cached_exe = str(candidate)
70
+ return _cached_exe
71
+
72
+ return None
73
+
74
+
75
+ def run_kicad_cli_sync(
76
+ args: list[str],
77
+ timeout: float = 60.0,
78
+ ) -> subprocess.CompletedProcess:
79
+ """Run kicad-cli synchronously (blocking).
80
+
81
+ Raises FileNotFoundError when kicad-cli cannot be located, so callers can
82
+ distinguish "KiCad not installed" from a failed run.
83
+ """
84
+ exe = find_kicad_cli()
85
+ if exe is None:
86
+ raise FileNotFoundError(
87
+ "kicad-cli not found in PATH or known KiCad install locations. "
88
+ "Install KiCad 7+ or set the KICAD_CLI environment variable."
89
+ )
90
+ return subprocess.run([exe] + args, capture_output=True, timeout=timeout)
91
+
92
+
93
+ async def run_kicad_cli(
94
+ args: list[str],
95
+ timeout: float = 60.0,
96
+ ) -> subprocess.CompletedProcess:
97
+ """Run kicad-cli off the event loop (non-blocking for the server).
98
+
99
+ Raises FileNotFoundError when kicad-cli cannot be located — same contract
100
+ as the sync variant, so existing ``except FileNotFoundError`` handlers
101
+ keep working.
102
+ """
103
+ return await asyncio.to_thread(run_kicad_cli_sync, args, timeout)
@@ -0,0 +1,103 @@
1
+ """KiCad installation detection and version utilities."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ _cached_install: tuple[Path, str] | None = None
7
+
8
+
9
+ def _parse_version(version_str: str) -> tuple[int, ...]:
10
+ """Parse version string like '10.0' into tuple for sorting."""
11
+ try:
12
+ return tuple(int(x) for x in version_str.split("."))
13
+ except (ValueError, AttributeError):
14
+ return (0,)
15
+
16
+
17
+ def find_kicad_install() -> tuple[Path, str] | None:
18
+ """Find the installed KiCad directory and version string.
19
+
20
+ Scans known install locations, preferring the newest version.
21
+
22
+ Returns:
23
+ (install_path, version_string) or None if not found
24
+ """
25
+ global _cached_install
26
+ if _cached_install is not None:
27
+ return _cached_install
28
+
29
+ # Windows: C:/Program Files/KiCad/{version}/
30
+ win_base = Path("C:/Program Files/KiCad")
31
+ if win_base.is_dir():
32
+ try:
33
+ version_dirs = sorted(
34
+ [d for d in win_base.iterdir() if d.is_dir()],
35
+ key=lambda d: _parse_version(d.name),
36
+ reverse=True,
37
+ )
38
+ for vd in version_dirs:
39
+ if (vd / "share" / "kicad").is_dir():
40
+ _cached_install = (vd, vd.name)
41
+ return _cached_install
42
+ except OSError:
43
+ pass
44
+
45
+ # macOS: single app bundle
46
+ mac_base = Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport")
47
+ if mac_base.is_dir():
48
+ _cached_install = (mac_base, "macos")
49
+ return _cached_install
50
+
51
+ # Linux: standard paths
52
+ for linux_base in [
53
+ Path("/usr/share/kicad"),
54
+ Path("/usr/local/share/kicad"),
55
+ ]:
56
+ if linux_base.is_dir():
57
+ _cached_install = (linux_base, "linux")
58
+ return _cached_install
59
+
60
+ return None
61
+
62
+
63
+ def get_kicad_symbol_dir() -> Path | None:
64
+ """Get the KiCad symbol library directory (.kicad_sym files)."""
65
+ kicad = find_kicad_install()
66
+ if kicad:
67
+ install_path, version_marker = kicad
68
+ # find_kicad_install() returns different path shapes per platform:
69
+ # Windows returns the top-level "<...>/KiCad/<version>" dir, with
70
+ # symbols nested under "share/kicad/symbols". Linux/macOS instead
71
+ # already return the "share/kicad" (or SharedSupport) directory
72
+ # itself, so appending "share/kicad" again pointed at a path that
73
+ # never exists there.
74
+ if version_marker in ("linux", "macos"):
75
+ sym_dir = install_path / "symbols"
76
+ else:
77
+ sym_dir = install_path / "share" / "kicad" / "symbols"
78
+ if sym_dir.is_dir():
79
+ return sym_dir
80
+ return None
81
+
82
+
83
+ def get_pcb_version() -> str:
84
+ """Get the correct PCB file version string for the installed KiCad.
85
+
86
+ Reads from a template PCB file if available, falls back to '20240130'.
87
+ """
88
+ kicad = find_kicad_install()
89
+ if kicad:
90
+ install_path, _version = kicad
91
+ for template_name in ["Arduino_Mega", "EuroCard160mmX100mm"]:
92
+ template_pcb = (
93
+ install_path / "share" / "kicad" / "template" / template_name / f"{template_name}.kicad_pcb"
94
+ )
95
+ if template_pcb.exists():
96
+ try:
97
+ content = template_pcb.read_text(encoding="utf-8", errors="replace")
98
+ m = re.search(r"\(kicad_pcb\s+\(version\s+(\d+)\)", content)
99
+ if m:
100
+ return m.group(1)
101
+ except OSError:
102
+ pass
103
+ return "20240130"
@@ -0,0 +1,197 @@
1
+ """
2
+ Client for an open parts registry (default: PartReel, https://partreel.com).
3
+
4
+ Lets tools search a public registry of verified KiCad parts and download
5
+ symbol/footprint/3D files without any account or API key. The registry URL is
6
+ configurable, so any service exposing the same JSON shape works.
7
+
8
+ Security posture (all enforced here, not in the tools):
9
+ - Asset downloads are restricted to HTTPS URLs on the registry's own host,
10
+ its subdomains, or hosts explicitly allowed via PARTS_REGISTRY_ASSET_HOSTS.
11
+ - Saved filenames are derived from the remote basename but must match the
12
+ extension allow-list for the requested format (prevents e.g. ``.exe``).
13
+ - Downloads are size-capped (MAX_ASSET_BYTES) and streamed to disk.
14
+ - Destination directories are validated by the caller via PathValidator.
15
+ """
16
+
17
+ import fnmatch
18
+ import json
19
+ import os
20
+ import urllib.parse
21
+ import urllib.request
22
+
23
+ DEFAULT_REGISTRY_URL = os.environ.get(
24
+ "PARTS_REGISTRY_URL", "https://partreel.com/api/v1"
25
+ )
26
+ USER_AGENT = "kicad-mcp-parts-registry"
27
+ MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB cap for any single asset download
28
+ HTTP_TIMEOUT = 30 # seconds
29
+
30
+ # format name -> allowed file extensions (lowercase)
31
+ FORMAT_EXTENSIONS = {
32
+ "footprint": (".kicad_mod",),
33
+ "symbol": (".kicad_sym",),
34
+ "step": (".step", ".stp"),
35
+ "preview": (".glb",),
36
+ "footprint_svg": (".svg",),
37
+ "symbol_svg": (".svg",),
38
+ }
39
+
40
+
41
+ class RegistryError(Exception):
42
+ """Raised for registry access or validation failures."""
43
+
44
+
45
+ class _NoCrossHostRedirect(urllib.request.HTTPRedirectHandler):
46
+ """Block HTTP redirects to a different host.
47
+
48
+ ``asset_url_allowed`` only checks the URL passed in; urllib otherwise
49
+ silently follows 3xx to any host, letting a compromised registry point
50
+ asset downloads (or JSON metadata) at an attacker-controlled host.
51
+ """
52
+
53
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
54
+ orig = (urllib.parse.urlparse(req.full_url).hostname or "").lower()
55
+ new = (urllib.parse.urlparse(newurl).hostname or "").lower()
56
+ if orig and new and (new == orig or new.endswith("." + orig)):
57
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
58
+ raise RegistryError(f"redirect to disallowed host blocked: {newurl}")
59
+
60
+
61
+ _DEFAULT_OPENER = urllib.request.build_opener(_NoCrossHostRedirect)
62
+
63
+
64
+ def _fetch(url: str, opener=None) -> bytes:
65
+ """GET a URL with a size cap. ``opener`` is injectable for tests."""
66
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
67
+ open_fn = opener or _DEFAULT_OPENER.open
68
+ with open_fn(req, timeout=HTTP_TIMEOUT) as resp:
69
+ declared = resp.headers.get("Content-Length")
70
+ data = resp.read(MAX_ASSET_BYTES + 1)
71
+ # Honour Content-Length so a truncated stream (proxy/server drops the
72
+ # connection mid-asset) is caught, not silently written as a half-file.
73
+ if declared and declared.isdigit() and int(declared) > MAX_ASSET_BYTES:
74
+ raise RegistryError(f"asset exceeds {MAX_ASSET_BYTES} byte limit: {url}")
75
+ if len(data) > MAX_ASSET_BYTES:
76
+ raise RegistryError(f"asset exceeds {MAX_ASSET_BYTES} byte limit: {url}")
77
+ return data
78
+
79
+
80
+ def registry_host(registry_url: str = DEFAULT_REGISTRY_URL) -> str:
81
+ """Hostname of the configured registry."""
82
+ return urllib.parse.urlparse(registry_url).hostname or ""
83
+
84
+
85
+ def extra_asset_hosts() -> list[str]:
86
+ """Extra allowed asset hosts from PARTS_REGISTRY_ASSET_HOSTS (comma list)."""
87
+ raw = os.environ.get("PARTS_REGISTRY_ASSET_HOSTS", "")
88
+ return [h.strip().lower() for h in raw.split(",") if h.strip()]
89
+
90
+
91
+ def asset_url_allowed(url: str, registry_url: str = DEFAULT_REGISTRY_URL) -> bool:
92
+ """True if ``url`` is HTTPS on the registry host, a subdomain of it, or an
93
+ explicitly allowed extra host."""
94
+ parsed = urllib.parse.urlparse(url)
95
+ if parsed.scheme != "https" or not parsed.hostname:
96
+ return False
97
+ host = parsed.hostname.lower()
98
+ root = registry_host(registry_url).lower()
99
+ if not root:
100
+ return False
101
+ if host == root or host.endswith("." + root):
102
+ return True
103
+ return host in extra_asset_hosts()
104
+
105
+
106
+ def filename_for_asset(url: str, part_id: str, fmt: str) -> str:
107
+ """Safe local filename for an asset: the remote basename when its extension
108
+ matches the format's allow-list, else ``<part_id>.<default ext>``."""
109
+ exts = FORMAT_EXTENSIONS.get(fmt)
110
+ if not exts:
111
+ raise RegistryError(f"unknown format: {fmt}")
112
+ remote = os.path.basename(urllib.parse.urlparse(url).path)
113
+ # basename() strips directories; also reject any residual traversal chars
114
+ if remote and ".." not in remote and remote.lower().endswith(exts):
115
+ return remote
116
+ return f"{part_id}{exts[0]}"
117
+
118
+
119
+ # Module-level index cache keyed by registry URL. Previously the cache lived on
120
+ # each RegistryClient instance, but the tools build a fresh client per call, so
121
+ # the ~21k-entry index was re-downloaded on every search.
122
+ _INDEX_CACHE: dict[str, list[dict]] = {}
123
+
124
+
125
+ class RegistryClient:
126
+ """Minimal client for the registry's static JSON API.
127
+
128
+ The full parts index is fetched once and cached at module level (keyed by
129
+ registry URL); the tools construct a fresh client per call.
130
+ """
131
+
132
+ def __init__(self, registry_url: str = DEFAULT_REGISTRY_URL, opener=None):
133
+ self.registry_url = registry_url.rstrip("/")
134
+ self._opener = opener
135
+
136
+ def _get_json(self, url: str):
137
+ try:
138
+ return json.loads(_fetch(url, self._opener).decode("utf-8"))
139
+ except RegistryError:
140
+ raise
141
+ except Exception as exc: # URLError, HTTPError, JSONDecodeError, ...
142
+ raise RegistryError(f"registry request failed: {exc}") from exc
143
+
144
+ def index(self) -> list[dict]:
145
+ """Full parts index (cached at module level)."""
146
+ if self.registry_url not in _INDEX_CACHE:
147
+ doc = self._get_json(f"{self.registry_url}/parts.json")
148
+ if isinstance(doc, list):
149
+ parts = doc
150
+ elif isinstance(doc, dict):
151
+ raw = doc.get("parts") or []
152
+ parts = raw if isinstance(raw, list) else []
153
+ else:
154
+ parts = [] # null / string / number — don't crash
155
+ _INDEX_CACHE[self.registry_url] = parts
156
+ return _INDEX_CACHE[self.registry_url]
157
+
158
+ def search(self, query: str, limit: int = 20) -> list[dict]:
159
+ """Case-insensitive AND-match of query tokens against id, name,
160
+ family, category, manufacturer and keywords. ``*`` wildcards work."""
161
+ tokens = [t.lower() for t in query.split() if t.strip()]
162
+ if not tokens:
163
+ return []
164
+ hits = []
165
+ for part in self.index():
166
+ if not isinstance(part, dict):
167
+ continue
168
+ haystack = " ".join(
169
+ str(part.get(k, ""))
170
+ for k in ("id", "name", "family", "category", "manufacturer")
171
+ ).lower()
172
+ haystack += " " + " ".join(part.get("keywords") or []).lower()
173
+ if all(
174
+ tok in haystack or fnmatch.fnmatch(haystack, f"*{tok}*")
175
+ for tok in tokens
176
+ ):
177
+ hits.append(part)
178
+ if len(hits) >= limit:
179
+ break
180
+ return hits
181
+
182
+ def get_part(self, part_id: str) -> dict:
183
+ """Full record for one part (includes download URLs in ``files``)."""
184
+ if not part_id or not all(c.isalnum() or c in "_-" for c in part_id):
185
+ raise RegistryError(f"invalid part id: {part_id}")
186
+ return self._get_json(f"{self.registry_url}/parts/{part_id}.json")
187
+
188
+ def download_asset(self, url: str, dest_dir: str, part_id: str, fmt: str) -> str:
189
+ """Download one asset into ``dest_dir`` (must already be validated by
190
+ the caller). Returns the written file path."""
191
+ if not asset_url_allowed(url, self.registry_url):
192
+ raise RegistryError(f"asset host not allowed: {url}")
193
+ data = _fetch(url, self._opener)
194
+ path = os.path.join(dest_dir, filename_for_asset(url, part_id, fmt))
195
+ with open(path, "wb") as f:
196
+ f.write(data)
197
+ return path