Merge pull request #44 from Kletternaut/feat/routing-component-tools-and-bugfixes

feat: Add missing routing/component tools and fix SWIG/UUID bugs
This commit is contained in:
mixelpixx
2026-02-28 07:58:05 -05:00
committed by GitHub
15 changed files with 3576 additions and 1836 deletions

View File

@@ -2,6 +2,102 @@
All notable changes to the KiCAD MCP Server project are documented here. All notable changes to the KiCAD MCP Server project are documented here.
## [2.2.0-alpha] - 2026-02-27
### New MCP Tools (TypeScript layer previously Python-only)
**Routing tools:**
- `delete_trace` - Delete traces by UUID, position or net name
- `query_traces` - Query/filter traces on the board
- `get_nets_list` - List all nets with net code and class
- `modify_trace` - Modify trace width or layer
- `create_netclass` - Create or update a net class
- `route_differential_pair` - Route a differential pair between two points
- `refill_zones` - Refill all copper zones ⚠️ SWIG segfault risk, prefer IPC/UI
**Component tools:**
- `get_component_pads` - Get all pad data for a component
- `get_component_list` - List all components on the board
- `get_pad_position` - Get absolute position of a specific pad
- `place_component_array` - Place components in a grid array
- `align_components` - Align components along an axis
- `duplicate_component` - Duplicate a component with offset
### Bug Fixes
- `routing.py`: Fix SwigPyObject UUID comparison (`str()``m_Uuid.AsString()`)
- `routing.py`: Fix SWIG iterator invalidation after `board.Remove()` by snapshotting `list(board.Tracks())`
- `routing.py`: Add `board.SetModified()` + `track = None` after `Remove()` to prevent dangling SWIG pointer crashes
- `routing.py`: Per-track `try/except` in `query_traces()` to skip invalid objects after bulk delete
- `routing.py`: Add missing return statement (mypy)
- `library.py`: Fix `search_footprints` parameter mapping (`search_term``pattern`)
- `library.py`: Fix field access (`fp.name``fp.full_name`)
- `library.py`: Accept both `pattern` and `search_term` parameter names
- `library.py`: Fix loop variable shadowing `Path` object (mypy)
- `design_rules.py`: Add type annotation for `violation_counts` (mypy)
### Pending additions (not yet committed)
**Datasheet tools:**
- `get_datasheet_url` - Return LCSC datasheet PDF URL and product page URL for a given
LCSC number (e.g. `C179739``https://www.lcsc.com/datasheet/C179739.pdf`).
No API key required URL is constructed directly from the LCSC number.
- `enrich_datasheets` - Scan a `.kicad_sch` file and write LCSC datasheet URLs into
every symbol that has an `LCSC` property but an empty `Datasheet` field. After
enrichment the URL appears natively in KiCAD's symbol properties, footprint browser
and any other tool that reads the standard KiCAD `Datasheet` field.
Supports `dry_run=true` for preview without writing.
Implementation: `python/commands/datasheet_manager.py` (text-based, no `skip` writes)
**Schematic tools:**
- `delete_schematic_component` - Remove a placed symbol from a `.kicad_sch` file by
reference designator (e.g. `R1`, `U3`).
### Bug Fixes (pending)
- `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool.
**Root cause (two separate issues):**
1. No MCP tool named `delete_schematic_component` existed. Claude had no way to call
it, so any "delete schematic component" request fell through to the PCB-only
`delete_component` tool, which searches `pcbnew.BOARD` and always returned
"Component not found" for schematic symbols.
2. `component_schematic.py::remove_component()` still used `skip` for writes.
PR #40 rewrote `DynamicSymbolLoader` (add path) to avoid `skip`-induced schematic
corruption, but `remove_component` (delete path) was not touched by that PR.
**Fix:**
- Added `delete_schematic_component` to the TypeScript tool layer (`schematic.ts`)
with clear docstring distinguishing it from the PCB `delete_component`.
- Implemented `_handle_delete_schematic_component` in `kicad_interface.py` using
direct text manipulation (parenthesis-depth tracking, same approach as PR #40).
Does not call `component_schematic.py::remove_component()` at all.
- Error message explicitly guides the user when the wrong tool is used:
*"note: this tool removes schematic symbols, use delete_component for PCB footprints"*
### Pending fixes (not yet committed)
- `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing
`schematic_path` parameter without it `get_net_connections` always fell back to
proximity matching which only returns one connection per component (first wire hit,
then `break`). PinLocator was never invoked. Fix: added `schematic_path: Optional[Path]`
to `generate_netlist` signature and threaded it through to `get_net_connections`,
and updated `_handle_generate_netlist` in `kicad_interface.py` to pass `schematic_path`.
- `server.ts`: Fix KiCAD bundled Python (3.11.5) not being selected on Windows the
detection condition `process.env.PYTHONPATH?.includes("KiCad")` was fragile and failed
in some environments, causing System Python 3.12 to be used instead. Since `pcbnew.pyd`
is compiled for KiCAD's Python 3.11.5, this resulted in `No module named 'pcbnew'`.
Fix: removed the condition, KiCAD bundled Python is now always preferred on Windows
when it exists at `C:\Program Files\KiCad\9.0\bin\python.exe`.
Also added `KICAD_PYTHON` to `claude_desktop_config.json` as explicit override.
- `pin_locator.py`: Fix `generate_netlist` timeout `get_pin_location` and
`get_all_symbol_pins` called `Schematic(schematic_path)` on every single pin lookup,
causing O(nets × components × pins) schematic file loads (e.g. 400+ loads for a
medium schematic). Fix: added `_schematic_cache` dict to `PinLocator.__init__`,
schematic is now loaded once per path and reused.
---
## [2.1.0-alpha] - 2026-01-10 ## [2.1.0-alpha] - 2026-01-10
### Phase 1: Intelligent Schematic Wiring System - Core Infrastructure ### Phase 1: Intelligent Schematic Wiring System - Core Infrastructure

View File

@@ -10,11 +10,13 @@ logger = logging.getLogger(__name__)
try: try:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
WIRE_MANAGER_AVAILABLE = True WIRE_MANAGER_AVAILABLE = True
except ImportError: except ImportError:
logger.warning("WireManager/PinLocator not available") logger.warning("WireManager/PinLocator not available")
WIRE_MANAGER_AVAILABLE = False WIRE_MANAGER_AVAILABLE = False
class ConnectionManager: class ConnectionManager:
"""Manage connections between components in schematics""" """Manage connections between components in schematics"""
@@ -29,7 +31,12 @@ class ConnectionManager:
return cls._pin_locator return cls._pin_locator
@staticmethod @staticmethod
def add_wire(schematic_path: Path, start_point: list, end_point: list, properties: dict = None): def add_wire(
schematic_path: Path,
start_point: list,
end_point: list,
properties: dict = None,
):
""" """
Add a wire between two points using WireManager Add a wire between two points using WireManager
@@ -47,11 +54,18 @@ class ConnectionManager:
logger.error("WireManager not available") logger.error("WireManager not available")
return False return False
stroke_width = properties.get('stroke_width', 0) if properties else 0 stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = properties.get('stroke_type', 'default') if properties else 'default' stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(schematic_path, start_point, end_point, success = WireManager.add_wire(
stroke_width=stroke_width, stroke_type=stroke_type) schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success return success
except Exception as e: except Exception as e:
logger.error(f"Error adding wire: {e}") logger.error(f"Error adding wire: {e}")
@@ -70,7 +84,7 @@ class ConnectionManager:
[x, y] coordinates or None if pin not found [x, y] coordinates or None if pin not found
""" """
try: try:
if not hasattr(symbol, 'pin'): if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins") logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None return None
@@ -82,7 +96,9 @@ class ConnectionManager:
break break
if not target_pin: if not target_pin:
logger.warning(f"Pin '{pin_name}' not found on {symbol.property.Reference.value}") logger.warning(
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
)
return None return None
# Get pin location relative to symbol # Get pin location relative to symbol
@@ -101,8 +117,14 @@ class ConnectionManager:
return None return None
@staticmethod @staticmethod
def add_connection(schematic_path: Path, source_ref: str, source_pin: str, def add_connection(
target_ref: str, target_pin: str, routing: str = 'direct'): schematic_path: Path,
source_ref: str,
source_pin: str,
target_ref: str,
target_pin: str,
routing: str = "direct",
):
""" """
Add a wire connection between two component pins Add a wire connection between two component pins
@@ -128,31 +150,41 @@ class ConnectionManager:
return False return False
# Get pin locations # Get pin locations
source_loc = locator.get_pin_location(schematic_path, source_ref, source_pin) source_loc = locator.get_pin_location(
target_loc = locator.get_pin_location(schematic_path, target_ref, target_pin) schematic_path, source_ref, source_pin
)
target_loc = locator.get_pin_location(
schematic_path, target_ref, target_pin
)
if not source_loc or not target_loc: if not source_loc or not target_loc:
logger.error("Could not determine pin locations") logger.error("Could not determine pin locations")
return False return False
# Create wire based on routing style # Create wire based on routing style
if routing == 'direct': if routing == "direct":
# Simple direct wire # Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc) success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == 'orthogonal_h': elif routing == "orthogonal_h":
# Orthogonal routing (horizontal first) # Orthogonal routing (horizontal first)
path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=True) path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=True
)
success = WireManager.add_polyline_wire(schematic_path, path) success = WireManager.add_polyline_wire(schematic_path, path)
elif routing == 'orthogonal_v': elif routing == "orthogonal_v":
# Orthogonal routing (vertical first) # Orthogonal routing (vertical first)
path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=False) path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=False
)
success = WireManager.add_polyline_wire(schematic_path, path) success = WireManager.add_polyline_wire(schematic_path, path)
else: else:
logger.error(f"Unknown routing style: {routing}") logger.error(f"Unknown routing style: {routing}")
return False return False
if success: if success:
logger.info(f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})") logger.info(
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
)
return True return True
else: else:
return False return False
@@ -160,6 +192,7 @@ class ConnectionManager:
except Exception as e: except Exception as e:
logger.error(f"Error adding connection: {e}") logger.error(f"Error adding connection: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return False return False
@@ -177,13 +210,12 @@ class ConnectionManager:
Label object or None on error Label object or None on error
""" """
try: try:
if not hasattr(schematic, 'label'): if not hasattr(schematic, "label"):
logger.error("Schematic does not have label collection") logger.error("Schematic does not have label collection")
return None return None
label = schematic.label.append( label = schematic.label.append(
text=net_name, text=net_name, at={"x": position[0], "y": position[1]}
at={'x': position[0], 'y': position[1]}
) )
logger.info(f"Added net label '{net_name}' at {position}") logger.info(f"Added net label '{net_name}' at {position}")
return label return label
@@ -192,7 +224,9 @@ class ConnectionManager:
return None return None
@staticmethod @staticmethod
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
""" """
Connect a component pin to a named net using a wire stub and label Connect a component pin to a named net using a wire stub and label
@@ -231,7 +265,9 @@ class ConnectionManager:
return False return False
# Add label at the end of the stub using WireManager # Add label at the end of the stub using WireManager
label_success = WireManager.add_label(schematic_path, net_name, stub_end, label_type='label') label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label"
)
if not label_success: if not label_success:
logger.error(f"Failed to add net label '{net_name}'") logger.error(f"Failed to add net label '{net_name}'")
return False return False
@@ -242,11 +278,14 @@ class ConnectionManager:
except Exception as e: except Exception as e:
logger.error(f"Error connecting to net: {e}") logger.error(f"Error connecting to net: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return False return False
@staticmethod @staticmethod
def get_net_connections(schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None): def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
""" """
Get all connections for a named net using wire graph analysis Get all connections for a named net using wire graph analysis
@@ -273,14 +312,14 @@ class ConnectionManager:
return dx < tolerance and dy < tolerance return dx < tolerance and dy < tolerance
# 1. Find all labels with this net name # 1. Find all labels with this net name
if not hasattr(schematic, 'label'): if not hasattr(schematic, "label"):
logger.warning("Schematic has no labels") logger.warning("Schematic has no labels")
return connections return connections
net_label_positions = [] net_label_positions = []
for label in schematic.label: for label in schematic.label:
if hasattr(label, 'value') and label.value == net_name: if hasattr(label, "value") and label.value == net_name:
if hasattr(label, 'at') and hasattr(label.at, 'value'): if hasattr(label, "at") and hasattr(label.at, "value"):
pos = label.at.value pos = label.at.value
net_label_positions.append([float(pos[0]), float(pos[1])]) net_label_positions.append([float(pos[0]), float(pos[1])])
@@ -288,21 +327,25 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'") logger.info(f"No labels found for net '{net_name}'")
return connections return connections
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
# 2. Find all wires connected to these label positions # 2. Find all wires connected to these label positions
if not hasattr(schematic, 'wire'): if not hasattr(schematic, "wire"):
logger.warning("Schematic has no wires") logger.warning("Schematic has no wires")
return connections return connections
connected_wire_points = set() connected_wire_points = set()
for wire in schematic.wire: for wire in schematic.wire:
if hasattr(wire, 'pts') and hasattr(wire.pts, 'xy'): if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline) # Get all points in this wire (polyline)
wire_points = [] wire_points = []
for point in wire.pts.xy: for point in wire.pts.xy:
if hasattr(point, 'value'): if hasattr(point, "value"):
wire_points.append([float(point.value[0]), float(point.value[1])]) wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
# Check if any wire point touches a label # Check if any wire point touches a label
wire_connected = False wire_connected = False
@@ -323,10 +366,12 @@ class ConnectionManager:
logger.debug(f"No wires connected to net '{net_name}' labels") logger.debug(f"No wires connected to net '{net_name}' labels")
return connections return connections
logger.debug(f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'") logger.debug(
f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'"
)
# 3. Find component pins at wire endpoints # 3. Find component pins at wire endpoints
if not hasattr(schematic, 'symbol'): if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols") logger.warning("Schematic has no symbols")
return connections return connections
@@ -337,15 +382,15 @@ class ConnectionManager:
for symbol in schematic.symbol: for symbol in schematic.symbol:
# Skip template symbols # Skip template symbols
if not hasattr(symbol.property, 'Reference'): if not hasattr(symbol.property, "Reference"):
continue continue
ref = symbol.property.Reference.value ref = symbol.property.Reference.value
if ref.startswith('_TEMPLATE'): if ref.startswith("_TEMPLATE"):
continue continue
# Get lib_id for pin location lookup # Get lib_id for pin location lookup
lib_id = symbol.lib_id.value if hasattr(symbol, 'lib_id') else None lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None
if not lib_id: if not lib_id:
continue continue
@@ -360,17 +405,18 @@ class ConnectionManager:
# Check each pin # Check each pin
for pin_num, pin_data in pins.items(): for pin_num, pin_data in pins.items():
# Get pin location # Get pin location
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
if not pin_loc: if not pin_loc:
continue continue
# Check if pin coincides with any wire point # Check if pin coincides with any wire point
for wire_pt in connected_wire_points: for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)): if points_coincide(pin_loc, list(wire_pt)):
connections.append({ connections.append(
"component": ref, {"component": ref, "pin": pin_num}
"pin": pin_num )
})
break # Pin found, no need to check more wire points break # Pin found, no need to check more wire points
except Exception as e: except Exception as e:
@@ -380,7 +426,7 @@ class ConnectionManager:
# Fallback: proximity-based matching if no PinLocator # Fallback: proximity-based matching if no PinLocator
if not locator or not schematic_path: if not locator or not schematic_path:
symbol_pos = symbol.at.value if hasattr(symbol, 'at') else None symbol_pos = symbol.at.value if hasattr(symbol, "at") else None
if not symbol_pos: if not symbol_pos:
continue continue
@@ -389,12 +435,11 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm) # Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points: for wire_pt in connected_wire_points:
dist = ((symbol_x - wire_pt[0])**2 + (symbol_y - wire_pt[1])**2)**0.5 dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold if dist < 10.0: # 10mm proximity threshold
connections.append({ connections.append({"component": ref, "pin": "unknown"})
"component": ref,
"pin": "unknown"
})
break # Only add once per component break # Only add once per component
logger.info(f"Found {len(connections)} connections for net '{net_name}'") logger.info(f"Found {len(connections)} connections for net '{net_name}'")
@@ -403,14 +448,20 @@ class ConnectionManager:
except Exception as e: except Exception as e:
logger.error(f"Error getting net connections: {e}") logger.error(f"Error getting net connections: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return [] return []
@staticmethod @staticmethod
def generate_netlist(schematic: Schematic): def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
""" """
Generate a netlist from the schematic Generate a netlist from the schematic
Args:
schematic: Schematic object
schematic_path: Optional path to schematic file (enables accurate pin matching
via PinLocator; without it, only one connection per component is found)
Returns: Returns:
Dictionary with net information: Dictionary with net information:
{ {
@@ -431,47 +482,58 @@ class ConnectionManager:
} }
""" """
try: try:
netlist = { netlist = {"nets": [], "components": []}
"nets": [],
"components": []
}
# Gather all components # Gather all components
if hasattr(schematic, 'symbol'): if hasattr(schematic, "symbol"):
for symbol in schematic.symbol: for symbol in schematic.symbol:
component_info = { component_info = {
"reference": symbol.property.Reference.value, "reference": symbol.property.Reference.value,
"value": symbol.property.Value.value if hasattr(symbol.property, 'Value') else "", "value": (
"footprint": symbol.property.Footprint.value if hasattr(symbol.property, 'Footprint') else "" symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
),
"footprint": (
symbol.property.Footprint.value
if hasattr(symbol.property, "Footprint")
else ""
),
} }
netlist["components"].append(component_info) netlist["components"].append(component_info)
# Gather all nets from labels # Gather all nets from labels
if hasattr(schematic, 'label'): if hasattr(schematic, "label"):
net_names = set() net_names = set()
for label in schematic.label: for label in schematic.label:
if hasattr(label, 'value'): if hasattr(label, "value"):
net_names.add(label.value) net_names.add(label.value)
# For each net, get connections # For each net, get connections
for net_name in net_names: for net_name in net_names:
connections = ConnectionManager.get_net_connections(schematic, net_name) connections = ConnectionManager.get_net_connections(
schematic, net_name, schematic_path
)
if connections: if connections:
netlist["nets"].append({ netlist["nets"].append(
"name": net_name, {"name": net_name, "connections": connections}
"connections": connections )
})
logger.info(f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components") logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
)
return netlist return netlist
except Exception as e: except Exception as e:
logger.error(f"Error generating netlist: {e}") logger.error(f"Error generating netlist: {e}")
return {"nets": [], "components": []} return {"nets": [], "components": []}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing) # Example Usage (for testing)
from schematic import SchematicManager # Assuming schematic.py is in the same directory from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic # Create a new schematic
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic") test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")

