diff --git a/config.py b/config.py index 20ade21..31db2b1 100644 --- a/config.py +++ b/config.py @@ -3,9 +3,28 @@ Configuration settings for the KiCad MCP server. """ import os -# KiCad paths -KICAD_USER_DIR = os.path.expanduser("~/Documents/KiCad") -KICAD_APP_PATH = "/Applications/KiCad/KiCad.app" +import platform + +# Determine operating system +system = platform.system() + +# KiCad paths based on operating system +if system == "Darwin": # macOS + KICAD_USER_DIR = os.path.expanduser("~/Documents/KiCad") + KICAD_APP_PATH = "/Applications/KiCad/KiCad.app" +elif system == "Windows": + KICAD_USER_DIR = os.path.expanduser("~/Documents/KiCad") + KICAD_APP_PATH = r"C:\Program Files\KiCad" +elif system == "Linux": + KICAD_USER_DIR = os.path.expanduser("~/kicad") + KICAD_APP_PATH = "/usr/share/kicad" +else: + # Default to macOS paths if system is unknown + KICAD_USER_DIR = os.path.expanduser("~/Documents/KiCad") + KICAD_APP_PATH = "/Applications/KiCad/KiCad.app" + +# Base path to KiCad's Python framework +KICAD_PYTHON_BASE = os.path.join(KICAD_APP_PATH, "Contents/Frameworks/Python.framework/Versions") # File extensions KICAD_EXTENSIONS = { diff --git a/kicad_mcp/server.py b/kicad_mcp/server.py index 375b2c6..25dbe01 100644 --- a/kicad_mcp/server.py +++ b/kicad_mcp/server.py @@ -15,21 +15,43 @@ from kicad_mcp.tools.export_tools import register_export_tools # Import prompt handlers from kicad_mcp.prompts.templates import register_prompts +# Import utils +from kicad_mcp.utils.logger import Logger +from kicad_mcp.utils.python_path import setup_kicad_python_path + +# Create logger for this module +logger = Logger(log_dir="logs") + def create_server() -> FastMCP: """Create and configure the KiCad MCP server.""" + logger.info("Initializing KiCad MCP server") + + # Try to set up KiCad Python path + kicad_modules_available = setup_kicad_python_path() + + if kicad_modules_available: + logger.info("KiCad Python modules successfully configured") + else: + logger.warning("KiCad Python modules not available - some features will be disabled") + # Initialize FastMCP server mcp = FastMCP("KiCad") + logger.info("Created FastMCP server instance") # Register resources + logger.debug("Registering resources...") register_project_resources(mcp) register_file_resources(mcp) # Register tools + logger.debug("Registering tools...") register_project_tools(mcp) register_analysis_tools(mcp) register_export_tools(mcp) # Register prompts + logger.debug("Registering prompts...") register_prompts(mcp) + logger.info("Server initialization complete") return mcp diff --git a/kicad_mcp/tools/export_tools.py b/kicad_mcp/tools/export_tools.py index c6bb5a1..be50508 100644 --- a/kicad_mcp/tools/export_tools.py +++ b/kicad_mcp/tools/export_tools.py @@ -1,68 +1,175 @@ """ -Export and file generation tools for KiCad projects. +Analysis and validation tools for KiCad projects. """ import os -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP +import tempfile +from typing import Dict, Any, Optional +from mcp.server.fastmcp import FastMCP, Context, Image from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.kicad_utils import get_project_name_from_path +from kicad_mcp.utils.logger import Logger +# Create logger for this module +logger = Logger() -def register_export_tools(mcp: FastMCP) -> None: - """Register export and file generation tools with the MCP server. +def register_analysis_tools(mcp: FastMCP, kicad_modules_available: bool = False) -> None: + """Register analysis and validation tools with the MCP server. Args: mcp: The FastMCP server instance + kicad_modules_available: Whether KiCad Python modules are available """ @mcp.tool() - def extract_bom(project_path: str) -> Dict[str, Any]: - """Extract a Bill of Materials (BOM) from a KiCad project.""" + def validate_project(project_path: str) -> Dict[str, Any]: + """Basic validation of a KiCad project.""" + logger.info(f"Validating project: {project_path}") + if not os.path.exists(project_path): - return {"success": False, "error": f"Project not found: {project_path}"} + logger.error(f"Project not found: {project_path}") + return {"valid": False, "error": f"Project not found: {project_path}"} - project_dir = os.path.dirname(project_path) - project_name = get_project_name_from_path(project_path) + issues = [] + files = get_project_files(project_path) - # Look for existing BOM files - bom_files = [] - for file in os.listdir(project_dir): - if file.startswith(project_name) and file.endswith('.csv') and 'bom' in file.lower(): - bom_files.append(os.path.join(project_dir, file)) + # Check for essential files + if "pcb" not in files: + logger.warning("Missing PCB layout file") + issues.append("Missing PCB layout file") - if not bom_files: - return { - "success": False, - "error": "No BOM files found. You need to generate a BOM using KiCad first." - } + if "schematic" not in files: + logger.warning("Missing schematic file") + issues.append("Missing schematic file") + + # Validate project file + try: + with open(project_path, 'r') as f: + import json + json.load(f) + logger.debug("Project file validated successfully") + except json.JSONDecodeError: + logger.error("Invalid project file format (JSON parsing error)") + issues.append("Invalid project file format (JSON parsing error)") + except Exception as e: + logger.error(f"Error reading project file: {str(e)}") + issues.append(f"Error reading project file: {str(e)}") + + result = { + "valid": len(issues) == 0, + "path": project_path, + "issues": issues if issues else None, + "files_found": list(files.keys()) + } + + logger.info(f"Validation result: {'valid' if result['valid'] else 'invalid'}") + return result + + @mcp.tool() + async def generate_project_thumbnail(project_path: str, ctx: Context) -> Optional[Image]: + """Generate a thumbnail of a KiCad project's PCB layout.""" + logger.info(f"Generating thumbnail for project: {project_path}") + + if not os.path.exists(project_path): + logger.error(f"Project not found: {project_path}") + ctx.info(f"Project not found: {project_path}") + return None + + # Get PCB file + files = get_project_files(project_path) + if "pcb" not in files: + logger.error("PCB file not found in project") + ctx.info("PCB file not found in project") + return None + + pcb_file = files["pcb"] + logger.info(f"Found PCB file: {pcb_file}") + + if not kicad_modules_available: + logger.warning("KiCad Python modules are not available - cannot generate thumbnail") + ctx.info("KiCad Python modules are not available") + return None try: - # Read the first BOM file - bom_path = bom_files[0] - with open(bom_path, 'r') as f: - bom_content = f.read() + # Try to import pcbnew + import pcbnew + logger.info("Successfully imported pcbnew module") - # Parse CSV (simplified) - lines = bom_content.strip().split('\n') - headers = lines[0].split(',') + # Load the PCB file + logger.debug(f"Loading PCB file: {pcb_file}") + board = pcbnew.LoadBoard(pcb_file) + if not board: + logger.error("Failed to load PCB file") + ctx.info("Failed to load PCB file") + return None + + # Get board dimensions + board_box = board.GetBoardEdgesBoundingBox() + width = board_box.GetWidth() / 1000000.0 # Convert to mm + height = board_box.GetHeight() / 1000000.0 - components = [] - for line in lines[1:]: - values = line.split(',') - if len(values) >= len(headers): - component = {} - for i, header in enumerate(headers): - component[header.strip()] = values[i].strip() - components.append(component) + logger.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm") + ctx.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm") - return { - "success": True, - "bom_file": bom_path, - "headers": headers, - "component_count": len(components), - "components": components - } - + # Create temporary directory for output + with tempfile.TemporaryDirectory() as temp_dir: + logger.debug(f"Created temporary directory: {temp_dir}") + + # Create PLOT_CONTROLLER for plotting + pctl = pcbnew.PLOT_CONTROLLER(board) + popt = pctl.GetPlotOptions() + + # Set plot options for PNG output + popt.SetOutputDirectory(temp_dir) + popt.SetPlotFrameRef(False) + popt.SetPlotValue(True) + popt.SetPlotReference(True) + popt.SetPlotInvisibleText(False) + popt.SetPlotViaOnMaskLayer(False) + popt.SetColorMode(True) # Color mode + + # Set color theme (if available in this version) + if hasattr(popt, "SetColorTheme"): + popt.SetColorTheme("default") + + # Calculate a reasonable scale to fit in a thumbnail + max_size = 800 # Max pixel dimension + scale = min(max_size / width, max_size / height) * 0.8 # 80% to leave some margin + + # Set plot scale if the function exists + if hasattr(popt, "SetScale"): + popt.SetScale(scale) + + # Determine output filename + plot_basename = "thumbnail" + output_filename = os.path.join(temp_dir, f"{plot_basename}.png") + + logger.debug(f"Plotting PCB to: {output_filename}") + + # Plot PNG + pctl.OpenPlotfile(plot_basename, pcbnew.PLOT_FORMAT_PNG, "Thumbnail") + pctl.PlotLayer() + pctl.ClosePlot() + + # The plot controller creates files with predictable names + plot_file = os.path.join(temp_dir, f"{plot_basename}.png") + + if not os.path.exists(plot_file): + logger.error(f"Expected plot file not found: {plot_file}") + ctx.info("Failed to generate PCB image") + return None + + # Read the image file + with open(plot_file, 'rb') as f: + img_data = f.read() + + logger.info(f"Successfully generated thumbnail, size: {len(img_data)} bytes") + return Image(data=img_data, format="png") + + except ImportError as e: + logger.error(f"Failed to import pcbnew module: {str(e)}") + ctx.info(f"Failed to import pcbnew module: {str(e)}") + return None except Exception as e: - return {"success": False, "error": str(e)} + logger.error(f"Error generating thumbnail: {str(e)}", exc_info=True) + ctx.info(f"Error generating thumbnail: {str(e)}") + return None diff --git a/kicad_mcp/utils/logger.py b/kicad_mcp/utils/logger.py new file mode 100644 index 0000000..a90b755 --- /dev/null +++ b/kicad_mcp/utils/logger.py @@ -0,0 +1,106 @@ +""" +Simple logger with automatic function-level context tracking for KiCad MCP Server. + +Usage examples: +# Creates logs in the "logs" directory by default +logger = Logger() + +# To disable file logging completely +logger = Logger(log_dir=None) + +# Or to specify a custom logs directory +logger = Logger(log_dir="custom_logs") +""" +import os +import sys +import logging +import inspect +from datetime import datetime +from pathlib import Path + + +class Logger: + """ + Simple logger that automatically tracks function-level context. + """ + def __init__(self, name=None, log_dir="logs", console_level=logging.INFO, file_level=logging.DEBUG): + """ + Initialize a logger with automatic function-level context. + + Args: + name: Logger name (defaults to calling module name) + log_dir: Directory to store log files (default: "logs" directory) + Set to None to disable file logging + console_level: Logging level for console output + file_level: Logging level for file output + """ + # If no name provided, try to determine it from the calling module + if name is None: + frame = inspect.currentframe().f_back + module = inspect.getmodule(frame) + self.name = module.__name__ if module else "kicad_mcp" + else: + self.name = name + + # Initialize Python's logger + self.logger = logging.getLogger(self.name) + self.logger.setLevel(logging.DEBUG) # Capture all levels, filtering at handler level + + # Only configure if not already configured + if not self.logger.handlers and not logging.getLogger().handlers: + # Create formatter with detailed context + formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(pathname)s:%(funcName)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Set up console output + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(console_level) + console_handler.setFormatter(formatter) + self.logger.addHandler(console_handler) + + # Set up file output by default unless explicitly disabled + if log_dir is not None: + log_dir_path = Path(log_dir) + log_dir_path.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + log_file = log_dir_path / f"kicad_mcp_{timestamp}.log" + + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(file_level) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + self.info(f"Logging session started, log file: {log_file}") + + def _get_caller_info(self): + """Get information about the function that called the logger.""" + # Skip this function, the log method, and get to the actual caller + frame = inspect.currentframe().f_back.f_back + return frame + + def debug(self, message): + """Log a debug message with caller context.""" + self.logger.debug(message) + + def info(self, message): + """Log an info message with caller context.""" + self.logger.info(message) + + def warning(self, message): + """Log a warning message with caller context.""" + self.logger.warning(message) + + def error(self, message): + """Log an error message with caller context.""" + self.logger.error(message) + + def critical(self, message): + """Log a critical message with caller context.""" + self.logger.critical(message) + + def exception(self, message): + """Log an exception message with caller context and traceback.""" + self.logger.exception(message) diff --git a/kicad_mcp/utils/python_path.py b/kicad_mcp/utils/python_path.py new file mode 100644 index 0000000..0e5c1b4 --- /dev/null +++ b/kicad_mcp/utils/python_path.py @@ -0,0 +1,99 @@ +""" +Python path handling for KiCad modules. +""" +import os +import sys +import glob +import platform +from kicad_mcp.utils.logger import Logger + +# Create logger for this module +logger = Logger() + +def setup_kicad_python_path(): + """ + Add KiCad Python modules to the Python path by detecting the appropriate version. + + Returns: + bool: True if successful, False otherwise + """ + system = platform.system() + logger.info(f"Setting up KiCad Python path for {system}") + + # Define search paths based on operating system + if system == "Darwin": # macOS + from kicad_mcp.config import KICAD_APP_PATH + + if not os.path.exists(KICAD_APP_PATH): + logger.error(f"KiCad application not found at {KICAD_APP_PATH}") + return False + + # Base path to Python framework + python_base = os.path.join(KICAD_APP_PATH, "Contents/Frameworks/Python.framework/Versions") + + # First try 'Current' symlink + current_path = os.path.join(python_base, "Current/lib/python*/site-packages") + site_packages = glob.glob(current_path) + + # If 'Current' symlink doesn't work, find all available Python versions + if not site_packages: + logger.debug("'Current' symlink not found, searching for numbered versions") + # Look for numbered versions like 3.9, 3.10, etc. + version_dirs = glob.glob(os.path.join(python_base, "[0-9]*")) + for version_dir in version_dirs: + potential_path = os.path.join(version_dir, "lib/python*/site-packages") + site_packages.extend(glob.glob(potential_path)) + + elif system == "Windows": + # Windows path - typically in Program Files + kicad_app_path = r"C:\Program Files\KiCad" + python_dirs = glob.glob(os.path.join(kicad_app_path, "lib", "python*")) + site_packages = [] + + for python_dir in python_dirs: + potential_path = os.path.join(python_dir, "site-packages") + if os.path.exists(potential_path): + site_packages.append(potential_path) + + elif system == "Linux": + # Common Linux installation paths + site_packages = [ + "/usr/lib/python3/dist-packages", # Debian/Ubuntu + "/usr/lib/python3.*/site-packages", # Red Hat/Fedora + "/usr/local/lib/python3.*/site-packages" # Source install + ] + + # Expand glob patterns + expanded_packages = [] + for pattern in site_packages: + if "*" in pattern: + expanded_packages.extend(glob.glob(pattern)) + else: + expanded_packages.append(pattern) + + site_packages = expanded_packages + + else: + logger.error(f"Unsupported operating system: {system}") + return False + + # Pick the first valid path found + for path in site_packages: + if os.path.exists(path): + # Check if pcbnew module exists in this path + pcbnew_path = os.path.join(path, "pcbnew.so") + if not os.path.exists(pcbnew_path): + # On Windows it might be pcbnew.pyd instead + pcbnew_path = os.path.join(path, "pcbnew.pyd") + + if os.path.exists(pcbnew_path): + if path not in sys.path: + sys.path.append(path) + logger.info(f"Added KiCad Python path: {path}") + logger.info(f"Found pcbnew module at: {pcbnew_path}") + return True + else: + logger.debug(f"Found site-packages at {path} but no pcbnew module") + + logger.error("Could not find a valid KiCad Python site-packages directory with pcbnew module") + return False diff --git a/main.py b/main.py index e7e2b8e..32968c5 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,16 @@ KiCad MCP Server - A Model Context Protocol server for KiCad on macOS. This server allows Claude and other MCP clients to interact with KiCad projects. """ from kicad_mcp.server import create_server +from kicad_mcp.utils.logger import Logger + +logger = Logger(log_dir="logs") if __name__ == "__main__": - server = create_server() - server.run(transport='stdio') + try: + logger.info("Starting KiCad MCP server") + server = create_server() + logger.info("Running server with stdio transport") + server.run(transport='stdio') + except Exception as e: + logger.exception(f"Unhandled exception: {str(e)}") + raise