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.
## [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
### Phase 1: Intelligent Schematic Wiring System - Core Infrastructure

View File

@@ -10,11 +10,13 @@ logger = logging.getLogger(__name__)
try:
from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator
WIRE_MANAGER_AVAILABLE = True
except ImportError:
logger.warning("WireManager/PinLocator not available")
WIRE_MANAGER_AVAILABLE = False
class ConnectionManager:
"""Manage connections between components in schematics"""
@@ -29,7 +31,12 @@ class ConnectionManager:
return cls._pin_locator
@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
@@ -47,11 +54,18 @@ class ConnectionManager:
logger.error("WireManager not available")
return False
stroke_width = properties.get('stroke_width', 0) if properties else 0
stroke_type = properties.get('stroke_type', 'default') if properties else 'default'
stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(schematic_path, start_point, end_point,
stroke_width=stroke_width, stroke_type=stroke_type)
success = WireManager.add_wire(
schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success
except Exception as e:
logger.error(f"Error adding wire: {e}")
@@ -70,7 +84,7 @@ class ConnectionManager:
[x, y] coordinates or None if pin not found
"""
try:
if not hasattr(symbol, 'pin'):
if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None
@@ -82,7 +96,9 @@ class ConnectionManager:
break
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
# Get pin location relative to symbol
@@ -101,8 +117,14 @@ class ConnectionManager:
return None
@staticmethod
def add_connection(schematic_path: Path, source_ref: str, source_pin: str,
target_ref: str, target_pin: str, routing: str = 'direct'):
def add_connection(
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
@@ -128,31 +150,41 @@ class ConnectionManager:
return False
# Get pin locations
source_loc = locator.get_pin_location(schematic_path, source_ref, source_pin)
target_loc = locator.get_pin_location(schematic_path, target_ref, target_pin)
source_loc = locator.get_pin_location(
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:
logger.error("Could not determine pin locations")
return False
# Create wire based on routing style
if routing == 'direct':
if routing == "direct":
# Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == 'orthogonal_h':
elif routing == "orthogonal_h":
# 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)
elif routing == 'orthogonal_v':
elif routing == "orthogonal_v":
# 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)
else:
logger.error(f"Unknown routing style: {routing}")
return False
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
else:
return False
@@ -160,6 +192,7 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error adding connection: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@@ -177,13 +210,12 @@ class ConnectionManager:
Label object or None on error
"""
try:
if not hasattr(schematic, 'label'):
if not hasattr(schematic, "label"):
logger.error("Schematic does not have label collection")
return None
label = schematic.label.append(
text=net_name,
at={'x': position[0], 'y': position[1]}
text=net_name, at={"x": position[0], "y": position[1]}
)
logger.info(f"Added net label '{net_name}' at {position}")
return label
@@ -192,7 +224,9 @@ class ConnectionManager:
return None
@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
@@ -231,7 +265,9 @@ class ConnectionManager:
return False
# 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:
logger.error(f"Failed to add net label '{net_name}'")
return False
@@ -242,11 +278,14 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@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
@@ -273,14 +312,14 @@ class ConnectionManager:
return dx < tolerance and dy < tolerance
# 1. Find all labels with this net name
if not hasattr(schematic, 'label'):
if not hasattr(schematic, "label"):
logger.warning("Schematic has no labels")
return connections
net_label_positions = []
for label in schematic.label:
if hasattr(label, 'value') and label.value == net_name:
if hasattr(label, 'at') and hasattr(label.at, 'value'):
if hasattr(label, "value") and label.value == net_name:
if hasattr(label, "at") and hasattr(label.at, "value"):
pos = label.at.value
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}'")
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
if not hasattr(schematic, 'wire'):
if not hasattr(schematic, "wire"):
logger.warning("Schematic has no wires")
return connections
connected_wire_points = set()
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)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, 'value'):
wire_points.append([float(point.value[0]), float(point.value[1])])
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
# Check if any wire point touches a label
wire_connected = False
@@ -323,10 +366,12 @@ class ConnectionManager:
logger.debug(f"No wires connected to net '{net_name}' labels")
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
if not hasattr(schematic, 'symbol'):
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols")
return connections
@@ -337,15 +382,15 @@ class ConnectionManager:
for symbol in schematic.symbol:
# Skip template symbols
if not hasattr(symbol.property, 'Reference'):
if not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith('_TEMPLATE'):
if ref.startswith("_TEMPLATE"):
continue
# 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:
continue
@@ -360,17 +405,18 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# 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:
continue
# Check if pin coincides with any wire point
for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)):
connections.append({
"component": ref,
"pin": pin_num
})
connections.append(
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more wire points
except Exception as e:
@@ -380,7 +426,7 @@ class ConnectionManager:
# Fallback: proximity-based matching if no PinLocator
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:
continue
@@ -389,12 +435,11 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm)
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
connections.append({
"component": ref,
"pin": "unknown"
})
connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
logger.info(f"Found {len(connections)} connections for net '{net_name}'")
@@ -403,14 +448,20 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error getting net connections: {e}")
import traceback
logger.error(traceback.format_exc())
return []
@staticmethod
def generate_netlist(schematic: Schematic):
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
"""
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:
Dictionary with net information:
{
@@ -431,47 +482,58 @@ class ConnectionManager:
}
"""
try:
netlist = {
"nets": [],
"components": []
}
netlist = {"nets": [], "components": []}
# Gather all components
if hasattr(schematic, 'symbol'):
if hasattr(schematic, "symbol"):
for symbol in schematic.symbol:
component_info = {
"reference": symbol.property.Reference.value,
"value": symbol.property.Value.value if hasattr(symbol.property, 'Value') else "",
"footprint": symbol.property.Footprint.value if hasattr(symbol.property, 'Footprint') else ""
"value": (
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)
# Gather all nets from labels
if hasattr(schematic, 'label'):
if hasattr(schematic, "label"):
net_names = set()
for label in schematic.label:
if hasattr(label, 'value'):
if hasattr(label, "value"):
net_names.add(label.value)
# For each net, get connections
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:
netlist["nets"].append({
"name": net_name,
"connections": connections
})
netlist["nets"].append(
{"name": net_name, "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
except Exception as e:
logger.error(f"Error generating netlist: {e}")
return {"nets": [], "components": []}
if __name__ == '__main__':
if __name__ == "__main__":
# 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
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
from typing import Dict, Any, Optional, List, Tuple
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class DesignRuleCommands:
"""Handles design rule checking and configuration"""
@@ -23,7 +24,7 @@ class DesignRuleCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
@@ -57,9 +58,13 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
# Set minimum values
if "minTrackWidth" in params:
@@ -72,13 +77,19 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
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
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
if "holeClearance" in params:
@@ -102,13 +113,13 @@ class DesignRuleCommands:
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
}
return {
"success": True,
"message": "Updated design rules",
"rules": response_rules
"rules": response_rules,
}
except Exception as e:
@@ -116,7 +127,7 @@ class DesignRuleCommands:
return {
"success": False,
"message": "Failed to set design rules",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -126,7 +137,7 @@ class DesignRuleCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
@@ -138,42 +149,34 @@ class DesignRuleCommands:
"clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
# Via settings (current values from methods)
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
# Via minimum values
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
# Micro via settings
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
# Other constraints
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
"silkClearance": design_settings.m_SilkClearance / scale,
}
return {
"success": True,
"rules": rules
}
return {"success": True, "rules": rules}
except Exception as e:
logger.error(f"Error getting design rules: {str(e)}")
return {
"success": False,
"message": "Failed to get design rules",
"errorDetails": str(e)
"errorDetails": str(e),
}
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -189,7 +192,7 @@ class DesignRuleCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
report_path = params.get("reportPath")
@@ -200,7 +203,7 @@ class DesignRuleCommands:
return {
"success": False,
"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
@@ -209,23 +212,28 @@ class DesignRuleCommands:
return {
"success": False,
"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
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
try:
# Build command
cmd = [
kicad_cli,
'pcb',
'drc',
'--format', 'json',
'--output', json_output,
'--units', 'mm',
board_file
"pcb",
"drc",
"--format",
"json",
"--output",
json_output,
"--units",
"mm",
board_file,
]
logger.info(f"Running DRC command: {' '.join(cmd)}")
@@ -235,7 +243,7 @@ class DesignRuleCommands:
cmd,
capture_output=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:
@@ -243,32 +251,34 @@ class DesignRuleCommands:
return {
"success": False,
"message": "DRC command failed",
"errorDetails": result.stderr
"errorDetails": result.stderr,
}
# 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)
# Parse violations from kicad-cli output
violations = []
violation_counts = {}
violation_counts: dict[str, int] = {}
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")
vseverity = violation.get("severity", "error")
violations.append({
"type": vtype,
"severity": vseverity,
"message": violation.get("description", ""),
"location": {
"x": violation.get("x", 0),
"y": violation.get("y", 0),
"unit": "mm"
violations.append(
{
"type": vtype,
"severity": vseverity,
"message": violation.get("description", ""),
"location": {
"x": violation.get("x", 0),
"y": violation.get("y", 0),
"unit": "mm",
},
}
})
)
# Count violations by type
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
@@ -280,30 +290,39 @@ class DesignRuleCommands:
# Determine where to save the violations file
board_dir = os.path.dirname(board_file)
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)
with open(violations_file, 'w', encoding='utf-8') as f:
json.dump({
"board": board_file,
"timestamp": drc_data.get("date", "unknown"),
"total_violations": len(violations),
"violation_counts": violation_counts,
"severity_counts": severity_counts,
"violations": violations
}, f, indent=2)
with open(violations_file, "w", encoding="utf-8") as f:
json.dump(
{
"board": board_file,
"timestamp": drc_data.get("date", "unknown"),
"total_violations": len(violations),
"violation_counts": violation_counts,
"severity_counts": severity_counts,
"violations": violations,
},
f,
indent=2,
)
# Save text report if requested
if report_path:
report_path = os.path.abspath(os.path.expanduser(report_path))
cmd_report = [
kicad_cli,
'pcb',
'drc',
'--format', 'report',
'--output', report_path,
'--units', 'mm',
board_file
"pcb",
"drc",
"--format",
"report",
"--output",
report_path,
"--units",
"mm",
board_file,
]
subprocess.run(cmd_report, capture_output=True, timeout=600)
@@ -314,10 +333,10 @@ class DesignRuleCommands:
"summary": {
"total": len(violations),
"by_severity": severity_counts,
"by_type": violation_counts
"by_type": violation_counts,
},
"violationsFile": violations_file,
"reportPath": report_path if report_path else None
"reportPath": report_path if report_path else None,
}
finally:
@@ -330,14 +349,14 @@ class DesignRuleCommands:
return {
"success": False,
"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:
logger.error(f"Error running DRC: {str(e)}")
return {
"success": False,
"message": "Failed to run DRC",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _find_kicad_cli(self) -> Optional[str]:
@@ -399,7 +418,7 @@ class DesignRuleCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
severity = params.get("severity", "all")
@@ -416,25 +435,27 @@ class DesignRuleCommands:
return {
"success": False,
"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
with open(violations_file, 'r', encoding='utf-8') as f:
with open(violations_file, "r", encoding="utf-8") as f:
data = json.load(f)
all_violations = data.get("violations", [])
# Filter by severity if specified
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:
filtered_violations = all_violations
return {
"success": True,
"violations": filtered_violations,
"violationsFile": violations_file # Include file path for reference
"violationsFile": violations_file, # Include file path for reference
}
except Exception as e:
@@ -442,5 +463,5 @@ class DesignRuleCommands:
return {
"success": False,
"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
import glob
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class LibraryManager:
@@ -85,7 +85,7 @@ class LibraryManager:
)
"""
try:
with open(table_path, 'r') as f:
with open(table_path, "r") as f:
content = f.read()
# Simple regex-based parser for lib entries
@@ -103,7 +103,9 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
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:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -124,23 +126,23 @@ class LibraryManager:
# Common KiCAD environment variables
env_vars = {
'KICAD9_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
'KICAD8_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
'KICAD_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
'KISYSMOD': self._find_kicad_footprint_dir(),
'KICAD9_3RD_PARTY': self._find_kicad_3rdparty_dir(),
'KICAD8_3RD_PARTY': self._find_kicad_3rdparty_dir(),
"KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KISYSMOD": self._find_kicad_footprint_dir(),
"KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
}
# Project directory
if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path)
env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables
for var, value in env_vars.items():
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
resolved = os.path.expanduser(resolved)
@@ -167,10 +169,10 @@ class LibraryManager:
]
# Also check environment variable
if 'KICAD9_FOOTPRINT_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_FOOTPRINT_DIR'])
if 'KICAD8_FOOTPRINT_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_FOOTPRINT_DIR'])
if "KICAD9_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
if "KICAD8_FOOTPRINT_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"])
for path in possible_paths:
if os.path.isdir(path):
@@ -190,26 +192,36 @@ class LibraryManager:
import json
# 1. Check shell environment variable first
if 'KICAD9_3RD_PARTY' in os.environ:
path = os.environ['KICAD9_3RD_PARTY']
if "KICAD9_3RD_PARTY" in os.environ:
path = os.environ["KICAD9_3RD_PARTY"]
if os.path.isdir(path):
return path
# 2. Check kicad_common.json for user-defined variables
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() / "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:
if config_path.exists():
try:
with open(config_path, 'r') as f:
with open(config_path, "r") as f:
config = json.load(f)
env_vars = config.get('environment', {}).get('vars', {})
if env_vars and 'KICAD9_3RD_PARTY' in env_vars:
path = env_vars['KICAD9_3RD_PARTY']
env_vars = config.get("environment", {}).get("vars", {})
if env_vars and "KICAD9_3RD_PARTY" in env_vars:
path = env_vars["KICAD9_3RD_PARTY"]
if os.path.isdir(path):
return path
except (json.JSONDecodeError, KeyError, TypeError):
@@ -231,10 +243,10 @@ class LibraryManager:
Path.home() / "Documents" / "KiCad" / version / "3rdparty",
]
for path in possible_paths:
if path.exists():
logger.info(f"Found KiCad 3rd party directory: {path}")
return str(path)
for candidate in possible_paths:
if candidate.exists():
logger.info(f"Found KiCad 3rd party directory: {candidate}")
return str(candidate)
logger.warning("Could not find KiCad 3rd party directory")
return None
@@ -325,7 +337,9 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
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)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -354,18 +368,22 @@ class LibraryManager:
for footprint in footprints:
if regex.search(footprint.lower()):
results.append({
'library': library_nickname,
'footprint': footprint,
'full_name': f"{library_nickname}:{footprint}"
})
results.append(
{
"library": library_nickname,
"footprint": footprint,
"full_name": f"{library_nickname}:{footprint}",
}
)
if len(results) >= limit:
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
@@ -385,11 +403,11 @@ class LibraryManager:
return None
return {
'library': library_nickname,
'footprint': footprint_name,
'full_name': f"{library_nickname}:{footprint_name}",
'path': str(fp_file),
'library_path': library_path
"library": library_nickname,
"footprint": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"path": str(fp_file),
"library_path": library_path,
}
@@ -404,39 +422,48 @@ class LibraryCommands:
"""List all available footprint libraries"""
try:
libraries = self.library_manager.list_libraries()
return {
"success": True,
"libraries": libraries,
"count": len(libraries)
}
return {"success": True, "libraries": libraries, "count": len(libraries)}
except Exception as e:
logger.error(f"Error listing libraries: {e}")
return {
"success": False,
"message": "Failed to list libraries",
"errorDetails": str(e)
"errorDetails": str(e),
}
def search_footprints(self, params: Dict) -> Dict:
"""Search for footprints by pattern"""
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)
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 {
"success": True,
"footprints": results,
"count": len(results),
"pattern": pattern
"pattern": pattern,
}
except Exception as e:
logger.error(f"Error searching footprints: {e}")
return {
"success": False,
"message": "Failed to search footprints",
"errorDetails": str(e)
"errorDetails": str(e),
}
def list_library_footprints(self, params: Dict) -> Dict:
@@ -444,10 +471,7 @@ class LibraryCommands:
try:
library = params.get("library")
if not library:
return {
"success": False,
"message": "Missing library parameter"
}
return {"success": False, "message": "Missing library parameter"}
footprints = self.library_manager.list_footprints(library)
@@ -455,14 +479,14 @@ class LibraryCommands:
"success": True,
"library": library,
"footprints": footprints,
"count": len(footprints)
"count": len(footprints),
}
except Exception as e:
logger.error(f"Error listing library footprints: {e}")
return {
"success": False,
"message": "Failed to list library footprints",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_footprint_info(self, params: Dict) -> Dict:
@@ -470,10 +494,7 @@ class LibraryCommands:
try:
footprint_spec = params.get("footprint")
if not footprint_spec:
return {
"success": False,
"message": "Missing footprint parameter"
}
return {"success": False, "message": "Missing footprint parameter"}
# Try to find the footprint
result = self.library_manager.find_footprint(footprint_spec)
@@ -491,17 +512,14 @@ class LibraryCommands:
"library": library_nickname,
"footprint": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path
"library_path": library_path,
}
return {
"success": True,
"footprint_info": info
}
return {"success": True, "footprint_info": info}
else:
return {
"success": False,
"message": f"Footprint not found: {footprint_spec}"
"message": f"Footprint not found: {footprint_spec}",
}
except Exception as e:
@@ -509,5 +527,5 @@ class LibraryCommands:
return {
"success": False,
"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 skip import Schematic
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class PinLocator:
@@ -23,6 +23,7 @@ class PinLocator:
def __init__(self):
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@staticmethod
def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]:
@@ -47,39 +48,39 @@ class PinLocator:
return
# 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_data = {
'x': 0,
'y': 0,
'angle': 0,
'length': 0,
'name': '',
'number': '',
'type': str(sexp[1]) if len(sexp) > 1 else 'passive'
"x": 0,
"y": 0,
"angle": 0,
"length": 0,
"name": "",
"number": "",
"type": str(sexp[1]) if len(sexp) > 1 else "passive",
}
# Extract pin attributes
for item in sexp:
if isinstance(item, list) and len(item) > 0:
if item[0] == Symbol('at') and len(item) >= 3:
pin_data['x'] = float(item[1])
pin_data['y'] = float(item[2])
if item[0] == Symbol("at") and len(item) >= 3:
pin_data["x"] = float(item[1])
pin_data["y"] = float(item[2])
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:
pin_data['length'] = float(item[1])
elif item[0] == Symbol("length") and len(item) >= 2:
pin_data["length"] = float(item[1])
elif item[0] == Symbol('name') and len(item) >= 2:
pin_data['name'] = str(item[1]).strip('"')
elif item[0] == Symbol("name") and len(item) >= 2:
pin_data["name"] = str(item[1]).strip('"')
elif item[0] == Symbol('number') and len(item) >= 2:
pin_data['number'] = str(item[1]).strip('"')
elif item[0] == Symbol("number") and len(item) >= 2:
pin_data["number"] = str(item[1]).strip('"')
# Store by pin number
if pin_data['number']:
pins[pin_data['number']] = pin_data
if pin_data["number"]:
pins[pin_data["number"]] = pin_data
# Recurse into sublists
for item in sexp:
@@ -108,7 +109,7 @@ class PinLocator:
try:
# 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_data = sexpdata.loads(sch_content)
@@ -116,7 +117,11 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
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
break
@@ -126,7 +131,11 @@ class PinLocator:
# Find the specific symbol definition
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('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -141,6 +150,7 @@ class PinLocator:
except Exception as e:
logger.error(f"Error getting symbol pins: {e}")
import traceback
logger.error(traceback.format_exc())
return {}
@@ -169,8 +179,9 @@ class PinLocator:
return (rotated_x, rotated_y)
def get_pin_location(self, schematic_path: Path, symbol_reference: str,
pin_number: str) -> Optional[List[float]]:
def get_pin_location(
self, schematic_path: Path, symbol_reference: str, pin_number: str
) -> Optional[List[float]]:
"""
Get the absolute location of a pin on a symbol instance
@@ -184,7 +195,11 @@ class PinLocator:
"""
try:
# 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
target_symbol = None
@@ -205,12 +220,16 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# 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:
logger.error(f"Symbol {symbol_reference} has no lib_id")
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
pins = self.get_symbol_pins(schematic_path, lib_id)
@@ -220,36 +239,49 @@ class PinLocator:
# Find the requested pin
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
pin_data = pins[pin_number]
# Get pin position relative to symbol origin
pin_rel_x = pin_data['x']
pin_rel_y = pin_data['y']
pin_rel_x = pin_data["x"]
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
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
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]
except Exception as e:
logger.error(f"Error getting pin location: {e}")
import traceback
logger.error(traceback.format_exc())
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
@@ -261,8 +293,11 @@ class PinLocator:
Dictionary mapping pin number -> [x, y] coordinates
"""
try:
# Load schematic
sch = Schematic(str(schematic_path))
# Load schematic (use cache)
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
target_symbol = None
@@ -276,7 +311,9 @@ class PinLocator:
return {}
# 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:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -289,7 +326,9 @@ class PinLocator:
# Calculate location for each pin
result = {}
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:
result[pin_num] = location
@@ -301,10 +340,11 @@ class PinLocator:
return {}
if __name__ == '__main__':
if __name__ == "__main__":
# Test pin location discovery
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 commands.component_schematic import ComponentManager
@@ -316,8 +356,10 @@ if __name__ == '__main__':
print("=" * 80)
# Create test schematic with components (cross-platform temp directory)
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')
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"
)
shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}")
@@ -327,11 +369,25 @@ if __name__ == '__main__':
sch = SchematicManager.load_schematic(str(test_path))
# 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)
# 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)
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
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import express from 'express';
import { spawn, exec, execSync, ChildProcess } from 'child_process';
import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { logger } from './logger.js';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import express from "express";
import { spawn, exec, execSync, ChildProcess } from "child_process";
import { existsSync } from "fs";
import { join, dirname } from "path";
import { logger } from "./logger.js";
// Import tool registration functions
import { registerProjectTools } from './tools/project.js';
import { registerBoardTools } from './tools/board.js';
import { registerComponentTools } from './tools/component.js';
import { registerRoutingTools } from './tools/routing.js';
import { registerDesignRuleTools } from './tools/design-rules.js';
import { registerExportTools } from './tools/export.js';
import { registerSchematicTools } from './tools/schematic.js';
import { registerLibraryTools } from './tools/library.js';
import { registerSymbolLibraryTools } from './tools/library-symbol.js';
import { registerJLCPCBApiTools } from './tools/jlcpcb-api.js';
import { registerUITools } from './tools/ui.js';
import { registerRouterTools } from './tools/router.js';
import { registerProjectTools } from "./tools/project.js";
import { registerBoardTools } from "./tools/board.js";
import { registerComponentTools } from "./tools/component.js";
import { registerRoutingTools } from "./tools/routing.js";
import { registerDesignRuleTools } from "./tools/design-rules.js";
import { registerExportTools } from "./tools/export.js";
import { registerSchematicTools } from "./tools/schematic.js";
import { registerLibraryTools } from "./tools/library.js";
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
import { registerDatasheetTools } from "./tools/datasheet.js";
import { registerUITools } from "./tools/ui.js";
import { registerRouterTools } from "./tools/router.js";
// Import resource registration functions
import { registerProjectResources } from './resources/project.js';
import { registerBoardResources } from './resources/board.js';
import { registerComponentResources } from './resources/component.js';
import { registerLibraryResources } from './resources/library.js';
import { registerProjectResources } from "./resources/project.js";
import { registerBoardResources } from "./resources/board.js";
import { registerComponentResources } from "./resources/component.js";
import { registerLibraryResources } from "./resources/library.js";
// Import prompt registration functions
import { registerComponentPrompts } from './prompts/component.js';
import { registerRoutingPrompts } from './prompts/routing.js';
import { registerDesignPrompts } from './prompts/design.js';
import { registerComponentPrompts } from "./prompts/component.js";
import { registerRoutingPrompts } from "./prompts/routing.js";
import { registerDesignPrompts } from "./prompts/design.js";
/**
* Find the Python executable to use
* Prioritizes virtual environment if available, falls back to system Python
*/
function findPythonExecutable(scriptPath: string): string {
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
const isWindows = process.platform === "win32";
const isMac = process.platform === "darwin";
const isLinux = !isWindows && !isMac;
// Get the project root (parent of the python/ directory)
@@ -49,8 +50,18 @@ function findPythonExecutable(scriptPath: string): string {
// Check for virtual environment
const venvPaths = [
join(projectRoot, 'venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'),
join(projectRoot, '.venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'),
join(
projectRoot,
"venv",
isWindows ? "Scripts" : "bin",
isWindows ? "python.exe" : "python",
),
join(
projectRoot,
".venv",
isWindows ? "Scripts" : "bin",
isWindows ? "python.exe" : "python",
),
];
for (const venvPath of venvPaths) {
@@ -62,27 +73,29 @@ function findPythonExecutable(scriptPath: string): string {
// Allow override via KICAD_PYTHON environment variable (any platform)
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;
}
// Platform-specific KiCAD bundled Python detection
if (isWindows && process.env.PYTHONPATH?.includes('KiCad')) {
// Windows: Try KiCAD's bundled Python
const kicadPython = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe';
if (isWindows) {
// Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it)
const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe";
if (existsSync(kicadPython)) {
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
return kicadPython;
}
} else if (isMac) {
// 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
const kicadAppPaths = [
'/Applications/KiCad/KiCad.app',
'/Applications/KiCAD/KiCad.app', // Alternative capitalization
`${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder
"/Applications/KiCad/KiCad.app",
"/Applications/KiCAD/KiCad.app", // Alternative capitalization
`${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder
];
// 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)
const homebrewPaths = [
'/opt/homebrew/bin/python3', // Apple Silicon
'/usr/local/bin/python3', // Intel Mac
'/opt/homebrew/bin/python3.12',
'/opt/homebrew/bin/python3.11',
"/opt/homebrew/bin/python3", // Apple Silicon
"/usr/local/bin/python3", // Intel Mac
"/opt/homebrew/bin/python3.12",
"/opt/homebrew/bin/python3.11",
];
for (const path of homebrewPaths) {
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;
}
}
} else if (isLinux) {
// Linux: Try KiCAD bundled Python locations first
const linuxKicadPaths = [
'/usr/lib/kicad/bin/python3',
'/usr/local/lib/kicad/bin/python3',
'/opt/kicad/bin/python3',
"/usr/lib/kicad/bin/python3",
"/usr/local/lib/kicad/bin/python3",
"/opt/kicad/bin/python3",
];
for (const path of linuxKicadPaths) {
@@ -127,17 +142,17 @@ function findPythonExecutable(scriptPath: string): string {
// Resolve system python3 to full path using 'which'
try {
const result = execSync('which python3', { encoding: 'utf-8' }).trim();
const result = execSync("which python3", { encoding: "utf-8" }).trim();
if (result && existsSync(result)) {
logger.info(`Resolved system Python via which: ${result}`);
return result;
}
} 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
const systemPaths = ['/usr/bin/python3', '/bin/python3'];
const systemPaths = ["/usr/bin/python3", "/bin/python3"];
for (const path of systemPaths) {
if (existsSync(path)) {
logger.info(`Found system Python at: ${path}`);
@@ -147,8 +162,8 @@ function findPythonExecutable(scriptPath: string): string {
}
// Default to system Python (last resort)
logger.info('Using system Python (no venv found)');
return isWindows ? 'python.exe' : 'python3';
logger.info("Using system Python (no venv found)");
return isWindows ? "python.exe" : "python3";
}
/**
@@ -159,11 +174,19 @@ export class KiCADMcpServer {
private pythonProcess: ChildProcess | null = null;
private kicadScriptPath: string;
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 responseBuffer: string = '';
private currentRequestHandler: { resolve: Function, reject: Function, timeoutHandle: NodeJS.Timeout } | null = null;
private responseBuffer: string = "";
private currentRequestHandler: {
resolve: Function;
reject: Function;
timeoutHandle: NodeJS.Timeout;
} | null = null;
/**
* Constructor for the KiCAD MCP Server
* @param kicadScriptPath Path to the Python KiCAD interface script
@@ -171,37 +194,39 @@ export class KiCADMcpServer {
*/
constructor(
kicadScriptPath: string,
logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info'
logLevel: "error" | "warn" | "info" | "debug" = "info",
) {
// Set up the logger
logger.setLogLevel(logLevel);
// Check if KiCAD script exists
this.kicadScriptPath = 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
this.server = new McpServer({
name: 'kicad-mcp-server',
version: '1.0.0',
description: 'MCP server for KiCAD PCB design operations'
name: "kicad-mcp-server",
version: "1.0.0",
description: "MCP server for KiCAD PCB design operations",
});
// Initialize STDIO transport
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
this.registerAll();
}
/**
* Register all tools, resources, and prompts
*/
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)
registerRouterTools(this.server, this.callKicadScript.bind(this));
@@ -217,6 +242,7 @@ export class KiCADMcpServer {
registerLibraryTools(this.server, this.callKicadScript.bind(this));
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
registerUITools(this.server, this.callKicadScript.bind(this));
// Register all resources
@@ -230,20 +256,26 @@ export class KiCADMcpServer {
registerRoutingPrompts(this.server);
registerDesignPrompts(this.server);
logger.info('All KiCAD tools, resources, and prompts registered');
logger.info('Router pattern enabled: 4 router tools + direct tools for discovery');
logger.info("All KiCAD tools, resources, and prompts registered");
logger.info(
"Router pattern enabled: 4 router tools + direct tools for discovery",
);
}
/**
* Validate prerequisites before starting the server
*/
private async validatePrerequisites(pythonExe: string): Promise<boolean> {
const isWindows = process.platform === 'win32';
const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
const isWindows = process.platform === "win32";
const isLinux =
process.platform !== "win32" && process.platform !== "darwin";
const errors: string[] = [];
// 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) {
// Absolute path: use existsSync
@@ -251,42 +283,61 @@ export class KiCADMcpServer {
errors.push(`Python executable not found: ${pythonExe}`);
if (isWindows) {
errors.push('Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/');
errors.push('Or run: .\\setup-windows.ps1 for automatic configuration');
errors.push(
"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) {
errors.push('Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable');
errors.push('Set KICAD_PYTHON to specify a custom Python path');
errors.push(
"Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable",
);
errors.push("Set KICAD_PYTHON to specify a custom Python path");
}
}
} else {
// Command name: verify it's executable via --version test
logger.info(`Validating command-based Python executable: ${pythonExe}`);
try {
const { stdout } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
exec(`"${pythonExe}" --version`, {
timeout: 3000,
env: { ...process.env }
}, (error: any, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
});
const { stdout } = await new Promise<{
stdout: string;
stderr: string;
}>((resolve, reject) => {
exec(
`"${pythonExe}" --version`,
{
timeout: 3000,
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()}`);
} catch (error: any) {
errors.push(`Python executable not found in PATH: ${pythonExe}`);
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) {
errors.push('');
errors.push('Linux troubleshooting:');
errors.push('1. Check if python3 is installed: which python3');
errors.push('2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)');
errors.push('3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config');
errors.push("");
errors.push("Linux troubleshooting:");
errors.push("1. Check if python3 is installed: which python3");
errors.push(
"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)
const distPath = join(dirname(dirname(this.kicadScriptPath)), 'dist', 'index.js');
const distPath = join(
dirname(dirname(this.kicadScriptPath)),
"dist",
"index.js",
);
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)
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')"`;
try {
const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
exec(testCommand, {
timeout: 5000,
env: { ...process.env }
}, (error: any, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
});
const { stdout, stderr } = await new Promise<{
stdout: string;
stderr: string;
}>((resolve, reject) => {
exec(
testCommand,
{
timeout: 5000,
env: { ...process.env },
},
(error: any, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
},
);
});
if (!stdout.includes('OK')) {
errors.push('pcbnew module import test failed');
if (!stdout.includes("OK")) {
errors.push("pcbnew module import test failed");
errors.push(`Output: ${stdout}`);
errors.push(`Errors: ${stderr}`);
if (isWindows) {
errors.push('');
errors.push('Windows troubleshooting:');
errors.push('1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages');
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');
errors.push("");
errors.push("Windows troubleshooting:");
errors.push(
"1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages",
);
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 {
logger.info('✓ pcbnew module validated successfully');
logger.info("✓ pcbnew module validated successfully");
}
} catch (error: any) {
errors.push(`pcbnew validation failed: ${error.message}`);
if (isWindows) {
errors.push('');
errors.push('This usually means:');
errors.push('- KiCAD is not installed');
errors.push('- PYTHONPATH is incorrect');
errors.push('- Python cannot find pcbnew module');
errors.push('');
errors.push('Quick fix: Run .\\setup-windows.ps1');
errors.push("");
errors.push("This usually means:");
errors.push("- KiCAD is not installed");
errors.push("- PYTHONPATH is incorrect");
errors.push("- Python cannot find pcbnew module");
errors.push("");
errors.push("Quick fix: Run .\\setup-windows.ps1");
}
}
}
// Log all errors
if (errors.length > 0) {
logger.error('='.repeat(70));
logger.error('STARTUP VALIDATION FAILED');
logger.error('='.repeat(70));
errors.forEach(err => logger.error(err));
logger.error('='.repeat(70));
logger.error("=".repeat(70));
logger.error("STARTUP VALIDATION FAILED");
logger.error("=".repeat(70));
errors.forEach((err) => logger.error(err));
logger.error("=".repeat(70));
// Also write to stderr for Claude Desktop to capture
process.stderr.write('\n' + '='.repeat(70) + '\n');
process.stderr.write('KiCAD MCP Server - Startup Validation Failed\n');
process.stderr.write('='.repeat(70) + '\n');
errors.forEach(err => process.stderr.write(err + '\n'));
process.stderr.write('='.repeat(70) + '\n\n');
process.stderr.write("\n" + "=".repeat(70) + "\n");
process.stderr.write("KiCAD MCP Server - Startup Validation Failed\n");
process.stderr.write("=".repeat(70) + "\n");
errors.forEach((err) => process.stderr.write(err + "\n"));
process.stderr.write("=".repeat(70) + "\n\n");
return false;
}
@@ -379,10 +445,12 @@ export class KiCADMcpServer {
*/
async start(): Promise<void> {
try {
logger.info('Starting KiCAD MCP server...');
logger.info("Starting KiCAD MCP server...");
// 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);
logger.info(`Using Python executable: ${pythonExe}`);
@@ -390,76 +458,82 @@ export class KiCADMcpServer {
// Validate prerequisites
const isValid = await this.validatePrerequisites(pythonExe);
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], {
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ["pipe", "pipe", "pipe"],
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
this.pythonProcess.on('exit', (code, signal) => {
logger.warn(`Python process exited with code ${code} and signal ${signal}`);
this.pythonProcess.on("exit", (code, signal) => {
logger.warn(
`Python process exited with code ${code} and signal ${signal}`,
);
this.pythonProcess = null;
});
// Listen for process errors
this.pythonProcess.on('error', (err) => {
this.pythonProcess.on("error", (err) => {
logger.error(`Python process error: ${err.message}`);
});
// Set up error logging for 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()}`);
});
}
// Set up persistent stdout handler (instead of adding/removing per request)
if (this.pythonProcess.stdout) {
this.pythonProcess.stdout.on('data', (data: Buffer) => {
this.pythonProcess.stdout.on("data", (data: Buffer) => {
this.handlePythonResponse(data);
});
}
// Connect server to STDIO transport
logger.info('Connecting MCP server to STDIO transport...');
logger.info("Connecting MCP server to STDIO transport...");
try {
await this.server.connect(this.stdioTransport);
logger.info('Successfully connected to STDIO transport');
logger.info("Successfully connected to STDIO transport");
} catch (error) {
logger.error(`Failed to connect to STDIO transport: ${error}`);
throw error;
}
// Write a ready message to stderr (for debugging)
process.stderr.write('KiCAD MCP SERVER READY\n');
logger.info('KiCAD MCP server started and ready');
process.stderr.write("KiCAD MCP SERVER READY\n");
logger.info("KiCAD MCP server started and ready");
} catch (error) {
logger.error(`Failed to start KiCAD MCP server: ${error}`);
throw error;
}
}
/**
* Stop the MCP server and clean up resources
*/
async stop(): Promise<void> {
logger.info('Stopping KiCAD MCP server...');
logger.info("Stopping KiCAD MCP server...");
// Kill the Python process if it's running
if (this.pythonProcess) {
this.pythonProcess.kill();
this.pythonProcess = null;
}
logger.info('KiCAD MCP server stopped');
logger.info("KiCAD MCP server stopped");
}
/**
* Call the KiCAD scripting interface to execute commands
*
@@ -471,7 +545,7 @@ export class KiCADMcpServer {
return new Promise((resolve, reject) => {
// Check if Python process is running
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"));
return;
}
@@ -479,17 +553,24 @@ export class KiCADMcpServer {
// Determine timeout based on command type
// DRC and export operations need longer timeouts for large boards
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)) {
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
this.requestQueue.push({
request: { command, params, timeout: commandTimeout },
resolve,
reject
reject,
});
// Process the queue if not already processing
@@ -498,7 +579,7 @@ export class KiCADMcpServer {
}
});
}
/**
* Handle incoming data from Python process stdout
* This is a persistent handler that processes all responses
@@ -519,8 +600,10 @@ export class KiCADMcpServer {
if (!this.currentRequestHandler) {
// No pending request, clear buffer if it has data (shouldn't happen)
if (this.responseBuffer.trim()) {
logger.warn(`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`);
this.responseBuffer = '';
logger.warn(
`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`,
);
this.responseBuffer = "";
}
return;
}
@@ -530,7 +613,9 @@ export class KiCADMcpServer {
const result = JSON.parse(this.responseBuffer);
// 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
if (this.currentRequestHandler.timeoutHandle) {
@@ -541,7 +626,7 @@ export class KiCADMcpServer {
const handler = this.currentRequestHandler;
// Clear state
this.responseBuffer = '';
this.responseBuffer = "";
this.currentRequestHandler = null;
this.processingRequest = false;
@@ -550,7 +635,6 @@ export class KiCADMcpServer {
// Process next request if any
setTimeout(() => this.processNextRequest(), 0);
} catch (e) {
// Not a complete JSON yet, keep collecting data
// This is normal for large responses that come in chunks
@@ -579,21 +663,29 @@ export class KiCADMcpServer {
const requestStr = JSON.stringify(request);
// Clear response buffer for new request
this.responseBuffer = '';
this.responseBuffer = "";
// Set a timeout (use command-specific timeout or default)
const timeoutDuration = request.timeout || 30000;
const timeoutHandle = setTimeout(() => {
logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`);
logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`);
logger.error(
`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`,
);
logger.error(
`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`,
);
// Clear state
this.responseBuffer = '';
this.responseBuffer = "";
this.currentRequestHandler = null;
this.processingRequest = false;
// 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
setTimeout(() => this.processNextRequest(), 0);
@@ -604,7 +696,7 @@ export class KiCADMcpServer {
// Write the request to the Python process
logger.debug(`Sending request: ${requestStr}`);
this.pythonProcess?.stdin?.write(requestStr + '\n');
this.pythonProcess?.stdin?.write(requestStr + "\n");
} catch (error) {
logger.error(`Error processing request: ${error}`);

View File

@@ -1,291 +1,644 @@
/**
* Component management tools for KiCAD MCP server
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { logger } from '../logger.js';
// Command function type for KiCAD script calls
type CommandFunction = (command: string, params: Record<string, unknown>) => Promise<any>;
/**
* Register component management tools with the MCP server
*
* @param server MCP server instance
* @param callKicadScript Function to call KiCAD script commands
*/
export function registerComponentTools(server: McpServer, callKicadScript: CommandFunction): void {
logger.info('Registering component management tools');
// ------------------------------------------------------
// Place Component Tool
// ------------------------------------------------------
server.tool(
"place_component",
{
componentId: z.string().describe("Identifier for the component to place (e.g., 'R_0603_10k')"),
position: z.object({
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
unit: z.enum(["mm", "inch"]).describe("Unit of measurement")
}).describe("Position coordinates and unit"),
reference: z.string().optional().describe("Optional desired reference (e.g., 'R5')"),
value: z.string().optional().describe("Optional component value (e.g., '10k')"),
footprint: z.string().optional().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')")
},
async ({ componentId, position, reference, value, footprint, rotation, layer }) => {
logger.debug(`Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`);
const result = await callKicadScript("place_component", {
componentId,
position,
reference,
value,
footprint,
rotation,
layer
});
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// Move Component Tool
// ------------------------------------------------------
server.tool(
"move_component",
{
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"),
position: z.object({
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
unit: z.enum(["mm", "inch"]).describe("Unit of measurement")
}).describe("New position coordinates and unit"),
rotation: z.number().optional().describe("Optional new rotation in degrees")
},
async ({ reference, position, rotation }) => {
logger.debug(`Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`);
const result = await callKicadScript("move_component", {
reference,
position,
rotation
});
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// Rotate Component Tool
// ------------------------------------------------------
server.tool(
"rotate_component",
{
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"),
angle: z.number().describe("Rotation angle in degrees (absolute, not relative)")
},
async ({ reference, angle }) => {
logger.debug(`Rotating component: ${reference} to ${angle} degrees`);
const result = await callKicadScript("rotate_component", {
reference,
angle
});
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// Delete Component Tool
// ------------------------------------------------------
server.tool(
"delete_component",
{
reference: z.string().describe("Reference designator of the component to delete (e.g., 'R5')")
},
async ({ reference }) => {
logger.debug(`Deleting component: ${reference}`);
const result = await callKicadScript("delete_component", { reference });
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// Edit Component Properties Tool
// ------------------------------------------------------
server.tool(
"edit_component",
{
reference: z.string().describe("Reference designator of the component (e.g., 'R5')"),
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")
},
async ({ reference, newReference, value, footprint }) => {
logger.debug(`Editing component: ${reference}`);
const result = await callKicadScript("edit_component", {
reference,
newReference,
value,
footprint
});
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// Find Component Tool
// ------------------------------------------------------
server.tool(
"find_component",
{
reference: z.string().optional().describe("Reference designator to search for"),
value: z.string().optional().describe("Component value to search for")
},
async ({ reference, value }) => {
logger.debug(`Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`);
const result = await callKicadScript("find_component", { reference, value });
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')")
},
async ({ reference }) => {
logger.debug(`Getting properties for component: ${reference}`);
const result = await callKicadScript("get_component_properties", { reference });
return {
content: [{
type: "text",
text: JSON.stringify(result)
}]
};
}
);
// ------------------------------------------------------
// 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)
}]
};
}
);
logger.info('Component management tools registered');
}
/**
* Component management tools for KiCAD MCP server
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { logger } from "../logger.js";
// Command function type for KiCAD script calls
type CommandFunction = (
command: string,
params: Record<string, unknown>,
) => Promise<any>;
/**
* Register component management tools with the MCP server
*
* @param server MCP server instance
* @param callKicadScript Function to call KiCAD script commands
*/
export function registerComponentTools(
server: McpServer,
callKicadScript: CommandFunction,
): void {
logger.info("Registering component management tools");
// ------------------------------------------------------
// Place Component Tool
// ------------------------------------------------------
server.tool(
"place_component",
{
componentId: z
.string()
.describe("Identifier for the component to place (e.g., 'R_0603_10k')"),
position: z
.object({
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
unit: z.enum(["mm", "inch"]).describe("Unit of measurement"),
})
.describe("Position coordinates and unit"),
reference: z
.string()
.optional()
.describe("Optional desired reference (e.g., 'R5')"),
value: z
.string()
.optional()
.describe("Optional component value (e.g., '10k')"),
footprint: z
.string()
.optional()
.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')"),
},
async ({
componentId,
position,
reference,
value,
footprint,
rotation,
layer,
}) => {
logger.debug(
`Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`,
);
const result = await callKicadScript("place_component", {
componentId,
position,
reference,
value,
footprint,
rotation,
layer,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Move Component Tool
// ------------------------------------------------------
server.tool(
"move_component",
{
reference: z
.string()
.describe("Reference designator of the component (e.g., 'R5')"),
position: z
.object({
x: z.number().describe("X coordinate"),
y: z.number().describe("Y coordinate"),
unit: z.enum(["mm", "inch"]).describe("Unit of measurement"),
})
.describe("New position coordinates and unit"),
rotation: z
.number()
.optional()
.describe("Optional new rotation in degrees"),
},
async ({ reference, position, rotation }) => {
logger.debug(
`Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`,
);
const result = await callKicadScript("move_component", {
reference,
position,
rotation,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Rotate Component Tool
// ------------------------------------------------------
server.tool(
"rotate_component",
{
reference: z
.string()
.describe("Reference designator of the component (e.g., 'R5')"),
angle: z
.number()
.describe("Rotation angle in degrees (absolute, not relative)"),
},
async ({ reference, angle }) => {
logger.debug(`Rotating component: ${reference} to ${angle} degrees`);
const result = await callKicadScript("rotate_component", {
reference,
angle,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Delete Component Tool
// ------------------------------------------------------
server.tool(
"delete_component",
{
reference: z
.string()
.describe(
"Reference designator of the component to delete (e.g., 'R5')",
),
},
async ({ reference }) => {
logger.debug(`Deleting component: ${reference}`);
const result = await callKicadScript("delete_component", { reference });
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Edit Component Properties Tool
// ------------------------------------------------------
server.tool(
"edit_component",
{
reference: z
.string()
.describe("Reference designator of the component (e.g., 'R5')"),
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"),
},
async ({ reference, newReference, value, footprint }) => {
logger.debug(`Editing component: ${reference}`);
const result = await callKicadScript("edit_component", {
reference,
newReference,
value,
footprint,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// Find Component Tool
// ------------------------------------------------------
server.tool(
"find_component",
{
reference: z
.string()
.optional()
.describe("Reference designator to search for"),
value: z.string().optional().describe("Component value to search for"),
},
async ({ reference, value }) => {
logger.debug(
`Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`,
);
const result = await callKicadScript("find_component", {
reference,
value,
});
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')"),
},
async ({ reference }) => {
logger.debug(`Getting properties for component: ${reference}`);
const result = await callKicadScript("get_component_properties", {
reference,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
// ------------------------------------------------------
// 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
*
* Exports all tool registration functions
*/
export { registerProjectTools } from './project.js';
export { registerBoardTools } from './board.js';
export { registerComponentTools } from './component.js';
export { registerRoutingTools } from './routing.js';
export { registerDesignRuleTools } from './design-rules.js';
export { registerExportTools } from './export.js';
export { registerSchematicTools } from './schematic.js';
export { registerLibraryTools } from './library.js';
export { registerUITools } from './ui.js';
/**
* Tools index for KiCAD MCP server
*
* Exports all tool registration functions
*/
export { registerProjectTools } from "./project.js";
export { registerBoardTools } from "./board.js";
export { registerComponentTools } from "./component.js";
export { registerRoutingTools } from "./routing.js";
export { registerDesignRuleTools } from "./design-rules.js";
export { registerExportTools } from "./export.js";
export { registerSchematicTools } from "./schematic.js";
export { registerLibraryTools } from "./library.js";
export { registerUITools } from "./ui.js";
export { registerDatasheetTools } from "./datasheet.js";

View File

@@ -1,159 +1,193 @@
/**
* Library tools for KiCAD MCP server
* Provides access to KiCAD footprint libraries and symbols
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerLibraryTools(server: McpServer, callKicadScript: Function) {
// List available footprint libraries
server.tool(
"list_libraries",
"List all available KiCAD footprint libraries",
{
search_paths: z.array(z.string()).optional()
.describe("Optional additional search paths for libraries")
},
async (args: { search_paths?: string[] }) => {
const result = await callKicadScript("list_libraries", args);
if (result.success && result.libraries) {
return {
content: [
{
type: "text",
text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join('\n')}`
}
]
};
}
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()
.describe("Search term or pattern to match footprint names"),
library: z.string().optional()
.describe("Optional specific library to search in"),
limit: z.number().optional().default(50)
.describe("Maximum number of results to return")
},
async (args: { search_term: string; library?: string; limit?: number }) => {
const result = await callKicadScript("search_footprints", args);
if (result.success && result.footprints) {
const footprintList = result.footprints.map((fp: any) =>
`${fp.library}:${fp.name}${fp.description ? ' - ' + fp.description : ''}`
).join('\n');
return {
content: [
{
type: "text",
text: `Found ${result.footprints.length} matching footprints:\n${footprintList}`
}
]
};
}
return {
content: [
{
type: "text",
text: `Failed to search footprints: ${result.message || 'Unknown error'}`
}
]
};
}
);
// List footprints in a specific library
server.tool(
"list_library_footprints",
"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()
.describe("Optional filter pattern for footprint names"),
limit: z.number().optional().default(100)
.describe("Maximum number of footprints to list")
},
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: [
{
type: "text",
text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}`
}
]
};
}
return {
content: [
{
type: "text",
text: `Failed to list footprints in library ${args.library_name}: ${result.message || 'Unknown error'}`
}
]
};
}
);
// Get detailed information about a specific footprint
server.tool(
"get_footprint_info",
"Get detailed information about a specific footprint",
{
library_name: z.string()
.describe("Name of the library containing the footprint"),
footprint_name: z.string()
.describe("Name of the footprint to get information about")
},
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'}`
}
]
};
}
);
}
/**
* Library tools for KiCAD MCP server
* Provides access to KiCAD footprint libraries and symbols
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerLibraryTools(
server: McpServer,
callKicadScript: Function,
) {
// List available footprint libraries
server.tool(
"list_libraries",
"List all available KiCAD footprint libraries",
{
search_paths: z
.array(z.string())
.optional()
.describe("Optional additional search paths for libraries"),
},
async (args: { search_paths?: string[] }) => {
const result = await callKicadScript("list_libraries", args);
if (result.success && result.libraries) {
return {
content: [
{
type: "text",
text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join("\n")}`,
},
],
};
}
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()
.describe("Search term or pattern to match footprint names"),
library: z
.string()
.optional()
.describe("Optional specific library to search in"),
limit: z
.number()
.optional()
.default(50)
.describe("Maximum number of results to return"),
},
async (args: { search_term: string; library?: string; limit?: number }) => {
const result = await callKicadScript("search_footprints", {
pattern: args.search_term,
library: args.library,
limit: args.limit,
});
if (result.success && result.footprints) {
const footprintList = result.footprints
.map(
(fp: any) =>
`${fp.full_name || fp.library + ":" + fp.footprint}${fp.description ? " - " + fp.description : ""}`,
)
.join("\n");
return {
content: [
{
type: "text",
text: `Found ${result.footprints.length} matching footprints:\n${footprintList}`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Failed to search footprints: ${result.message || "Unknown error"}`,
},
],
};
},
);
// List footprints in a specific library
server.tool(
"list_library_footprints",
"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()
.describe("Optional filter pattern for footprint names"),
limit: z
.number()
.optional()
.default(100)
.describe("Maximum number of footprints to list"),
},
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: [
{
type: "text",
text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Failed to list footprints in library ${args.library_name}: ${result.message || "Unknown error"}`,
},
],
};
},
);
// Get detailed information about a specific footprint
server.tool(
"get_footprint_info",
"Get detailed information about a specific footprint",
{
library_name: z
.string()
.describe("Name of the library containing the footprint"),
footprint_name: z
.string()
.describe("Name of the footprint to get information about"),
},
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
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerRoutingTools(server: McpServer, callKicadScript: Function) {
// Add net tool
server.tool(
"add_net",
"Create a new net on the PCB",
{
name: z.string().describe("Net name"),
netClass: z.string().optional().describe("Net class name"),
},
async (args: { name: string; netClass?: string }) => {
const result = await callKicadScript("add_net", args);
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({
x: z.number(),
y: z.number(),
unit: z.string().optional()
}).describe("Start position"),
end: z.object({
x: z.number(),
y: z.number(),
unit: z.string().optional()
}).describe("End position"),
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);
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
);
// Add via tool
server.tool(
"add_via",
"Add a via to the PCB",
{
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)"),
},
async (args: any) => {
const result = await callKicadScript("add_via", args);
return {
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)
}]
};
}
);
}
/**
* Routing tools for KiCAD MCP server
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerRoutingTools(
server: McpServer,
callKicadScript: Function,
) {
// Add net tool
server.tool(
"add_net",
"Create a new net on the PCB",
{
name: z.string().describe("Net name"),
netClass: z.string().optional().describe("Net class name"),
},
async (args: { name: string; netClass?: string }) => {
const result = await callKicadScript("add_net", args);
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({
x: z.number(),
y: z.number(),
unit: z.string().optional(),
})
.describe("Start position"),
end: z
.object({
x: z.number(),
y: z.number(),
unit: z.string().optional(),
})
.describe("End position"),
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);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Add via tool
server.tool(
"add_via",
"Add a via to the PCB",
{
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)"),
},
async (args: any) => {
const result = await callKicadScript("add_via", args);
return {
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
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerSchematicTools(server: McpServer, callKicadScript: Function) {
// Create schematic tool
server.tool(
"create_schematic",
"Create a new schematic",
{
name: z.string().describe("Schematic name"),
path: z.string().optional().describe("Optional path"),
},
async (args: { name: string; path?: string }) => {
const result = await callKicadScript("create_schematic", args);
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"),
symbol: z.string().describe("Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)"),
reference: z.string().describe("Component reference (e.g., R1, U1)"),
value: z.string().optional().describe("Component value"),
position: z.object({
x: z.number(),
y: z.number()
}).optional().describe("Position on schematic"),
},
async (args: { schematicPath: string; symbol: string; reference: string; value?: string; position?: { x: number; y: number } }) => {
// Transform to what Python backend expects
const [library, symbolName] = args.symbol.includes(':')
? args.symbol.split(':')
: ['Device', args.symbol];
const transformed = {
schematicPath: args.schematicPath,
component: {
library,
type: symbolName,
reference: args.reference,
value: args.value,
// Python expects flat x, y not nested position
x: args.position?.x ?? 0,
y: args.position?.y ?? 0
}
};
const result = await callKicadScript("add_schematic_component", transformed);
if (result.success) {
return {
content: [{
type: "text",
text: `Successfully added ${args.reference} (${args.symbol}) to schematic`
}]
};
} else {
return {
content: [{
type: "text",
text: `Failed to add component: ${result.message || JSON.stringify(result)}`
}]
};
}
}
);
// Connect components with wire
server.tool(
"add_wire",
"Add a wire connection in the schematic",
{
start: z.object({
x: z.number(),
y: z.number()
}).describe("Start position"),
end: z.object({
x: z.number(),
y: z.number()
}).describe("End position"),
},
async (args: any) => {
const result = await callKicadScript("add_wire", args);
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
);
// Add pin-to-pin connection
server.tool(
"add_schematic_connection",
"Connect two component pins with a wire",
{
schematicPath: z.string().describe("Path to the schematic file"),
sourceRef: z.string().describe("Source component reference (e.g., R1)"),
sourcePin: z.string().describe("Source pin name/number (e.g., 1, 2, GND)"),
targetRef: z.string().describe("Target component reference (e.g., C1)"),
targetPin: z.string().describe("Target pin name/number (e.g., 1, 2, VCC)")
},
async (args: { schematicPath: string; sourceRef: string; sourcePin: string; targetRef: string; targetPin: string }) => {
const result = await callKicadScript("add_schematic_connection", args);
if (result.success) {
return {
content: [{
type: "text",
text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`
}]
};
} else {
return {
content: [{
type: "text",
text: `Failed to add connection: ${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"),
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")
},
async (args: { schematicPath: string; netName: string; position: number[] }) => {
const result = await callKicadScript("add_schematic_net_label", args);
if (result.success) {
return {
content: [{
type: "text",
text: `Successfully added net label '${args.netName}' at position [${args.position}]`
}]
};
} else {
return {
content: [{
type: "text",
text: `Failed to add net label: ${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'}`
}]
};
}
}
);
}
/**
* Schematic tools for KiCAD MCP server
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerSchematicTools(
server: McpServer,
callKicadScript: Function,
) {
// Create schematic tool
server.tool(
"create_schematic",
"Create a new schematic",
{
name: z.string().describe("Schematic name"),
path: z.string().optional().describe("Optional path"),
},
async (args: { name: string; path?: string }) => {
const result = await callKicadScript("create_schematic", args);
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"),
symbol: z
.string()
.describe(
"Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)",
),
reference: z.string().describe("Component reference (e.g., R1, U1)"),
value: z.string().optional().describe("Component value"),
position: z
.object({
x: z.number(),
y: z.number(),
})
.optional()
.describe("Position on schematic"),
},
async (args: {
schematicPath: string;
symbol: string;
reference: string;
value?: string;
position?: { x: number; y: number };
}) => {
// Transform to what Python backend expects
const [library, symbolName] = args.symbol.includes(":")
? args.symbol.split(":")
: ["Device", args.symbol];
const transformed = {
schematicPath: args.schematicPath,
component: {
library,
type: symbolName,
reference: args.reference,
value: args.value,
// Python expects flat x, y not nested position
x: args.position?.x ?? 0,
y: args.position?.y ?? 0,
},
};
const result = await callKicadScript(
"add_schematic_component",
transformed,
);
if (result.success) {
return {
content: [
{
type: "text",
text: `Successfully added ${args.reference} (${args.symbol}) to schematic`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to add component: ${result.message || JSON.stringify(result)}`,
},
],
};
}
},
);
// Delete component from schematic
server.tool(
"delete_schematic_component",
`Remove a placed symbol from a KiCAD schematic (.kicad_sch).
This removes the symbol instance (the placed component) from the schematic.
It does NOT remove the symbol definition from lib_symbols.
Note: This tool operates on schematic files (.kicad_sch).
To remove a footprint from a PCB, use delete_component instead.`,
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z
.string()
.describe("Reference designator of the component to remove (e.g. R1, U3)"),
},
async (args: { schematicPath: string; reference: string }) => {
const result = await callKicadScript("delete_schematic_component", args);
if (result.success) {
return {
content: [
{
type: "text",
text: `Successfully removed ${args.reference} from schematic`,
},
],
};
}
return {
content: [
{
type: "text",
text: `Failed to remove component: ${result.message || "Unknown error"}`,
},
],
};
},
);
// Connect components with wire
server.tool(
"add_wire",
"Add a wire connection in the schematic",
{
start: z
.object({
x: z.number(),
y: z.number(),
})
.describe("Start position"),
end: z
.object({
x: z.number(),
y: z.number(),
})
.describe("End position"),
},
async (args: any) => {
const result = await callKicadScript("add_wire", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Add pin-to-pin connection
server.tool(
"add_schematic_connection",
"Connect two component pins with a wire",
{
schematicPath: z.string().describe("Path to the schematic file"),
sourceRef: z.string().describe("Source component reference (e.g., R1)"),
sourcePin: z
.string()
.describe("Source pin name/number (e.g., 1, 2, GND)"),
targetRef: z.string().describe("Target component reference (e.g., C1)"),
targetPin: z
.string()
.describe("Target pin name/number (e.g., 1, 2, VCC)"),
},
async (args: {
schematicPath: string;
sourceRef: string;
sourcePin: string;
targetRef: string;
targetPin: string;
}) => {
const result = await callKicadScript("add_schematic_connection", args);
if (result.success) {
return {
content: [
{
type: "text",
text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to add connection: ${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"),
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"),
},
async (args: {
schematicPath: string;
netName: string;
position: number[];
}) => {
const result = await callKicadScript("add_schematic_net_label", args);
if (result.success) {
return {
content: [
{
type: "text",
text: `Successfully added net label '${args.netName}' at position [${args.position}]`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to add net label: ${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"}`,
},
],
};
}
},
);
}