""" 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)}