View File

@@ -0,0 +1,289 @@
"""
Datasheet Manager for KiCAD MCP Server
Enriches KiCAD schematic symbols with datasheet URLs derived from LCSC part numbers.
Uses direct text manipulation (like dynamic_symbol_loader.py) to avoid
skip-library-induced schematic corruption.
URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf
No API key required.
"""
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("kicad_interface")
LCSC_DATASHEET_URL = "https://www.lcsc.com/datasheet/{lcsc}.pdf"
LCSC_PRODUCT_URL = "https://www.lcsc.com/product-detail/{lcsc}.html"
# Values treated as "empty" datasheet
EMPTY_DATASHEET_VALUES = {"~", "", "~{DATASHEET}"}
class DatasheetManager:
"""
Enriches KiCAD schematics with LCSC datasheet URLs.
Reads .kicad_sch files, finds symbol instances that have an LCSC property
but an empty Datasheet property, and fills in the LCSC datasheet URL.
"""
@staticmethod
def _normalize_lcsc(lcsc: str) -> Optional[str]:
"""
Normalize LCSC number to standard format 'C123456'.
Accepts: 'C123456', '123456', 'c123456'
Returns: 'C123456' or None if invalid
"""
lcsc = lcsc.strip()
if not lcsc:
return None
# Remove leading C/c
without_prefix = lcsc.lstrip("Cc")
if without_prefix.isdigit():
return f"C{without_prefix}"
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.
These lines must be excluded from symbol-instance processing.
"""
lib_sym_start = None
lib_sym_end = None
depth = 0
for i, line in enumerate(lines):
if "(lib_symbols" in line and lib_sym_start is None:
lib_sym_start = i
depth = 0
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif lib_sym_start is not None and lib_sym_end is None:
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
lib_sym_end = i
break
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
Returns dict with:
- lcsc: normalized LCSC number or None
- datasheet_line: line index of Datasheet property or None
- datasheet_value: current Datasheet value or None
"""
lcsc_value = None
datasheet_line_idx = None
datasheet_current = None
for k in range(block_start, block_end + 1):
line = lines[k]
lcsc_match = re.search(r'\(property\s+"LCSC"\s+"([^"]*)"', line)
if lcsc_match:
lcsc_value = lcsc_match.group(1)
ds_match = re.search(r'\(property\s+"Datasheet"\s+"([^"]*)"', line)
if ds_match:
datasheet_line_idx = k
datasheet_current = ds_match.group(1)
return {
"lcsc": lcsc_value,
"datasheet_line": datasheet_line_idx,
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
For each placed symbol that has:
- (property "LCSC" "C123456") set
- (property "Datasheet" "~") or empty
Sets:
- (property "Datasheet" "https://www.lcsc.com/datasheet/C123456.pdf")
Args:
schematic_path: Path to .kicad_sch file
dry_run: If True, return what would be changed without writing
Returns:
{
"success": True,
"updated": <count>,
"already_set": <count>,
"no_lcsc": <count>,
"no_datasheet_field": <count>,
"details": [{"reference": "...", "lcsc": "...", "url": "..."}]
}
"""
schematic_path = Path(schematic_path)
if not schematic_path.exists():
return {
"success": False,
"message": f"Schematic not found: {schematic_path}",
}
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
new_lines = list(lines)
lib_sym_start, lib_sym_end = self._find_lib_symbols_range(lines)
updated = 0
already_set = 0
no_lcsc = 0
no_datasheet_field = 0
details = []
i = 0
while i < len(new_lines):
line = new_lines[i]
# Skip lib_symbols section
if lib_sym_start is not None and lib_sym_end is not None:
if lib_sym_start <= i <= lib_sym_end:
i += 1
continue
# Detect placed symbol: (symbol (lib_id "...")
if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", line):
block_start = i
block_depth = 0
for ch in line:
if ch == "(":
block_depth += 1
elif ch == ")":
block_depth -= 1
j = i + 1
while j < len(new_lines) and block_depth > 0:
for ch in new_lines[j]:
if ch == "(":
block_depth += 1
elif ch == ")":
block_depth -= 1
if block_depth > 0:
j += 1
else:
break
block_end = j
info = self._process_symbol_block(new_lines, block_start, block_end)
raw_lcsc = info["lcsc"]
ds_line = info["datasheet_line"]
ds_value = info["datasheet_value"]
# Extract reference for reporting
ref_match = None
for k in range(block_start, block_end + 1):
m = re.search(r'\(property\s+"Reference"\s+"([^"]+)"', new_lines[k])
if m:
ref_match = m.group(1)
break
reference = ref_match or "?"
if not raw_lcsc:
no_lcsc += 1
elif ds_line is None:
no_datasheet_field += 1
logger.warning(
f"Symbol {reference} has LCSC={raw_lcsc} but no Datasheet property"
)
else:
lcsc_norm = self._normalize_lcsc(raw_lcsc)
if not lcsc_norm:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
new_lines[ds_line] = re.sub(
r'(property\s+"Datasheet"\s+)"[^"]*"',
f'\\1"{url}"',
new_lines[ds_line],
)
updated += 1
details.append(
{
"reference": reference,
"lcsc": lcsc_norm,
"url": url,
"dry_run": dry_run,
}
)
logger.info(
f"{'[DRY RUN] ' if dry_run else ''}Set Datasheet for "
f"{reference} ({lcsc_norm}): {url}"
)
i = block_end + 1
continue
i += 1
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
return {
"success": True,
"updated": updated,
"already_set": already_set,
"no_lcsc": no_lcsc,
"no_datasheet_field": no_datasheet_field,
"dry_run": dry_run,
"details": details,
"schematic": str(schematic_path),
}
def get_datasheet_url(self, lcsc: str) -> Optional[str]:
"""
Return the LCSC datasheet URL for a given LCSC number.
No network request pure URL construction.
"""
norm = self._normalize_lcsc(lcsc)
if norm:
return LCSC_DATASHEET_URL.format(lcsc=norm)
return None
def get_product_url(self, lcsc: str) -> Optional[str]:
"""Return the LCSC product page URL."""
norm = self._normalize_lcsc(lcsc)
if norm:
return LCSC_PRODUCT_URL.format(lcsc=norm)
return None

View File

@@ -7,7 +7,8 @@ import pcbnew
import logging import logging
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class DesignRuleCommands: class DesignRuleCommands:
"""Handles design rule checking and configuration""" """Handles design rule checking and configuration"""
@@ -23,7 +24,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
design_settings = self.board.GetDesignSettings() design_settings = self.board.GetDesignSettings()
@@ -57,9 +58,13 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0) # Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params: if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
if "microViaDrill" in params: if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
# Set minimum values # Set minimum values
if "minTrackWidth" in params: if "minTrackWidth" in params:
@@ -72,13 +77,19 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params: if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
if "minMicroViaDrill" in params: if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) design_settings.m_MicroViasMinDrill = int(
params["minMicroViaDrill"] * scale
)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params: if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) design_settings.m_MinThroughDrill = int(
params["minHoleDiameter"] * scale
)
# KiCAD 9.0: Added hole clearance settings # KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params: if "holeClearance" in params:
@@ -102,13 +113,13 @@ class DesignRuleCommands:
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale, "holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale, "holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
} }
return { return {
"success": True, "success": True,
"message": "Updated design rules", "message": "Updated design rules",
"rules": response_rules "rules": response_rules,
} }
except Exception as e: except Exception as e:
@@ -116,7 +127,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "Failed to set design rules", "message": "Failed to set design rules",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -126,7 +137,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
design_settings = self.board.GetDesignSettings() design_settings = self.board.GetDesignSettings()
@@ -138,42 +149,34 @@ class DesignRuleCommands:
"clearance": design_settings.m_MinClearance / scale, "clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale, "trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale, "minTrackWidth": design_settings.m_TrackMinWidth / scale,
# Via settings (current values from methods) # Via settings (current values from methods)
"viaDiameter": design_settings.GetCurrentViaSize() / scale, "viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale, "viaDrill": design_settings.GetCurrentViaDrill() / scale,
# Via minimum values # Via minimum values
"minViaDiameter": design_settings.m_ViasMinSize / scale, "minViaDiameter": design_settings.m_ViasMinSize / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
# Micro via settings # Micro via settings
"microViaDiameter": design_settings.m_MicroViasMinSize / scale, "microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale, "microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter) # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
"minThroughDrill": design_settings.m_MinThroughDrill / scale, "minThroughDrill": design_settings.m_MinThroughDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale, "holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale, "holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
# Other constraints # Other constraints
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale, "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
"silkClearance": design_settings.m_SilkClearance / scale, "silkClearance": design_settings.m_SilkClearance / scale,
} }
return { return {"success": True, "rules": rules}
"success": True,
"rules": rules
}
except Exception as e: except Exception as e:
logger.error(f"Error getting design rules: {str(e)}") logger.error(f"Error getting design rules: {str(e)}")
return { return {
"success": False, "success": False,
"message": "Failed to get design rules", "message": "Failed to get design rules",
"errorDetails": str(e) "errorDetails": str(e),
} }
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -189,7 +192,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
report_path = params.get("reportPath") report_path = params.get("reportPath")
@@ -200,7 +203,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "Board file not found", "message": "Board file not found",
"errorDetails": "Cannot run DRC without a saved board file" "errorDetails": "Cannot run DRC without a saved board file",
} }
# Find kicad-cli executable # Find kicad-cli executable
@@ -209,23 +212,28 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "kicad-cli not found", "message": "kicad-cli not found",
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH." "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
} }
# Create temporary JSON output file # Create temporary JSON output file
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp: with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
json_output = tmp.name json_output = tmp.name
try: try:
# Build command # Build command
cmd = [ cmd = [
kicad_cli, kicad_cli,
'pcb', "pcb",
'drc', "drc",
'--format', 'json', "--format",
'--output', json_output, "json",
'--units', 'mm', "--output",
board_file json_output,
"--units",
"mm",
board_file,
] ]
logger.info(f"Running DRC command: {' '.join(cmd)}") logger.info(f"Running DRC command: {' '.join(cmd)}")
@@ -235,7 +243,7 @@ class DesignRuleCommands:
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=600 # 10 minute timeout for large boards (21MB PCB needs time) timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
) )
if result.returncode != 0: if result.returncode != 0:
@@ -243,32 +251,34 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "DRC command failed", "message": "DRC command failed",
"errorDetails": result.stderr "errorDetails": result.stderr,
} }
# Read JSON output # Read JSON output
with open(json_output, 'r', encoding='utf-8') as f: with open(json_output, "r", encoding="utf-8") as f:
drc_data = json.load(f) drc_data = json.load(f)
# Parse violations from kicad-cli output # Parse violations from kicad-cli output
violations = [] violations = []
violation_counts = {} violation_counts: dict[str, int] = {}
severity_counts = {"error": 0, "warning": 0, "info": 0} severity_counts = {"error": 0, "warning": 0, "info": 0}
for violation in drc_data.get('violations', []): for violation in drc_data.get("violations", []):
vtype = violation.get("type", "unknown") vtype = violation.get("type", "unknown")
vseverity = violation.get("severity", "error") vseverity = violation.get("severity", "error")
violations.append({ violations.append(
"type": vtype, {
"severity": vseverity, "type": vtype,
"message": violation.get("description", ""), "severity": vseverity,
"location": { "message": violation.get("description", ""),
"x": violation.get("x", 0), "location": {
"y": violation.get("y", 0), "x": violation.get("x", 0),
"unit": "mm" "y": violation.get("y", 0),
"unit": "mm",
},
} }
}) )
# Count violations by type # Count violations by type
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1 violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
@@ -280,30 +290,39 @@ class DesignRuleCommands:
# Determine where to save the violations file # Determine where to save the violations file
board_dir = os.path.dirname(board_file) board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0] board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") violations_file = os.path.join(
board_dir, f"{board_name}_drc_violations.json"
)
# Always save violations to JSON file (for large result sets) # Always save violations to JSON file (for large result sets)
with open(violations_file, 'w', encoding='utf-8') as f: with open(violations_file, "w", encoding="utf-8") as f:
json.dump({ json.dump(
"board": board_file, {
"timestamp": drc_data.get("date", "unknown"), "board": board_file,
"total_violations": len(violations), "timestamp": drc_data.get("date", "unknown"),
"violation_counts": violation_counts, "total_violations": len(violations),
"severity_counts": severity_counts, "violation_counts": violation_counts,
"violations": violations "severity_counts": severity_counts,
}, f, indent=2) "violations": violations,
},
f,
indent=2,
)
# Save text report if requested # Save text report if requested
if report_path: if report_path:
report_path = os.path.abspath(os.path.expanduser(report_path)) report_path = os.path.abspath(os.path.expanduser(report_path))
cmd_report = [ cmd_report = [
kicad_cli, kicad_cli,
'pcb', "pcb",
'drc', "drc",
'--format', 'report', "--format",
'--output', report_path, "report",
'--units', 'mm', "--output",
board_file report_path,
"--units",
"mm",
board_file,
] ]
subprocess.run(cmd_report, capture_output=True, timeout=600) subprocess.run(cmd_report, capture_output=True, timeout=600)
@@ -314,10 +333,10 @@ class DesignRuleCommands:
"summary": { "summary": {
"total": len(violations), "total": len(violations),
"by_severity": severity_counts, "by_severity": severity_counts,
"by_type": violation_counts "by_type": violation_counts,
}, },
"violationsFile": violations_file, "violationsFile": violations_file,
"reportPath": report_path if report_path else None "reportPath": report_path if report_path else None,
} }
finally: finally:
@@ -330,14 +349,14 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "DRC command timed out", "message": "DRC command timed out",
"errorDetails": "Command took longer than 600 seconds (10 minutes)" "errorDetails": "Command took longer than 600 seconds (10 minutes)",
} }
except Exception as e: except Exception as e:
logger.error(f"Error running DRC: {str(e)}") logger.error(f"Error running DRC: {str(e)}")
return { return {
"success": False, "success": False,
"message": "Failed to run DRC", "message": "Failed to run DRC",
"errorDetails": str(e) "errorDetails": str(e),
} }
def _find_kicad_cli(self) -> Optional[str]: def _find_kicad_cli(self) -> Optional[str]:
@@ -399,7 +418,7 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
severity = params.get("severity", "all") severity = params.get("severity", "all")
@@ -416,25 +435,27 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "Violations file not found", "message": "Violations file not found",
"errorDetails": "run_drc did not create violations file" "errorDetails": "run_drc did not create violations file",
} }
# Load violations from file # Load violations from file
with open(violations_file, 'r', encoding='utf-8') as f: with open(violations_file, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
all_violations = data.get("violations", []) all_violations = data.get("violations", [])
# Filter by severity if specified # Filter by severity if specified
if severity != "all": if severity != "all":
filtered_violations = [v for v in all_violations if v.get("severity") == severity] filtered_violations = [
v for v in all_violations if v.get("severity") == severity
]
else: else:
filtered_violations = all_violations filtered_violations = all_violations
return { return {
"success": True, "success": True,
"violations": filtered_violations, "violations": filtered_violations,
"violationsFile": violations_file # Include file path for reference "violationsFile": violations_file, # Include file path for reference
} }
except Exception as e: except Exception as e:
@@ -442,5 +463,5 @@ class DesignRuleCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get DRC violations", "message": "Failed to get DRC violations",
"errorDetails": str(e) "errorDetails": str(e),
} }

View File

@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import glob import glob
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class LibraryManager: class LibraryManager:
@@ -85,7 +85,7 @@ class LibraryManager:
) )
""" """
try: try:
with open(table_path, 'r') as f: with open(table_path, "r") as f:
content = f.read() content = f.read()
# Simple regex-based parser for lib entries # Simple regex-based parser for lib entries
@@ -103,7 +103,9 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}") logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else: else:
logger.warning(f" Could not resolve URI for library {nickname}: {uri}") logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
except Exception as e: except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -124,23 +126,23 @@ class LibraryManager:
# Common KiCAD environment variables # Common KiCAD environment variables
env_vars = { env_vars = {
'KICAD9_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), "KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
'KICAD8_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), "KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
'KICAD_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), "KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
'KISYSMOD': self._find_kicad_footprint_dir(), "KISYSMOD": self._find_kicad_footprint_dir(),
'KICAD9_3RD_PARTY': self._find_kicad_3rdparty_dir(), "KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
'KICAD8_3RD_PARTY': self._find_kicad_3rdparty_dir(), "KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
} }
# Project directory # Project directory
if self.project_path: if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path) env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables # Replace environment variables
for var, value in env_vars.items(): for var, value in env_vars.items():
if value: if value:
resolved = resolved.replace(f'${{{var}}}', value) resolved = resolved.replace(f"${{{var}}}", value)
resolved = resolved.replace(f'${var}', value) resolved = resolved.replace(f"${var}", value)
# Expand ~ to home directory # Expand ~ to home directory
resolved = os.path.expanduser(resolved) resolved = os.path.expanduser(resolved)
@@ -167,10 +169,10 @@ class LibraryManager:
] ]
# Also check environment variable # Also check environment variable
if 'KICAD9_FOOTPRINT_DIR' in os.environ: if "KICAD9_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ['KICAD9_FOOTPRINT_DIR']) possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
if 'KICAD8_FOOTPRINT_DIR' in os.environ: if "KICAD8_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ['KICAD8_FOOTPRINT_DIR']) possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"])
for path in possible_paths: for path in possible_paths:
if os.path.isdir(path): if os.path.isdir(path):
@@ -190,26 +192,36 @@ class LibraryManager:
import json import json
# 1. Check shell environment variable first # 1. Check shell environment variable first
if 'KICAD9_3RD_PARTY' in os.environ: if "KICAD9_3RD_PARTY" in os.environ:
path = os.environ['KICAD9_3RD_PARTY'] path = os.environ["KICAD9_3RD_PARTY"]
if os.path.isdir(path): if os.path.isdir(path):
return path return path
# 2. Check kicad_common.json for user-defined variables # 2. Check kicad_common.json for user-defined variables
kicad_common_paths = [ kicad_common_paths = [
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "kicad_common.json", # macOS Path.home()
/ "Library"
/ "Preferences"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
] ]
for config_path in kicad_common_paths: for config_path in kicad_common_paths:
if config_path.exists(): if config_path.exists():
try: try:
with open(config_path, 'r') as f: with open(config_path, "r") as f:
config = json.load(f) config = json.load(f)
env_vars = config.get('environment', {}).get('vars', {}) env_vars = config.get("environment", {}).get("vars", {})
if env_vars and 'KICAD9_3RD_PARTY' in env_vars: if env_vars and "KICAD9_3RD_PARTY" in env_vars:
path = env_vars['KICAD9_3RD_PARTY'] path = env_vars["KICAD9_3RD_PARTY"]
if os.path.isdir(path): if os.path.isdir(path):
return path return path
except (json.JSONDecodeError, KeyError, TypeError): except (json.JSONDecodeError, KeyError, TypeError):
@@ -231,10 +243,10 @@ class LibraryManager:
Path.home() / "Documents" / "KiCad" / version / "3rdparty", Path.home() / "Documents" / "KiCad" / version / "3rdparty",
] ]
for path in possible_paths: for candidate in possible_paths:
if path.exists(): if candidate.exists():
logger.info(f"Found KiCad 3rd party directory: {path}") logger.info(f"Found KiCad 3rd party directory: {candidate}")
return str(path) return str(candidate)
logger.warning("Could not find KiCad 3rd party directory") logger.warning("Could not find KiCad 3rd party directory")
return None return None
@@ -325,7 +337,9 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items(): for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists(): if fp_file.exists():
logger.info(f"Found footprint {footprint_name} in library {library_nickname}") logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
return (library_path, footprint_name) return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}") logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -354,18 +368,22 @@ class LibraryManager:
for footprint in footprints: for footprint in footprints:
if regex.search(footprint.lower()): if regex.search(footprint.lower()):
results.append({ results.append(
'library': library_nickname, {
'footprint': footprint, "library": library_nickname,
'full_name': f"{library_nickname}:{footprint}" "footprint": footprint,
}) "full_name": f"{library_nickname}:{footprint}",
}
)
if len(results) >= limit: if len(results) >= limit:
return results return results
return results return results
def get_footprint_info(self, library_nickname: str, footprint_name: str) -> Optional[Dict[str, str]]: def get_footprint_info(
self, library_nickname: str, footprint_name: str
) -> Optional[Dict[str, str]]:
""" """
Get information about a specific footprint Get information about a specific footprint
@@ -385,11 +403,11 @@ class LibraryManager:
return None return None
return { return {
'library': library_nickname, "library": library_nickname,
'footprint': footprint_name, "footprint": footprint_name,
'full_name': f"{library_nickname}:{footprint_name}", "full_name": f"{library_nickname}:{footprint_name}",
'path': str(fp_file), "path": str(fp_file),
'library_path': library_path "library_path": library_path,
} }
@@ -404,39 +422,48 @@ class LibraryCommands:
"""List all available footprint libraries""" """List all available footprint libraries"""
try: try:
libraries = self.library_manager.list_libraries() libraries = self.library_manager.list_libraries()
return { return {"success": True, "libraries": libraries, "count": len(libraries)}
"success": True,
"libraries": libraries,
"count": len(libraries)
}
except Exception as e: except Exception as e:
logger.error(f"Error listing libraries: {e}") logger.error(f"Error listing libraries: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to list libraries", "message": "Failed to list libraries",
"errorDetails": str(e) "errorDetails": str(e),
} }
def search_footprints(self, params: Dict) -> Dict: def search_footprints(self, params: Dict) -> Dict:
"""Search for footprints by pattern""" """Search for footprints by pattern"""
try: try:
pattern = params.get("pattern", "*") # Support both 'pattern' and 'search_term' parameter names
pattern = params.get("pattern") or params.get("search_term", "*")
limit = params.get("limit", 20) limit = params.get("limit", 20)
library_filter = params.get("library")
results = self.library_manager.search_footprints(pattern, limit) results = self.library_manager.search_footprints(
pattern, limit * 10 if library_filter else limit
)
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
return { return {
"success": True, "success": True,
"footprints": results, "footprints": results,
"count": len(results), "count": len(results),
"pattern": pattern "pattern": pattern,
} }
except Exception as e: except Exception as e:
logger.error(f"Error searching footprints: {e}") logger.error(f"Error searching footprints: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to search footprints", "message": "Failed to search footprints",
"errorDetails": str(e) "errorDetails": str(e),
} }
def list_library_footprints(self, params: Dict) -> Dict: def list_library_footprints(self, params: Dict) -> Dict:
@@ -444,10 +471,7 @@ class LibraryCommands:
try: try:
library = params.get("library") library = params.get("library")
if not library: if not library:
return { return {"success": False, "message": "Missing library parameter"}
"success": False,
"message": "Missing library parameter"
}
footprints = self.library_manager.list_footprints(library) footprints = self.library_manager.list_footprints(library)
@@ -455,14 +479,14 @@ class LibraryCommands:
"success": True, "success": True,
"library": library, "library": library,
"footprints": footprints, "footprints": footprints,
"count": len(footprints) "count": len(footprints),
} }
except Exception as e: except Exception as e:
logger.error(f"Error listing library footprints: {e}") logger.error(f"Error listing library footprints: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to list library footprints", "message": "Failed to list library footprints",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_footprint_info(self, params: Dict) -> Dict: def get_footprint_info(self, params: Dict) -> Dict:
@@ -470,10 +494,7 @@ class LibraryCommands:
try: try:
footprint_spec = params.get("footprint") footprint_spec = params.get("footprint")
if not footprint_spec: if not footprint_spec:
return { return {"success": False, "message": "Missing footprint parameter"}
"success": False,
"message": "Missing footprint parameter"
}
# Try to find the footprint # Try to find the footprint
result = self.library_manager.find_footprint(footprint_spec) result = self.library_manager.find_footprint(footprint_spec)
@@ -491,17 +512,14 @@ class LibraryCommands:
"library": library_nickname, "library": library_nickname,
"footprint": footprint_name, "footprint": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}", "full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path "library_path": library_path,
} }
return { return {"success": True, "footprint_info": info}
"success": True,
"footprint_info": info
}
else: else:
return { return {
"success": False, "success": False,
"message": f"Footprint not found: {footprint_spec}" "message": f"Footprint not found: {footprint_spec}",
} }
except Exception as e: except Exception as e:
@@ -509,5 +527,5 @@ class LibraryCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get footprint info", "message": "Failed to get footprint info",
"errorDetails": str(e) "errorDetails": str(e),
} }

View File

@@ -14,7 +14,7 @@ import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
from skip import Schematic from skip import Schematic
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class PinLocator: class PinLocator:
@@ -23,6 +23,7 @@ class PinLocator:
def __init__(self): def __init__(self):
"""Initialize pin locator with empty cache""" """Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@staticmethod @staticmethod
def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]: def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]:
@@ -47,39 +48,39 @@ class PinLocator:
return return
# Check if this is a pin definition # Check if this is a pin definition
if len(sexp) > 0 and sexp[0] == Symbol('pin'): if len(sexp) > 0 and sexp[0] == Symbol("pin"):
# Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num")) # Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num"))
pin_data = { pin_data = {
'x': 0, "x": 0,
'y': 0, "y": 0,
'angle': 0, "angle": 0,
'length': 0, "length": 0,
'name': '', "name": "",
'number': '', "number": "",
'type': str(sexp[1]) if len(sexp) > 1 else 'passive' "type": str(sexp[1]) if len(sexp) > 1 else "passive",
} }
# Extract pin attributes # Extract pin attributes
for item in sexp: for item in sexp:
if isinstance(item, list) and len(item) > 0: if isinstance(item, list) and len(item) > 0:
if item[0] == Symbol('at') and len(item) >= 3: if item[0] == Symbol("at") and len(item) >= 3:
pin_data['x'] = float(item[1]) pin_data["x"] = float(item[1])
pin_data['y'] = float(item[2]) pin_data["y"] = float(item[2])
if len(item) >= 4: if len(item) >= 4:
pin_data['angle'] = float(item[3]) pin_data["angle"] = float(item[3])
elif item[0] == Symbol('length') and len(item) >= 2: elif item[0] == Symbol("length") and len(item) >= 2:
pin_data['length'] = float(item[1]) pin_data["length"] = float(item[1])
elif item[0] == Symbol('name') and len(item) >= 2: elif item[0] == Symbol("name") and len(item) >= 2:
pin_data['name'] = str(item[1]).strip('"') pin_data["name"] = str(item[1]).strip('"')
elif item[0] == Symbol('number') and len(item) >= 2: elif item[0] == Symbol("number") and len(item) >= 2:
pin_data['number'] = str(item[1]).strip('"') pin_data["number"] = str(item[1]).strip('"')
# Store by pin number # Store by pin number
if pin_data['number']: if pin_data["number"]:
pins[pin_data['number']] = pin_data pins[pin_data["number"]] = pin_data
# Recurse into sublists # Recurse into sublists
for item in sexp: for item in sexp:
@@ -108,7 +109,7 @@ class PinLocator:
try: try:
# Read schematic # Read schematic
with open(schematic_path, 'r', encoding='utf-8') as f: with open(schematic_path, "r", encoding="utf-8") as f:
sch_content = f.read() sch_content = f.read()
sch_data = sexpdata.loads(sch_content) sch_data = sexpdata.loads(sch_content)
@@ -116,7 +117,11 @@ class PinLocator:
# Find lib_symbols section # Find lib_symbols section
lib_symbols = None lib_symbols = None
for item in sch_data: for item in sch_data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('lib_symbols'): if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
lib_symbols = item lib_symbols = item
break break
@@ -126,7 +131,11 @@ class PinLocator:
# Find the specific symbol definition # Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol('symbol'): if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
symbol_name = str(item[1]).strip('"') symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id: if symbol_name == lib_id:
# Found the symbol, parse pins # Found the symbol, parse pins
@@ -141,6 +150,7 @@ class PinLocator:
except Exception as e: except Exception as e:
logger.error(f"Error getting symbol pins: {e}") logger.error(f"Error getting symbol pins: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {} return {}
@@ -169,8 +179,9 @@ class PinLocator:
return (rotated_x, rotated_y) return (rotated_x, rotated_y)
def get_pin_location(self, schematic_path: Path, symbol_reference: str, def get_pin_location(
pin_number: str) -> Optional[List[float]]: self, schematic_path: Path, symbol_reference: str, pin_number: str
) -> Optional[List[float]]:
""" """
Get the absolute location of a pin on a symbol instance Get the absolute location of a pin on a symbol instance
@@ -184,7 +195,11 @@ class PinLocator:
""" """
try: try:
# Load schematic with kicad-skip to get symbol instance # Load schematic with kicad-skip to get symbol instance
sch = Schematic(str(schematic_path)) # Use cache to avoid reloading the file for every pin lookup
sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
# Find the symbol instance # Find the symbol instance
target_symbol = None target_symbol = None
@@ -205,12 +220,16 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id # Get symbol lib_id
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id: if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id") logger.error(f"Symbol {symbol_reference} has no lib_id")
return None return None
logger.debug(f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}") logger.debug(
f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}"
)
# Get pin definitions for this symbol # Get pin definitions for this symbol
pins = self.get_symbol_pins(schematic_path, lib_id) pins = self.get_symbol_pins(schematic_path, lib_id)
@@ -220,36 +239,49 @@ class PinLocator:
# Find the requested pin # Find the requested pin
if pin_number not in pins: if pin_number not in pins:
logger.error(f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}") logger.error(
f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}"
)
return None return None
pin_data = pins[pin_number] pin_data = pins[pin_number]
# Get pin position relative to symbol origin # Get pin position relative to symbol origin
pin_rel_x = pin_data['x'] pin_rel_x = pin_data["x"]
pin_rel_y = pin_data['y'] pin_rel_y = pin_data["y"]
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
# Apply symbol rotation to pin position # Apply symbol rotation to pin position
if symbol_rotation != 0: if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) pin_rel_x, pin_rel_y = self.rotate_point(
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
# Calculate absolute position # Calculate absolute position
abs_x = symbol_x + pin_rel_x abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y abs_y = symbol_y + pin_rel_y
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
return [abs_x, abs_y] return [abs_x, abs_y]
except Exception as e: except Exception as e:
logger.error(f"Error getting pin location: {e}") logger.error(f"Error getting pin location: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return None return None
def get_all_symbol_pins(self, schematic_path: Path, symbol_reference: str) -> Dict[str, List[float]]: def get_all_symbol_pins(
self, schematic_path: Path, symbol_reference: str
) -> Dict[str, List[float]]:
""" """
Get locations of all pins on a symbol instance Get locations of all pins on a symbol instance
@@ -261,8 +293,11 @@ class PinLocator:
Dictionary mapping pin number -> [x, y] coordinates Dictionary mapping pin number -> [x, y] coordinates
""" """
try: try:
# Load schematic # Load schematic (use cache)
sch = Schematic(str(schematic_path)) sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
# Find symbol # Find symbol
target_symbol = None target_symbol = None
@@ -276,7 +311,9 @@ class PinLocator:
return {} return {}
# Get lib_id # Get lib_id
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id: if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id") logger.error(f"Symbol {symbol_reference} has no lib_id")
return {} return {}
@@ -289,7 +326,9 @@ class PinLocator:
# Calculate location for each pin # Calculate location for each pin
result = {} result = {}
for pin_num in pins.keys(): for pin_num in pins.keys():
location = self.get_pin_location(schematic_path, symbol_reference, pin_num) location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
if location: if location:
result[pin_num] = location result[pin_num] = location
@@ -301,10 +340,11 @@ class PinLocator:
return {} return {}
if __name__ == '__main__': if __name__ == "__main__":
# Test pin location discovery # Test pin location discovery
import sys import sys
sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python')
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path from pathlib import Path
from commands.component_schematic import ComponentManager from commands.component_schematic import ComponentManager
@@ -316,8 +356,10 @@ if __name__ == '__main__':
print("=" * 80) print("=" * 80)
# Create test schematic with components (cross-platform temp directory) # Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / 'test_pin_locator.kicad_sch' test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch') template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path) shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}") print(f"\n✓ Created test schematic: {test_path}")
@@ -327,11 +369,25 @@ if __name__ == '__main__':
sch = SchematicManager.load_schematic(str(test_path)) sch = SchematicManager.load_schematic(str(test_path))
# Add resistor at (100, 100), rotation 0 # Add resistor at (100, 100), rotation 0
r1_def = {'type': 'R', 'reference': 'R1', 'value': '10k', 'x': 100, 'y': 100, 'rotation': 0} r1_def = {
"type": "R",
"reference": "R1",
"value": "10k",
"x": 100,
"y": 100,
"rotation": 0,
}
ComponentManager.add_component(sch, r1_def, test_path) ComponentManager.add_component(sch, r1_def, test_path)
# Add capacitor at (150, 100), rotation 90 # Add capacitor at (150, 100), rotation 90
c1_def = {'type': 'C', 'reference': 'C1', 'value': '100nF', 'x': 150, 'y': 100, 'rotation': 90} c1_def = {
"type": "C",
"reference": "C1",
"value": "100nF",
"x": 150,
"y": 100,
"rotation": 90,
}
ComponentManager.add_component(sch, c1_def, test_path) ComponentManager.add_component(sch, c1_def, test_path)
SchematicManager.save_schematic(sch, str(test_path)) SchematicManager.save_schematic(sch, str(test_path))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,46 +2,47 @@
* KiCAD MCP Server implementation * KiCAD MCP Server implementation
*/ */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import express from 'express'; import express from "express";
import { spawn, exec, execSync, ChildProcess } from 'child_process'; import { spawn, exec, execSync, ChildProcess } from "child_process";
import { existsSync } from 'fs'; import { existsSync } from "fs";
import { join, dirname } from 'path'; import { join, dirname } from "path";
import { logger } from './logger.js'; import { logger } from "./logger.js";
// Import tool registration functions // Import tool registration functions
import { registerProjectTools } from './tools/project.js'; import { registerProjectTools } from "./tools/project.js";
import { registerBoardTools } from './tools/board.js'; import { registerBoardTools } from "./tools/board.js";
import { registerComponentTools } from './tools/component.js'; import { registerComponentTools } from "./tools/component.js";
import { registerRoutingTools } from './tools/routing.js'; import { registerRoutingTools } from "./tools/routing.js";
import { registerDesignRuleTools } from './tools/design-rules.js'; import { registerDesignRuleTools } from "./tools/design-rules.js";
import { registerExportTools } from './tools/export.js'; import { registerExportTools } from "./tools/export.js";
import { registerSchematicTools } from './tools/schematic.js'; import { registerSchematicTools } from "./tools/schematic.js";
import { registerLibraryTools } from './tools/library.js'; import { registerLibraryTools } from "./tools/library.js";
import { registerSymbolLibraryTools } from './tools/library-symbol.js'; import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
import { registerJLCPCBApiTools } from './tools/jlcpcb-api.js'; import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
import { registerUITools } from './tools/ui.js'; import { registerDatasheetTools } from "./tools/datasheet.js";
import { registerRouterTools } from './tools/router.js'; import { registerUITools } from "./tools/ui.js";
import { registerRouterTools } from "./tools/router.js";
// Import resource registration functions // Import resource registration functions
import { registerProjectResources } from './resources/project.js'; import { registerProjectResources } from "./resources/project.js";
import { registerBoardResources } from './resources/board.js'; import { registerBoardResources } from "./resources/board.js";
import { registerComponentResources } from './resources/component.js'; import { registerComponentResources } from "./resources/component.js";
import { registerLibraryResources } from './resources/library.js'; import { registerLibraryResources } from "./resources/library.js";
// Import prompt registration functions // Import prompt registration functions
import { registerComponentPrompts } from './prompts/component.js'; import { registerComponentPrompts } from "./prompts/component.js";
import { registerRoutingPrompts } from './prompts/routing.js'; import { registerRoutingPrompts } from "./prompts/routing.js";
import { registerDesignPrompts } from './prompts/design.js'; import { registerDesignPrompts } from "./prompts/design.js";
/** /**
* Find the Python executable to use * Find the Python executable to use
* Prioritizes virtual environment if available, falls back to system Python * Prioritizes virtual environment if available, falls back to system Python
*/ */
function findPythonExecutable(scriptPath: string): string { function findPythonExecutable(scriptPath: string): string {
const isWindows = process.platform === 'win32'; const isWindows = process.platform === "win32";
const isMac = process.platform === 'darwin'; const isMac = process.platform === "darwin";
const isLinux = !isWindows && !isMac; const isLinux = !isWindows && !isMac;
// Get the project root (parent of the python/ directory) // Get the project root (parent of the python/ directory)
@@ -49,8 +50,18 @@ function findPythonExecutable(scriptPath: string): string {
// Check for virtual environment // Check for virtual environment
const venvPaths = [ const venvPaths = [
join(projectRoot, 'venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'), join(
join(projectRoot, '.venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'), projectRoot,
"venv",
isWindows ? "Scripts" : "bin",
isWindows ? "python.exe" : "python",
),
join(
projectRoot,
".venv",
isWindows ? "Scripts" : "bin",
isWindows ? "python.exe" : "python",
),
]; ];
for (const venvPath of venvPaths) { for (const venvPath of venvPaths) {
@@ -62,27 +73,29 @@ function findPythonExecutable(scriptPath: string): string {
// Allow override via KICAD_PYTHON environment variable (any platform) // Allow override via KICAD_PYTHON environment variable (any platform)
if (process.env.KICAD_PYTHON) { if (process.env.KICAD_PYTHON) {
logger.info(`Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`); logger.info(
`Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`,
);
return process.env.KICAD_PYTHON; return process.env.KICAD_PYTHON;
} }
// Platform-specific KiCAD bundled Python detection // Platform-specific KiCAD bundled Python detection
if (isWindows && process.env.PYTHONPATH?.includes('KiCad')) { if (isWindows) {
// Windows: Try KiCAD's bundled Python // Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it)
const kicadPython = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe'; const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe";
if (existsSync(kicadPython)) { if (existsSync(kicadPython)) {
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`); logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
return kicadPython; return kicadPython;
} }
} else if (isMac) { } else if (isMac) {
// macOS: Try KiCAD's bundled Python (check multiple versions and locations) // macOS: Try KiCAD's bundled Python (check multiple versions and locations)
const kicadPythonVersions = ['3.9', '3.10', '3.11', '3.12', '3.13']; const kicadPythonVersions = ["3.9", "3.10", "3.11", "3.12", "3.13"];
// Standard KiCAD installation paths // Standard KiCAD installation paths
const kicadAppPaths = [ const kicadAppPaths = [
'/Applications/KiCad/KiCad.app', "/Applications/KiCad/KiCad.app",
'/Applications/KiCAD/KiCad.app', // Alternative capitalization "/Applications/KiCAD/KiCad.app", // Alternative capitalization
`${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder `${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder
]; ];
// Check all KiCAD app locations with all Python versions // Check all KiCAD app locations with all Python versions
@@ -98,24 +111,26 @@ function findPythonExecutable(scriptPath: string): string {
// Fallback to Homebrew Python (if pcbnew is installed via pip) // Fallback to Homebrew Python (if pcbnew is installed via pip)
const homebrewPaths = [ const homebrewPaths = [
'/opt/homebrew/bin/python3', // Apple Silicon "/opt/homebrew/bin/python3", // Apple Silicon
'/usr/local/bin/python3', // Intel Mac "/usr/local/bin/python3", // Intel Mac
'/opt/homebrew/bin/python3.12', "/opt/homebrew/bin/python3.12",
'/opt/homebrew/bin/python3.11', "/opt/homebrew/bin/python3.11",
]; ];
for (const path of homebrewPaths) { for (const path of homebrewPaths) {
if (existsSync(path)) { if (existsSync(path)) {
logger.info(`Found Homebrew Python at: ${path} (ensure pcbnew is importable)`); logger.info(
`Found Homebrew Python at: ${path} (ensure pcbnew is importable)`,
);
return path; return path;
} }
} }
} else if (isLinux) { } else if (isLinux) {
// Linux: Try KiCAD bundled Python locations first // Linux: Try KiCAD bundled Python locations first
const linuxKicadPaths = [ const linuxKicadPaths = [
'/usr/lib/kicad/bin/python3', "/usr/lib/kicad/bin/python3",
'/usr/local/lib/kicad/bin/python3', "/usr/local/lib/kicad/bin/python3",
'/opt/kicad/bin/python3', "/opt/kicad/bin/python3",
]; ];
for (const path of linuxKicadPaths) { for (const path of linuxKicadPaths) {
@@ -127,17 +142,17 @@ function findPythonExecutable(scriptPath: string): string {
// Resolve system python3 to full path using 'which' // Resolve system python3 to full path using 'which'
try { try {
const result = execSync('which python3', { encoding: 'utf-8' }).trim(); const result = execSync("which python3", { encoding: "utf-8" }).trim();
if (result && existsSync(result)) { if (result && existsSync(result)) {
logger.info(`Resolved system Python via which: ${result}`); logger.info(`Resolved system Python via which: ${result}`);
return result; return result;
} }
} catch (e) { } catch (e) {
logger.warn('Failed to resolve python3 via which command'); logger.warn("Failed to resolve python3 via which command");
} }
// Fallback to common system paths // Fallback to common system paths
const systemPaths = ['/usr/bin/python3', '/bin/python3']; const systemPaths = ["/usr/bin/python3", "/bin/python3"];
for (const path of systemPaths) { for (const path of systemPaths) {
if (existsSync(path)) { if (existsSync(path)) {
logger.info(`Found system Python at: ${path}`); logger.info(`Found system Python at: ${path}`);
@@ -147,8 +162,8 @@ function findPythonExecutable(scriptPath: string): string {
} }
// Default to system Python (last resort) // Default to system Python (last resort)
logger.info('Using system Python (no venv found)'); logger.info("Using system Python (no venv found)");
return isWindows ? 'python.exe' : 'python3'; return isWindows ? "python.exe" : "python3";
} }
/** /**
@@ -159,11 +174,19 @@ export class KiCADMcpServer {
private pythonProcess: ChildProcess | null = null; private pythonProcess: ChildProcess | null = null;
private kicadScriptPath: string; private kicadScriptPath: string;
private stdioTransport!: StdioServerTransport; private stdioTransport!: StdioServerTransport;
private requestQueue: Array<{ request: any, resolve: Function, reject: Function }> = []; private requestQueue: Array<{
request: any;
resolve: Function;
reject: Function;
}> = [];
private processingRequest = false; private processingRequest = false;
private responseBuffer: string = ''; private responseBuffer: string = "";
private currentRequestHandler: { resolve: Function, reject: Function, timeoutHandle: NodeJS.Timeout } | null = null; private currentRequestHandler: {
resolve: Function;
reject: Function;
timeoutHandle: NodeJS.Timeout;
} | null = null;
/** /**
* Constructor for the KiCAD MCP Server * Constructor for the KiCAD MCP Server
* @param kicadScriptPath Path to the Python KiCAD interface script * @param kicadScriptPath Path to the Python KiCAD interface script
@@ -171,37 +194,39 @@ export class KiCADMcpServer {
*/ */
constructor( constructor(
kicadScriptPath: string, kicadScriptPath: string,
logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info' logLevel: "error" | "warn" | "info" | "debug" = "info",
) { ) {
// Set up the logger // Set up the logger
logger.setLogLevel(logLevel); logger.setLogLevel(logLevel);
// Check if KiCAD script exists // Check if KiCAD script exists
this.kicadScriptPath = kicadScriptPath; this.kicadScriptPath = kicadScriptPath;
if (!existsSync(this.kicadScriptPath)) { if (!existsSync(this.kicadScriptPath)) {
throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); throw new Error(
`KiCAD interface script not found: ${this.kicadScriptPath}`,
);
} }
// Initialize the MCP server // Initialize the MCP server
this.server = new McpServer({ this.server = new McpServer({
name: 'kicad-mcp-server', name: "kicad-mcp-server",
version: '1.0.0', version: "1.0.0",
description: 'MCP server for KiCAD PCB design operations' description: "MCP server for KiCAD PCB design operations",
}); });
// Initialize STDIO transport // Initialize STDIO transport
this.stdioTransport = new StdioServerTransport(); this.stdioTransport = new StdioServerTransport();
logger.info('Using STDIO transport for local communication'); logger.info("Using STDIO transport for local communication");
// Register tools, resources, and prompts // Register tools, resources, and prompts
this.registerAll(); this.registerAll();
} }
/** /**
* Register all tools, resources, and prompts * Register all tools, resources, and prompts
*/ */
private registerAll(): void { private registerAll(): void {
logger.info('Registering KiCAD tools, resources, and prompts...'); logger.info("Registering KiCAD tools, resources, and prompts...");
// Register router tools FIRST (for tool discovery and execution) // Register router tools FIRST (for tool discovery and execution)
registerRouterTools(this.server, this.callKicadScript.bind(this)); registerRouterTools(this.server, this.callKicadScript.bind(this));
@@ -217,6 +242,7 @@ export class KiCADMcpServer {
registerLibraryTools(this.server, this.callKicadScript.bind(this)); registerLibraryTools(this.server, this.callKicadScript.bind(this));
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this)); registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this)); registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
registerUITools(this.server, this.callKicadScript.bind(this)); registerUITools(this.server, this.callKicadScript.bind(this));
// Register all resources // Register all resources
@@ -230,20 +256,26 @@ export class KiCADMcpServer {
registerRoutingPrompts(this.server); registerRoutingPrompts(this.server);
registerDesignPrompts(this.server); registerDesignPrompts(this.server);
logger.info('All KiCAD tools, resources, and prompts registered'); logger.info("All KiCAD tools, resources, and prompts registered");
logger.info('Router pattern enabled: 4 router tools + direct tools for discovery'); logger.info(
"Router pattern enabled: 4 router tools + direct tools for discovery",
);
} }
/** /**
* Validate prerequisites before starting the server * Validate prerequisites before starting the server
*/ */
private async validatePrerequisites(pythonExe: string): Promise<boolean> { private async validatePrerequisites(pythonExe: string): Promise<boolean> {
const isWindows = process.platform === 'win32'; const isWindows = process.platform === "win32";
const isLinux = process.platform !== 'win32' && process.platform !== 'darwin'; const isLinux =
process.platform !== "win32" && process.platform !== "darwin";
const errors: string[] = []; const errors: string[] = [];
// Check if Python executable exists (for absolute paths) or is executable (for commands) // Check if Python executable exists (for absolute paths) or is executable (for commands)
const isAbsolutePath = pythonExe.startsWith('/') || pythonExe.startsWith('C:') || pythonExe.startsWith('\\'); const isAbsolutePath =
pythonExe.startsWith("/") ||
pythonExe.startsWith("C:") ||
pythonExe.startsWith("\\");
if (isAbsolutePath) { if (isAbsolutePath) {
// Absolute path: use existsSync // Absolute path: use existsSync
@@ -251,42 +283,61 @@ export class KiCADMcpServer {
errors.push(`Python executable not found: ${pythonExe}`); errors.push(`Python executable not found: ${pythonExe}`);
if (isWindows) { if (isWindows) {
errors.push('Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/'); errors.push(
errors.push('Or run: .\\setup-windows.ps1 for automatic configuration'); "Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/",
);
errors.push(
"Or run: .\\setup-windows.ps1 for automatic configuration",
);
} else if (isLinux) { } else if (isLinux) {
errors.push('Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable'); errors.push(
errors.push('Set KICAD_PYTHON to specify a custom Python path'); "Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable",
);
errors.push("Set KICAD_PYTHON to specify a custom Python path");
} }
} }
} else { } else {
// Command name: verify it's executable via --version test // Command name: verify it's executable via --version test
logger.info(`Validating command-based Python executable: ${pythonExe}`); logger.info(`Validating command-based Python executable: ${pythonExe}`);
try { try {
const { stdout } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { const { stdout } = await new Promise<{
exec(`"${pythonExe}" --version`, { stdout: string;
timeout: 3000, stderr: string;
env: { ...process.env } }>((resolve, reject) => {
}, (error: any, stdout: string, stderr: string) => { exec(
if (error) { `"${pythonExe}" --version`,
reject(error); {
} else { timeout: 3000,
resolve({ stdout, stderr }); env: { ...process.env },
} },
}); (error: any, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
},
);
}); });
logger.info(`Python version check passed: ${stdout.trim()}`); logger.info(`Python version check passed: ${stdout.trim()}`);
} catch (error: any) { } catch (error: any) {
errors.push(`Python executable not found in PATH: ${pythonExe}`); errors.push(`Python executable not found in PATH: ${pythonExe}`);
errors.push(`Error: ${error.message}`); errors.push(`Error: ${error.message}`);
errors.push('Set KICAD_PYTHON environment variable to specify full path'); errors.push(
"Set KICAD_PYTHON environment variable to specify full path",
);
if (isLinux) { if (isLinux) {
errors.push(''); errors.push("");
errors.push('Linux troubleshooting:'); errors.push("Linux troubleshooting:");
errors.push('1. Check if python3 is installed: which python3'); errors.push("1. Check if python3 is installed: which python3");
errors.push('2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)'); errors.push(
errors.push('3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config'); "2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)",
);
errors.push(
"3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config",
);
} }
} }
} }
@@ -297,76 +348,91 @@ export class KiCADMcpServer {
} }
// Check if dist/index.js exists (if running from compiled code) // Check if dist/index.js exists (if running from compiled code)
const distPath = join(dirname(dirname(this.kicadScriptPath)), 'dist', 'index.js'); const distPath = join(
dirname(dirname(this.kicadScriptPath)),
"dist",
"index.js",
);
if (!existsSync(distPath)) { if (!existsSync(distPath)) {
errors.push('Project not built. Run: npm run build'); errors.push("Project not built. Run: npm run build");
} }
// Try to test pcbnew import (quick validation) // Try to test pcbnew import (quick validation)
if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) { if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) {
logger.info('Validating pcbnew module access...'); logger.info("Validating pcbnew module access...");
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
try { try {
const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { const { stdout, stderr } = await new Promise<{
exec(testCommand, { stdout: string;
timeout: 5000, stderr: string;
env: { ...process.env } }>((resolve, reject) => {
}, (error: any, stdout: string, stderr: string) => { exec(
if (error) { testCommand,
reject(error); {
} else { timeout: 5000,
resolve({ stdout, stderr }); env: { ...process.env },
} },
}); (error: any, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
},
);
}); });
if (!stdout.includes('OK')) { if (!stdout.includes("OK")) {
errors.push('pcbnew module import test failed'); errors.push("pcbnew module import test failed");
errors.push(`Output: ${stdout}`); errors.push(`Output: ${stdout}`);
errors.push(`Errors: ${stderr}`); errors.push(`Errors: ${stderr}`);
if (isWindows) { if (isWindows) {
errors.push(''); errors.push("");
errors.push('Windows troubleshooting:'); errors.push("Windows troubleshooting:");
errors.push('1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages'); errors.push(
errors.push('2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"'); "1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages",
errors.push('3. Run: .\\setup-windows.ps1 for automatic fix'); );
errors.push('4. See: docs/WINDOWS_TROUBLESHOOTING.md'); errors.push(
'2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"',
);
errors.push("3. Run: .\\setup-windows.ps1 for automatic fix");
errors.push("4. See: docs/WINDOWS_TROUBLESHOOTING.md");
} }
} else { } else {
logger.info('✓ pcbnew module validated successfully'); logger.info("✓ pcbnew module validated successfully");
} }
} catch (error: any) { } catch (error: any) {
errors.push(`pcbnew validation failed: ${error.message}`); errors.push(`pcbnew validation failed: ${error.message}`);
if (isWindows) { if (isWindows) {
errors.push(''); errors.push("");
errors.push('This usually means:'); errors.push("This usually means:");
errors.push('- KiCAD is not installed'); errors.push("- KiCAD is not installed");
errors.push('- PYTHONPATH is incorrect'); errors.push("- PYTHONPATH is incorrect");
errors.push('- Python cannot find pcbnew module'); errors.push("- Python cannot find pcbnew module");
errors.push(''); errors.push("");
errors.push('Quick fix: Run .\\setup-windows.ps1'); errors.push("Quick fix: Run .\\setup-windows.ps1");
} }
} }
} }
// Log all errors // Log all errors
if (errors.length > 0) { if (errors.length > 0) {
logger.error('='.repeat(70)); logger.error("=".repeat(70));
logger.error('STARTUP VALIDATION FAILED'); logger.error("STARTUP VALIDATION FAILED");
logger.error('='.repeat(70)); logger.error("=".repeat(70));
errors.forEach(err => logger.error(err)); errors.forEach((err) => logger.error(err));
logger.error('='.repeat(70)); logger.error("=".repeat(70));
// Also write to stderr for Claude Desktop to capture // Also write to stderr for Claude Desktop to capture
process.stderr.write('\n' + '='.repeat(70) + '\n'); process.stderr.write("\n" + "=".repeat(70) + "\n");
process.stderr.write('KiCAD MCP Server - Startup Validation Failed\n'); process.stderr.write("KiCAD MCP Server - Startup Validation Failed\n");
process.stderr.write('='.repeat(70) + '\n'); process.stderr.write("=".repeat(70) + "\n");
errors.forEach(err => process.stderr.write(err + '\n')); errors.forEach((err) => process.stderr.write(err + "\n"));
process.stderr.write('='.repeat(70) + '\n\n'); process.stderr.write("=".repeat(70) + "\n\n");
return false; return false;
} }
@@ -379,10 +445,12 @@ export class KiCADMcpServer {
*/ */
async start(): Promise<void> { async start(): Promise<void> {
try { try {
logger.info('Starting KiCAD MCP server...'); logger.info("Starting KiCAD MCP server...");
// Start the Python process for KiCAD scripting // Start the Python process for KiCAD scripting
logger.info(`Starting Python process with script: ${this.kicadScriptPath}`); logger.info(
`Starting Python process with script: ${this.kicadScriptPath}`,
);
const pythonExe = findPythonExecutable(this.kicadScriptPath); const pythonExe = findPythonExecutable(this.kicadScriptPath);
logger.info(`Using Python executable: ${pythonExe}`); logger.info(`Using Python executable: ${pythonExe}`);
@@ -390,76 +458,82 @@ export class KiCADMcpServer {
// Validate prerequisites // Validate prerequisites
const isValid = await this.validatePrerequisites(pythonExe); const isValid = await this.validatePrerequisites(pythonExe);
if (!isValid) { if (!isValid) {
throw new Error('Prerequisites validation failed. See logs above for details.'); throw new Error(
"Prerequisites validation failed. See logs above for details.",
);
} }
this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ["pipe", "pipe", "pipe"],
env: { env: {
...process.env, ...process.env,
PYTHONPATH: process.env.PYTHONPATH || 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages' PYTHONPATH:
} process.env.PYTHONPATH ||
"C:/Program Files/KiCad/9.0/lib/python3/dist-packages",
},
}); });
// Listen for process exit // Listen for process exit
this.pythonProcess.on('exit', (code, signal) => { this.pythonProcess.on("exit", (code, signal) => {
logger.warn(`Python process exited with code ${code} and signal ${signal}`); logger.warn(
`Python process exited with code ${code} and signal ${signal}`,
);
this.pythonProcess = null; this.pythonProcess = null;
}); });
// Listen for process errors // Listen for process errors
this.pythonProcess.on('error', (err) => { this.pythonProcess.on("error", (err) => {
logger.error(`Python process error: ${err.message}`); logger.error(`Python process error: ${err.message}`);
}); });
// Set up error logging for stderr // Set up error logging for stderr
if (this.pythonProcess.stderr) { if (this.pythonProcess.stderr) {
this.pythonProcess.stderr.on('data', (data: Buffer) => { this.pythonProcess.stderr.on("data", (data: Buffer) => {
logger.error(`Python stderr: ${data.toString()}`); logger.error(`Python stderr: ${data.toString()}`);
}); });
} }
// Set up persistent stdout handler (instead of adding/removing per request) // Set up persistent stdout handler (instead of adding/removing per request)
if (this.pythonProcess.stdout) { if (this.pythonProcess.stdout) {
this.pythonProcess.stdout.on('data', (data: Buffer) => { this.pythonProcess.stdout.on("data", (data: Buffer) => {
this.handlePythonResponse(data); this.handlePythonResponse(data);
}); });
} }
// Connect server to STDIO transport // Connect server to STDIO transport
logger.info('Connecting MCP server to STDIO transport...'); logger.info("Connecting MCP server to STDIO transport...");
try { try {
await this.server.connect(this.stdioTransport); await this.server.connect(this.stdioTransport);
logger.info('Successfully connected to STDIO transport'); logger.info("Successfully connected to STDIO transport");
} catch (error) { } catch (error) {
logger.error(`Failed to connect to STDIO transport: ${error}`); logger.error(`Failed to connect to STDIO transport: ${error}`);
throw error; throw error;
} }
// Write a ready message to stderr (for debugging) // Write a ready message to stderr (for debugging)
process.stderr.write('KiCAD MCP SERVER READY\n'); process.stderr.write("KiCAD MCP SERVER READY\n");
logger.info('KiCAD MCP server started and ready'); logger.info("KiCAD MCP server started and ready");
} catch (error) { } catch (error) {
logger.error(`Failed to start KiCAD MCP server: ${error}`); logger.error(`Failed to start KiCAD MCP server: ${error}`);
throw error; throw error;
} }
} }
/** /**
* Stop the MCP server and clean up resources * Stop the MCP server and clean up resources
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
logger.info('Stopping KiCAD MCP server...'); logger.info("Stopping KiCAD MCP server...");
// Kill the Python process if it's running // Kill the Python process if it's running
if (this.pythonProcess) { if (this.pythonProcess) {
this.pythonProcess.kill(); this.pythonProcess.kill();
this.pythonProcess = null; this.pythonProcess = null;
} }
logger.info('KiCAD MCP server stopped'); logger.info("KiCAD MCP server stopped");
} }
/** /**
* Call the KiCAD scripting interface to execute commands * Call the KiCAD scripting interface to execute commands
* *
@@ -471,7 +545,7 @@ export class KiCADMcpServer {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Check if Python process is running // Check if Python process is running
if (!this.pythonProcess) { if (!this.pythonProcess) {
logger.error('Python process is not running'); logger.error("Python process is not running");
reject(new Error("Python process for KiCAD scripting is not running")); reject(new Error("Python process for KiCAD scripting is not running"));
return; return;
} }
@@ -479,17 +553,24 @@ export class KiCADMcpServer {
// Determine timeout based on command type // Determine timeout based on command type
// DRC and export operations need longer timeouts for large boards // DRC and export operations need longer timeouts for large boards
let commandTimeout = 30000; // Default 30 seconds let commandTimeout = 30000; // Default 30 seconds
const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d']; const longRunningCommands = [
"run_drc",
"export_gerber",
"export_pdf",
"export_3d",
];
if (longRunningCommands.includes(command)) { if (longRunningCommands.includes(command)) {
commandTimeout = 600000; // 10 minutes for long operations commandTimeout = 600000; // 10 minutes for long operations
logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`); logger.info(
`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`,
);
} }
// Add request to queue with timeout info // Add request to queue with timeout info
this.requestQueue.push({ this.requestQueue.push({
request: { command, params, timeout: commandTimeout }, request: { command, params, timeout: commandTimeout },
resolve, resolve,
reject reject,
}); });
// Process the queue if not already processing // Process the queue if not already processing
@@ -498,7 +579,7 @@ export class KiCADMcpServer {
} }
}); });
} }
/** /**
* Handle incoming data from Python process stdout * Handle incoming data from Python process stdout
* This is a persistent handler that processes all responses * This is a persistent handler that processes all responses
@@ -519,8 +600,10 @@ export class KiCADMcpServer {
if (!this.currentRequestHandler) { if (!this.currentRequestHandler) {
// No pending request, clear buffer if it has data (shouldn't happen) // No pending request, clear buffer if it has data (shouldn't happen)
if (this.responseBuffer.trim()) { if (this.responseBuffer.trim()) {
logger.warn(`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`); logger.warn(
this.responseBuffer = ''; `Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`,
);
this.responseBuffer = "";
} }
return; return;
} }
@@ -530,7 +613,9 @@ export class KiCADMcpServer {
const result = JSON.parse(this.responseBuffer); const result = JSON.parse(this.responseBuffer);
// If we get here, we have a valid JSON response // If we get here, we have a valid JSON response
logger.debug(`Completed KiCAD command with result: ${result.success ? 'success' : 'failure'}`); logger.debug(
`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`,
);
// Clear the timeout since we got a response // Clear the timeout since we got a response
if (this.currentRequestHandler.timeoutHandle) { if (this.currentRequestHandler.timeoutHandle) {
@@ -541,7 +626,7 @@ export class KiCADMcpServer {
const handler = this.currentRequestHandler; const handler = this.currentRequestHandler;
// Clear state // Clear state
this.responseBuffer = ''; this.responseBuffer = "";
this.currentRequestHandler = null; this.currentRequestHandler = null;
this.processingRequest = false; this.processingRequest = false;
@@ -550,7 +635,6 @@ export class KiCADMcpServer {
// Process next request if any // Process next request if any
setTimeout(() => this.processNextRequest(), 0); setTimeout(() => this.processNextRequest(), 0);
} catch (e) { } catch (e) {
// Not a complete JSON yet, keep collecting data // Not a complete JSON yet, keep collecting data
// This is normal for large responses that come in chunks // This is normal for large responses that come in chunks
@@ -579,21 +663,29 @@ export class KiCADMcpServer {
const requestStr = JSON.stringify(request); const requestStr = JSON.stringify(request);
// Clear response buffer for new request // Clear response buffer for new request
this.responseBuffer = ''; this.responseBuffer = "";
// Set a timeout (use command-specific timeout or default) // Set a timeout (use command-specific timeout or default)
const timeoutDuration = request.timeout || 30000; const timeoutDuration = request.timeout || 30000;
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`); logger.error(
logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`); `Command timeout after ${timeoutDuration / 1000}s: ${request.command}`,
);
logger.error(
`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`,
);
// Clear state // Clear state
this.responseBuffer = ''; this.responseBuffer = "";
this.currentRequestHandler = null; this.currentRequestHandler = null;
this.processingRequest = false; this.processingRequest = false;
// Reject the promise // Reject the promise
reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`)); reject(
new Error(
`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`,
),
);
// Process next request // Process next request
setTimeout(() => this.processNextRequest(), 0); setTimeout(() => this.processNextRequest(), 0);
@@ -604,7 +696,7 @@ export class KiCADMcpServer {
// Write the request to the Python process // Write the request to the Python process
logger.debug(`Sending request: ${requestStr}`); logger.debug(`Sending request: ${requestStr}`);
this.pythonProcess?.stdin?.write(requestStr + '\n'); this.pythonProcess?.stdin?.write(requestStr + "\n");
} catch (error) { } catch (error) {
logger.error(`Error processing request: ${error}`); logger.error(`Error processing request: ${error}`);

View File

@@ -1,291 +1,644 @@
/** /**
* Component management tools for KiCAD MCP server * Component management tools for KiCAD MCP server
*/ */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from 'zod'; import { z } from "zod";
import { logger } from '../logger.js'; import { logger } from "../logger.js";
// Command function type for KiCAD script calls // Command function type for KiCAD script calls
type CommandFunction = (command: string, params: Record<string, unknown>) => Promise<any>; type CommandFunction = (
command: string,
/** params: Record<string, unknown>,
* Register component management tools with the MCP server ) => Promise<any>;
*
* @param server MCP server instance /**
* @param callKicadScript Function to call KiCAD script commands * Register component management tools with the MCP server
*/ *
export function registerComponentTools(server: McpServer, callKicadScript: CommandFunction): void { * @param server MCP server instance
logger.info('Registering component management tools'); * @param callKicadScript Function to call KiCAD script commands
*/
// ------------------------------------------------------ export function registerComponentTools(
// Place Component Tool server: McpServer,
// ------------------------------------------------------ callKicadScript: CommandFunction,
server.tool( ): void {
"place_component", logger.info("Registering component management tools");
{
componentId: z.string().describe("Identifier for the component to place (e.g., 'R_0603_10k')"), // ------------------------------------------------------
position: z.object({ // Place Component Tool
x: z.number().describe("X coordinate"), // ------------------------------------------------------
y: z.number().describe("Y coordinate"), server.tool(
unit: z.enum(["mm", "inch"]).describe("Unit of measurement") "place_component",
}).describe("Position coordinates and unit"), {
reference: z.string().optional().describe("Optional desired reference (e.g., 'R5')"), componentId: z
value: z.string().optional().describe("Optional component value (e.g., '10k')"), .string()
footprint: z.string().optional().describe("Optional specific footprint name"), .describe("Identifier for the component to place (e.g., 'R_0603_10k')"),
rotation: z.number().optional().describe("Optional rotation in degrees"), position: z
layer: z.string().optional().describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')") .object({
}, x: z.number().describe("X coordinate"),
async ({ componentId, position, reference, value, footprint, rotation, layer }) => { y: z.number().describe("Y coordinate"),
logger.debug(`Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`); unit: z.enum(["mm", "inch"]).describe("Unit of measurement"),
const result = await callKicadScript("place_component", { })
componentId, .describe("Position coordinates and unit"),
position, reference: z
reference, .string()
value, .optional()
footprint, .describe("Optional desired reference (e.g., 'R5')"),
rotation, value: z
layer .string()
}); .optional()
.describe("Optional component value (e.g., '10k')"),
return { footprint: z
content: [{ .string()
type: "text", .optional()
text: JSON.stringify(result) .describe("Optional specific footprint name"),
}] rotation: z.number().optional().describe("Optional rotation in degrees"),
}; layer: z
} .string()
); .optional()
.describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')"),
// ------------------------------------------------------ },
// Move Component Tool async ({
// ------------------------------------------------------ componentId,
server.tool( position,
"move_component", reference,
{ value,
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), footprint,
position: z.object({ rotation,
x: z.number().describe("X coordinate"), layer,
y: z.number().describe("Y coordinate"), }) => {
unit: z.enum(["mm", "inch"]).describe("Unit of measurement") logger.debug(
}).describe("New position coordinates and unit"), `Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`,
rotation: z.number().optional().describe("Optional new rotation in degrees") );
}, const result = await callKicadScript("place_component", {
async ({ reference, position, rotation }) => { componentId,
logger.debug(`Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`); position,
const result = await callKicadScript("move_component", { reference,
reference, value,
position, footprint,
rotation rotation,
}); layer,
});
return {
content: [{ return {
type: "text", content: [
text: JSON.stringify(result) {
}] type: "text",
}; text: JSON.stringify(result),
} },
); ],
};
// ------------------------------------------------------ },
// Rotate Component Tool );
// ------------------------------------------------------
server.tool( // ------------------------------------------------------
"rotate_component", // Move Component Tool
{ // ------------------------------------------------------
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), server.tool(
angle: z.number().describe("Rotation angle in degrees (absolute, not relative)") "move_component",
}, {
async ({ reference, angle }) => { reference: z
logger.debug(`Rotating component: ${reference} to ${angle} degrees`); .string()
const result = await callKicadScript("rotate_component", { .describe("Reference designator of the component (e.g., 'R5')"),
reference, position: z
angle .object({
}); x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
return { unit: z.enum(["mm", "inch"]).describe("Unit of measurement"),
content: [{ })
type: "text", .describe("New position coordinates and unit"),
text: JSON.stringify(result) rotation: z
}] .number()
}; .optional()
} .describe("Optional new rotation in degrees"),
); },
async ({ reference, position, rotation }) => {
// ------------------------------------------------------ logger.debug(
// Delete Component Tool `Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`,
// ------------------------------------------------------ );
server.tool( const result = await callKicadScript("move_component", {
"delete_component", reference,
{ position,
reference: z.string().describe("Reference designator of the component to delete (e.g., 'R5')") rotation,
}, });
async ({ reference }) => {
logger.debug(`Deleting component: ${reference}`); return {
const result = await callKicadScript("delete_component", { reference }); content: [
{
return { type: "text",
content: [{ text: JSON.stringify(result),
type: "text", },
text: JSON.stringify(result) ],
}] };
}; },
} );
);
// ------------------------------------------------------
// ------------------------------------------------------ // Rotate Component Tool
// Edit Component Properties Tool // ------------------------------------------------------
// ------------------------------------------------------ server.tool(
server.tool( "rotate_component",
"edit_component", {
{ reference: z
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), .string()
newReference: z.string().optional().describe("Optional new reference designator"), .describe("Reference designator of the component (e.g., 'R5')"),
value: z.string().optional().describe("Optional new component value"), angle: z
footprint: z.string().optional().describe("Optional new footprint") .number()
}, .describe("Rotation angle in degrees (absolute, not relative)"),
async ({ reference, newReference, value, footprint }) => { },
logger.debug(`Editing component: ${reference}`); async ({ reference, angle }) => {
const result = await callKicadScript("edit_component", { logger.debug(`Rotating component: ${reference} to ${angle} degrees`);
reference, const result = await callKicadScript("rotate_component", {
newReference, reference,
value, angle,
footprint });
});
return {
return { content: [
content: [{ {
type: "text", type: "text",
text: JSON.stringify(result) text: JSON.stringify(result),
}] },
}; ],
} };
); },
);
// ------------------------------------------------------
// Find Component Tool // ------------------------------------------------------
// ------------------------------------------------------ // Delete Component Tool
server.tool( // ------------------------------------------------------
"find_component", server.tool(
{ "delete_component",
reference: z.string().optional().describe("Reference designator to search for"), {
value: z.string().optional().describe("Component value to search for") reference: z
}, .string()
async ({ reference, value }) => { .describe(
logger.debug(`Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`); "Reference designator of the component to delete (e.g., 'R5')",
const result = await callKicadScript("find_component", { reference, value }); ),
},
return { async ({ reference }) => {
content: [{ logger.debug(`Deleting component: ${reference}`);
type: "text", const result = await callKicadScript("delete_component", { reference });
text: JSON.stringify(result)
}] return {
}; content: [
} {
); type: "text",
text: JSON.stringify(result),
// ------------------------------------------------------ },
// Get Component Properties Tool ],
// ------------------------------------------------------ };
server.tool( },
"get_component_properties", );
{
reference: z.string().describe("Reference designator of the component (e.g., 'R5')") // ------------------------------------------------------
}, // Edit Component Properties Tool
async ({ reference }) => { // ------------------------------------------------------
logger.debug(`Getting properties for component: ${reference}`); server.tool(
const result = await callKicadScript("get_component_properties", { reference }); "edit_component",
{
return { reference: z
content: [{ .string()
type: "text", .describe("Reference designator of the component (e.g., 'R5')"),
text: JSON.stringify(result) newReference: z
}] .string()
}; .optional()
} .describe("Optional new reference designator"),
); value: z.string().optional().describe("Optional new component value"),
footprint: z.string().optional().describe("Optional new footprint"),
// ------------------------------------------------------ },
// Add Component Annotation Tool async ({ reference, newReference, value, footprint }) => {
// ------------------------------------------------------ logger.debug(`Editing component: ${reference}`);
server.tool( const result = await callKicadScript("edit_component", {
"add_component_annotation", reference,
{ newReference,
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), value,
annotation: z.string().describe("Annotation or comment text to add"), footprint,
visible: z.boolean().optional().describe("Whether the annotation should be visible on the PCB") });
},
async ({ reference, annotation, visible }) => { return {
logger.debug(`Adding annotation to component: ${reference}`); content: [
const result = await callKicadScript("add_component_annotation", { {
reference, type: "text",
annotation, text: JSON.stringify(result),
visible },
}); ],
};
return { },
content: [{ );
type: "text",
text: JSON.stringify(result) // ------------------------------------------------------
}] // Find Component Tool
}; // ------------------------------------------------------
} server.tool(
); "find_component",
{
// ------------------------------------------------------ reference: z
// Group Components Tool .string()
// ------------------------------------------------------ .optional()
server.tool( .describe("Reference designator to search for"),
"group_components", value: z.string().optional().describe("Component value to search for"),
{ },
references: z.array(z.string()).describe("Reference designators of components to group"), async ({ reference, value }) => {
groupName: z.string().describe("Name for the component group") logger.debug(
}, `Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`,
async ({ references, groupName }) => { );
logger.debug(`Grouping components: ${references.join(', ')} as ${groupName}`); const result = await callKicadScript("find_component", {
const result = await callKicadScript("group_components", { reference,
references, value,
groupName });
});
return {
return { content: [
content: [{ {
type: "text", type: "text",
text: JSON.stringify(result) text: JSON.stringify(result),
}] },
}; ],
} };
); },
);
// ------------------------------------------------------
// Replace Component Tool // ------------------------------------------------------
// ------------------------------------------------------ // Get Component Properties Tool
server.tool( // ------------------------------------------------------
"replace_component", server.tool(
{ "get_component_properties",
reference: z.string().describe("Reference designator of the component to replace"), {
newComponentId: z.string().describe("ID of the new component to use"), reference: z
newFootprint: z.string().optional().describe("Optional new footprint"), .string()
newValue: z.string().optional().describe("Optional new component value") .describe("Reference designator of the component (e.g., 'R5')"),
}, },
async ({ reference, newComponentId, newFootprint, newValue }) => { async ({ reference }) => {
logger.debug(`Replacing component: ${reference} with ${newComponentId}`); logger.debug(`Getting properties for component: ${reference}`);
const result = await callKicadScript("replace_component", { const result = await callKicadScript("get_component_properties", {
reference, reference,
newComponentId, });
newFootprint,
newValue return {
}); content: [
{
return { type: "text",
content: [{ text: JSON.stringify(result),
type: "text", },
text: JSON.stringify(result) ],
}] };
}; },
} );
);
// ------------------------------------------------------
logger.info('Component management tools registered'); // Add Component Annotation Tool
} // ------------------------------------------------------
server.tool(
"add_component_annotation",
{
reference: z
.string()
.describe("Reference designator of the component (e.g., 'R5')"),
annotation: z.string().describe("Annotation or comment text to add"),
visible: z
.boolean()
.optional()
.describe("Whether the annotation should be visible on the PCB"),
},
async ({ reference, annotation, visible }) => {
logger.debug(`Adding annotation to component: ${reference}`);
const result = await callKicadScript("add_component_annotation", {
reference,
annotation,
visible,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Group Components Tool
// ------------------------------------------------------
server.tool(
"group_components",
{
references: z
.array(z.string())
.describe("Reference designators of components to group"),
groupName: z.string().describe("Name for the component group"),
},
async ({ references, groupName }) => {
logger.debug(
`Grouping components: ${references.join(", ")} as ${groupName}`,
);
const result = await callKicadScript("group_components", {
references,
groupName,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Replace Component Tool
// ------------------------------------------------------
server.tool(
"replace_component",
{
reference: z
.string()
.describe("Reference designator of the component to replace"),
newComponentId: z.string().describe("ID of the new component to use"),
newFootprint: z.string().optional().describe("Optional new footprint"),
newValue: z.string().optional().describe("Optional new component value"),
},
async ({ reference, newComponentId, newFootprint, newValue }) => {
logger.debug(`Replacing component: ${reference} with ${newComponentId}`);
const result = await callKicadScript("replace_component", {
reference,
newComponentId,
newFootprint,
newValue,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Get Component Pads Tool
// ------------------------------------------------------
server.tool(
"get_component_pads",
{
reference: z
.string()
.describe("Reference designator of the component (e.g., 'U1')"),
unit: z
.enum(["mm", "inch"])
.optional()
.describe("Unit for coordinates (default: mm)"),
},
async ({ reference, unit }) => {
logger.debug(`Getting pads for component: ${reference}`);
const result = await callKicadScript("get_component_pads", {
reference,
unit: unit || "mm",
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Get Component List Tool
// ------------------------------------------------------
server.tool(
"get_component_list",
{
layer: z
.string()
.optional()
.describe("Filter by layer (e.g., 'F.Cu', 'B.Cu')"),
boundingBox: z
.object({
x1: z.number(),
y1: z.number(),
x2: z.number(),
y2: z.number(),
unit: z.enum(["mm", "inch"]).optional(),
})
.optional()
.describe("Filter by bounding box region"),
unit: z
.enum(["mm", "inch"])
.optional()
.describe("Unit for coordinates (default: mm)"),
},
async ({ layer, boundingBox, unit }) => {
logger.debug("Getting component list");
const result = await callKicadScript("get_component_list", {
layer,
boundingBox,
unit: unit || "mm",
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Get Pad Position Tool
// ------------------------------------------------------
server.tool(
"get_pad_position",
{
reference: z
.string()
.describe("Component reference designator (e.g., 'U1')"),
pad: z.string().describe("Pad number or name (e.g., '1', 'A1')"),
unit: z
.enum(["mm", "inch"])
.optional()
.describe("Unit for coordinates (default: mm)"),
},
async ({ reference, pad, unit }) => {
logger.debug(`Getting pad position for ${reference} pad ${pad}`);
const result = await callKicadScript("get_pad_position", {
reference,
pad,
unit: unit || "mm",
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Place Component Array Tool
// ------------------------------------------------------
server.tool(
"place_component_array",
{
componentId: z.string().describe("Component identifier"),
startPosition: z
.object({
x: z.number(),
y: z.number(),
unit: z.enum(["mm", "inch"]),
})
.describe("Starting position"),
rows: z.number().describe("Number of rows"),
columns: z.number().describe("Number of columns"),
rowSpacing: z.number().describe("Spacing between rows"),
columnSpacing: z.number().describe("Spacing between columns"),
startReference: z
.string()
.optional()
.describe("Starting reference (e.g., 'R1')"),
footprint: z.string().optional().describe("Footprint name"),
value: z.string().optional().describe("Component value"),
rotation: z.number().optional().describe("Rotation in degrees"),
},
async ({
componentId,
startPosition,
rows,
columns,
rowSpacing,
columnSpacing,
startReference,
footprint,
value,
rotation,
}) => {
logger.debug(
`Placing component array: ${rows}x${columns} of ${componentId}`,
);
const result = await callKicadScript("place_component_array", {
componentId,
startPosition,
rows,
columns,
rowSpacing,
columnSpacing,
startReference,
footprint,
value,
rotation,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Align Components Tool
// ------------------------------------------------------
server.tool(
"align_components",
{
references: z
.array(z.string())
.describe("Array of component references to align"),
alignmentType: z
.enum(["horizontal", "vertical", "grid"])
.describe("Type of alignment"),
spacing: z
.number()
.optional()
.describe("Spacing between components in mm"),
referenceComponent: z
.string()
.optional()
.describe("Reference component for alignment"),
},
async ({ references, alignmentType, spacing, referenceComponent }) => {
logger.debug(`Aligning components: ${references.join(", ")}`);
const result = await callKicadScript("align_components", {
references,
alignmentType,
spacing,
referenceComponent,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Duplicate Component Tool
// ------------------------------------------------------
server.tool(
"duplicate_component",
{
reference: z.string().describe("Reference of component to duplicate"),
offset: z
.object({
x: z.number(),
y: z.number(),
unit: z.enum(["mm", "inch"]).optional(),
})
.describe("Offset from original position"),
newReference: z.string().optional().describe("New reference designator"),
count: z
.number()
.optional()
.describe("Number of duplicates (default: 1)"),
},
async ({ reference, offset, newReference, count }) => {
logger.debug(`Duplicating component: ${reference}`);
const result = await callKicadScript("duplicate_component", {
reference,
offset,
newReference,
count,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
logger.info("Component management tools registered");
}

123
src/tools/datasheet.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* Datasheet tools for KiCAD MCP server
*
* Enriches KiCAD schematic symbols with LCSC datasheet URLs.
* URL schema: https://www.lcsc.com/datasheet/<LCSC#>.pdf (no API key required)
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerDatasheetTools(
server: McpServer,
callKicadScript: Function,
) {
// ── enrich_datasheets ──────────────────────────────────────────────────────
server.tool(
"enrich_datasheets",
`Fill in missing Datasheet URLs in a KiCAD schematic using LCSC part numbers.
For every placed symbol that has:
• (property "LCSC" "C123456") set
• (property "Datasheet" "~") or empty
Sets the Datasheet field to:
https://www.lcsc.com/datasheet/C123456.pdf
The URL is then visible in KiCAD's footprint browser, symbol properties dialog,
and any tool that reads the standard KiCAD Datasheet field.
No API key or internet lookup required the URL is constructed directly.
Use dry_run=true to preview changes without writing.`,
{
schematic_path: z
.string()
.describe("Path to the .kicad_sch file to enrich"),
dry_run: z
.boolean()
.optional()
.default(false)
.describe(
"If true, show what would be changed without writing to disk (default: false)",
),
},
async (args: { schematic_path: string; dry_run?: boolean }) => {
const result = await callKicadScript("enrich_datasheets", args);
if (result.success) {
const lines: string[] = [];
if (args.dry_run) {
lines.push(`[DRY RUN] Schematic: ${result.schematic}\n`);
} else {
lines.push(`Schematic: ${result.schematic}\n`);
}
lines.push(`✓ Updated: ${result.updated}`);
lines.push(` Already set: ${result.already_set}`);
lines.push(` No LCSC number: ${result.no_lcsc}`);
lines.push(` No field: ${result.no_datasheet_field}`);
if (result.details && result.details.length > 0) {
lines.push("\nComponents updated:");
for (const d of result.details) {
lines.push(` ${d.reference.padEnd(6)} ${d.lcsc.padEnd(12)}${d.url}`);
}
}
if (result.updated === 0 && !args.dry_run) {
lines.push(
"\nNo changes needed all LCSC components already have a Datasheet URL.",
);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [
{
type: "text",
text: `Failed to enrich datasheets: ${result.message || "Unknown error"}`,
},
],
};
},
);
// ── get_datasheet_url ──────────────────────────────────────────────────────
server.tool(
"get_datasheet_url",
`Get the LCSC datasheet URL for a component by LCSC number.
Returns the direct PDF URL and product page URL.
No network request URL is constructed from the LCSC number alone.
Example: get_datasheet_url("C179739")
→ https://www.lcsc.com/datasheet/C179739.pdf`,
{
lcsc: z
.string()
.describe(
'LCSC part number, with or without "C" prefix (e.g. "C179739" or "179739")',
),
},
async (args: { lcsc: string }) => {
const result = await callKicadScript("get_datasheet_url", { lcsc: args.lcsc });
if (result.success) {
const lines = [
`LCSC: ${result.lcsc}`,
`Datasheet PDF: ${result.datasheet_url}`,
`Product page: ${result.product_url}`,
];
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [
{
type: "text",
text: `Invalid LCSC number: ${args.lcsc}`,
},
],
};
},
);
}

View File

@@ -1,15 +1,16 @@
/** /**
* Tools index for KiCAD MCP server * Tools index for KiCAD MCP server
* *
* Exports all tool registration functions * Exports all tool registration functions
*/ */
export { registerProjectTools } from './project.js'; export { registerProjectTools } from "./project.js";
export { registerBoardTools } from './board.js'; export { registerBoardTools } from "./board.js";
export { registerComponentTools } from './component.js'; export { registerComponentTools } from "./component.js";
export { registerRoutingTools } from './routing.js'; export { registerRoutingTools } from "./routing.js";
export { registerDesignRuleTools } from './design-rules.js'; export { registerDesignRuleTools } from "./design-rules.js";
export { registerExportTools } from './export.js'; export { registerExportTools } from "./export.js";
export { registerSchematicTools } from './schematic.js'; export { registerSchematicTools } from "./schematic.js";
export { registerLibraryTools } from './library.js'; export { registerLibraryTools } from "./library.js";
export { registerUITools } from './ui.js'; export { registerUITools } from "./ui.js";
export { registerDatasheetTools } from "./datasheet.js";

View File

@@ -1,159 +1,193 @@
/** /**
* Library tools for KiCAD MCP server * Library tools for KiCAD MCP server
* Provides access to KiCAD footprint libraries and symbols * Provides access to KiCAD footprint libraries and symbols
*/ */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from 'zod'; import { z } from "zod";
export function registerLibraryTools(server: McpServer, callKicadScript: Function) { export function registerLibraryTools(
// List available footprint libraries server: McpServer,
server.tool( callKicadScript: Function,
"list_libraries", ) {
"List all available KiCAD footprint libraries", // List available footprint libraries
{ server.tool(
search_paths: z.array(z.string()).optional() "list_libraries",
.describe("Optional additional search paths for libraries") "List all available KiCAD footprint libraries",
}, {
async (args: { search_paths?: string[] }) => { search_paths: z
const result = await callKicadScript("list_libraries", args); .array(z.string())
if (result.success && result.libraries) { .optional()
return { .describe("Optional additional search paths for libraries"),
content: [ },
{ async (args: { search_paths?: string[] }) => {
type: "text", const result = await callKicadScript("list_libraries", args);
text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join('\n')}` if (result.success && result.libraries) {
} return {
] content: [
}; {
} type: "text",
return { text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join("\n")}`,
content: [ },
{ ],
type: "text", };
text: `Failed to list libraries: ${result.message || 'Unknown error'}` }
} return {
] content: [
}; {
} type: "text",
); text: `Failed to list libraries: ${result.message || "Unknown error"}`,
},
// Search for footprints across all libraries ],
server.tool( };
"search_footprints", },
"Search for footprints matching a pattern across all libraries", );
{
search_term: z.string() // Search for footprints across all libraries
.describe("Search term or pattern to match footprint names"), server.tool(
library: z.string().optional() "search_footprints",
.describe("Optional specific library to search in"), "Search for footprints matching a pattern across all libraries",
limit: z.number().optional().default(50) {
.describe("Maximum number of results to return") search_term: z
}, .string()
async (args: { search_term: string; library?: string; limit?: number }) => { .describe("Search term or pattern to match footprint names"),
const result = await callKicadScript("search_footprints", args); library: z
if (result.success && result.footprints) { .string()
const footprintList = result.footprints.map((fp: any) => .optional()
`${fp.library}:${fp.name}${fp.description ? ' - ' + fp.description : ''}` .describe("Optional specific library to search in"),
).join('\n'); limit: z
return { .number()
content: [ .optional()
{ .default(50)
type: "text", .describe("Maximum number of results to return"),
text: `Found ${result.footprints.length} matching footprints:\n${footprintList}` },
} async (args: { search_term: string; library?: string; limit?: number }) => {
] const result = await callKicadScript("search_footprints", {
}; pattern: args.search_term,
} library: args.library,
return { limit: args.limit,
content: [ });
{ if (result.success && result.footprints) {
type: "text", const footprintList = result.footprints
text: `Failed to search footprints: ${result.message || 'Unknown error'}` .map(
} (fp: any) =>
] `${fp.full_name || fp.library + ":" + fp.footprint}${fp.description ? " - " + fp.description : ""}`,
}; )
} .join("\n");
); return {
content: [
// List footprints in a specific library {
server.tool( type: "text",
"list_library_footprints", text: `Found ${result.footprints.length} matching footprints:\n${footprintList}`,
"List all footprints in a specific KiCAD library", },
{ ],
library_name: z.string() };
.describe("Name of the library to list footprints from"), }
filter: z.string().optional() return {
.describe("Optional filter pattern for footprint names"), content: [
limit: z.number().optional().default(100) {
.describe("Maximum number of footprints to list") type: "text",
}, text: `Failed to search footprints: ${result.message || "Unknown error"}`,
async (args: { library_name: string; filter?: string; limit?: number }) => { },
const result = await callKicadScript("list_library_footprints", args); ],
if (result.success && result.footprints) { };
const footprintList = result.footprints.map((fp: string) => ` - ${fp}`).join('\n'); },
return { );
content: [
{ // List footprints in a specific library
type: "text", server.tool(
text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}` "list_library_footprints",
} "List all footprints in a specific KiCAD library",
] {
}; library_name: z
} .string()
return { .describe("Name of the library to list footprints from"),
content: [ filter: z
{ .string()
type: "text", .optional()
text: `Failed to list footprints in library ${args.library_name}: ${result.message || 'Unknown error'}` .describe("Optional filter pattern for footprint names"),
} limit: z
] .number()
}; .optional()
} .default(100)
); .describe("Maximum number of footprints to list"),
},
// Get detailed information about a specific footprint async (args: { library_name: string; filter?: string; limit?: number }) => {
server.tool( const result = await callKicadScript("list_library_footprints", args);
"get_footprint_info", if (result.success && result.footprints) {
"Get detailed information about a specific footprint", const footprintList = result.footprints
{ .map((fp: string) => ` - ${fp}`)
library_name: z.string() .join("\n");
.describe("Name of the library containing the footprint"), return {
footprint_name: z.string() content: [
.describe("Name of the footprint to get information about") {
}, type: "text",
async (args: { library_name: string; footprint_name: string }) => { text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}`,
const result = await callKicadScript("get_footprint_info", args); },
if (result.success && result.info) { ],
const info = result.info; };
const details = [ }
`Footprint: ${info.name}`, return {
`Library: ${info.library}`, content: [
info.description ? `Description: ${info.description}` : '', {
info.keywords ? `Keywords: ${info.keywords}` : '', type: "text",
info.pads ? `Number of pads: ${info.pads}` : '', text: `Failed to list footprints in library ${args.library_name}: ${result.message || "Unknown error"}`,
info.layers ? `Layers used: ${info.layers.join(', ')}` : '', },
info.courtyard ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm` : '', ],
info.attributes ? `Attributes: ${JSON.stringify(info.attributes)}` : '' };
].filter(line => line).join('\n'); },
);
return {
content: [ // Get detailed information about a specific footprint
{ server.tool(
type: "text", "get_footprint_info",
text: details "Get detailed information about a specific footprint",
} {
] library_name: z
}; .string()
} .describe("Name of the library containing the footprint"),
return { footprint_name: z
content: [ .string()
{ .describe("Name of the footprint to get information about"),
type: "text", },
text: `Failed to get footprint info: ${result.message || 'Unknown error'}` async (args: { library_name: string; footprint_name: string }) => {
} const result = await callKicadScript("get_footprint_info", args);
] if (result.success && result.info) {
}; const info = result.info;
} const details = [
); `Footprint: ${info.name}`,
} `Library: ${info.library}`,
info.description ? `Description: ${info.description}` : "",
info.keywords ? `Keywords: ${info.keywords}` : "",
info.pads ? `Number of pads: ${info.pads}` : "",
info.layers ? `Layers used: ${info.layers.join(", ")}` : "",
info.courtyard
? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm`
: "",
info.attributes
? `Attributes: ${JSON.stringify(info.attributes)}`
: "",
]
.filter((line) => line)
.join("\n");
return {
content: [
{
type: "text",
text: details,
},
],
};
}
return {
content: [
{
type: "text",
text: `Failed to get footprint info: ${result.message || "Unknown error"}`,
},
],
};
},
);
}

View File

@@ -1,101 +1,324 @@
/** /**
* Routing tools for KiCAD MCP server * Routing tools for KiCAD MCP server
*/ */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from 'zod'; import { z } from "zod";
export function registerRoutingTools(server: McpServer, callKicadScript: Function) { export function registerRoutingTools(
// Add net tool server: McpServer,
server.tool( callKicadScript: Function,
"add_net", ) {
"Create a new net on the PCB", // Add net tool
{ server.tool(
name: z.string().describe("Net name"), "add_net",
netClass: z.string().optional().describe("Net class name"), "Create a new net on the PCB",
}, {
async (args: { name: string; netClass?: string }) => { name: z.string().describe("Net name"),
const result = await callKicadScript("add_net", args); netClass: z.string().optional().describe("Net class name"),
return { },
content: [{ async (args: { name: string; netClass?: string }) => {
type: "text", const result = await callKicadScript("add_net", args);
text: JSON.stringify(result, null, 2) return {
}] content: [
}; {
} type: "text",
); text: JSON.stringify(result, null, 2),
},
// Route trace tool ],
server.tool( };
"route_trace", },
"Route a trace between two points", );
{
start: z.object({ // Route trace tool
x: z.number(), server.tool(
y: z.number(), "route_trace",
unit: z.string().optional() "Route a trace between two points",
}).describe("Start position"), {
end: z.object({ start: z
x: z.number(), .object({
y: z.number(), x: z.number(),
unit: z.string().optional() y: z.number(),
}).describe("End position"), unit: z.string().optional(),
layer: z.string().describe("PCB layer"), })
width: z.number().describe("Trace width in mm"), .describe("Start position"),
net: z.string().describe("Net name"), end: z
}, .object({
async (args: any) => { x: z.number(),
const result = await callKicadScript("route_trace", args); y: z.number(),
return { unit: z.string().optional(),
content: [{ })
type: "text", .describe("End position"),
text: JSON.stringify(result, null, 2) layer: z.string().describe("PCB layer"),
}] width: z.number().describe("Trace width in mm"),
}; net: z.string().describe("Net name"),
} },
); async (args: any) => {
const result = await callKicadScript("route_trace", args);
// Add via tool return {
server.tool( content: [
"add_via", {
"Add a via to the PCB", type: "text",
{ text: JSON.stringify(result, null, 2),
position: z.object({ },
x: z.number(), ],
y: z.number(), };
unit: z.string().optional() },
}).describe("Via position"), );
net: z.string().describe("Net name"),
viaType: z.string().optional().describe("Via type (through, blind, buried)"), // Add via tool
}, server.tool(
async (args: any) => { "add_via",
const result = await callKicadScript("add_via", args); "Add a via to the PCB",
return { {
content: [{ position: z
type: "text", .object({
text: JSON.stringify(result, null, 2) x: z.number(),
}] y: z.number(),
}; unit: z.string().optional(),
} })
); .describe("Via position"),
net: z.string().describe("Net name"),
// Add copper pour tool viaType: z
server.tool( .string()
"add_copper_pour", .optional()
"Add a copper pour (ground/power plane) to the PCB", .describe("Via type (through, blind, buried)"),
{ },
layer: z.string().describe("PCB layer"), async (args: any) => {
net: z.string().describe("Net name"), const result = await callKicadScript("add_via", args);
clearance: z.number().optional().describe("Clearance in mm"), return {
}, content: [
async (args: any) => { {
const result = await callKicadScript("add_copper_pour", args); type: "text",
return { text: JSON.stringify(result, null, 2),
content: [{ },
type: "text", ],
text: JSON.stringify(result, null, 2) };
}] },
}; );
}
); // Add copper pour tool
} server.tool(
"add_copper_pour",
"Add a copper pour (ground/power plane) to the PCB",
{
layer: z.string().describe("PCB layer"),
net: z.string().describe("Net name"),
clearance: z.number().optional().describe("Clearance in mm"),
},
async (args: any) => {
const result = await callKicadScript("add_copper_pour", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Delete trace tool
server.tool(
"delete_trace",
"Delete traces from the PCB. Can delete by UUID, position, or bulk-delete all traces on a net.",
{
traceUuid: z
.string()
.optional()
.describe("UUID of a specific trace to delete"),
position: z
.object({
x: z.number(),
y: z.number(),
unit: z.enum(["mm", "inch"]).optional(),
})
.optional()
.describe("Delete trace nearest to this position"),
net: z
.string()
.optional()
.describe("Delete all traces on this net (bulk delete)"),
layer: z
.string()
.optional()
.describe("Filter by layer when using net-based deletion"),
includeVias: z
.boolean()
.optional()
.describe("Include vias in net-based deletion"),
},
async (args: any) => {
const result = await callKicadScript("delete_trace", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Query traces tool
server.tool(
"query_traces",
"Query traces on the board with optional filters by net, layer, or bounding box.",
{
net: z.string().optional().describe("Filter by net name"),
layer: z.string().optional().describe("Filter by layer name"),
boundingBox: z
.object({
x1: z.number(),
y1: z.number(),
x2: z.number(),
y2: z.number(),
unit: z.enum(["mm", "inch"]).optional(),
})
.optional()
.describe("Filter by bounding box region"),
unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates"),
},
async (args: any) => {
const result = await callKicadScript("query_traces", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Get nets list tool
server.tool(
"get_nets_list",
"Get a list of all nets in the PCB with optional statistics.",
{
includeStats: z
.boolean()
.optional()
.describe("Include statistics (track count, total length, etc.)"),
unit: z
.enum(["mm", "inch"])
.optional()
.describe("Unit for length measurements"),
},
async (args: any) => {
const result = await callKicadScript("get_nets_list", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Modify trace tool
server.tool(
"modify_trace",
"Modify an existing trace (change width, layer, or net).",
{
traceUuid: z.string().describe("UUID of the trace to modify"),
width: z.number().optional().describe("New trace width in mm"),
layer: z.string().optional().describe("New layer name"),
net: z.string().optional().describe("New net name"),
},
async (args: any) => {
const result = await callKicadScript("modify_trace", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Create netclass tool
server.tool(
"create_netclass",
"Create a new net class with custom design rules.",
{
name: z.string().describe("Net class name"),
traceWidth: z.number().optional().describe("Default trace width in mm"),
clearance: z.number().optional().describe("Clearance in mm"),
viaDiameter: z.number().optional().describe("Via diameter in mm"),
viaDrill: z.number().optional().describe("Via drill size in mm"),
},
async (args: any) => {
const result = await callKicadScript("create_netclass", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Route differential pair tool
server.tool(
"route_differential_pair",
"Route a differential pair between two sets of points.",
{
positivePad: z
.object({
reference: z.string(),
pad: z.string(),
})
.describe("Positive pad (component and pad number)"),
negativePad: z
.object({
reference: z.string(),
pad: z.string(),
})
.describe("Negative pad (component and pad number)"),
layer: z.string().describe("PCB layer"),
width: z.number().describe("Trace width in mm"),
gap: z.number().describe("Gap between traces in mm"),
positiveNet: z.string().describe("Positive net name"),
negativeNet: z.string().describe("Negative net name"),
},
async (args: any) => {
const result = await callKicadScript("route_differential_pair", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Refill zones tool
server.tool(
"refill_zones",
"Refill all copper zones on the board. WARNING: SWIG path has known segfault risk (see KNOWN_ISSUES.md). Prefer using IPC backend (KiCAD open) or triggering zone fill via KiCAD UI instead.",
{},
async (args: any) => {
const result = await callKicadScript("refill_zones", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
}

View File

@@ -1,268 +1,383 @@
/** /**
* Schematic tools for KiCAD MCP server * Schematic tools for KiCAD MCP server
*/ */
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from 'zod'; import { z } from "zod";
export function registerSchematicTools(server: McpServer, callKicadScript: Function) { export function registerSchematicTools(
// Create schematic tool server: McpServer,
server.tool( callKicadScript: Function,
"create_schematic", ) {
"Create a new schematic", // Create schematic tool
{ server.tool(
name: z.string().describe("Schematic name"), "create_schematic",
path: z.string().optional().describe("Optional path"), "Create a new schematic",
}, {
async (args: { name: string; path?: string }) => { name: z.string().describe("Schematic name"),
const result = await callKicadScript("create_schematic", args); path: z.string().optional().describe("Optional path"),
return { },
content: [{ async (args: { name: string; path?: string }) => {
type: "text", const result = await callKicadScript("create_schematic", args);
text: JSON.stringify(result, null, 2) return {
}] content: [
}; {
} type: "text",
); text: JSON.stringify(result, null, 2),
},
// Add component to schematic ],
server.tool( };
"add_schematic_component", },
"Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')", );
{
schematicPath: z.string().describe("Path to the schematic file"), // Add component to schematic
symbol: z.string().describe("Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)"), server.tool(
reference: z.string().describe("Component reference (e.g., R1, U1)"), "add_schematic_component",
value: z.string().optional().describe("Component value"), "Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')",
position: z.object({ {
x: z.number(), schematicPath: z.string().describe("Path to the schematic file"),
y: z.number() symbol: z
}).optional().describe("Position on schematic"), .string()
}, .describe(
async (args: { schematicPath: string; symbol: string; reference: string; value?: string; position?: { x: number; y: number } }) => { "Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)",
// Transform to what Python backend expects ),
const [library, symbolName] = args.symbol.includes(':') reference: z.string().describe("Component reference (e.g., R1, U1)"),
? args.symbol.split(':') value: z.string().optional().describe("Component value"),
: ['Device', args.symbol]; position: z
.object({
const transformed = { x: z.number(),
schematicPath: args.schematicPath, y: z.number(),
component: { })
library, .optional()
type: symbolName, .describe("Position on schematic"),
reference: args.reference, },
value: args.value, async (args: {
// Python expects flat x, y not nested position schematicPath: string;
x: args.position?.x ?? 0, symbol: string;
y: args.position?.y ?? 0 reference: string;
} value?: string;
}; position?: { x: number; y: number };
}) => {
const result = await callKicadScript("add_schematic_component", transformed); // Transform to what Python backend expects
if (result.success) { const [library, symbolName] = args.symbol.includes(":")
return { ? args.symbol.split(":")
content: [{ : ["Device", args.symbol];
type: "text",
text: `Successfully added ${args.reference} (${args.symbol}) to schematic` const transformed = {
}] schematicPath: args.schematicPath,
}; component: {
} else { library,
return { type: symbolName,
content: [{ reference: args.reference,
type: "text", value: args.value,
text: `Failed to add component: ${result.message || JSON.stringify(result)}` // Python expects flat x, y not nested position
}] x: args.position?.x ?? 0,
}; y: args.position?.y ?? 0,
} },
} };
);
const result = await callKicadScript(
// Connect components with wire "add_schematic_component",
server.tool( transformed,
"add_wire", );
"Add a wire connection in the schematic", if (result.success) {
{ return {
start: z.object({ content: [
x: z.number(), {
y: z.number() type: "text",
}).describe("Start position"), text: `Successfully added ${args.reference} (${args.symbol}) to schematic`,
end: z.object({ },
x: z.number(), ],
y: z.number() };
}).describe("End position"), } else {
}, return {
async (args: any) => { content: [
const result = await callKicadScript("add_wire", args); {
return { type: "text",
content: [{ text: `Failed to add component: ${result.message || JSON.stringify(result)}`,
type: "text", },
text: JSON.stringify(result, null, 2) ],
}] };
}; }
} },
); );
// Add pin-to-pin connection // Delete component from schematic
server.tool( server.tool(
"add_schematic_connection", "delete_schematic_component",
"Connect two component pins with a wire", `Remove a placed symbol from a KiCAD schematic (.kicad_sch).
{
schematicPath: z.string().describe("Path to the schematic file"), This removes the symbol instance (the placed component) from the schematic.
sourceRef: z.string().describe("Source component reference (e.g., R1)"), It does NOT remove the symbol definition from lib_symbols.
sourcePin: z.string().describe("Source pin name/number (e.g., 1, 2, GND)"),
targetRef: z.string().describe("Target component reference (e.g., C1)"), Note: This tool operates on schematic files (.kicad_sch).
targetPin: z.string().describe("Target pin name/number (e.g., 1, 2, VCC)") To remove a footprint from a PCB, use delete_component instead.`,
}, {
async (args: { schematicPath: string; sourceRef: string; sourcePin: string; targetRef: string; targetPin: string }) => { schematicPath: z.string().describe("Path to the .kicad_sch file"),
const result = await callKicadScript("add_schematic_connection", args); reference: z
if (result.success) { .string()
return { .describe("Reference designator of the component to remove (e.g. R1, U3)"),
content: [{ },
type: "text", async (args: { schematicPath: string; reference: string }) => {
text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}` const result = await callKicadScript("delete_schematic_component", args);
}] if (result.success) {
}; return {
} else { content: [
return { {
content: [{ type: "text",
type: "text", text: `Successfully removed ${args.reference} from schematic`,
text: `Failed to add connection: ${result.message || 'Unknown error'}` },
}] ],
}; };
} }
} return {
); content: [
{
// Add net label type: "text",
server.tool( text: `Failed to remove component: ${result.message || "Unknown error"}`,
"add_schematic_net_label", },
"Add a net label to the schematic", ],
{ };
schematicPath: z.string().describe("Path to the schematic file"), },
netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), );
position: z.array(z.number()).length(2).describe("Position [x, y] for the label")
}, // Connect components with wire
async (args: { schematicPath: string; netName: string; position: number[] }) => { server.tool(
const result = await callKicadScript("add_schematic_net_label", args); "add_wire",
if (result.success) { "Add a wire connection in the schematic",
return { {
content: [{ start: z
type: "text", .object({
text: `Successfully added net label '${args.netName}' at position [${args.position}]` x: z.number(),
}] y: z.number(),
}; })
} else { .describe("Start position"),
return { end: z
content: [{ .object({
type: "text", x: z.number(),
text: `Failed to add net label: ${result.message || 'Unknown error'}` y: z.number(),
}] })
}; .describe("End position"),
} },
} async (args: any) => {
); const result = await callKicadScript("add_wire", args);
return {
// Connect pin to net content: [
server.tool( {
"connect_to_net", type: "text",
"Connect a component pin to a named net", text: JSON.stringify(result, null, 2),
{ },
schematicPath: z.string().describe("Path to the schematic file"), ],
componentRef: z.string().describe("Component reference (e.g., U1, R1)"), };
pinName: z.string().describe("Pin name/number to connect"), },
netName: z.string().describe("Name of the net to connect to") );
},
async (args: { schematicPath: string; componentRef: string; pinName: string; netName: string }) => { // Add pin-to-pin connection
const result = await callKicadScript("connect_to_net", args); server.tool(
if (result.success) { "add_schematic_connection",
return { "Connect two component pins with a wire",
content: [{ {
type: "text", schematicPath: z.string().describe("Path to the schematic file"),
text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'` sourceRef: z.string().describe("Source component reference (e.g., R1)"),
}] sourcePin: z
}; .string()
} else { .describe("Source pin name/number (e.g., 1, 2, GND)"),
return { targetRef: z.string().describe("Target component reference (e.g., C1)"),
content: [{ targetPin: z
type: "text", .string()
text: `Failed to connect to net: ${result.message || 'Unknown error'}` .describe("Target pin name/number (e.g., 1, 2, VCC)"),
}] },
}; async (args: {
} schematicPath: string;
} sourceRef: string;
); sourcePin: string;
targetRef: string;
// Get net connections targetPin: string;
server.tool( }) => {
"get_net_connections", const result = await callKicadScript("add_schematic_connection", args);
"Get all connections for a named net", if (result.success) {
{ return {
schematicPath: z.string().describe("Path to the schematic file"), content: [
netName: z.string().describe("Name of the net to query") {
}, type: "text",
async (args: { schematicPath: string; netName: string }) => { text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`,
const result = await callKicadScript("get_net_connections", args); },
if (result.success && result.connections) { ],
const connectionList = result.connections.map((conn: any) => };
` - ${conn.component}/${conn.pin}` } else {
).join('\n'); return {
return { content: [
content: [{ {
type: "text", type: "text",
text: `Net '${args.netName}' connections:\n${connectionList}` text: `Failed to add connection: ${result.message || "Unknown error"}`,
}] },
}; ],
} else { };
return { }
content: [{ },
type: "text", );
text: `Failed to get net connections: ${result.message || 'Unknown error'}`
}] // Add net label
}; server.tool(
} "add_schematic_net_label",
} "Add a net label to the schematic",
); {
schematicPath: z.string().describe("Path to the schematic file"),
// Generate netlist netName: z
server.tool( .string()
"generate_netlist", .describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"),
"Generate a netlist from the schematic", position: z
{ .array(z.number())
schematicPath: z.string().describe("Path to the schematic file") .length(2)
}, .describe("Position [x, y] for the label"),
async (args: { schematicPath: string }) => { },
const result = await callKicadScript("generate_netlist", args); async (args: {
if (result.success && result.netlist) { schematicPath: string;
const netlist = result.netlist; netName: string;
const output = [ position: number[];
`=== Netlist for ${args.schematicPath} ===`, }) => {
`\nComponents (${netlist.components.length}):`, const result = await callKicadScript("add_schematic_net_label", args);
...netlist.components.map((comp: any) => if (result.success) {
` ${comp.reference}: ${comp.value} (${comp.footprint || 'No footprint'})` return {
), content: [
`\nNets (${netlist.nets.length}):`, {
...netlist.nets.map((net: any) => { type: "text",
const connections = net.connections.map((conn: any) => text: `Successfully added net label '${args.netName}' at position [${args.position}]`,
`${conn.component}/${conn.pin}` },
).join(', '); ],
return ` ${net.name}: ${connections}`; };
}) } else {
].join('\n'); return {
content: [
return { {
content: [{ type: "text",
type: "text", text: `Failed to add net label: ${result.message || "Unknown error"}`,
text: output },
}] ],
}; };
} else { }
return { },
content: [{ );
type: "text",
text: `Failed to generate netlist: ${result.message || 'Unknown error'}` // Connect pin to net
}] server.tool(
}; "connect_to_net",
} "Connect a component pin to a named net",
} {
); schematicPath: z.string().describe("Path to the schematic file"),
} componentRef: z.string().describe("Component reference (e.g., U1, R1)"),
pinName: z.string().describe("Pin name/number to connect"),
netName: z.string().describe("Name of the net to connect to"),
},
async (args: {
schematicPath: string;
componentRef: string;
pinName: string;
netName: string;
}) => {
const result = await callKicadScript("connect_to_net", args);
if (result.success) {
return {
content: [
{
type: "text",
text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to connect to net: ${result.message || "Unknown error"}`,
},
],
};
}
},
);
// Get net connections
server.tool(
"get_net_connections",
"Get all connections for a named net",
{
schematicPath: z.string().describe("Path to the schematic file"),
netName: z.string().describe("Name of the net to query"),
},
async (args: { schematicPath: string; netName: string }) => {
const result = await callKicadScript("get_net_connections", args);
if (result.success && result.connections) {
const connectionList = result.connections
.map((conn: any) => ` - ${conn.component}/${conn.pin}`)
.join("\n");
return {
content: [
{
type: "text",
text: `Net '${args.netName}' connections:\n${connectionList}`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to get net connections: ${result.message || "Unknown error"}`,
},
],
};
}
},
);
// Generate netlist
server.tool(
"generate_netlist",
"Generate a netlist from the schematic",
{
schematicPath: z.string().describe("Path to the schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("generate_netlist", args);
if (result.success && result.netlist) {
const netlist = result.netlist;
const output = [
`=== Netlist for ${args.schematicPath} ===`,
`\nComponents (${netlist.components.length}):`,
...netlist.components.map(
(comp: any) =>
` ${comp.reference}: ${comp.value} (${comp.footprint || "No footprint"})`,
),
`\nNets (${netlist.nets.length}):`,
...netlist.nets.map((net: any) => {
const connections = net.connections
.map((conn: any) => `${conn.component}/${conn.pin}`)
.join(", ");
return ` ${net.name}: ${connections}`;
}),
].join("\n");
return {
content: [
{
type: "text",
text: output,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to generate netlist: ${result.message || "Unknown error"}`,
},
],
};
}
},
);
}