add logger + support for other operating systems (lower prio)
This commit is contained in:
parent
9fa890bf90
commit
047f9f6af7
21
config.py
21
config.py
@ -3,9 +3,28 @@ Configuration settings for the KiCad MCP server.
|
||||
"""
|
||||
import os
|
||||
|
||||
# KiCad paths
|
||||
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 = {
|
||||
|
@ -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
|
||||
|
@ -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
106
kicad_mcp/utils/logger.py
Normal 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)
|
99
kicad_mcp/utils/python_path.py
Normal file
99
kicad_mcp/utils/python_path.py
Normal 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
|
9
main.py
9
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__":
|
||||
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user