From 8c04038371f1c9c5c7efb61b570d632490951d76 Mon Sep 17 00:00:00 2001 From: ByteBard Date: Fri, 14 Nov 2025 16:38:16 -0500 Subject: [PATCH 1/5] various fixes for kicad 9 --- python/commands/board/layers.py | 11 +- python/commands/board/view.py | 27 ++- python/commands/component.py | 2 +- python/commands/design_rules.py | 343 +++++++++++++++++++++++++------- python/commands/export.py | 72 ++++--- src/server.ts | 65 +++--- 6 files changed, 377 insertions(+), 143 deletions(-) diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index 9d4d6c0..1e407ec 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -151,8 +151,9 @@ class BoardLayerCommands: layers.append({ "name": self.board.GetLayerName(layer_id), "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id, - "isActive": layer_id == self.board.GetActiveLayer() + "id": layer_id + # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting }) return { @@ -173,7 +174,7 @@ class BoardLayerCommands: type_map = { "copper": pcbnew.LT_SIGNAL, "technical": pcbnew.LT_SIGNAL, - "user": pcbnew.LT_USER, + "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead "signal": pcbnew.LT_SIGNAL } return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) @@ -184,7 +185,7 @@ class BoardLayerCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - pcbnew.LT_USER: "user" + pcbnew.LT_JUMPER: "jumper" } + # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 66f823f..8760880 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -58,8 +58,9 @@ class BoardViewCommands: "unit": "mm" }, "layers": layers, - "title": self.board.GetTitleBlock().GetTitle(), - "activeLayer": self.board.GetActiveLayer() + "title": self.board.GetTitleBlock().GetTitle() + # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting } } @@ -95,26 +96,32 @@ class BoardViewCommands: plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) plot_opts.SetScale(1) plot_opts.SetMirror(False) - plot_opts.SetExcludeEdgeLayer(False) + # Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers plot_opts.SetPlotFrameRef(False) plot_opts.SetPlotValue(True) plot_opts.SetPlotReference(True) # Plot to SVG first (for vector output) - temp_svg = os.path.join(os.path.dirname(self.board.GetFileName()), "temp_view.svg") + # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") - + # Plot specified layers or all enabled layers + # Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer() if layers: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): - plotter.PlotLayer(layer_id) - + plotter.SetLayer(layer_id) + plotter.PlotLayer() + + # Get the actual filename that was created (includes project name prefix) + temp_svg = plotter.GetPlotFileName() + plotter.ClosePlot() # Convert SVG to requested format @@ -165,7 +172,7 @@ class BoardViewCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - pcbnew.LT_USER: "user" + pcbnew.LT_JUMPER: "jumper" } + # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") diff --git a/python/commands/component.py b/python/commands/component.py index a9da4e5..d395145 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -405,7 +405,7 @@ class ComponentCommands: "attributes": { "smd": module.GetAttributes() & pcbnew.FP_SMD, "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, - "virtual": module.GetAttributes() & pcbnew.FP_VIRTUAL + "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY } } } diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 6054353..9ef158a 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -33,65 +33,82 @@ class DesignRuleCommands: # Set clearance if "clearance" in params: - design_settings.SetMinClearance(int(params["clearance"] * scale)) + design_settings.m_MinClearance = int(params["clearance"] * scale) + + # KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed) + # Track if we set any custom track/via values + custom_values_set = False - # Set track width if "trackWidth" in params: - design_settings.SetCurrentTrackWidth(int(params["trackWidth"] * scale)) + design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale)) + custom_values_set = True - # Set via settings + # Via settings if "viaDiameter" in params: - design_settings.SetCurrentViaSize(int(params["viaDiameter"] * scale)) + design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale)) + custom_values_set = True if "viaDrill" in params: - design_settings.SetCurrentViaDrill(int(params["viaDrill"] * scale)) + design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale)) + custom_values_set = True - # Set micro via settings + # KiCAD 9.0: Activate custom track/via values so they become the current values + if custom_values_set: + design_settings.UseCustomTrackViaSize(True) + + # Set micro via settings (use properties - methods removed in KiCAD 9.0) if "microViaDiameter" in params: - design_settings.SetCurrentMicroViaSize(int(params["microViaDiameter"] * scale)) + design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) if "microViaDrill" in params: - design_settings.SetCurrentMicroViaDrill(int(params["microViaDrill"] * scale)) + design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) # Set minimum values if "minTrackWidth" in params: design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale) if "minViaDiameter" in params: design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale) + + # KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead if "minViaDrill" in params: - design_settings.m_ViasMinDrill = int(params["minViaDrill"] * scale) + design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) + if "minMicroViaDiameter" in params: design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) if "minMicroViaDrill" in params: design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) - # Set hole diameter + # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill if "minHoleDiameter" in params: - design_settings.m_MinHoleDiameter = int(params["minHoleDiameter"] * scale) + design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) - # Set courtyard settings - if "requireCourtyard" in params: - design_settings.m_RequireCourtyards = params["requireCourtyard"] - if "courtyardClearance" in params: - design_settings.m_CourtyardMinClearance = int(params["courtyardClearance"] * scale) + # KiCAD 9.0: Added hole clearance settings + if "holeClearance" in params: + design_settings.m_HoleClearance = int(params["holeClearance"] * scale) + if "holeToHoleMin" in params: + design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale) + + # Build response with KiCAD 9.0 compatible properties + # After UseCustomTrackViaSize(True), GetCurrent* returns the custom values + response_rules = { + "clearance": design_settings.m_MinClearance / scale, + "trackWidth": design_settings.GetCurrentTrackWidth() / scale, + "viaDiameter": design_settings.GetCurrentViaSize() / scale, + "viaDrill": design_settings.GetCurrentViaDrill() / scale, + "microViaDiameter": design_settings.m_MicroViasMinSize / scale, + "microViaDrill": design_settings.m_MicroViasMinDrill / scale, + "minTrackWidth": design_settings.m_TrackMinWidth / scale, + "minViaDiameter": design_settings.m_ViasMinSize / scale, + "minThroughDrill": design_settings.m_MinThroughDrill / scale, + "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, + "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, + "holeClearance": design_settings.m_HoleClearance / scale, + "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, + "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale + } return { "success": True, "message": "Updated design rules", - "rules": { - "clearance": design_settings.GetMinClearance() / scale, - "trackWidth": design_settings.GetCurrentTrackWidth() / scale, - "viaDiameter": design_settings.GetCurrentViaSize() / scale, - "viaDrill": design_settings.GetCurrentViaDrill() / scale, - "microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale, - "microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale, - "minTrackWidth": design_settings.m_TrackMinWidth / scale, - "minViaDiameter": design_settings.m_ViasMinSize / scale, - "minViaDrill": design_settings.m_ViasMinDrill / scale, - "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, - "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, - "minHoleDiameter": design_settings.m_MinHoleDiameter / scale, - "requireCourtyard": design_settings.m_RequireCourtyards, - "courtyardClearance": design_settings.m_CourtyardMinClearance / scale - } + "rules": response_rules } except Exception as e: @@ -103,7 +120,7 @@ class DesignRuleCommands: } def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get current design rules""" + """Get current design rules - KiCAD 9.0 compatible""" try: if not self.board: return { @@ -115,24 +132,40 @@ class DesignRuleCommands: design_settings = self.board.GetDesignSettings() scale = 1000000 # nm to mm + # Build rules dict with KiCAD 9.0 compatible properties + rules = { + # Core clearance and track settings + "clearance": design_settings.m_MinClearance / scale, + "trackWidth": design_settings.GetCurrentTrackWidth() / scale, + "minTrackWidth": design_settings.m_TrackMinWidth / scale, + + # Via settings (current values from methods) + "viaDiameter": design_settings.GetCurrentViaSize() / scale, + "viaDrill": design_settings.GetCurrentViaDrill() / scale, + + # Via minimum values + "minViaDiameter": design_settings.m_ViasMinSize / scale, + "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, + + # Micro via settings + "microViaDiameter": design_settings.m_MicroViasMinSize / scale, + "microViaDrill": design_settings.m_MicroViasMinDrill / scale, + "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, + "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, + + # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter) + "minThroughDrill": design_settings.m_MinThroughDrill / scale, + "holeClearance": design_settings.m_HoleClearance / scale, + "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, + + # Other constraints + "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale, + "silkClearance": design_settings.m_SilkClearance / scale, + } + return { "success": True, - "rules": { - "clearance": design_settings.GetMinClearance() / scale, - "trackWidth": design_settings.GetCurrentTrackWidth() / scale, - "viaDiameter": design_settings.GetCurrentViaSize() / scale, - "viaDrill": design_settings.GetCurrentViaDrill() / scale, - "microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale, - "microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale, - "minTrackWidth": design_settings.m_TrackMinWidth / scale, - "minViaDiameter": design_settings.m_ViasMinSize / scale, - "minViaDrill": design_settings.m_ViasMinDrill / scale, - "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, - "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, - "minHoleDiameter": design_settings.m_MinHoleDiameter / scale, - "requireCourtyard": design_settings.m_RequireCourtyards, - "courtyardClearance": design_settings.m_CourtyardMinClearance / scale - } + "rules": rules } except Exception as e: @@ -144,7 +177,13 @@ class DesignRuleCommands: } def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Run Design Rule Check""" + """Run Design Rule Check using kicad-cli""" + import subprocess + import json + import tempfile + import platform + import shutil + try: if not self.board: return { @@ -155,38 +194,144 @@ class DesignRuleCommands: report_path = params.get("reportPath") - # Create DRC runner - drc = pcbnew.DRC(self.board) - - # Run DRC - drc.Run() + # Get the board file path + board_file = self.board.GetFileName() + if not board_file or not os.path.exists(board_file): + return { + "success": False, + "message": "Board file not found", + "errorDetails": "Cannot run DRC without a saved board file" + } - # Get violations - violations = [] - for marker in drc.GetMarkers(): - violations.append({ - "type": marker.GetErrorCode(), - "severity": "error", - "message": marker.GetDescription(), - "location": { - "x": marker.GetPos().x / 1000000, - "y": marker.GetPos().y / 1000000, - "unit": "mm" + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH." + } + + # Create temporary JSON output file + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp: + json_output = tmp.name + + try: + # Build command + cmd = [ + kicad_cli, + 'pcb', + 'drc', + '--format', 'json', + '--output', json_output, + '--units', 'mm', + board_file + ] + + logger.info(f"Running DRC command: {' '.join(cmd)}") + + # Run DRC + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600 # 10 minute timeout for large boards (21MB PCB needs time) + ) + + if result.returncode != 0: + logger.error(f"DRC command failed: {result.stderr}") + return { + "success": False, + "message": "DRC command failed", + "errorDetails": result.stderr } - }) - # Save report if path provided - if report_path: - report_path = os.path.abspath(os.path.expanduser(report_path)) - drc.WriteReport(report_path) + # Read JSON output + with open(json_output, 'r', encoding='utf-8') as f: + drc_data = json.load(f) + # Parse violations from kicad-cli output + violations = [] + violation_counts = {} + severity_counts = {"error": 0, "warning": 0, "info": 0} + + for violation in drc_data.get('violations', []): + vtype = violation.get("type", "unknown") + vseverity = violation.get("severity", "error") + + violations.append({ + "type": vtype, + "severity": vseverity, + "message": violation.get("description", ""), + "location": { + "x": violation.get("x", 0), + "y": violation.get("y", 0), + "unit": "mm" + } + }) + + # Count violations by type + violation_counts[vtype] = violation_counts.get(vtype, 0) + 1 + + # Count by severity + if vseverity in severity_counts: + severity_counts[vseverity] += 1 + + # Determine where to save the violations file + board_dir = os.path.dirname(board_file) + board_name = os.path.splitext(os.path.basename(board_file))[0] + violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") + + # Always save violations to JSON file (for large result sets) + with open(violations_file, 'w', encoding='utf-8') as f: + json.dump({ + "board": board_file, + "timestamp": drc_data.get("date", "unknown"), + "total_violations": len(violations), + "violation_counts": violation_counts, + "severity_counts": severity_counts, + "violations": violations + }, f, indent=2) + + # Save text report if requested + if report_path: + report_path = os.path.abspath(os.path.expanduser(report_path)) + cmd_report = [ + kicad_cli, + 'pcb', + 'drc', + '--format', 'report', + '--output', report_path, + '--units', 'mm', + board_file + ] + subprocess.run(cmd_report, capture_output=True, timeout=600) + + # Return summary only (not full violations list) + return { + "success": True, + "message": f"Found {len(violations)} DRC violations", + "summary": { + "total": len(violations), + "by_severity": severity_counts, + "by_type": violation_counts + }, + "violationsFile": violations_file, + "reportPath": report_path if report_path else None + } + + finally: + # Clean up temp JSON file + if os.path.exists(json_output): + os.unlink(json_output) + + except subprocess.TimeoutExpired: + logger.error("DRC command timed out") return { - "success": True, - "message": f"Found {len(violations)} DRC violations", - "violations": violations, - "reportPath": report_path if report_path else None + "success": False, + "message": "DRC command timed out", + "errorDetails": "Command took longer than 600 seconds (10 minutes)" } - except Exception as e: logger.error(f"Error running DRC: {str(e)}") return { @@ -195,6 +340,50 @@ class DesignRuleCommands: "errorDetails": str(e) } + def _find_kicad_cli(self) -> Optional[str]: + """Find kicad-cli executable""" + import platform + import shutil + + # Try system PATH first + cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli" + cli_path = shutil.which(cli_name) + if cli_path: + return cli_path + + # Try common installation paths (version-specific) + if platform.system() == "Windows": + common_paths = [ + r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\bin\kicad-cli.exe", + ] + for path in common_paths: + if os.path.exists(path): + return path + elif platform.system() == "Darwin": # macOS + common_paths = [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + for path in common_paths: + if os.path.exists(path): + return path + else: # Linux + common_paths = [ + "/usr/bin/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + for path in common_paths: + if os.path.exists(path): + return path + + return None + def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get list of DRC violations""" try: diff --git a/python/commands/export.py b/python/commands/export.py index 255333d..059f32b 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -63,13 +63,15 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) # Generate drill files if requested @@ -137,7 +139,7 @@ class ExportCommands: # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) - + # Set up plot options plot_opts = plotter.GetPlotOptions() plot_opts.SetOutputDirectory(os.path.dirname(output_path)) @@ -145,22 +147,28 @@ class ExportCommands: plot_opts.SetPlotFrameRef(frame_reference) plot_opts.SetPlotValue(True) plot_opts.SetPlotReference(True) - plot_opts.SetMonochrome(black_and_white) + plot_opts.SetBlackAndWhite(black_and_white) - # Set page size - page_sizes = { - "A4": (297, 210), - "A3": (420, 297), - "A2": (594, 420), - "A1": (841, 594), - "A0": (1189, 841), - "Letter": (279.4, 215.9), - "Legal": (355.6, 215.9), - "Tabloid": (431.8, 279.4) - } - if page_size in page_sizes: - height, width = page_sizes[page_size] - plot_opts.SetPageSettings((width, height)) + # KiCAD 9.0 page size handling: + # - SetPageSettings() was removed in KiCAD 9.0 + # - SetA4Output(bool) forces A4 page size when True + # - For other sizes, KiCAD auto-scales to fit the board + # - SetAutoScale(True) enables automatic scaling to fit page + if page_size == "A4": + plot_opts.SetA4Output(True) + else: + # For non-A4 sizes, disable A4 forcing and use auto-scale + plot_opts.SetA4Output(False) + plot_opts.SetAutoScale(True) + # Note: KiCAD 9.0 doesn't support explicit page size selection + # for formats other than A4. The PDF will auto-scale to fit. + logger.warning(f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead.") + + # Open plot for writing + # Note: For PDF, all layers are combined into a single file + # KiCAD prepends the board filename to the plot file name + base_name = os.path.basename(output_path).replace('.pdf', '') + plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, '') # Plot specified layers or all enabled layers plotted_layers = [] @@ -168,22 +176,34 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) + # Close the plot file to finalize the PDF + plotter.ClosePlot() + + # KiCAD automatically prepends the board name to the output file + # Get the actual output filename that was created + board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0] + actual_filename = f"{board_name}-{base_name}.pdf" + actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename) + return { "success": True, "message": "Exported PDF file", "file": { - "path": output_path, + "path": actual_output_path, + "requestedPath": output_path, "layers": plotted_layers, - "pageSize": page_size + "pageSize": page_size if page_size == "A4" else "auto-scaled" } } @@ -230,7 +250,7 @@ class ExportCommands: plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG) plot_opts.SetPlotValue(include_components) plot_opts.SetPlotReference(include_components) - plot_opts.SetMonochrome(black_and_white) + plot_opts.SetBlackAndWhite(black_and_white) # Plot specified layers or all enabled layers plotted_layers = [] @@ -238,13 +258,15 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) return { diff --git a/src/server.ts b/src/server.ts index 3e8fd72..f2dec85 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import express from 'express'; -import { spawn, ChildProcess } from 'child_process'; +import { spawn, exec, ChildProcess } from 'child_process'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { logger } from './logger.js'; @@ -176,7 +176,6 @@ export class KiCADMcpServer { logger.info('Validating pcbnew module access...'); const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; - const { exec } = require('child_process'); try { const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { @@ -325,7 +324,7 @@ export class KiCADMcpServer { /** * Call the KiCAD scripting interface to execute commands - * + * * @param command The command to execute * @param params The parameters for the command * @returns The result of the command execution @@ -338,14 +337,23 @@ export class KiCADMcpServer { reject(new Error("Python process for KiCAD scripting is not running")); return; } - - // Add request to queue + + // Determine timeout based on command type + // DRC and export operations need longer timeouts for large boards + let commandTimeout = 30000; // Default 30 seconds + const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d']; + if (longRunningCommands.includes(command)) { + commandTimeout = 600000; // 10 minutes for long operations + logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`); + } + + // Add request to queue with timeout info this.requestQueue.push({ - request: { command, params }, + request: { command, params, timeout: commandTimeout }, resolve, reject }); - + // Process the queue if not already processing if (!this.processingRequest) { this.processNextRequest(); @@ -376,40 +384,46 @@ export class KiCADMcpServer { // Set up response handling let responseData = ''; - + let timeoutHandle: NodeJS.Timeout | null = null; + // Clear any previous listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Set up new listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.on('data', (data: Buffer) => { const chunk = data.toString(); logger.debug(`Received data chunk: ${chunk.length} bytes`); responseData += chunk; - + // Check if we have a complete response try { // Try to parse the response as JSON const result = JSON.parse(responseData); - + // If we get here, we have a valid JSON response logger.debug(`Completed KiCAD command: ${request.command} with result: ${result.success ? 'success' : 'failure'}`); - + + // Clear the timeout since we got a response + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + // Reset processing flag this.processingRequest = false; - + // Process next request if any setTimeout(() => this.processNextRequest(), 0); - + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Resolve the promise with the result resolve(result); } catch (e) { @@ -417,26 +431,27 @@ export class KiCADMcpServer { } }); } - - // Set a timeout - const timeout = setTimeout(() => { - logger.error(`Command timeout: ${request.command}`); - + + // Set a timeout (use command-specific timeout or default) + const timeoutDuration = request.timeout || 30000; + timeoutHandle = setTimeout(() => { + logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`); + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Reset processing flag this.processingRequest = false; - + // Process next request setTimeout(() => this.processNextRequest(), 0); - + // Reject the promise - reject(new Error(`Command timeout: ${request.command}`)); - }, 30000); // 30 seconds timeout + reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`)); + }, timeoutDuration); // Write the request to the Python process logger.debug(`Sending request: ${requestStr}`); From afcfe842cfd8bd9a3ce61752fe3704f94249585c Mon Sep 17 00:00:00 2001 From: ByteBard Date: Mon, 17 Nov 2025 15:40:20 -0500 Subject: [PATCH 2/5] Fix KiCAD 9.0 API compatibility for board, export, and view commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit resolves critical KiCAD 9.0 API compatibility issues identified through comprehensive testing and code audit. ## Major Fixes ### board/size.py - Board Outline Creation - **Issue:** Stub implementation didn't create actual board edges - **Fix:** Delegate to BoardOutlineCommands for proper Edge.Cuts geometry creation - **Impact:** set_board_size() now creates actual rectangular board outlines - **Tested:** 100x80mm and 4x3 inch board creation validated ### board/view.py - Board Extents Query - **Issue:** get_board_extents() command not implemented - **Fix:** New implementation returns complete bounding box data - **Returns:** left, top, right, bottom, width, height, center coordinates - **Tested:** Successfully returns 93.55mm × 212.05mm for test board ### board/__init__.py - Delegation Wiring - **Fix:** Added get_board_extents() delegation to BoardViewCommands - **Impact:** Completes board query API surface ### export.py - 3D Model Export - **Issue:** Get3DViewer() doesn't work in headless KiCAD 9.0 - **Fix:** Replace with kicad-cli subprocess calls for STEP/VRML export - **New:** _find_kicad_cli() helper for cross-platform CLI detection - **Features:** - STEP export with component/copper/silkscreen/soldermask options - VRML export with configurable units - 5-minute timeout for large boards - Proper error handling and validation - **Tested:** - STEP export: 144.65 MB (full), 114.61 MB (board-only) - VRML export: 60.69 MB - All exports successful on 415-component board ## Test Results All changes validated with test_kicad_9_fixes.py: - ✅ Board outline creation (mm and inch units) - ✅ Board extents query - ✅ STEP 3D export (full and board-only) - ✅ VRML 3D export - ✅ Error handling and validation ## Compatibility - **KiCAD Version:** 9.0.6 (tested) - **Backward Compatible:** Yes (kicad-cli available in KiCAD 8.0+) - **Platform:** Windows, macOS, Linux ## Breaking Changes None - all changes are additions or fixes to broken functionality. ## Related - Complements previous fixes: design_rules.py, component.py, layers.py - Part of comprehensive KiCAD 9.0 compatibility effort - Documentation: PYTHON_AUDIT_REPORT_FINAL.md, TEST_RESULTS.md Fixes # --- python/commands/board/__init__.py | 5 ++ python/commands/board/size.py | 52 ++++++----- python/commands/board/view.py | 54 +++++++++++ python/commands/export.py | 145 ++++++++++++++++++++++++++---- 4 files changed, 210 insertions(+), 46 deletions(-) diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 374d0b3..08b974e 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -75,3 +75,8 @@ class BoardCommands: """Get a 2D image of the PCB""" self.view_commands.board = self.board return self.view_commands.get_board_2d_view(params) + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + self.view_commands.board = self.board + return self.view_commands.get_board_extents(params) diff --git a/python/commands/board/size.py b/python/commands/board/size.py index d8ba8a6..a4f9e2e 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -16,7 +16,7 @@ class BoardSizeCommands: self.board = board def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set the size of the PCB board""" + """Set the size of the PCB board by creating edge cuts outline""" try: if not self.board: return { @@ -36,35 +36,33 @@ class BoardSizeCommands: "errorDetails": "Both width and height are required" } - # Convert to internal units (nanometers) - scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm - width_nm = int(width * scale) - height_nm = int(height * scale) + # Create board outline using BoardOutlineCommands + # This properly creates edge cuts on Edge.Cuts layer + from commands.board.outline import BoardOutlineCommands + outline_commands = BoardOutlineCommands(self.board) - # Set board size using KiCAD 9.0 API - # Note: In KiCAD 9.0, SetSize takes two separate parameters instead of VECTOR2I - board_box = self.board.GetBoardEdgesBoundingBox() - try: - # Try KiCAD 9.0+ API (two parameters) - board_box.SetSize(width_nm, height_nm) - except TypeError: - # Fall back to older API (VECTOR2I) - board_box.SetSize(pcbnew.VECTOR2I(width_nm, height_nm)) + # Create rectangular outline centered at origin + result = outline_commands.add_board_outline({ + "shape": "rectangle", + "centerX": width / 2, # Center X + "centerY": height / 2, # Center Y + "width": width, + "height": height, + "unit": unit + }) - # Note: SetBoardEdgesBoundingBox might not exist in all versions - # The board bounding box is typically derived from actual edge cuts - # For now, we'll just note the size was calculated - logger.info(f"Board size set to {width}x{height} {unit}") - - return { - "success": True, - "message": f"Set board size to {width}x{height} {unit}", - "size": { - "width": width, - "height": height, - "unit": unit + if result.get("success"): + return { + "success": True, + "message": f"Created board outline: {width}x{height} {unit}", + "size": { + "width": width, + "height": height, + "unit": unit + } } - } + else: + return result except Exception as e: logger.error(f"Error setting board size: {str(e)}") diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 8760880..aaad991 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -176,3 +176,57 @@ class BoardViewCommands: } # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first" + } + + # Get unit preference (default to mm) + unit = params.get("unit", "mm") + scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch + + # Get board bounding box + board_box = self.board.GetBoardEdgesBoundingBox() + + # Extract bounds in nanometers, then convert + left = board_box.GetLeft() / scale + top = board_box.GetTop() / scale + right = board_box.GetRight() / scale + bottom = board_box.GetBottom() / scale + width = board_box.GetWidth() / scale + height = board_box.GetHeight() / scale + + # Get center point + center_x = board_box.GetCenter().x / scale + center_y = board_box.GetCenter().y / scale + + return { + "success": True, + "extents": { + "left": left, + "top": top, + "right": right, + "bottom": bottom, + "width": width, + "height": height, + "center": { + "x": center_x, + "y": center_y + }, + "unit": unit + } + } + + except Exception as e: + logger.error(f"Error getting board extents: {str(e)}") + return { + "success": False, + "message": "Failed to get board extents", + "errorDetails": str(e) + } diff --git a/python/commands/export.py b/python/commands/export.py index 059f32b..0fca2e6 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -287,7 +287,11 @@ class ExportCommands: } def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export 3D model files""" + """Export 3D model files using kicad-cli (KiCAD 9.0 compatible)""" + import subprocess + import platform + import shutil + try: if not self.board: return { @@ -310,46 +314,108 @@ class ExportCommands: "errorDetails": "outputPath parameter is required" } + # Get board file path + board_file = self.board.GetFileName() + if not board_file or not os.path.exists(board_file): + return { + "success": False, + "message": "Board file not found", + "errorDetails": "Board must be saved before exporting 3D models" + } + # Create output directory if it doesn't exist output_path = os.path.abspath(os.path.expanduser(output_path)) os.makedirs(os.path.dirname(output_path), exist_ok=True) - # Get 3D viewer - viewer = self.board.Get3DViewer() - if not viewer: + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: return { "success": False, - "message": "3D viewer not available", - "errorDetails": "Could not initialize 3D viewer" + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH." } - # Set export options - viewer.SetCopperLayersOn(include_copper) - viewer.SetSolderMaskLayersOn(include_solder_mask) - viewer.SetSilkScreenLayersOn(include_silkscreen) - viewer.Set3DModelsOn(include_components) + # Build command based on format + format_upper = format.upper() + + if format_upper == "STEP": + cmd = [ + kicad_cli, + 'pcb', 'export', 'step', + '--output', output_path, + '--force' # Overwrite existing file + ] + + # Add options based on parameters + if not include_components: + cmd.append('--no-components') + if include_copper: + cmd.extend(['--include-tracks', '--include-pads', '--include-zones']) + if include_silkscreen: + cmd.append('--include-silkscreen') + if include_solder_mask: + cmd.append('--include-soldermask') + + cmd.append(board_file) + + elif format_upper == "VRML": + cmd = [ + kicad_cli, + 'pcb', 'export', 'vrml', + '--output', output_path, + '--units', 'mm', # Use mm for consistency + '--force' + ] + + if not include_components: + # Note: VRML export doesn't have a direct --no-components flag + # The models will be included by default, but can be controlled via 3D settings + pass + + cmd.append(board_file) - # Export based on format - if format == "STEP": - viewer.ExportSTEPFile(output_path) - elif format == "VRML": - viewer.ExportVRMLFile(output_path) else: return { "success": False, "message": "Unsupported format", - "errorDetails": f"Format {format} is not supported" + "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'." + } + + # Execute kicad-cli command + logger.info(f"Running 3D export command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300 # 5 minute timeout for 3D export + ) + + if result.returncode != 0: + logger.error(f"3D export command failed: {result.stderr}") + return { + "success": False, + "message": "3D export command failed", + "errorDetails": result.stderr } return { "success": True, - "message": f"Exported {format} file", + "message": f"Exported {format_upper} file", "file": { "path": output_path, - "format": format + "format": format_upper } } + except subprocess.TimeoutExpired: + logger.error("3D export command timed out") + return { + "success": False, + "message": "3D export timed out", + "errorDetails": "Export took longer than 5 minutes" + } except Exception as e: logger.error(f"Error exporting 3D model: {str(e)}") return { @@ -495,3 +561,44 @@ class ExportCommands: import json with open(path, 'w') as f: json.dump({"components": components}, f, indent=2) + + def _find_kicad_cli(self) -> Optional[str]: + """Find kicad-cli executable in system PATH or common locations + + Returns: + Path to kicad-cli executable, or None if not found + """ + import shutil + import platform + + # Try system PATH first + cli_path = shutil.which("kicad-cli") + if cli_path: + return cli_path + + # Try platform-specific default locations + system = platform.system() + + if system == "Windows": + possible_paths = [ + r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", + ] + elif system == "Darwin": # macOS + possible_paths = [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + else: # Linux + possible_paths = [ + "/usr/bin/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None From bc7de4705363e74220167e4f2c834633c2d5c834 Mon Sep 17 00:00:00 2001 From: ByteBard Date: Mon, 17 Nov 2025 16:12:22 -0500 Subject: [PATCH 3/5] Fix Gerber drill file export for KiCAD 9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace EXCELLON_WRITER.SetOptions() Python API with kicad-cli subprocess for reliable drill file generation across KiCAD versions. Issue: - KiCAD 9.0 changed EXCELLON_WRITER.SetOptions() signature - Added 3 required parameters: aMinimalHeader, aOffset, aMerge_PTH_NPTH - API signature unstable across versions Fix: - Replace Python API with kicad-cli subprocess approach - Use 'kicad-cli pcb export drill' command - More stable and version-independent - Generates separate PTH/NPTH drill files correctly Command used: ``` kicad-cli pcb export drill \ --output \ --format excellon \ --drill-origin absolute \ --excellon-separate-th \ ``` Testing: - Integration test: 12/12 tests passed (100% success) - Generates PTH.drl and NPTH.drl files correctly - Works with KiCAD 9.0.6 on Windows Related to earlier fixes: - design_rules.py: SetCurrent* → SetCustom* API migration - view.py: PlotLayer() signature update - component.py: FP_VIRTUAL → FP_BOARD_ONLY Status: All KiCAD 9.0 API changes resolved ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- python/commands/export.py | 44 +++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/python/commands/export.py b/python/commands/export.py index 0fca2e6..4a26104 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -77,19 +77,37 @@ class ExportCommands: # Generate drill files if requested drill_files = [] if generate_drill_files: - drill_writer = pcbnew.EXCELLON_WRITER(self.board) - drill_writer.SetFormat(True) - drill_writer.SetMapFileFormat(pcbnew.PLOT_FORMAT_GERBER) - - merge_npth = False # Keep plated/non-plated holes separate - drill_writer.SetOptions(merge_npth) - - drill_writer.CreateDrillandMapFilesSet(output_dir, True, generate_map_file) - - # Get list of generated drill files - for file in os.listdir(output_dir): - if file.endswith(".drl") or file.endswith(".cnc"): - drill_files.append(file) + # KiCAD 9.0: Use kicad-cli for more reliable drill file generation + # The Python API's EXCELLON_WRITER.SetOptions() signature changed + board_file = self.board.GetFileName() + kicad_cli = self._find_kicad_cli() + + if kicad_cli and board_file and os.path.exists(board_file): + import subprocess + # Generate drill files using kicad-cli + cmd = [ + kicad_cli, + 'pcb', 'export', 'drill', + '--output', output_dir, + '--format', 'excellon', + '--drill-origin', 'absolute', + '--excellon-separate-th', # Separate plated/non-plated + board_file + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + # Get list of generated drill files + for file in os.listdir(output_dir): + if file.endswith((".drl", ".cnc")): + drill_files.append(file) + else: + logger.warning(f"Drill file generation failed: {result.stderr}") + except Exception as drill_error: + logger.warning(f"Could not generate drill files: {str(drill_error)}") + else: + logger.warning("kicad-cli not available for drill file generation") return { "success": True, From bb1c7a08836e0f6d2fc43b95fc6e1c74954224b1 Mon Sep 17 00:00:00 2001 From: ByteBard Date: Mon, 17 Nov 2025 16:44:00 -0500 Subject: [PATCH 4/5] Fix BOM export for KiCAD 9.0 - GetFootprintName() API change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace FOOTPRINT.GetFootprintName() with str(GetFPID()) for KiCAD 9.0 compatibility. Issue: - KiCAD 9.0 removed FOOTPRINT.GetFootprintName() method - BOM export was failing with AttributeError Fix: - Changed: module.GetFootprintName() → str(module.GetFPID()) - GetFPID() returns the footprint library ID (LIB_ID object) - Converting to string gives "Library:Footprint" format Testing: - Endpoint test: export_bom ✅ PASS - Generates CSV BOM with all 415 components - Footprint names correctly formatted (e.g., "Capacitor_SMD:C_0603_1608Metric") Related KiCAD 9.0 fixes in this repo: - design_rules.py: SetCurrent* → SetCustom* API - export.py: EXCELLON_WRITER → kicad-cli for drill files - view.py: PlotLayer() signature update - component.py: FP_VIRTUAL → FP_BOARD_ONLY Status: All export operations working ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- python/commands/export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/commands/export.py b/python/commands/export.py index 4a26104..e0749ed 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -1,4 +1,4 @@ -""" +""" Export command implementations for KiCAD interface """ @@ -474,7 +474,7 @@ class ExportCommands: component = { "reference": module.GetReference(), "value": module.GetValue(), - "footprint": module.GetFootprintName(), + "footprint": str(module.GetFPID()), "layer": self.board.GetLayerName(module.GetLayer()) } From 70c0be6fd04068b7234b1c5f2cac5738aaa47c55 Mon Sep 17 00:00:00 2001 From: ByteBard Date: Tue, 18 Nov 2025 17:29:21 -0500 Subject: [PATCH 5/5] Fix MCP protocol compliance and Windows compatibility This commit fixes two critical issues for MCP STDIO transport: 1. Logger fix (src/logger.ts) - CRITICAL MCP protocol compliance - Change all log levels to use console.error() (stderr) exclusively - Previous code sent info/debug logs to stdout via console.log() - MCP protocol uses stdout for JSON-RPC messages - Logging to stdout corrupts protocol communication - Impact: Prevents intermittent MCP failures from log pollution 2. Windows compatibility fix (src/index.ts) - Remove import.meta.url conditional check that fails on Windows - Path separator differences (forward slash vs backslash) cause the file:// URL comparison to fail - Server now runs main() unconditionally as intended - Impact: Reliable server startup on Windows Changes: - src/logger.ts: Use console.error() for all log levels - src/index.ts: Remove 'if (import.meta.url === ...)' check - src/index.ts: Add explanatory comment about Windows issue Tested on Windows 11 with KiCAD 9.0.6. Integration tests confirm 36KB logs to stderr, 0 bytes to stdout. Fixes: MCP protocol corruption, Windows startup failures --- src/index.ts | 13 ++++++------- src/logger.ts | 19 ++++--------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index d7b37c5..6f31054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,13 +107,12 @@ async function shutdownServer(server: KiCADMcpServer) { } } -// Run the main function if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - logger.error(`Unhandled error in main: ${error}`); - process.exit(1); - }); -} +// Run the main function - always run when imported as module entry point +// The import.meta.url check was failing on Windows due to path separators +main().catch((error) => { + console.error(`Unhandled error in main: ${error}`); + process.exit(1); +}); // For testing and programmatic usage export { KiCADMcpServer }; diff --git a/src/logger.ts b/src/logger.ts index d597e12..6a22d8b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -86,21 +86,10 @@ class Logger { private log(level: LogLevel, message: string): void { const timestamp = new Date().toISOString(); const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - - // Log to console - switch (level) { - case 'error': - console.error(formattedMessage); - break; - case 'warn': - console.warn(formattedMessage); - break; - case 'info': - case 'debug': - default: - console.log(formattedMessage); - break; - } + + // Log to console.error (stderr) only - stdout is reserved for MCP protocol + // All log levels go to stderr to avoid corrupting STDIO MCP transport + console.error(formattedMessage); // Log to file try {