feat: extend KiCad MCP server capabilities

This commit is contained in:
Clément SAILLANT
2026-03-08 03:17:21 +01:00
parent 5f3bf952ef
commit 8d2f46716a
14 changed files with 2316 additions and 158 deletions
+119 -16
View File
@@ -4,15 +4,18 @@ Board view command implementations for KiCAD interface
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
from PIL import Image
import io
import base64
import logging
from typing import Dict, Any, Optional, List, Tuple
from PIL import Image
import io
import base64
import tempfile
import subprocess
import shutil
logger = logging.getLogger('kicad_interface')
class BoardViewCommands:
class BoardViewCommands:
"""Handles board viewing operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -166,16 +169,16 @@ class BoardViewCommands:
"errorDetails": str(e)
}
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
@@ -230,3 +233,103 @@ class BoardViewCommands:
"message": "Failed to get board extents",
"errorDetails": str(e)
}
def _find_kicad_cli(self) -> Optional[str]:
return shutil.which("kicad-cli")
def get_board_3d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Render a 3D board preview using kicad-cli."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
board_file = self.board.GetFileName()
if not board_file or not os.path.exists(board_file):
return {
"success": False,
"message": "Board file not found",
"errorDetails": "Save the board before requesting a 3D preview",
}
kicad_cli = self._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "KiCad CLI is required for 3D renders",
}
angle = str(params.get("angle", "isometric")).lower()
width = int(params.get("width", 1600))
height = int(params.get("height", 900))
render_args = []
if angle == "isometric":
render_args.extend(["--side", "top", "--rotate", "-45,0,45", "--perspective"])
elif angle in {"top", "bottom", "left", "right", "front", "back"}:
render_args.extend(["--side", angle])
else:
return {
"success": False,
"message": "Unsupported angle",
"errorDetails": f"Angle '{angle}' is not supported",
}
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
output_path = tmp.name
try:
cmd = [
kicad_cli,
"pcb",
"render",
"--output",
output_path,
"--width",
str(width),
"--height",
str(height),
"--background",
"transparent",
"--quality",
"high",
*render_args,
board_file,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=180,
)
if result.returncode != 0:
return {
"success": False,
"message": "3D render failed",
"errorDetails": result.stderr.strip() or result.stdout.strip(),
}
with open(output_path, "rb") as f:
image_bytes = f.read()
return {
"success": True,
"imageData": base64.b64encode(image_bytes).decode("utf-8"),
"format": "png",
"angle": angle,
}
finally:
if os.path.exists(output_path):
os.remove(output_path)
except Exception as e:
logger.error(f"Error getting board 3D view: {str(e)}")
return {
"success": False,
"message": "Failed to get board 3D view",
"errorDetails": str(e),
}
+506 -12
View File
@@ -4,15 +4,17 @@ Component-related command implementations for KiCAD interface
import os
import pcbnew
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import base64
from commands.library import LibraryManager
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import base64
import io
from commands.library import LibraryManager
from PIL import Image, ImageDraw
logger = logging.getLogger('kicad_interface')
class ComponentCommands:
class ComponentCommands:
"""Handles component-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
@@ -914,7 +916,7 @@ class ComponentCommands:
"errorDetails": str(e)
}
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Duplicate an existing component"""
try:
if not self.board:
@@ -1010,11 +1012,503 @@ class ComponentCommands:
except Exception as e:
logger.error(f"Error duplicating component: {str(e)}")
return {
"success": False,
"message": "Failed to duplicate component",
"errorDetails": str(e)
}
return {
"success": False,
"message": "Failed to duplicate component",
"errorDetails": str(e)
}
def add_component_annotation(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a text annotation close to a component footprint."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
annotation = params.get("annotation")
visible = params.get("visible", True)
if not reference or not annotation:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and annotation are required",
}
module = self.board.FindFootprintByReference(reference)
if not module:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}",
}
bbox = module.GetBoundingBox()
layer_name = "B.SilkS" if self.board.GetLayerName(module.GetLayer()).startswith("B.") else "F.SilkS"
layer_id = self.board.GetLayerID(layer_name)
text_item = pcbnew.PCB_TEXT(self.board)
text_item.SetText(annotation)
text_item.SetLayer(layer_id)
text_item.SetPosition(
pcbnew.VECTOR2I(
bbox.GetRight() + 800000,
bbox.GetTop() - 800000,
)
)
text_item.SetTextSize(pcbnew.VECTOR2I(1000000, 1000000))
text_item.SetTextThickness(150000)
if hasattr(text_item, "SetVisible"):
text_item.SetVisible(bool(visible))
self.board.Add(text_item)
return {
"success": True,
"message": f"Added annotation to {reference}",
"annotation": {
"reference": reference,
"text": annotation,
"layer": layer_name,
"visible": bool(visible),
},
}
except Exception as e:
logger.error(f"Error adding component annotation: {str(e)}")
return {
"success": False,
"message": "Failed to add component annotation",
"errorDetails": str(e),
}
def group_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create or extend a real KiCad PCB group with the selected components."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
references = params.get("references", [])
group_name = params.get("groupName")
if not references or not group_name:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "references and groupName are required",
}
modules = []
missing = []
for reference in references:
module = self.board.FindFootprintByReference(reference)
if not module:
missing.append(reference)
continue
modules.append(module)
if not modules:
return {
"success": False,
"message": "No valid components found",
"errorDetails": f"Missing references: {', '.join(missing)}",
}
group = None
if hasattr(self.board, "Groups"):
for existing in self.board.Groups():
name = existing.GetName() if hasattr(existing, "GetName") else ""
if name == group_name:
group = existing
break
if group is None:
group = pcbnew.PCB_GROUP(self.board)
if hasattr(group, "SetName"):
group.SetName(group_name)
self.board.Add(group)
for module in modules:
group.AddItem(module)
if hasattr(module, "SetParentGroup"):
module.SetParentGroup(group)
return {
"success": True,
"message": f"Grouped {len(modules)} component(s) into {group_name}",
"group": {
"name": group_name,
"references": [module.GetReference() for module in modules],
"missing": missing,
},
}
except Exception as e:
logger.error(f"Error grouping components: {str(e)}")
return {
"success": False,
"message": "Failed to group components",
"errorDetails": str(e),
}
def replace_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Replace a component footprint while preserving placement and reference."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
new_component_id = params.get("newComponentId")
new_footprint = params.get("newFootprint")
new_value = params.get("newValue")
if not reference or not new_component_id:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and newComponentId are required",
}
module = self.board.FindFootprintByReference(reference)
if not module:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}",
}
pos = module.GetPosition()
layer = self.board.GetLayerName(module.GetLayer())
rotation = module.GetOrientation().AsDegrees()
value = new_value or module.GetValue()
temp_reference = f"{reference}_TMP"
replacement_spec = new_footprint or new_component_id
place_result = self.place_component(
{
"componentId": new_component_id,
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm",
},
"reference": temp_reference,
"value": value,
"footprint": replacement_spec,
"rotation": rotation,
"layer": layer,
}
)
if not place_result.get("success"):
return place_result
delete_result = self.delete_component({"reference": reference})
if not delete_result.get("success"):
return delete_result
rename_result = self.edit_component(
{
"reference": temp_reference,
"newReference": reference,
"value": value,
"footprint": replacement_spec,
}
)
if not rename_result.get("success"):
return rename_result
return {
"success": True,
"message": f"Replaced component {reference}",
"component": {
"reference": reference,
"newComponentId": new_component_id,
"footprint": replacement_spec,
"value": value,
},
}
except Exception as e:
logger.error(f"Error replacing component: {str(e)}")
return {
"success": False,
"message": "Failed to replace component",
"errorDetails": str(e),
}
def get_component_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get pad and net connectivity for a component."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
if not reference:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required",
}
module = self.board.FindFootprintByReference(reference)
if not module:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}",
}
nets = {}
pads = []
for pad in module.Pads():
net_name = pad.GetNetname() or ""
pad_entry = {
"pad": pad.GetNumber(),
"net": net_name,
"position": {
"x": pad.GetPosition().x / 1000000,
"y": pad.GetPosition().y / 1000000,
"unit": "mm",
},
}
pads.append(pad_entry)
if net_name:
nets.setdefault(net_name, []).append(pad.GetNumber())
related_components = {}
for other in self.board.GetFootprints():
other_ref = other.GetReference()
if other_ref == reference:
continue
for pad in other.Pads():
net_name = pad.GetNetname() or ""
if net_name and net_name in nets:
related_components.setdefault(net_name, set()).add(other_ref)
return {
"success": True,
"reference": reference,
"pads": pads,
"connections": [
{
"net": net_name,
"pads": pad_numbers,
"connectedReferences": sorted(related_components.get(net_name, set())),
}
for net_name, pad_numbers in sorted(nets.items())
],
}
except Exception as e:
logger.error(f"Error getting component connections: {str(e)}")
return {
"success": False,
"message": "Failed to get component connections",
"errorDetails": str(e),
}
def get_component_placement(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get placement data for all components on the board."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
placements = []
for module in self.board.GetFootprints():
bbox = module.GetBoundingBox()
placements.append(
{
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"layer": self.board.GetLayerName(module.GetLayer()),
"position": {
"x": module.GetPosition().x / 1000000,
"y": module.GetPosition().y / 1000000,
"unit": "mm",
},
"rotation": module.GetOrientation().AsDegrees(),
"bounds": {
"left": bbox.GetLeft() / 1000000,
"top": bbox.GetTop() / 1000000,
"right": bbox.GetRight() / 1000000,
"bottom": bbox.GetBottom() / 1000000,
"unit": "mm",
},
}
)
return {
"success": True,
"placements": placements,
"count": len(placements),
}
except Exception as e:
logger.error(f"Error getting component placement: {str(e)}")
return {
"success": False,
"message": "Failed to get component placement",
"errorDetails": str(e),
}
def get_component_groups(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get explicit KiCad groups, or derived prefix groups when none exist."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
grouped = {}
derived = {}
for module in self.board.GetFootprints():
reference = module.GetReference()
parent_group = module.GetParentGroup() if hasattr(module, "GetParentGroup") else None
if parent_group:
name = ""
if hasattr(parent_group, "GetName"):
name = parent_group.GetName()
if not name and hasattr(parent_group, "GetFriendlyName"):
name = parent_group.GetFriendlyName()
name = name or "unnamed-group"
grouped.setdefault(name, {"name": name, "source": "board", "references": []})
grouped[name]["references"].append(reference)
else:
prefix = "".join(ch for ch in reference if ch.isalpha()) or "misc"
derived.setdefault(prefix, {"name": prefix, "source": "derived-prefix", "references": []})
derived[prefix]["references"].append(reference)
groups = list(grouped.values()) if grouped else list(derived.values())
for group in groups:
group["references"].sort()
group["count"] = len(group["references"])
return {
"success": True,
"groups": sorted(groups, key=lambda group: group["name"]),
"count": len(groups),
}
except Exception as e:
logger.error(f"Error getting component groups: {str(e)}")
return {
"success": False,
"message": "Failed to get component groups",
"errorDetails": str(e),
}
def get_component_visualization(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Render a lightweight top-down PNG preview for a component footprint."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
if not reference:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required",
}
module = self.board.FindFootprintByReference(reference)
if not module:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}",
}
bbox = module.GetBoundingBox()
margin_nm = 500000
min_x = bbox.GetLeft() - margin_nm
min_y = bbox.GetTop() - margin_nm
max_x = bbox.GetRight() + margin_nm
max_y = bbox.GetBottom() + margin_nm
width_nm = max(max_x - min_x, 1000000)
height_nm = max(max_y - min_y, 1000000)
canvas_size = 320
padding = 20
scale = min(
(canvas_size - 2 * padding) / width_nm,
(canvas_size - 2 * padding) / height_nm,
)
def to_px(x_nm: int, y_nm: int) -> Tuple[int, int]:
x = padding + int((x_nm - min_x) * scale)
y = padding + int((y_nm - min_y) * scale)
return x, y
image = Image.new("RGBA", (canvas_size, canvas_size), (255, 255, 255, 0))
draw = ImageDraw.Draw(image)
body_left, body_top = to_px(bbox.GetLeft(), bbox.GetTop())
body_right, body_bottom = to_px(bbox.GetRight(), bbox.GetBottom())
draw.rounded_rectangle(
[body_left, body_top, body_right, body_bottom],
radius=10,
outline=(36, 74, 124, 255),
width=3,
fill=(230, 238, 250, 180),
)
for pad in module.Pads():
pad_box = pad.GetBoundingBox()
x0, y0 = to_px(pad_box.GetLeft(), pad_box.GetTop())
x1, y1 = to_px(pad_box.GetRight(), pad_box.GetBottom())
shape = pad.GetShape()
circle_shapes = {
getattr(pcbnew, "PAD_SHAPE_CIRCLE", object()),
getattr(pcbnew, "PAD_SHAPE_OVAL", object()),
}
if shape in circle_shapes:
draw.ellipse([x0, y0, x1, y1], fill=(201, 96, 74, 255), outline=(122, 41, 27, 255))
else:
draw.rounded_rectangle(
[x0, y0, x1, y1],
radius=4,
fill=(201, 96, 74, 255),
outline=(122, 41, 27, 255),
)
draw.text((16, 12), reference, fill=(22, 22, 22, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return {
"success": True,
"imageData": base64.b64encode(buffer.getvalue()).decode("utf-8"),
"format": "png",
"reference": reference,
}
except Exception as e:
logger.error(f"Error getting component visualization: {str(e)}")
return {
"success": False,
"message": "Failed to get component visualization",
"errorDetails": str(e),
}
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any],
rows: int, columns: int, spacing_x: float, spacing_y: float,
+193 -11
View File
@@ -2,15 +2,16 @@
Design rules command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import json
logger = logging.getLogger("kicad_interface")
class DesignRuleCommands:
class DesignRuleCommands:
"""Handles design rule checking and configuration"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -410,7 +411,7 @@ class DesignRuleCommands:
return None
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Get list of DRC violations
@@ -467,8 +468,189 @@ class DesignRuleCommands:
except Exception as e:
logger.error(f"Error getting DRC violations: {str(e)}")
return {
"success": False,
"message": "Failed to get DRC violations",
"errorDetails": str(e),
}
return {
"success": False,
"message": "Failed to get DRC violations",
"errorDetails": str(e),
}
def _get_project_file(self) -> Optional[str]:
if not self.board:
return None
board_file = self.board.GetFileName()
if not board_file:
return None
return os.path.splitext(board_file)[0] + ".kicad_pro"
def assign_net_to_class(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Assign an existing net to an existing net class."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
net_name = params.get("net")
net_class_name = params.get("netClass")
if not net_name or not net_class_name:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "net and netClass are required",
}
net_classes = self.board.GetNetClasses()
net_class = net_classes.Find(net_class_name)
if not net_class:
return {
"success": False,
"message": "Net class not found",
"errorDetails": f"Net class '{net_class_name}' does not exist",
}
nets_map = self.board.GetNetInfo().NetsByName()
if not nets_map.has_key(net_name):
return {
"success": False,
"message": "Net not found",
"errorDetails": f"Net '{net_name}' does not exist",
}
net = nets_map[net_name]
net.SetClass(net_class)
return {
"success": True,
"message": f"Assigned net {net_name} to class {net_class_name}",
"assignment": {"net": net_name, "netClass": net_class_name},
}
except Exception as e:
logger.error(f"Error assigning net to class: {str(e)}")
return {
"success": False,
"message": "Failed to assign net to class",
"errorDetails": str(e),
}
def set_layer_constraints(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Persist layer-specific constraints in the project file under kicad_mcp."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
if not layer:
return {
"success": False,
"message": "Missing layer parameter",
"errorDetails": "layer is required",
}
project_file = self._get_project_file()
if not project_file:
return {
"success": False,
"message": "Project file unavailable",
"errorDetails": "Save the board before setting layer constraints",
}
payload = {}
if os.path.exists(project_file):
with open(project_file, "r", encoding="utf-8") as f:
try:
payload = json.load(f)
except json.JSONDecodeError:
payload = {}
constraints = {
key: value
for key, value in {
"minTrackWidth": params.get("minTrackWidth"),
"minClearance": params.get("minClearance"),
"minViaDiameter": params.get("minViaDiameter"),
"minViaDrill": params.get("minViaDrill"),
}.items()
if value is not None
}
payload.setdefault("kicad_mcp", {}).setdefault("layer_constraints", {})[layer] = constraints
with open(project_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
return {
"success": True,
"message": f"Stored layer constraints for {layer}",
"layer": layer,
"constraints": constraints,
"projectFile": project_file,
}
except Exception as e:
logger.error(f"Error setting layer constraints: {str(e)}")
return {
"success": False,
"message": "Failed to set layer constraints",
"errorDetails": str(e),
}
def check_clearance(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Approximate clearance check using item positions and bounding boxes."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
scale = 1000000
min_clearance = self.board.GetDesignSettings().m_MinClearance / scale
def _resolve_point(item: Dict[str, Any]) -> Optional[Tuple[float, float]]:
if item.get("position"):
pos = item["position"]
unit_scale = 1.0 if pos.get("unit", "mm") == "mm" else 25.4
return (float(pos.get("x", 0)) * unit_scale, float(pos.get("y", 0)) * unit_scale)
if item.get("reference"):
module = self.board.FindFootprintByReference(item["reference"])
if module:
pos = module.GetPosition()
return (pos.x / scale, pos.y / scale)
return None
point1 = _resolve_point(params.get("item1", {}))
point2 = _resolve_point(params.get("item2", {}))
if not point1 or not point2:
return {
"success": False,
"message": "Unable to resolve both items",
"errorDetails": "Provide either positions or component references",
}
dx = point1[0] - point2[0]
dy = point1[1] - point2[1]
distance = (dx * dx + dy * dy) ** 0.5
return {
"success": True,
"clearance": {
"actual": distance,
"required": min_clearance,
"passes": distance >= min_clearance,
"unit": "mm",
},
}
except Exception as e:
logger.error(f"Error checking clearance: {str(e)}")
return {
"success": False,
"message": "Failed to check clearance",
"errorDetails": str(e),
}
+188 -14
View File
@@ -2,16 +2,17 @@
Export command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
logger = logging.getLogger("kicad_interface")
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
import subprocess
logger = logging.getLogger("kicad_interface")
class ExportCommands:
class ExportCommands:
"""Handles export-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -462,7 +463,7 @@ class ExportCommands:
"errorDetails": str(e),
}
def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export Bill of Materials"""
try:
if not self.board:
@@ -550,11 +551,184 @@ class ExportCommands:
except Exception as e:
logger.error(f"Error exporting BOM: {str(e)}")
return {
"success": False,
"message": "Failed to export BOM",
"errorDetails": str(e),
}
return {
"success": False,
"message": "Failed to export BOM",
"errorDetails": str(e),
}
def export_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export the current project's schematic netlist using kicad-cli."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
output_path = params.get("outputPath")
if not output_path:
return {
"success": False,
"message": "Missing output path",
"errorDetails": "outputPath parameter is required",
}
board_file = self.board.GetFileName()
if not board_file:
return {
"success": False,
"message": "Board file not found",
"errorDetails": "Save the board before exporting a netlist",
}
schematic_path = os.path.splitext(board_file)[0] + ".kicad_sch"
if not os.path.exists(schematic_path):
return {
"success": False,
"message": "Schematic file not found",
"errorDetails": f"No schematic found at {schematic_path}",
}
output_path = os.path.abspath(os.path.expanduser(output_path))
os.makedirs(os.path.dirname(output_path), exist_ok=True)
format_map = {
"kicad": "kicadsexpr",
"spice": "spice",
"cadstar": "cadstar",
"orcadpcb2": "orcadpcb2",
}
format_name = str(params.get("format", "KiCad")).lower()
cli_format = format_map.get(format_name, "kicadsexpr")
kicad_cli = self._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "KiCad CLI is required for netlist export",
}
cmd = [
kicad_cli,
"sch",
"export",
"netlist",
"--output",
output_path,
"--format",
cli_format,
schematic_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode != 0:
return {
"success": False,
"message": "Netlist export failed",
"errorDetails": result.stderr.strip() or result.stdout.strip(),
}
return {
"success": True,
"message": "Exported netlist",
"file": {"path": output_path, "format": cli_format, "schematicPath": schematic_path},
}
except Exception as e:
logger.error(f"Error exporting netlist: {str(e)}")
return {
"success": False,
"message": "Failed to export netlist",
"errorDetails": str(e),
}
def export_position_file(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export a pick-and-place position file using kicad-cli."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
output_path = params.get("outputPath")
if not output_path:
return {
"success": False,
"message": "Missing output path",
"errorDetails": "outputPath parameter is required",
}
board_file = self.board.GetFileName()
if not board_file or not os.path.exists(board_file):
return {
"success": False,
"message": "Board file not found",
"errorDetails": "Save the board before exporting a position file",
}
output_path = os.path.abspath(os.path.expanduser(output_path))
os.makedirs(os.path.dirname(output_path), exist_ok=True)
side_map = {"top": "front", "bottom": "back", "both": "both"}
format_map = {"csv": "csv", "ascii": "ascii"}
units_map = {"mm": "mm", "inch": "in"}
side = side_map.get(str(params.get("side", "both")).lower(), "both")
format_name = format_map.get(str(params.get("format", "ASCII")).lower(), "ascii")
units = units_map.get(str(params.get("units", "mm")).lower(), "mm")
kicad_cli = self._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "KiCad CLI is required for position export",
}
cmd = [
kicad_cli,
"pcb",
"export",
"pos",
"--output",
output_path,
"--side",
side,
"--format",
format_name,
"--units",
units,
board_file,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode != 0:
return {
"success": False,
"message": "Position export failed",
"errorDetails": result.stderr.strip() or result.stdout.strip(),
}
return {
"success": True,
"message": "Exported position file",
"file": {"path": output_path, "format": format_name, "units": units, "side": side},
}
except Exception as e:
logger.error(f"Error exporting position file: {str(e)}")
return {
"success": False,
"message": "Failed to export position file",
"errorDetails": str(e),
}
def export_vrml(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export VRML via the generic 3D export path."""
bridged = dict(params)
bridged["format"] = "VRML"
return self.export_3d(bridged)
def _export_bom_csv(self, path: str, components: List[Dict[str, Any]]) -> None:
"""Export BOM to CSV format"""
+6 -6
View File
@@ -40,12 +40,12 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
if not self.app_id or not self.access_key or not self.secret_key:
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
if not self.app_id or not self.access_key or not self.secret_key:
logger.info("JLCPCB API credentials not found. JLCPCB API features stay disabled until credentials are configured.")
@staticmethod
def _generate_nonce() -> str:
+6 -6
View File
@@ -40,10 +40,10 @@ class LibraryManager:
# Load global libraries
global_table = self._get_global_fp_lib_table()
if global_table and global_table.exists():
logger.info(f"Loading global fp-lib-table from: {global_table}")
self._parse_fp_lib_table(global_table)
else:
logger.warning(f"Global fp-lib-table not found at: {global_table}")
logger.info(f"Loading global fp-lib-table from: {global_table}")
self._parse_fp_lib_table(global_table)
else:
logger.info(f"Global fp-lib-table not found at: {global_table}")
# Load project-specific libraries if project path provided
if self.project_path:
@@ -312,8 +312,8 @@ class LibraryManager:
logger.info(f"Found KiCad 3rd party directory: {candidate}")
return str(candidate)
logger.warning("Could not find KiCad 3rd party directory")
return None
logger.info("Could not find KiCad 3rd party directory")
return None
def list_libraries(self) -> List[str]:
"""Get list of available library nicknames"""
+1 -1
View File
@@ -62,7 +62,7 @@ class SymbolLibraryManager:
logger.info(f"Loading global sym-lib-table from: {global_table}")
self._parse_sym_lib_table(global_table)
else:
logger.warning(f"Global sym-lib-table not found at: {global_table}")
logger.info(f"Global sym-lib-table not found at: {global_table}")
# Load project-specific libraries if project path provided
if self.project_path:
+199 -18
View File
@@ -2,21 +2,42 @@
Project-related command implementations for KiCAD interface
"""
import os
import pcbnew # type: ignore
import logging
import shutil
from typing import Dict, Any, Optional
import os
import pcbnew # type: ignore
import logging
import shutil
from typing import Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger("kicad_interface")
class ProjectCommands:
class ProjectCommands:
"""Handles project-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def _get_project_context(self) -> Optional[Dict[str, str]]:
if not self.board:
return None
board_path = self.board.GetFileName()
if not board_path:
return None
board_path = os.path.abspath(os.path.expanduser(board_path))
project_root, board_name = os.path.split(board_path)
project_stem, _ = os.path.splitext(board_name)
return {
"board_path": board_path,
"project_root": project_root,
"project_name": project_stem,
"project_file": os.path.join(project_root, f"{project_stem}.kicad_pro"),
"schematic_file": os.path.join(project_root, f"{project_stem}.kicad_sch"),
}
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new KiCAD project"""
@@ -222,10 +243,10 @@ class ProjectCommands:
"errorDetails": str(e),
}
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current project"""
try:
if not self.board:
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
@@ -253,8 +274,168 @@ class ProjectCommands:
except Exception as e:
logger.error(f"Error getting project info: {str(e)}")
return {
"success": False,
"message": "Failed to get project information",
"errorDetails": str(e),
}
return {
"success": False,
"message": "Failed to get project information",
"errorDetails": str(e),
}
def get_project_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get resolved project file paths and title block metadata."""
try:
context = self._get_project_context()
if not context:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
title_block = self.board.GetTitleBlock()
project_file = context["project_file"]
board_path = context["board_path"]
schematic_file = context["schematic_file"]
return {
"success": True,
"properties": {
"projectName": context["project_name"],
"projectRoot": context["project_root"],
"projectFile": project_file,
"boardFile": board_path,
"schematicFile": schematic_file,
"exists": {
"projectFile": os.path.exists(project_file),
"boardFile": os.path.exists(board_path),
"schematicFile": os.path.exists(schematic_file),
},
"titleBlock": {
"title": title_block.GetTitle(),
"date": title_block.GetDate(),
"revision": title_block.GetRevision(),
"company": title_block.GetCompany(),
"comment1": title_block.GetComment(0),
"comment2": title_block.GetComment(1),
"comment3": title_block.GetComment(2),
"comment4": title_block.GetComment(3),
},
},
}
except Exception as e:
logger.error(f"Error getting project properties: {str(e)}")
return {
"success": False,
"message": "Failed to get project properties",
"errorDetails": str(e),
}
def get_project_files(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List the files that make up the current KiCad project directory."""
try:
context = self._get_project_context()
if not context:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
project_root = context["project_root"]
project_name = context["project_name"]
files = []
for entry in sorted(os.listdir(project_root)):
path = os.path.join(project_root, entry)
if not os.path.isfile(path):
continue
if not (
entry.startswith(project_name)
or entry in {"fp-lib-table", "sym-lib-table"}
or entry.endswith(
(
".kicad_pro",
".kicad_prl",
".kicad_pcb",
".kicad_sch",
".dru",
".json",
".csv",
".txt",
)
)
):
continue
ext = os.path.splitext(entry)[1].lower()
file_type = {
".kicad_pro": "project",
".kicad_prl": "local-settings",
".kicad_pcb": "board",
".kicad_sch": "schematic",
".kicad_dru": "design-rules",
".csv": "report",
".json": "report",
".txt": "report",
}.get(ext, "auxiliary")
stat = os.stat(path)
files.append(
{
"name": entry,
"path": path,
"type": file_type,
"sizeBytes": stat.st_size,
"modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(),
}
)
return {
"success": True,
"projectRoot": project_root,
"files": files,
"count": len(files),
}
except Exception as e:
logger.error(f"Error getting project files: {str(e)}")
return {
"success": False,
"message": "Failed to get project files",
"errorDetails": str(e),
}
def get_project_status(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the runtime status of the current project."""
try:
context = self._get_project_context()
if not context:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
files_result = self.get_project_files({})
board_box = self.board.GetBoardEdgesBoundingBox()
return {
"success": True,
"status": {
"projectName": context["project_name"],
"projectRoot": context["project_root"],
"boardLoaded": self.board is not None,
"boardSaved": bool(context["board_path"]) and os.path.exists(context["board_path"]),
"projectFileExists": os.path.exists(context["project_file"]),
"schematicFileExists": os.path.exists(context["schematic_file"]),
"boardOutlinePresent": board_box.GetWidth() > 0 and board_box.GetHeight() > 0,
"componentCount": len(list(self.board.GetFootprints())),
"fileCount": files_result.get("count", 0),
},
}
except Exception as e:
logger.error(f"Error getting project status: {str(e)}")
return {
"success": False,
"message": "Failed to get project status",
"errorDetails": str(e),
}
+31 -32
View File
@@ -1006,15 +1006,14 @@ class RoutingCommands:
"errorDetails": "name parameter is required",
}
# Get net classes
net_classes = self.board.GetNetClasses()
# Create new net class if it doesn't exist
if not net_classes.Find(name):
netclass = pcbnew.NETCLASS(name)
net_classes.Add(netclass)
else:
netclass = net_classes.Find(name)
# KiCad 10 exposes netclasses_map with lowercase helpers and item assignment.
net_classes = self.board.GetNetClasses()
if net_classes.has_key(name):
netclass = net_classes[name]
else:
netclass = pcbnew.NETCLASS(name)
net_classes[name] = netclass
# Set properties
scale = 1000000 # mm to nm
@@ -1022,18 +1021,18 @@ class RoutingCommands:
netclass.SetClearance(int(clearance * scale))
if track_width is not None:
netclass.SetTrackWidth(int(track_width * scale))
if via_diameter is not None:
netclass.SetViaDiameter(int(via_diameter * scale))
if via_drill is not None:
netclass.SetViaDrill(int(via_drill * scale))
if uvia_diameter is not None:
netclass.SetMicroViaDiameter(int(uvia_diameter * scale))
if uvia_drill is not None:
netclass.SetMicroViaDrill(int(uvia_drill * scale))
if diff_pair_width is not None:
netclass.SetDiffPairWidth(int(diff_pair_width * scale))
if diff_pair_gap is not None:
netclass.SetDiffPairGap(int(diff_pair_gap * scale))
if via_diameter is not None:
netclass.SetViaDiameter(int(via_diameter * scale))
if via_drill is not None:
netclass.SetViaDrill(int(via_drill * scale))
if uvia_diameter is not None:
netclass.SetuViaDiameter(int(uvia_diameter * scale))
if uvia_drill is not None:
netclass.SetuViaDrill(int(uvia_drill * scale))
if diff_pair_width is not None:
netclass.SetDiffPairWidth(int(diff_pair_width * scale))
if diff_pair_gap is not None:
netclass.SetDiffPairGap(int(diff_pair_gap * scale))
# Add nets to net class
netinfo = self.board.GetNetInfo()
@@ -1047,17 +1046,17 @@ class RoutingCommands:
"success": True,
"message": f"Created net class: {name}",
"netClass": {
"name": name,
"clearance": netclass.GetClearance() / scale,
"trackWidth": netclass.GetTrackWidth() / scale,
"viaDiameter": netclass.GetViaDiameter() / scale,
"viaDrill": netclass.GetViaDrill() / scale,
"uviaDiameter": netclass.GetMicroViaDiameter() / scale,
"uviaDrill": netclass.GetMicroViaDrill() / scale,
"diffPairWidth": netclass.GetDiffPairWidth() / scale,
"diffPairGap": netclass.GetDiffPairGap() / scale,
"nets": nets,
},
"name": name,
"clearance": netclass.GetClearance() / scale,
"trackWidth": netclass.GetTrackWidth() / scale,
"viaDiameter": netclass.GetViaDiameter() / scale,
"viaDrill": netclass.GetViaDrill() / scale,
"uviaDiameter": netclass.GetuViaDiameter() / scale,
"uviaDrill": netclass.GetuViaDrill() / scale,
"diffPairWidth": netclass.GetDiffPairWidth() / scale,
"diffPairGap": netclass.GetDiffPairGap() / scale,
"nets": nets,
},
}
except Exception as e:
+995 -18
View File
File diff suppressed because it is too large Load Diff
+18 -5
View File
@@ -16,6 +16,15 @@ const __dirname = dirname(__filename);
// Default config location
const DEFAULT_CONFIG_PATH = join(dirname(__dirname), 'config', 'default-config.json');
const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const;
function resolveLogLevel(): (typeof LOG_LEVELS)[number] {
const value = process.env.KICAD_MCP_LOG_LEVEL;
return LOG_LEVELS.includes(value as (typeof LOG_LEVELS)[number])
? (value as (typeof LOG_LEVELS)[number])
: 'warn';
}
/**
* Server configuration schema
*/
@@ -25,7 +34,7 @@ const ConfigSchema = z.object({
description: z.string().default('MCP server for KiCAD PCB design operations'),
pythonPath: z.string().optional(),
kicadPath: z.string().optional(),
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
logLevel: z.enum(LOG_LEVELS).default(resolveLogLevel()),
logDir: z.string().optional()
});
@@ -41,14 +50,18 @@ export type Config = z.infer<typeof ConfigSchema>;
* @returns Loaded and validated configuration
*/
export async function loadConfig(configPath?: string): Promise<Config> {
const envOverrides = {
logLevel: resolveLogLevel(),
};
try {
// Determine which config file to load
const filePath = configPath || DEFAULT_CONFIG_PATH;
// Check if file exists
if (!existsSync(filePath)) {
logger.warn(`Configuration file not found: ${filePath}, using defaults`);
return ConfigSchema.parse({});
logger.debug(`Configuration file not found: ${filePath}, using defaults`);
return ConfigSchema.parse(envOverrides);
}
// Read and parse configuration
@@ -56,11 +69,11 @@ export async function loadConfig(configPath?: string): Promise<Config> {
const config = JSON.parse(configData);
// Validate configuration
return ConfigSchema.parse(config);
return ConfigSchema.parse({ ...config, ...envOverrides });
} catch (error) {
logger.error(`Error loading configuration: ${error}`);
// Return default configuration
return ConfigSchema.parse({});
return ConfigSchema.parse(envOverrides);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ async function main() {
// Create the server
const server = new KiCADMcpServer(
kicadScriptPath,
config.logLevel
config.logLevel,
);
// Start the server
+1 -1
View File
@@ -16,7 +16,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
* Logger class for KiCAD MCP server
*/
class Logger {
private logLevel: LogLevel = 'info';
private logLevel: LogLevel = 'warn';
private logDir: string = DEFAULT_LOG_DIR;
/**
+52 -17
View File
@@ -220,6 +220,7 @@ export class KiCADMcpServer {
reject: Function;
timeoutHandle: NodeJS.Timeout;
} | null = null;
private pythonStderrBuffer = "";
private buildPythonEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
@@ -240,7 +241,7 @@ export class KiCADMcpServer {
*/
constructor(
kicadScriptPath: string,
logLevel: "error" | "warn" | "info" | "debug" = "info",
logLevel: "error" | "warn" | "info" | "debug" = "warn",
) {
// Set up the logger
logger.setLogLevel(logLevel);
@@ -272,12 +273,8 @@ export class KiCADMcpServer {
* Register all tools, resources, and prompts
*/
private registerAll(): void {
logger.info("Registering KiCAD tools, resources, and prompts...");
logger.info("Registering KiCAD MCP runtime surface");
// Register router tools FIRST (for tool discovery and execution)
registerRouterTools(this.server, this.callKicadScript.bind(this));
// Register all tools
registerProjectTools(this.server, this.callKicadScript.bind(this));
registerBoardTools(this.server, this.callKicadScript.bind(this));
registerComponentTools(this.server, this.callKicadScript.bind(this));
@@ -287,28 +284,65 @@ export class KiCADMcpServer {
registerSchematicTools(this.server, this.callKicadScript.bind(this));
registerLibraryTools(this.server, this.callKicadScript.bind(this));
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
registerRouterTools(this.server, this.callKicadScript.bind(this));
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerFootprintTools(this.server, this.callKicadScript.bind(this));
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
registerUITools(this.server, this.callKicadScript.bind(this));
// Register all resources
registerProjectResources(this.server, this.callKicadScript.bind(this));
registerBoardResources(this.server, this.callKicadScript.bind(this));
registerComponentResources(this.server, this.callKicadScript.bind(this));
registerLibraryResources(this.server, this.callKicadScript.bind(this));
// Register all prompts
registerComponentPrompts(this.server);
registerRoutingPrompts(this.server);
registerDesignPrompts(this.server);
registerFootprintPrompts(this.server);
logger.info("All KiCAD tools, resources, and prompts registered");
logger.info(
"Router pattern enabled: 4 router tools + direct tools for discovery",
);
logger.info("KiCAD MCP runtime registration complete");
}
private logPythonStderrLine(rawLine: string): void {
const line = rawLine.trim();
if (!line) {
return;
}
const severityMatch = line.match(/\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]/i);
const message = `python ${line}`;
switch (severityMatch?.[1]?.toUpperCase()) {
case "DEBUG":
logger.debug(message);
return;
case "INFO":
logger.info(message);
return;
case "WARNING":
logger.warn(message);
return;
case "ERROR":
case "CRITICAL":
logger.error(message);
return;
default:
if (/(traceback|exception|error)/i.test(line)) {
logger.error(message);
return;
}
logger.debug(message);
}
}
private handlePythonStderr(data: Buffer): void {
this.pythonStderrBuffer += data.toString();
const lines = this.pythonStderrBuffer.split(/\r?\n/);
this.pythonStderrBuffer = lines.pop() ?? "";
for (const line of lines) {
this.logPythonStderrLine(line);
}
}
/**
@@ -520,6 +554,10 @@ export class KiCADMcpServer {
// Listen for process exit
this.pythonProcess.on("exit", (code, signal) => {
if (this.pythonStderrBuffer.trim()) {
this.logPythonStderrLine(this.pythonStderrBuffer);
this.pythonStderrBuffer = "";
}
logger.warn(
`Python process exited with code ${code} and signal ${signal}`,
);
@@ -534,7 +572,7 @@ export class KiCADMcpServer {
// Set up error logging for stderr
if (this.pythonProcess.stderr) {
this.pythonProcess.stderr.on("data", (data: Buffer) => {
logger.error(`Python stderr: ${data.toString()}`);
this.handlePythonStderr(data);
});
}
@@ -555,9 +593,6 @@ export class KiCADMcpServer {
throw error;
}
// Write a ready message to stderr (for debugging)
process.stderr.write("KiCAD MCP SERVER READY\n");
logger.info("KiCAD MCP server started and ready");
} catch (error) {
logger.error(`Failed to start KiCAD MCP server: ${error}`);