Migrate from FastMCP 2.14.5 to 3.1.0 with complete architectural overhaul. Adopt src-layout packaging, lazy config functions to eliminate .env race condition, and decorator-based tool registration. Consolidate 14 tool modules into 8 focused modules (33 tools total). Add 9 new schematic tools via kicad-sch-api for creating and manipulating .kicad_sch files. Drop pandas dependency (BOM uses stdlib csv). Remove ~17k lines of stubs, over-engineering, and dead code. All checks pass: ruff clean, mypy 0 errors, 17/17 tests green.
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""
|
|
Design Rule Check (DRC) tools for KiCad MCP server.
|
|
|
|
Combines basic DRC checking via kicad-cli with advanced rule set
|
|
management for different PCB technologies (standard, HDI, RF, automotive).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
from mckicad.server import mcp
|
|
from mckicad.utils.file_utils import get_project_files
|
|
from mckicad.utils.secure_subprocess import run_kicad_command
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Technology-specific manufacturing constraints and rule definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MANUFACTURING_CONSTRAINTS: dict[str, dict[str, Any]] = {
|
|
"standard": {
|
|
"min_track_width_mm": 0.15,
|
|
"min_clearance_mm": 0.15,
|
|
"min_via_drill_mm": 0.3,
|
|
"min_via_diameter_mm": 0.6,
|
|
"min_annular_ring_mm": 0.15,
|
|
"min_drill_size_mm": 0.2,
|
|
"max_layer_count": 6,
|
|
"min_board_thickness_mm": 0.8,
|
|
"max_board_thickness_mm": 3.2,
|
|
"copper_weights_oz": [0.5, 1.0, 2.0],
|
|
"min_silk_width_mm": 0.15,
|
|
"min_silk_clearance_mm": 0.15,
|
|
"min_courtyard_clearance_mm": 0.25,
|
|
},
|
|
"hdi": {
|
|
"min_track_width_mm": 0.075,
|
|
"min_clearance_mm": 0.075,
|
|
"min_via_drill_mm": 0.1,
|
|
"min_via_diameter_mm": 0.25,
|
|
"min_annular_ring_mm": 0.075,
|
|
"min_drill_size_mm": 0.1,
|
|
"max_layer_count": 20,
|
|
"min_board_thickness_mm": 0.4,
|
|
"max_board_thickness_mm": 3.2,
|
|
"copper_weights_oz": [0.33, 0.5, 1.0],
|
|
"min_silk_width_mm": 0.1,
|
|
"min_silk_clearance_mm": 0.1,
|
|
"min_courtyard_clearance_mm": 0.15,
|
|
"microvia_supported": True,
|
|
"sequential_buildup": True,
|
|
},
|
|
"rf": {
|
|
"min_track_width_mm": 0.127,
|
|
"min_clearance_mm": 0.2,
|
|
"min_via_drill_mm": 0.25,
|
|
"min_via_diameter_mm": 0.5,
|
|
"min_annular_ring_mm": 0.125,
|
|
"min_drill_size_mm": 0.2,
|
|
"max_layer_count": 8,
|
|
"min_board_thickness_mm": 0.8,
|
|
"max_board_thickness_mm": 3.2,
|
|
"copper_weights_oz": [0.5, 1.0],
|
|
"min_silk_width_mm": 0.15,
|
|
"min_silk_clearance_mm": 0.15,
|
|
"min_courtyard_clearance_mm": 0.25,
|
|
"controlled_impedance": True,
|
|
"via_stitching_pitch_mm": 2.0,
|
|
},
|
|
"automotive": {
|
|
"min_track_width_mm": 0.2,
|
|
"min_clearance_mm": 0.25,
|
|
"min_via_drill_mm": 0.35,
|
|
"min_via_diameter_mm": 0.7,
|
|
"min_annular_ring_mm": 0.175,
|
|
"min_drill_size_mm": 0.3,
|
|
"max_layer_count": 8,
|
|
"min_board_thickness_mm": 1.0,
|
|
"max_board_thickness_mm": 3.2,
|
|
"copper_weights_oz": [1.0, 2.0, 3.0],
|
|
"min_silk_width_mm": 0.2,
|
|
"min_silk_clearance_mm": 0.2,
|
|
"min_courtyard_clearance_mm": 0.5,
|
|
"temp_range_c": [-40, 125],
|
|
"vibration_rated": True,
|
|
},
|
|
}
|
|
|
|
_TECHNOLOGY_RECOMMENDATIONS: dict[str, list[str]] = {
|
|
"standard": [
|
|
"Maintain 0.15mm minimum track width for cost-effective manufacturing",
|
|
"Use 0.15mm clearance for reliable production yields",
|
|
"Consider 6-layer maximum for standard processes",
|
|
],
|
|
"hdi": [
|
|
"Use microvias for high-density routing",
|
|
"Maintain controlled impedance for signal integrity",
|
|
"Consider sequential build-up for complex designs",
|
|
],
|
|
"rf": [
|
|
"Maintain consistent dielectric properties",
|
|
"Use ground via stitching for EMI control",
|
|
"Control trace geometry for impedance matching",
|
|
],
|
|
"automotive": [
|
|
"Design for extended temperature range operation (-40 to +125 C)",
|
|
"Increase clearances for vibration resistance",
|
|
"Use thermal management for high-power components",
|
|
],
|
|
}
|
|
|
|
_APPLICABLE_STANDARDS: dict[str, list[str]] = {
|
|
"standard": ["IPC-2221", "IPC-2222"],
|
|
"hdi": ["IPC-2226", "IPC-6016"],
|
|
"rf": ["IPC-2221", "IPC-2141"],
|
|
"automotive": ["ISO 26262", "AEC-Q100"],
|
|
}
|
|
|
|
|
|
def _build_rule_set(name: str, technology: str, description: str) -> dict[str, Any]:
|
|
"""Build a rule set from manufacturing constraints for a given technology."""
|
|
tech = technology.lower()
|
|
if tech not in _MANUFACTURING_CONSTRAINTS:
|
|
tech = "standard"
|
|
|
|
constraints = _MANUFACTURING_CONSTRAINTS[tech]
|
|
rules = []
|
|
|
|
rules.append({
|
|
"name": f"{name}_clearance",
|
|
"type": "clearance",
|
|
"severity": "error",
|
|
"constraint": {"min_mm": constraints["min_clearance_mm"]},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_track_width",
|
|
"type": "track_width",
|
|
"severity": "error",
|
|
"constraint": {"min_mm": constraints["min_track_width_mm"]},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_via_size",
|
|
"type": "via_size",
|
|
"severity": "error",
|
|
"constraint": {
|
|
"min_drill_mm": constraints["min_via_drill_mm"],
|
|
"min_diameter_mm": constraints["min_via_diameter_mm"],
|
|
},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_annular_ring",
|
|
"type": "annular_ring",
|
|
"severity": "error",
|
|
"constraint": {"min_mm": constraints["min_annular_ring_mm"]},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_drill_size",
|
|
"type": "drill_size",
|
|
"severity": "warning",
|
|
"constraint": {"min_mm": constraints["min_drill_size_mm"]},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_silk_clearance",
|
|
"type": "silk_clearance",
|
|
"severity": "warning",
|
|
"constraint": {
|
|
"min_width_mm": constraints["min_silk_width_mm"],
|
|
"min_clearance_mm": constraints["min_silk_clearance_mm"],
|
|
},
|
|
"enabled": True,
|
|
})
|
|
rules.append({
|
|
"name": f"{name}_courtyard",
|
|
"type": "courtyard_clearance",
|
|
"severity": "warning",
|
|
"constraint": {"min_mm": constraints["min_courtyard_clearance_mm"]},
|
|
"enabled": True,
|
|
})
|
|
|
|
return {
|
|
"name": name,
|
|
"technology": tech,
|
|
"description": description or f"{tech.upper()} PCB rules for {name}",
|
|
"rules": rules,
|
|
"rule_count": len(rules),
|
|
}
|
|
|
|
|
|
def _rules_to_kicad_format(rule_set: dict[str, Any]) -> str:
|
|
"""Convert a rule set to KiCad DRC rule text format."""
|
|
lines = [
|
|
f"# KiCad DRC Rules: {rule_set['name']}",
|
|
f"# Technology: {rule_set['technology']}",
|
|
f"# {rule_set['description']}",
|
|
"",
|
|
]
|
|
|
|
for rule in rule_set["rules"]:
|
|
if not rule.get("enabled", True):
|
|
continue
|
|
|
|
constraint = rule["constraint"]
|
|
rule_type = rule["type"]
|
|
|
|
lines.append(f"(rule \"{rule['name']}\"")
|
|
lines.append(f" (severity {rule['severity']})")
|
|
|
|
if rule_type == "clearance":
|
|
lines.append(f" (constraint clearance (min {constraint['min_mm']}mm))")
|
|
elif rule_type == "track_width":
|
|
lines.append(f" (constraint track_width (min {constraint['min_mm']}mm))")
|
|
elif rule_type == "via_size":
|
|
lines.append(f" (constraint via_diameter (min {constraint['min_diameter_mm']}mm))")
|
|
lines.append(f" (constraint hole_size (min {constraint['min_drill_mm']}mm))")
|
|
elif rule_type == "annular_ring":
|
|
lines.append(f" (constraint annular_width (min {constraint['min_mm']}mm))")
|
|
elif rule_type == "drill_size":
|
|
lines.append(f" (constraint hole_size (min {constraint['min_mm']}mm))")
|
|
elif rule_type == "silk_clearance":
|
|
lines.append(f" (constraint silk_clearance (min {constraint['min_clearance_mm']}mm))")
|
|
elif rule_type == "courtyard_clearance":
|
|
lines.append(f" (constraint courtyard_clearance (min {constraint['min_mm']}mm))")
|
|
|
|
lines.append(")")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCP Tool definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@mcp.tool()
|
|
def run_drc_check(project_path: str) -> dict[str, Any]:
|
|
"""Run a Design Rule Check on a KiCad PCB using kicad-cli.
|
|
|
|
Locates the .kicad_pcb file for the given project, runs DRC via
|
|
``kicad-cli pcb drc``, and parses the JSON report to extract
|
|
violation counts and categories.
|
|
|
|
Args:
|
|
project_path: Absolute path to the .kicad_pro file.
|
|
|
|
Returns:
|
|
Dictionary with violation count, categorised violations, and
|
|
the raw violation list from KiCad.
|
|
"""
|
|
logger.info("Running DRC check for project: %s", project_path)
|
|
|
|
if not os.path.exists(project_path):
|
|
logger.warning("Project not found: %s", project_path)
|
|
return {"success": False, "data": None, "error": f"Project not found: {project_path}"}
|
|
|
|
# Locate the PCB file
|
|
files = get_project_files(project_path)
|
|
if "pcb" not in files:
|
|
logger.warning("PCB file not found in project: %s", project_path)
|
|
return {"success": False, "data": None, "error": "PCB file not found in project"}
|
|
|
|
pcb_file = files["pcb"]
|
|
logger.info("Found PCB file: %s", pcb_file)
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="mckicad_drc_") as temp_dir:
|
|
output_file = os.path.join(temp_dir, "drc_report.json")
|
|
|
|
result = run_kicad_command(
|
|
command_args=[
|
|
"pcb", "drc",
|
|
"--format", "json",
|
|
"--output", output_file,
|
|
pcb_file,
|
|
],
|
|
input_files=[pcb_file],
|
|
output_files=[output_file],
|
|
)
|
|
|
|
# kicad-cli may return non-zero when violations exist, so we
|
|
# check for the output file rather than just the return code.
|
|
if not os.path.exists(output_file):
|
|
error_msg = result.stderr.strip() if result.stderr else "DRC report file not created"
|
|
logger.error("DRC report not created: %s", error_msg)
|
|
return {"success": False, "data": None, "error": error_msg}
|
|
|
|
with open(output_file) as f:
|
|
try:
|
|
drc_report = json.load(f)
|
|
except json.JSONDecodeError as exc:
|
|
logger.error("Failed to parse DRC JSON report: %s", exc)
|
|
return {"success": False, "data": None, "error": "Failed to parse DRC report JSON"}
|
|
|
|
violations = drc_report.get("violations", [])
|
|
violation_count = len(violations)
|
|
|
|
# Categorise violations by message
|
|
violation_categories: dict[str, int] = {}
|
|
for v in violations:
|
|
msg = v.get("message", "Unknown")
|
|
violation_categories[msg] = violation_categories.get(msg, 0) + 1
|
|
|
|
logger.info("DRC completed: %d violation(s)", violation_count)
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"pcb_file": pcb_file,
|
|
"total_violations": violation_count,
|
|
"violation_categories": violation_categories,
|
|
"violations": violations,
|
|
},
|
|
"error": None,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("DRC check failed: %s", e, exc_info=True)
|
|
return {"success": False, "data": None, "error": str(e)}
|
|
|
|
|
|
@mcp.tool()
|
|
def create_drc_rule_set(
|
|
name: str,
|
|
technology: str = "standard",
|
|
description: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Create a DRC rule set optimised for a specific PCB technology.
|
|
|
|
Generates a collection of manufacturing rules (clearance, track width,
|
|
via size, annular ring, drill size, silk, courtyard) tuned for the
|
|
requested technology tier.
|
|
|
|
Args:
|
|
name: Human-readable name for the rule set (e.g. "MyBoard_Rules").
|
|
technology: One of "standard", "hdi", "rf", or "automotive".
|
|
description: Optional free-text description.
|
|
|
|
Returns:
|
|
Dictionary containing the generated rules, their parameters, and
|
|
the technology profile used.
|
|
"""
|
|
logger.info("Creating DRC rule set '%s' for technology '%s'", name, technology)
|
|
|
|
tech = technology.lower()
|
|
if tech not in _MANUFACTURING_CONSTRAINTS:
|
|
return {
|
|
"success": False,
|
|
"data": None,
|
|
"error": (
|
|
f"Unknown technology: {technology}. "
|
|
f"Valid options: {list(_MANUFACTURING_CONSTRAINTS.keys())}"
|
|
),
|
|
}
|
|
|
|
try:
|
|
rule_set = _build_rule_set(name, tech, description)
|
|
logger.info("Created rule set '%s' with %d rules", name, rule_set["rule_count"])
|
|
return {"success": True, "data": rule_set, "error": None}
|
|
except Exception as e:
|
|
logger.error("Failed to create rule set '%s': %s", name, e)
|
|
return {"success": False, "data": None, "error": str(e)}
|
|
|
|
|
|
@mcp.tool()
|
|
def export_kicad_drc_rules(
|
|
rule_set_name: str = "Standard",
|
|
technology: str = "standard",
|
|
) -> dict[str, Any]:
|
|
"""Export DRC rules in KiCad-compatible text format.
|
|
|
|
Builds a rule set for the given technology and serialises it to the
|
|
KiCad custom DRC rule syntax that can be pasted into a project's
|
|
design rules file.
|
|
|
|
Args:
|
|
rule_set_name: Name to assign to the exported rule set.
|
|
technology: Technology profile ("standard", "hdi", "rf", "automotive").
|
|
|
|
Returns:
|
|
Dictionary containing the KiCad-format rule text and metadata.
|
|
"""
|
|
logger.info("Exporting KiCad DRC rules for '%s' (%s)", rule_set_name, technology)
|
|
|
|
tech = technology.lower()
|
|
if tech not in _MANUFACTURING_CONSTRAINTS:
|
|
return {
|
|
"success": False,
|
|
"data": None,
|
|
"error": (
|
|
f"Unknown technology: {technology}. "
|
|
f"Valid options: {list(_MANUFACTURING_CONSTRAINTS.keys())}"
|
|
),
|
|
}
|
|
|
|
try:
|
|
rule_set = _build_rule_set(rule_set_name, tech, "")
|
|
kicad_text = _rules_to_kicad_format(rule_set)
|
|
|
|
active_count = sum(1 for r in rule_set["rules"] if r.get("enabled", True))
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"rule_set_name": rule_set_name,
|
|
"technology": tech,
|
|
"kicad_rules": kicad_text,
|
|
"rule_count": rule_set["rule_count"],
|
|
"active_rules": active_count,
|
|
"usage": "Copy the kicad_rules text into your project's custom DRC rules file",
|
|
},
|
|
"error": None,
|
|
}
|
|
except Exception as e:
|
|
logger.error("Failed to export DRC rules: %s", e)
|
|
return {"success": False, "data": None, "error": str(e)}
|
|
|
|
|
|
@mcp.tool()
|
|
def get_manufacturing_constraints(technology: str = "standard") -> dict[str, Any]:
|
|
"""Get manufacturing constraints and design guidelines for a PCB technology.
|
|
|
|
Returns the numeric manufacturing limits (minimum track width,
|
|
clearance, via size, etc.) along with design recommendations and
|
|
applicable industry standards for the chosen technology tier.
|
|
|
|
Args:
|
|
technology: Technology profile ("standard", "hdi", "rf", "automotive").
|
|
|
|
Returns:
|
|
Dictionary with constraints, recommendations, and applicable standards.
|
|
"""
|
|
logger.info("Getting manufacturing constraints for technology: %s", technology)
|
|
|
|
tech = technology.lower()
|
|
if tech not in _MANUFACTURING_CONSTRAINTS:
|
|
return {
|
|
"success": False,
|
|
"data": None,
|
|
"error": (
|
|
f"Unknown technology: {technology}. "
|
|
f"Valid options: {list(_MANUFACTURING_CONSTRAINTS.keys())}"
|
|
),
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"technology": tech,
|
|
"constraints": _MANUFACTURING_CONSTRAINTS[tech],
|
|
"recommendations": _TECHNOLOGY_RECOMMENDATIONS.get(tech, []),
|
|
"applicable_standards": _APPLICABLE_STANDARDS.get(tech, []),
|
|
},
|
|
"error": None,
|
|
}
|