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.
346 lines
12 KiB
Python
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)}
|