#!/usr/bin/env python3 """ KiCAD Python Interface Script for Model Context Protocol This script handles communication between the MCP TypeScript server and KiCAD's Python API (pcbnew). It receives commands via stdin as JSON and returns responses via stdout also as JSON. """ import sys import json import traceback import logging import os from typing import Dict, Any, Optional # Configure logging log_dir = os.path.join(os.path.expanduser('~'), '.kicad-mcp', 'logs') os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, 'kicad_interface.log') logging.basicConfig( level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(log_file), logging.StreamHandler(sys.stderr) ] ) logger = logging.getLogger('kicad_interface') # Log Python environment details logger.info(f"Python version: {sys.version}") logger.info(f"Python executable: {sys.executable}") logger.info(f"Platform: {sys.platform}") logger.info(f"Working directory: {os.getcwd()}") # Windows-specific diagnostics if sys.platform == 'win32': logger.info("=== Windows Environment Diagnostics ===") logger.info(f"PYTHONPATH: {os.environ.get('PYTHONPATH', 'NOT SET')}") logger.info(f"PATH: {os.environ.get('PATH', 'NOT SET')[:200]}...") # Truncate PATH # Check for common KiCAD installations common_kicad_paths = [ r"C:\Program Files\KiCad", r"C:\Program Files (x86)\KiCad" ] found_kicad = False for base_path in common_kicad_paths: if os.path.exists(base_path): logger.info(f"Found KiCAD installation at: {base_path}") # List versions try: versions = [d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d))] logger.info(f" Versions found: {', '.join(versions)}") for version in versions: python_path = os.path.join(base_path, version, 'lib', 'python3', 'dist-packages') if os.path.exists(python_path): logger.info(f" ✓ Python path exists: {python_path}") found_kicad = True else: logger.warning(f" ✗ Python path missing: {python_path}") except Exception as e: logger.warning(f" Could not list versions: {e}") if not found_kicad: logger.warning("No KiCAD installations found in standard locations!") logger.warning("Please ensure KiCAD 9.0+ is installed from https://www.kicad.org/download/windows/") logger.info("========================================") # Add utils directory to path for imports utils_dir = os.path.join(os.path.dirname(__file__)) if utils_dir not in sys.path: sys.path.insert(0, utils_dir) # Import platform helper and add KiCAD paths from utils.platform_helper import PlatformHelper from utils.kicad_process import check_and_launch_kicad, KiCADProcessManager logger.info(f"Detecting KiCAD Python paths for {PlatformHelper.get_platform_name()}...") paths_added = PlatformHelper.add_kicad_to_python_path() if paths_added: logger.info("Successfully added KiCAD Python paths to sys.path") else: logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path") logger.info(f"Current Python path: {sys.path}") # Check if auto-launch is enabled AUTO_LAUNCH_KICAD = os.environ.get("KICAD_AUTO_LAUNCH", "false").lower() == "true" if AUTO_LAUNCH_KICAD: logger.info("KiCAD auto-launch enabled") # Import KiCAD's Python API try: logger.info("Attempting to import pcbnew module...") import pcbnew # type: ignore logger.info(f"Successfully imported pcbnew module from: {pcbnew.__file__}") logger.info(f"pcbnew version: {pcbnew.GetBuildVersion()}") except ImportError as e: logger.error(f"Failed to import pcbnew module: {e}") logger.error(f"Current sys.path: {sys.path}") # Platform-specific help message help_message = "" if sys.platform == 'win32': help_message = """ Windows Troubleshooting: 1. Verify KiCAD is installed: C:\\Program Files\\KiCad\\9.0 2. Check PYTHONPATH environment variable points to: C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages 3. Test with: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew" 4. Log file location: %USERPROFILE%\\.kicad-mcp\\logs\\kicad_interface.log 5. Run setup-windows.ps1 for automatic configuration """ elif sys.platform == 'darwin': help_message = """ macOS Troubleshooting: 1. Verify KiCAD is installed: /Applications/KiCad/KiCad.app 2. Check PYTHONPATH points to KiCAD's Python packages 3. Run: python3 -c "import pcbnew" to test """ else: # Linux help_message = """ Linux Troubleshooting: 1. Verify KiCAD is installed: apt list --installed | grep kicad 2. Check: /usr/lib/kicad/lib/python3/dist-packages exists 3. Test: python3 -c "import pcbnew" """ logger.error(help_message) error_response = { "success": False, "message": "Failed to import pcbnew module - KiCAD Python API not found", "errorDetails": f"Error: {str(e)}\n\n{help_message}\n\nPython sys.path:\n{chr(10).join(sys.path)}" } print(json.dumps(error_response)) sys.exit(1) except Exception as e: logger.error(f"Unexpected error importing pcbnew: {e}") logger.error(traceback.format_exc()) error_response = { "success": False, "message": "Error importing pcbnew module", "errorDetails": str(e) } print(json.dumps(error_response)) sys.exit(1) # Import command handlers try: logger.info("Importing command handlers...") from commands.project import ProjectCommands from commands.board import BoardCommands from commands.component import ComponentCommands from commands.routing import RoutingCommands from commands.design_rules import DesignRuleCommands from commands.export import ExportCommands from commands.schematic import SchematicManager from commands.component_schematic import ComponentManager from commands.connection_schematic import ConnectionManager from commands.library_schematic import LibraryManager as SchematicLibraryManager from commands.library import LibraryManager as FootprintLibraryManager, LibraryCommands logger.info("Successfully imported all command handlers") except ImportError as e: logger.error(f"Failed to import command handlers: {e}") error_response = { "success": False, "message": "Failed to import command handlers", "errorDetails": str(e) } print(json.dumps(error_response)) sys.exit(1) class KiCADInterface: """Main interface class to handle KiCAD operations""" def __init__(self): """Initialize the interface and command handlers""" self.board = None self.project_filename = None logger.info("Initializing command handlers...") # Initialize footprint library manager self.footprint_library = FootprintLibraryManager() # Initialize command handlers self.project_commands = ProjectCommands(self.board) self.board_commands = BoardCommands(self.board) self.component_commands = ComponentCommands(self.board, self.footprint_library) self.routing_commands = RoutingCommands(self.board) self.design_rule_commands = DesignRuleCommands(self.board) self.export_commands = ExportCommands(self.board) self.library_commands = LibraryCommands(self.footprint_library) # Schematic-related classes don't need board reference # as they operate directly on schematic files # Command routing dictionary self.command_routes = { # Project commands "create_project": self.project_commands.create_project, "open_project": self.project_commands.open_project, "save_project": self.project_commands.save_project, "get_project_info": self.project_commands.get_project_info, # Board commands "set_board_size": self.board_commands.set_board_size, "add_layer": self.board_commands.add_layer, "set_active_layer": self.board_commands.set_active_layer, "get_board_info": self.board_commands.get_board_info, "get_layer_list": self.board_commands.get_layer_list, "get_board_2d_view": self.board_commands.get_board_2d_view, "add_board_outline": self.board_commands.add_board_outline, "add_mounting_hole": self.board_commands.add_mounting_hole, "add_text": self.board_commands.add_text, "add_board_text": self.board_commands.add_text, # Alias for TypeScript tool # Component commands "place_component": self.component_commands.place_component, "move_component": self.component_commands.move_component, "rotate_component": self.component_commands.rotate_component, "delete_component": self.component_commands.delete_component, "edit_component": self.component_commands.edit_component, "get_component_properties": self.component_commands.get_component_properties, "get_component_list": self.component_commands.get_component_list, "place_component_array": self.component_commands.place_component_array, "align_components": self.component_commands.align_components, "duplicate_component": self.component_commands.duplicate_component, # Routing commands "add_net": self.routing_commands.add_net, "route_trace": self.routing_commands.route_trace, "add_via": self.routing_commands.add_via, "delete_trace": self.routing_commands.delete_trace, "get_nets_list": self.routing_commands.get_nets_list, "create_netclass": self.routing_commands.create_netclass, "add_copper_pour": self.routing_commands.add_copper_pour, "route_differential_pair": self.routing_commands.route_differential_pair, # Design rule commands "set_design_rules": self.design_rule_commands.set_design_rules, "get_design_rules": self.design_rule_commands.get_design_rules, "run_drc": self.design_rule_commands.run_drc, "get_drc_violations": self.design_rule_commands.get_drc_violations, # Export commands "export_gerber": self.export_commands.export_gerber, "export_pdf": self.export_commands.export_pdf, "export_svg": self.export_commands.export_svg, "export_3d": self.export_commands.export_3d, "export_bom": self.export_commands.export_bom, # Library commands (footprint management) "list_libraries": self.library_commands.list_libraries, "search_footprints": self.library_commands.search_footprints, "list_library_footprints": self.library_commands.list_library_footprints, "get_footprint_info": self.library_commands.get_footprint_info, # Schematic commands "create_schematic": self._handle_create_schematic, "load_schematic": self._handle_load_schematic, "add_schematic_component": self._handle_add_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, "list_schematic_libraries": self._handle_list_schematic_libraries, "export_schematic_pdf": self._handle_export_schematic_pdf, # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, "launch_kicad_ui": self._handle_launch_kicad_ui } logger.info("KiCAD interface initialized") def handle_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]: """Route command to appropriate handler""" logger.info(f"Handling command: {command}") logger.debug(f"Command parameters: {params}") try: # Get the handler for the command handler = self.command_routes.get(command) if handler: # Execute the command result = handler(params) logger.debug(f"Command result: {result}") # Update board reference if command was successful if result.get("success", False): if command == "create_project" or command == "open_project": logger.info("Updating board reference...") # Get board from the project commands handler self.board = self.project_commands.board self._update_command_handlers() return result else: logger.error(f"Unknown command: {command}") return { "success": False, "message": f"Unknown command: {command}", "errorDetails": "The specified command is not supported" } except Exception as e: # Get the full traceback traceback_str = traceback.format_exc() logger.error(f"Error handling command {command}: {str(e)}\n{traceback_str}") return { "success": False, "message": f"Error handling command: {command}", "errorDetails": f"{str(e)}\n{traceback_str}" } def _update_command_handlers(self): """Update board reference in all command handlers""" logger.debug("Updating board reference in command handlers") self.project_commands.board = self.board self.board_commands.board = self.board self.component_commands.board = self.board self.routing_commands.board = self.board self.design_rule_commands.board = self.board self.export_commands.board = self.board # Schematic command handlers def _handle_create_schematic(self, params): """Create a new schematic""" logger.info("Creating schematic") try: project_name = params.get("projectName") path = params.get("path", ".") metadata = params.get("metadata", {}) if not project_name: return {"success": False, "message": "Project name is required"} schematic = SchematicManager.create_schematic(project_name, metadata) file_path = f"{path}/{project_name}.kicad_sch" success = SchematicManager.save_schematic(schematic, file_path) return {"success": success, "file_path": file_path} except Exception as e: logger.error(f"Error creating schematic: {str(e)}") return {"success": False, "message": str(e)} def _handle_load_schematic(self, params): """Load an existing schematic""" logger.info("Loading schematic") try: filename = params.get("filename") if not filename: return {"success": False, "message": "Filename is required"} schematic = SchematicManager.load_schematic(filename) success = schematic is not None if success: metadata = SchematicManager.get_schematic_metadata(schematic) return {"success": success, "metadata": metadata} else: return {"success": False, "message": "Failed to load schematic"} except Exception as e: logger.error(f"Error loading schematic: {str(e)}") return {"success": False, "message": str(e)} def _handle_add_schematic_component(self, params): """Add a component to a schematic""" logger.info("Adding component to schematic") try: schematic_path = params.get("schematicPath") component = params.get("component", {}) if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not component: return {"success": False, "message": "Component definition is required"} schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} component_obj = ComponentManager.add_component(schematic, component) success = component_obj is not None if success: SchematicManager.save_schematic(schematic, schematic_path) return {"success": True} else: return {"success": False, "message": "Failed to add component"} except Exception as e: logger.error(f"Error adding component to schematic: {str(e)}") return {"success": False, "message": str(e)} def _handle_add_schematic_wire(self, params): """Add a wire to a schematic""" logger.info("Adding wire to schematic") try: schematic_path = params.get("schematicPath") start_point = params.get("startPoint") end_point = params.get("endPoint") if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not start_point or not end_point: return {"success": False, "message": "Start and end points are required"} schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} wire = ConnectionManager.add_wire(schematic, start_point, end_point) success = wire is not None if success: SchematicManager.save_schematic(schematic, schematic_path) return {"success": True} else: return {"success": False, "message": "Failed to add wire"} except Exception as e: logger.error(f"Error adding wire to schematic: {str(e)}") return {"success": False, "message": str(e)} def _handle_list_schematic_libraries(self, params): """List available symbol libraries""" logger.info("Listing schematic libraries") try: search_paths = params.get("searchPaths") libraries = LibraryManager.list_available_libraries(search_paths) return {"success": True, "libraries": libraries} except Exception as e: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} def _handle_export_schematic_pdf(self, params): """Export schematic to PDF""" logger.info("Exporting schematic to PDF") try: schematic_path = params.get("schematicPath") output_path = params.get("outputPath") if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not output_path: return {"success": False, "message": "Output path is required"} import subprocess result = subprocess.run( ["kicad-cli", "sch", "export", "pdf", "--output", output_path, schematic_path], capture_output=True, text=True ) success = result.returncode == 0 message = result.stderr if not success else "" return {"success": success, "message": message} except Exception as e: logger.error(f"Error exporting schematic to PDF: {str(e)}") return {"success": False, "message": str(e)} def _handle_check_kicad_ui(self, params): """Check if KiCAD UI is running""" logger.info("Checking if KiCAD UI is running") try: manager = KiCADProcessManager() is_running = manager.is_running() processes = manager.get_process_info() if is_running else [] return { "success": True, "running": is_running, "processes": processes, "message": "KiCAD is running" if is_running else "KiCAD is not running" } except Exception as e: logger.error(f"Error checking KiCAD UI status: {str(e)}") return {"success": False, "message": str(e)} def _handle_launch_kicad_ui(self, params): """Launch KiCAD UI""" logger.info("Launching KiCAD UI") try: project_path = params.get("projectPath") auto_launch = params.get("autoLaunch", AUTO_LAUNCH_KICAD) # Convert project path to Path object if provided from pathlib import Path path_obj = Path(project_path) if project_path else None result = check_and_launch_kicad(path_obj, auto_launch) return { "success": True, **result } except Exception as e: logger.error(f"Error launching KiCAD UI: {str(e)}") return {"success": False, "message": str(e)} def main(): """Main entry point""" logger.info("Starting KiCAD interface...") interface = KiCADInterface() try: logger.info("Processing commands from stdin...") # Process commands from stdin for line in sys.stdin: try: # Parse command logger.debug(f"Received input: {line.strip()}") command_data = json.loads(line) # Check if this is JSON-RPC 2.0 format if 'jsonrpc' in command_data and command_data['jsonrpc'] == '2.0': logger.info("Detected JSON-RPC 2.0 format message") method = command_data.get('method') params = command_data.get('params', {}) request_id = command_data.get('id') # Handle MCP protocol methods if method == 'initialize': logger.info("Handling MCP initialize") response = { 'jsonrpc': '2.0', 'id': request_id, 'result': { 'protocolVersion': '2025-06-18', 'capabilities': { 'tools': {} }, 'serverInfo': { 'name': 'kicad-mcp-server', 'version': '0.1.0' } } } elif method == 'tools/list': logger.info("Handling MCP tools/list") # Return list of available tools tools = [] for cmd_name in interface.command_routes.keys(): tools.append({ 'name': cmd_name, 'description': f'KiCAD command: {cmd_name}', 'inputSchema': { 'type': 'object', 'properties': {} } }) response = { 'jsonrpc': '2.0', 'id': request_id, 'result': { 'tools': tools } } elif method == 'tools/call': logger.info("Handling MCP tools/call") tool_name = params.get('name') tool_params = params.get('arguments', {}) # Execute the command result = interface.handle_command(tool_name, tool_params) response = { 'jsonrpc': '2.0', 'id': request_id, 'result': { 'content': [ { 'type': 'text', 'text': json.dumps(result) } ] } } else: logger.error(f"Unknown JSON-RPC method: {method}") response = { 'jsonrpc': '2.0', 'id': request_id, 'error': { 'code': -32601, 'message': f'Method not found: {method}' } } else: # Handle legacy custom format logger.info("Detected custom format message") command = command_data.get("command") params = command_data.get("params", {}) if not command: logger.error("Missing command field") response = { "success": False, "message": "Missing command", "errorDetails": "The command field is required" } else: # Handle command response = interface.handle_command(command, params) # Send response logger.debug(f"Sending response: {response}") print(json.dumps(response)) sys.stdout.flush() except json.JSONDecodeError as e: logger.error(f"Invalid JSON input: {str(e)}") response = { "success": False, "message": "Invalid JSON input", "errorDetails": str(e) } print(json.dumps(response)) sys.stdout.flush() except KeyboardInterrupt: logger.info("KiCAD interface stopped") sys.exit(0) except Exception as e: logger.error(f"Unexpected error: {str(e)}\n{traceback.format_exc()}") sys.exit(1) if __name__ == "__main__": main()