add logger + support for other operating systems (lower prio)

This commit is contained in:
Lama 2025-03-20 02:25:49 -04:00
parent 9fa890bf90
commit 047f9f6af7
6 changed files with 412 additions and 50 deletions

View File

@ -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 = {

View File

@ -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

View File

@ -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
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)
# Get board dimensions
board_box = board.GetBoardEdgesBoundingBox()
width = board_box.GetWidth() / 1000000.0 # Convert to mm
height = board_box.GetHeight() / 1000000.0
return {
"success": True,
"bom_file": bom_path,
"headers": headers,
"component_count": len(components),
"components": components
}
logger.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm")
ctx.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm")
# 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

106
kicad_mcp/utils/logger.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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__":
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