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

346 lines
12 KiB
Python

"""
File export tools for KiCad MCP server.
Provides tools for generating SVG renders, Gerber files, drill files,
and PDFs from KiCad PCB and schematic files using kicad-cli.
"""
import contextlib
import logging
import os
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__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _resolve_pcb(project_path: str) -> tuple[str | None, str | None]:
"""Return (pcb_file, error_message). One will always be None."""
if not os.path.exists(project_path):
return None, f"Project not found: {project_path}"
files = get_project_files(project_path)
pcb = files.get("pcb")
if not pcb:
return None, "PCB file not found in project"
return pcb, None
def _resolve_file(project_path: str, file_type: str) -> tuple[str | None, str | None]:
"""Return (resolved_file, error_message) for pcb or schematic."""
if not os.path.exists(project_path):
return None, f"Project not found: {project_path}"
files = get_project_files(project_path)
target = files.get(file_type)
if not target:
return None, f"{file_type.capitalize()} file not found in project"
return target, None
# ---------------------------------------------------------------------------
# MCP Tool definitions
# ---------------------------------------------------------------------------
@mcp.tool()
def generate_pcb_svg(project_path: str) -> dict[str, Any]:
"""Generate an SVG render of a KiCad PCB layout.
Uses ``kicad-cli pcb export svg`` to produce a multi-layer SVG of
the board. The SVG content is returned as a string so the caller
can display or save it.
Args:
project_path: Absolute path to the .kicad_pro file.
Returns:
Dictionary with the SVG content, output path, and file size.
"""
logger.info("Generating PCB SVG for project: %s", project_path)
pcb_file, err = _resolve_pcb(project_path)
if err or not pcb_file:
logger.warning(err)
return {"success": False, "data": None, "error": err}
project_dir = os.path.dirname(project_path)
basename = os.path.basename(pcb_file)
stem = os.path.splitext(basename)[0]
output_file = os.path.join(project_dir, f"{stem}.svg")
try:
result = run_kicad_command(
command_args=[
"pcb", "export", "svg",
"--output", output_file,
"--layers",
"F.Cu,B.Cu,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts",
pcb_file,
],
input_files=[pcb_file],
output_files=[output_file],
)
if result.returncode != 0:
error_msg = result.stderr.strip() if result.stderr else "SVG export failed"
logger.error("SVG export failed (rc=%d): %s", result.returncode, error_msg)
return {"success": False, "data": None, "error": error_msg}
if not os.path.exists(output_file):
logger.error("SVG output file not created: %s", output_file)
return {"success": False, "data": None, "error": "SVG output file was not created"}
with open(output_file, encoding="utf-8") as f:
svg_content = f.read()
file_size = os.path.getsize(output_file)
logger.info("SVG generated: %s (%d bytes)", output_file, file_size)
return {
"success": True,
"data": {
"output_file": output_file,
"file_size": file_size,
"svg_content": svg_content,
},
"error": None,
}
except Exception as e:
logger.error("SVG generation failed: %s", e, exc_info=True)
return {"success": False, "data": None, "error": str(e)}
@mcp.tool()
def export_gerbers(project_path: str) -> dict[str, Any]:
"""Export Gerber manufacturing files from a KiCad PCB.
Runs ``kicad-cli pcb export gerbers`` and writes the output into a
``gerbers/`` subdirectory alongside the project. Returns the list
of generated files.
Args:
project_path: Absolute path to the .kicad_pro file.
Returns:
Dictionary with output directory path and list of generated files.
"""
logger.info("Exporting Gerbers for project: %s", project_path)
pcb_file, err = _resolve_pcb(project_path)
if err or not pcb_file:
logger.warning(err)
return {"success": False, "data": None, "error": err}
project_dir = os.path.dirname(project_path)
output_dir = os.path.join(project_dir, "gerbers")
os.makedirs(output_dir, exist_ok=True)
try:
result = run_kicad_command(
command_args=[
"pcb", "export", "gerbers",
"--output", output_dir + os.sep,
pcb_file,
],
input_files=[pcb_file],
)
if result.returncode != 0:
error_msg = result.stderr.strip() if result.stderr else "Gerber export failed"
logger.error("Gerber export failed (rc=%d): %s", result.returncode, error_msg)
return {"success": False, "data": None, "error": error_msg}
generated_files = []
try:
for entry in os.listdir(output_dir):
full = os.path.join(output_dir, entry)
if os.path.isfile(full):
generated_files.append(entry)
except OSError as exc:
logger.warning("Could not list Gerber output directory: %s", exc)
if not generated_files:
logger.warning("No Gerber files were generated")
return {"success": False, "data": None, "error": "No Gerber files were generated"}
logger.info("Exported %d Gerber file(s) to %s", len(generated_files), output_dir)
return {
"success": True,
"data": {
"output_dir": output_dir,
"files": sorted(generated_files),
"file_count": len(generated_files),
},
"error": None,
}
except Exception as e:
logger.error("Gerber export failed: %s", e, exc_info=True)
return {"success": False, "data": None, "error": str(e)}
@mcp.tool()
def export_drill(project_path: str) -> dict[str, Any]:
"""Export drill files from a KiCad PCB.
Runs ``kicad-cli pcb export drill`` and writes output to a
``gerbers/`` subdirectory (common convention to co-locate with
Gerber files).
Args:
project_path: Absolute path to the .kicad_pro file.
Returns:
Dictionary with output directory path and list of generated files.
"""
logger.info("Exporting drill files for project: %s", project_path)
pcb_file, err = _resolve_pcb(project_path)
if err or not pcb_file:
logger.warning(err)
return {"success": False, "data": None, "error": err}
project_dir = os.path.dirname(project_path)
output_dir = os.path.join(project_dir, "gerbers")
os.makedirs(output_dir, exist_ok=True)
try:
result = run_kicad_command(
command_args=[
"pcb", "export", "drill",
"--output", output_dir + os.sep,
pcb_file,
],
input_files=[pcb_file],
)
if result.returncode != 0:
error_msg = result.stderr.strip() if result.stderr else "Drill export failed"
logger.error("Drill export failed (rc=%d): %s", result.returncode, error_msg)
return {"success": False, "data": None, "error": error_msg}
# Collect drill-related files (.drl, .exc, .xln)
drill_extensions = {".drl", ".exc", ".xln"}
generated_files = []
try:
for entry in os.listdir(output_dir):
full = os.path.join(output_dir, entry)
_, ext = os.path.splitext(entry)
if os.path.isfile(full) and ext.lower() in drill_extensions:
generated_files.append(entry)
except OSError as exc:
logger.warning("Could not list drill output directory: %s", exc)
if not generated_files:
# Maybe kicad-cli used a different extension -- list everything new
with contextlib.suppress(OSError):
generated_files = [
e for e in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, e))
]
logger.info("Exported %d drill file(s) to %s", len(generated_files), output_dir)
return {
"success": True,
"data": {
"output_dir": output_dir,
"files": sorted(generated_files),
"file_count": len(generated_files),
},
"error": None,
}
except Exception as e:
logger.error("Drill export failed: %s", e, exc_info=True)
return {"success": False, "data": None, "error": str(e)}
@mcp.tool()
def export_pdf(project_path: str, file_type: str = "pcb") -> dict[str, Any]:
"""Export a PDF from a KiCad PCB or schematic.
Runs ``kicad-cli pcb export pdf`` or ``kicad-cli sch export pdf``
depending on *file_type*.
Args:
project_path: Absolute path to the .kicad_pro file.
file_type: Either "pcb" or "schematic".
Returns:
Dictionary with the output PDF path and file size.
"""
logger.info("Exporting PDF (%s) for project: %s", file_type, project_path)
ft = file_type.lower().strip()
if ft not in ("pcb", "schematic"):
return {
"success": False,
"data": None,
"error": f"Invalid file_type: {file_type}. Must be 'pcb' or 'schematic'.",
}
source_file, err = _resolve_file(project_path, ft)
if err or not source_file:
logger.warning(err)
return {"success": False, "data": None, "error": err}
project_dir = os.path.dirname(project_path)
stem = os.path.splitext(os.path.basename(source_file))[0]
output_file = os.path.join(project_dir, f"{stem}.pdf")
try:
if ft == "pcb":
cmd_args = [
"pcb", "export", "pdf",
"--output", output_file,
source_file,
]
else:
cmd_args = [
"sch", "export", "pdf",
"--output", output_file,
source_file,
]
result = run_kicad_command(
command_args=cmd_args,
input_files=[source_file],
output_files=[output_file],
)
if result.returncode != 0:
error_msg = result.stderr.strip() if result.stderr else "PDF export failed"
logger.error("PDF export failed (rc=%d): %s", result.returncode, error_msg)
return {"success": False, "data": None, "error": error_msg}
if not os.path.exists(output_file):
logger.error("PDF output file not created: %s", output_file)
return {"success": False, "data": None, "error": "PDF output file was not created"}
file_size = os.path.getsize(output_file)
logger.info("PDF exported: %s (%d bytes)", output_file, file_size)
return {
"success": True,
"data": {
"output_file": output_file,
"source_file": source_file,
"file_type": ft,
"file_size": file_size,
},
"error": None,
}
except Exception as e:
logger.error("PDF export failed: %s", e, exc_info=True)
return {"success": False, "data": None, "error": str(e)}