Ryan Malloy 4ae38fed59 Rebuild on FastMCP 3 with src-layout and kicad-sch-api integration
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.
2026-03-03 18:26:54 -07:00

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,
}