From 06451566928b486aeed2a9c31f7577cedc776197 Mon Sep 17 00:00:00 2001 From: chinese1123243 Date: Thu, 5 Feb 2026 15:09:50 +0800 Subject: [PATCH 1/5] Fix MCP server startup issues and add tools --- README.md | 59 +- esp_utils.py | 167 +++- main.py | 2531 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 2537 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index cf9fb05..6d37a63 100644 --- a/README.md +++ b/README.md @@ -17,21 +17,72 @@ This project is currently a **Proof of Concept (PoC)** for an MCP server tailore **Current Capabilities:** -**Core Features:** -* `run_esp_idf_install`: Install ESP-IDF dependencies and toolchain via `install.sh`. +**Core Features (30 tools available):** + +**Project Management:** * `create_esp_project`: Create a new ESP-IDF project. * `setup_project_esp_target`: Set target chip for ESP-IDF projects (esp32, esp32c3, esp32s3, etc.). +* `get_project_info`: Get detailed information about an ESP-IDF project. +* `list_components`: List all components in an ESP-IDF project. + +**Build & Flash:** * `build_esp_project`: Build ESP-IDF projects with incremental build support. -* `list_esp_serial_ports`: List available serial ports for ESP devices. +* `clean_esp_project`: Clean build files from an ESP-IDF project. * `flash_esp_project`: Flash built firmware to connected ESP devices. +* `erase_flash_esp`: Erase flash memory on ESP device. +* `flash_and_monitor_esp`: Flash firmware and immediately monitor serial output. + +**Device Operations:** +* `list_esp_serial_ports`: List available serial ports for ESP devices. +* `monitor_esp`: Monitor serial output from ESP device. + +**Configuration:** +* `menuconfig_esp`: Run menuconfig to configure ESP-IDF project. +* `get_project_config`: Get project configuration information (sdkconfig). +* `set_esp_partition`: Set partition table for ESP-IDF project. +* `get_esp_idf_version`: Get ESP-IDF version information. +* `check_esp_idf_env`: Check ESP-IDF environment status and configuration. +* `run_esp_idf_install`: Run install.sh script in ESP-IDF directory. + +**Debugging:** +* `gdb_attach`: Attach GDB debugger to ESP device. +* `get_core_dump`: Get core dump information from ESP device. + +**Runtime Analysis:** +* `get_heap_info`: Get heap memory information from ESP device. +* `get_task_stats`: Get FreeRTOS task statistics from ESP device. + +**Testing:** * `run_pytest`: Run pytest tests with pytest-embedded support for ESP-IDF projects. +**File Operations:** +* `read_file`: Read contents of a file in the project. +* `write_file`: Write content to a file in the project. +* `list_files`: List files and directories in a project path. + +**Analysis Tools:** +* `parse_build_log`: Parse and analyze build log with structured output for AI analysis. +* `analyze_memory_map`: Analyze memory usage from .map file. +* `compare_sdkconfig`: Compare two sdkconfig files and output structured differences. +* `analyze_dependencies`: Analyze component dependencies from CMakeLists.txt files. +* `format_device_log`: Parse and format device serial logs with structured output. + **Additional Features:** * Flexible ESP-IDF path management: supports per-project ESP-IDF versions via `idf_path` parameter. +* Dynamic ESP-IDF path detection: automatically reads IDF_PATH from MCP configuration. * SDK config management: supports custom `sdkconfig_defaults` files for build configuration (multiple files can be specified separated by semicolons). * Build time tracking for performance monitoring. * Optional port specification for flashing operations. -* Includes experimental support for automatic issue fixing based on build logs. +* Windows Git Bash path compatibility: automatic path conversion for cross-platform support. + +**Recent Bug Fixes & Improvements:** +* Fixed event loop nesting errors by implementing synchronous server communication +* Fixed JSON-RPC protocol errors with proper empty line handling +* Improved environment variable handling with better error messages +* Fixed subprocess encoding issues (UTF-8 with error replacement) +* Added Git Bash path conversion for Windows (E:/path → /e/path) +* Implemented dynamic ESP-IDF path detection from MCP configuration +* Added comprehensive error handling and logging **Vision & Future Work:** The long-term vision is to expand this MCP into a comprehensive toolkit for interacting with embedded devices, potentially integrating with home assistant platforms, and streamlining documentation access for ESP-IDF and related technologies. diff --git a/esp_utils.py b/esp_utils.py index 1fe9cca..059a1fc 100644 --- a/esp_utils.py +++ b/esp_utils.py @@ -2,12 +2,14 @@ Utility functions for ESP-IDF tools """ import os -import asyncio +import logging from typing import Tuple +logger = logging.getLogger(__name__) -async def run_command_async(command: str) -> Tuple[int, str, str]: - """Run a command asynchronously and capture output + +def run_command_async(command: str) -> Tuple[int, str, str]: + """Run a command and capture output Args: command: The command to run @@ -15,15 +17,32 @@ async def run_command_async(command: str) -> Tuple[int, str, str]: Returns: Tuple[int, str, str]: Return code, stdout, stderr """ + import subprocess + try: - process = await asyncio.create_subprocess_shell( + # Use subprocess.run for synchronous execution + # Use UTF-8 encoding with error handling for cross-platform compatibility + result = subprocess.run( command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + shell=True, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', # Replace invalid characters instead of raising error + timeout=300 # 5 minutes timeout ) - stdout, stderr = await process.communicate() - return process.returncode, stdout.decode(), stderr.decode() + + logger.debug(f"Command executed: {command}") + logger.debug(f"Return code: {result.returncode}") + logger.debug(f"Stdout: {result.stdout[:200]}...") + logger.debug(f"Stderr: {result.stderr[:200]}...") + + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired as e: + logger.error(f"Command timeout: {e}") + return 1, "", f"Command timeout after 300 seconds: {str(e)}" except Exception as e: + logger.error(f"Error executing command: {e}") return 1, "", f"Error executing command: {str(e)}" def get_esp_idf_dir(idf_path: str = None) -> str: @@ -38,22 +57,69 @@ def get_esp_idf_dir(idf_path: str = None) -> str: Raises: ValueError: If idf_path is not provided and IDF_PATH environment variable is not set """ - if idf_path: - return idf_path - if "IDF_PATH" in os.environ: - return os.environ["IDF_PATH"] - raise ValueError("IDF_PATH must be provided either as parameter or environment variable") + if idf_path and idf_path.strip(): + # Normalize the path + normalized_path = os.path.abspath(os.path.expanduser(idf_path.strip())) + logger.info(f"Using provided ESP-IDF path: {normalized_path}") + return normalized_path + + if "IDF_PATH" in os.environ and os.environ["IDF_PATH"].strip(): + normalized_path = os.path.abspath(os.path.expanduser(os.environ["IDF_PATH"].strip())) + logger.info(f"Using ESP-IDF path from environment variable: {normalized_path}") + return normalized_path + + error_msg = "IDF_PATH must be provided either as parameter or environment variable. Please set the IDF_PATH environment variable to point to your ESP-IDF installation directory." + logger.error(error_msg) + raise ValueError(error_msg) + +def convert_to_bash_path(windows_path: str) -> str: + """Convert Windows path to Git Bash-compatible path + + Args: + windows_path: Windows path (e.g., 'E:\\path\\to\\file' or 'E:/path/to/file') + + Returns: + str: Git Bash-compatible path (e.g., '/e/path/to/file') + """ + # Normalize to forward slashes first + normalized = windows_path.replace('\\', '/') + + # Check if it's an absolute Windows path with drive letter + if len(normalized) >= 2 and normalized[1] == ':': + # E:/path -> /e/path + return '/' + normalized[0].lower() + normalized[2:] + + return normalized + def get_export_script(idf_path: str = None) -> str: - """Get the path to the ESP-IDF export script + """Get path to ESP-IDF export script Args: idf_path: Optional path to ESP-IDF directory. If None or empty, uses IDF_PATH environment variable. Returns: - str: Path to the export script + str: Path to export script + + Raises: + FileNotFoundError: If export.sh script is not found """ - return os.path.join(get_esp_idf_dir(idf_path), "export.sh") + esp_idf_dir = get_esp_idf_dir(idf_path) + export_script = os.path.join(esp_idf_dir, "export.sh") + + if not os.path.exists(export_script): + error_msg = f"ESP-IDF export script not found at: {export_script}. Please verify your ESP-IDF installation." + logger.error(error_msg) + raise FileNotFoundError(error_msg) + + # Convert Windows path to bash-compatible path for Git Bash + if os.name == 'nt': + export_script_bash = convert_to_bash_path(export_script) + logger.debug(f"Using export script (converted for bash): {export_script_bash}") + return export_script_bash + + logger.debug(f"Using export script: {export_script}") + return export_script def check_esp_idf_installed(idf_path: str = None) -> bool: """Check if ESP-IDF is installed @@ -65,28 +131,71 @@ def check_esp_idf_installed(idf_path: str = None) -> bool: bool: True if ESP-IDF is installed, False otherwise """ try: - return os.path.exists(get_esp_idf_dir(idf_path)) - except ValueError: + esp_idf_dir = get_esp_idf_dir(idf_path) + is_installed = os.path.exists(esp_idf_dir) + logger.info(f"ESP-IDF installed check: {is_installed} at {esp_idf_dir}") + return is_installed + except ValueError as e: + logger.warning(f"ESP-IDF installation check failed: {e}") return False -async def list_serial_ports() -> Tuple[int, str, str]: +def list_serial_ports() -> Tuple[int, str, str]: """List available serial ports for ESP devices Returns: Tuple[int, str, str]: Return code, stdout with port list, stderr """ + import subprocess + try: - # Try to use idf.py to list ports (if available) - process = await asyncio.create_subprocess_shell( - "python -m serial.tools.list_ports", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await process.communicate() - return process.returncode, stdout.decode(), stderr.decode() + # Try to list COM ports on Windows using mode command + if os.name == 'nt': # Windows + result = subprocess.run( + ["mode"], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=10 + ) + if result.returncode == 0: + # Parse COM ports from mode output + lines = result.stdout.split('\n') + com_ports = [] + for line in lines: + if 'COM' in line: + parts = line.split() + for part in parts: + if part.startswith('COM'): + com_ports.append(part) + if com_ports: + port_list = '\n'.join(com_ports) + logger.debug(f"Found COM ports: {com_ports}") + return 0, f"Available serial ports:\n{port_list}", "" + + # Try pyserial if available + try: + import serial.tools.list_ports + ports = serial.tools.list_ports.comports() + if ports: + port_list = '\n'.join([f"{port.device} - {port.description}" for port in ports]) + logger.debug(f"Found serial ports via pyserial: {len(ports)}") + return 0, f"Available serial ports:\n{port_list}", "" + except ImportError: + pass + + # Fallback: try common port patterns + logger.warning("Using fallback port list") + common_ports = ["COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1", + "/dev/cu.usbserial-*", "/dev/cu.SLAB_USBtoUART"] + port_info = "Common ESP device ports to try:\n" + "\n".join(common_ports) + return 0, port_info, "Note: Could not auto-detect ports, showing common ports" + except Exception as e: # Fallback: try common port patterns - common_ports = ["/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1", - "/dev/cu.usbserial-*", "/dev/cu.SLAB_USBtoUART", "COM1", "COM2", "COM3"] + logger.warning(f"Failed to list serial ports: {e}") + common_ports = ["COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1"] port_info = "Common ESP device ports to try:\n" + "\n".join(common_ports) return 0, port_info, f"Note: Could not auto-detect ports. Error: {str(e)}" diff --git a/main.py b/main.py index bc22af9..91f0c13 100644 --- a/main.py +++ b/main.py @@ -1,258 +1,2415 @@ -import logging +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ESP MCP Server - Synchronous implementation (like cheat-engine)""" + +import json import shlex import time -from typing import Tuple - -from mcp.server.fastmcp import FastMCP +import sys import os +import io +import traceback +from typing import Any, Optional + from esp_utils import run_command_async, get_export_script, list_serial_ports, get_esp_idf_dir -mcp = FastMCP("esp-mcp") - -@mcp.tool() -async def build_esp_project(project_path: str, idf_path: str = None, sdkconfig_defaults: str = None) -> Tuple[str, str]: - """Build an ESP-IDF project. Can Incremental Build. Similar to `idf.py build`. - - Args: - project_path: Path to the project. - idf_path: Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set. - - If None or empty: uses IDF_PATH environment variable - - If provided: uses the specified path, allowing different projects to use different ESP-IDF versions. - sdkconfig_defaults: Optional sdkconfig defaults files. Multiple files can be specified separated by semicolons. - Example: "sdkconfig.defaults;sdkconfig.ci.release" - - If provided: uses the specified sdkconfig defaults files. This will cause reconfigure and full rebuild. - - If None: uses default incremental build behavior. - Note: Only use this parameter when you need to modify config. For incremental builds, omit this parameter. - - Returns: - tuple: (stdout, stderr) - Build logs and error messages. Time information is included in stdout. - """ +# Configure stdio for proper MCP communication +sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', newline='\n') +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + +# Configure logging to stderr for proper MCP communication +import logging +logging.basicConfig( + level=logging.WARNING, + format='%(levelname)s - %(message)s', + stream=sys.stderr, + force=True +) +logger = logging.getLogger(__name__) + + +# ============ Logger ============ +class Logger: + @staticmethod + def log(msg: str, level: str = "INFO"): + sys.stderr.write(f"[ESP-MCP-{level}] {msg}\n") + sys.stderr.flush() + + @staticmethod + def info(msg: str): Logger.log(msg, "INFO") + + @staticmethod + def error(msg: str): Logger.log(msg, "ERROR") + +log = Logger() + + +# ============ Tool Definitions ============ +TOOLS = [ + { + "name": "build_esp_project", + "description": "Build an ESP-IDF project. Can Incremental Build. Similar to `idf.py build`.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to project." + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + }, + "sdkconfig_defaults": { + "type": "string", + "description": "Optional sdkconfig defaults files separated by semicolons." + } + }, + "required": ["project_path"] + } + }, + { + "name": "clean_esp_project", + "description": "Clean build files from an ESP-IDF project. Similar to `idf.py fullclean`.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "full_clean": { + "type": "boolean", + "description": "Perform full clean (remove build directory). Default: false." + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "erase_flash_esp", + "description": "Erase flash memory on ESP device. Similar to `idf.py erase-flash`.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "baud": { + "type": "integer", + "description": "Baud rate for flashing (default: 460800)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "monitor_esp", + "description": "Monitor serial output from ESP device. Similar to `idf.py monitor`.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "baud": { + "type": "integer", + "description": "Baud rate for monitor (default: 115200)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "flash_and_monitor_esp", + "description": "Flash firmware and immediately monitor serial output. Similar to `idf.py flash monitor`.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "baud": { + "type": "integer", + "description": "Baud rate for flashing/monitoring" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "menuconfig_esp", + "description": "Run menuconfig to configure ESP-IDF project. Note: This requires terminal interaction.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "get_esp_idf_version", + "description": "Get ESP-IDF version information.", + "inputSchema": { + "type": "object", + "properties": { + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": [] + } + }, + { + "name": "check_esp_idf_env", + "description": "Check ESP-IDF environment status and configuration.", + "inputSchema": { + "type": "object", + "properties": { + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": [] + } + }, + { + "name": "get_project_config", + "description": "Get project configuration information (sdkconfig).", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "config_key": { + "type": "string", + "description": "Optional specific config key to retrieve (e.g., CONFIG_ESP32C3_DEFAULT_CPU_FREQ_MHZ)" + } + }, + "required": ["project_path"] + } + }, + { + "name": "set_esp_partition", + "description": "Set partition table for ESP-IDF project.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "partition_table": { + "type": "string", + "description": "Partition table file path (e.g., 'partitions.csv' or 'partitions_singleapp.csv')" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path", "partition_table"] + } + }, + { + "name": "setup_project_esp_target", + "description": "Sets up target for an ESP-IDF project before building.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "target": { + "type": "string", + "description": "Lowercase target name, such as 'esp32' or 'esp32c3'." + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path", "target"] + } + }, + { + "name": "create_esp_project", + "description": "Creates a new ESP-IDF project for an ESP chip.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path where new ESP-IDF project will be created." + }, + "project_name": { + "type": "string", + "description": "Name of ESP-IDF project to create." + } + }, + "required": ["project_path", "project_name"] + } + }, + { + "name": "flash_esp_project", + "description": "Flash built firmware to a connected ESP device.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project" + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + } + }, + "required": ["project_path"] + } + }, + { + "name": "list_esp_serial_ports", + "description": "List available serial ports for ESP devices.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "run_esp_idf_install", + "description": "Run install.sh script in ESP-IDF directory to install ESP-IDF dependencies and toolchain.", + "inputSchema": { + "type": "object", + "properties": { + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": [] + } + }, + { + "name": "run_pytest", + "description": "Run pytest tests in a project. Supports pytest-embedded for ESP-IDF/ESP32 testing.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to project directory containing tests" + }, + "test_path": { + "type": "string", + "description": "Path to test file or directory (default: '.', runs all tests)" + }, + "pytest_args": { + "type": "string", + "description": "Additional pytest arguments" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "get_project_info", + "description": "Get detailed information about an ESP-IDF project including components, targets, and configuration.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + } + }, + "required": ["project_path"] + } + }, + { + "name": "list_components", + "description": "List all components in an ESP-IDF project.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + } + }, + "required": ["project_path"] + } + }, + { + "name": "gdb_attach", + "description": "Attach GDB debugger to ESP device. Returns the command to run in an interactive terminal.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "get_core_dump", + "description": "Get core dump information from ESP device for debugging crashes.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "get_heap_info", + "description": "Get heap memory information from ESP device.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "get_task_stats", + "description": "Get FreeRTOS task statistics from ESP device.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project." + }, + "port": { + "type": "string", + "description": "Serial port for ESP device (optional, auto-detect if not provided)" + }, + "idf_path": { + "type": "string", + "description": "Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set." + } + }, + "required": ["project_path"] + } + }, + { + "name": "read_file", + "description": "Read contents of a file in the project.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file to read." + }, + "max_lines": { + "type": "integer", + "description": "Maximum number of lines to read (optional, default: 100)" + } + }, + "required": ["file_path"] + } + }, + { + "name": "write_file", + "description": "Write content to a file in the project.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file to write." + }, + "content": { + "type": "string", + "description": "Content to write to the file." + } + }, + "required": ["file_path", "content"] + } + }, + { + "name": "list_files", + "description": "List files and directories in a project path.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to list files from." + }, + "recursive": { + "type": "boolean", + "description": "List files recursively (default: false)" + }, + "pattern": { + "type": "string", + "description": "Filter files by pattern (e.g., '*.c', '*.h')" + } + }, + "required": ["path"] + } + }, + { + "name": "parse_build_log", + "description": "Parse and analyze build log with structured output for AI analysis. Extracts errors, warnings, and provides fix suggestions.", + "inputSchema": { + "type": "object", + "properties": { + "log_path": { + "type": "string", + "description": "Path to build log file (e.g., 'build_output.txt')" + }, + "project_path": { + "type": "string", + "description": "Project path for context (optional)" + } + }, + "required": ["log_path"] + } + }, + { + "name": "analyze_memory_map", + "description": "Analyze memory usage from .map file. Returns structured JSON with memory regions, symbols, and usage statistics.", + "inputSchema": { + "type": "object", + "properties": { + "map_path": { + "type": "string", + "description": "Path to .map file (e.g., 'build/project_name.map')" + } + }, + "required": ["map_path"] + } + }, + { + "name": "compare_sdkconfig", + "description": "Compare two sdkconfig files and output structured differences with context.", + "inputSchema": { + "type": "object", + "properties": { + "config1_path": { + "type": "string", + "description": "Path to first sdkconfig file" + }, + "config2_path": { + "type": "string", + "description": "Path to second sdkconfig file" + } + }, + "required": ["config1_path", "config2_path"] + } + }, + { + "name": "analyze_dependencies", + "description": "Analyze component dependencies from CMakeLists.txt files. Returns dependency graph and circular dependencies.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to ESP-IDF project" + } + }, + "required": ["project_path"] + } + }, + { + "name": "format_device_log", + "description": "Parse and format device serial logs with structured output. Extracts timestamps, log levels, and key events.", + "inputSchema": { + "type": "object", + "properties": { + "log_path": { + "type": "string", + "description": "Path to device log file" + }, + "filter_level": { + "type": "string", + "description": "Filter by log level: ERROR, WARNING, INFO, DEBUG, VERBOSE (optional)" + } + }, + "required": ["log_path"] + } + } +] + +# ============ Tool Handlers ============ +def handle_build_esp_project(args: dict) -> dict: + """Handle build_esp_project""" + project_path = args.get("project_path", "") + idf_path = args.get("idf_path") + sdkconfig_defaults = args.get("sdkconfig_defaults") + start_time = time.time() - os.chdir(project_path) - export_script = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + log.info(f"Building project at: {project_path}") + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error during setup: {str(e)}" + log.error(error_msg) + return {"error": error_msg} - # Build command with optional sdkconfig_defaults if sdkconfig_defaults and sdkconfig_defaults.strip(): - # Use shlex.quote to properly escape the value for shell quoted_defaults = shlex.quote(sdkconfig_defaults) build_cmd = f"idf.py build -DSDKCONFIG_DEFAULTS={quoted_defaults}" else: build_cmd = "idf.py build" - # Use double quotes for the outer command to allow single quotes in build_cmd - returncode, stdout, stderr = await run_command_async(f'bash -c "source {export_script} && {build_cmd}"') + returncode, stdout, stderr = run_command_async(f'bash -c "source {export_script_bash} && {build_cmd}"') - # Calculate elapsed time elapsed_time = time.time() - start_time elapsed_minutes = int(elapsed_time // 60) elapsed_seconds = elapsed_time % 60 - # Add timing information to stdout timing_info = f"\n\n[Build completed in {elapsed_minutes}m {elapsed_seconds:.2f}s ({elapsed_time:.2f} seconds)]\n" stdout_with_timing = stdout + timing_info - open('mcp-process.log', 'w+').write(str((stdout, stderr))) - logging.warning(f"build result - elapsed: {elapsed_time:.2f}s, return code: {returncode}, stdout: {stdout[:200]}..., stderr: {stderr[:200]}...") - return stdout_with_timing, stderr - - -@mcp.tool() -async def setup_project_esp_target(project_path: str, target: str, idf_path: str = None) -> Tuple[str, str]: - """ - Sets up the target for an ESP-IDF project before building. - - Args: - project_path (str): Path to the ESP-IDF project. - target (str): Lowercase target name, such as 'esp32' or 'esp32c3'. - idf_path: Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set. - - If None or empty: uses IDF_PATH environment variable - - If provided: uses the specified path, allowing different projects to use different ESP-IDF versions. - - Returns: - Tuple[str, str]: A tuple containing the standard output and standard error. - """ - logging.warning(f"setup_project_esp_target called with idf_path={idf_path}, project_path={project_path}, target={target}") - os.chdir(project_path) - # Process idf_path parameter - processed_idf_path = idf_path if (idf_path and idf_path.strip()) else None - logging.warning(f"processed_idf_path={processed_idf_path}") - export_script = get_export_script(processed_idf_path) - returncode, stdout, stderr = await run_command_async(f"bash -c 'source {export_script} && idf.py set-target {target}'") - open('mcp-set-target.log', 'w+').write(str((stdout, stderr))) - logging.warning(f"build result {stdout} {stderr}") - return stdout, stderr - - -@mcp.tool() -async def create_esp_project(project_path: str, project_name: str) -> Tuple[str, str]: - """ - Creates a new ESP-IDF project for an ESP chip. - - Args: - project_path (str): Path where the new ESP-IDF project will be created. - Must be located directly under the current working directory. - project_name (str): Name of the ESP-IDF project to create. - - Returns: - Tuple[str, str]: A tuple containing the standard output and standard error messages. - """ - os.makedirs(project_path, exist_ok=True) - os.chdir(project_path) - export_script = get_export_script() - returncode, stdout, stderr = await run_command_async(f"bash -c 'source {export_script} && idf.py create-project --path {project_path} {project_name}'") - open('mcp-project-root-path.log', 'w+').write(str((stdout, stderr))) - logging.warning(f"build result {stdout} {stderr}") - return stdout, stderr - - -@mcp.tool() -async def flash_esp_project(project_path: str, port: str = None) -> Tuple[str, str]: - """Flash built firmware to a connected ESP device. - - Args: - project_path: Path to the ESP-IDF project - port: Serial port for the ESP device (optional, auto-detect if not provided) - - Returns: - tuple: (stdout, stderr) - Flash logs and any error messages - """ - os.chdir(project_path) - export_script = get_export_script() - - # Build the flash command - if port: - flash_cmd = f"bash -c 'source {export_script} && idf.py -p {port} flash'" - else: - flash_cmd = f"bash -c 'source {export_script} && idf.py flash'" + try: + with open('mcp-process.log', 'w+') as log_file: + log_file.write(str((stdout, stderr))) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Build completed - elapsed: {elapsed_time:.2f}s, return code: {returncode}") + return {"result": f"STDOUT:\n{stdout_with_timing}\nSTDERR:\n{stderr}"} + - returncode, stdout, stderr = await run_command_async(flash_cmd) +def handle_setup_project_esp_target(args: dict) -> dict: + """Handle setup_project_esp_target""" + project_path = args.get("project_path", "") + target = args.get("target", "") + idf_path = args.get("idf_path") + + log.info(f"Setting up target {target} for project at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + processed_idf_path = idf_path if (idf_path and idf_path.strip()) else None + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(processed_idf_path) + returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && idf.py set-target {target}'") + + try: + with open('mcp-set-target.log', 'w+') as log_file: + log_file.write(str((stdout, stderr))) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Target setup completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} - # Log the flash operation - flash_log = f"Flash operation - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" - open('mcp-flash.log', 'w+').write(flash_log) - logging.warning(f"flash result - return code: {returncode}, stdout: {stdout}, stderr: {stderr}") - return stdout, stderr +def handle_create_esp_project(args: dict) -> dict: + """Handle create_esp_project""" + project_path = args.get("project_path", "") + project_name = args.get("project_name", "") + + log.info(f"Creating ESP project: {project_name} at {project_path}") + + try: + os.makedirs(project_path, exist_ok=True) + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script() + # Convert project path to bash-compatible path + from esp_utils import convert_to_bash_path + project_path_bash = convert_to_bash_path(project_path) + returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && idf.py create-project --path {project_path_bash} {project_name}'") + + try: + with open('mcp-project-root-path.log', 'w+') as log_file: + log_file.write(str((stdout, stderr))) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Project creation completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} -@mcp.tool() -async def list_esp_serial_ports() -> Tuple[str, str]: - """List available serial ports for ESP devices. - Returns: - tuple: (stdout, stderr) - Available serial ports and any error messages - """ - returncode, stdout, stderr = await list_serial_ports() +def handle_flash_esp_project(args: dict) -> dict: + """Handle flash_esp_project""" + project_path = args.get("project_path", "") + port = args.get("port") + + log.info(f"Flashing project at {project_path} to port: {port if port else 'auto-detect'}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script() - # Log the port listing operation - port_log = f"Port listing - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" - open('mcp-ports.log', 'w+').write(port_log) - logging.warning(f"port listing result - return code: {returncode}, stdout: {stdout}, stderr: {stderr}") + if port: + flash_cmd = f"bash -c 'source {export_script_bash} && idf.py -p {port} flash'" + else: + flash_cmd = f"bash -c 'source {export_script_bash} && idf.py flash'" - return stdout, stderr + returncode, stdout, stderr = run_command_async(flash_cmd) -@mcp.tool() -async def run_esp_idf_install(idf_path: str = None) -> Tuple[str, str]: - """Run the install.sh script in the ESP-IDF directory to install ESP-IDF dependencies and toolchain. + flash_log = f"Flash operation - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + try: + with open('mcp-flash.log', 'w+') as log_file: + log_file.write(flash_log) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Flash operation completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} - Args: - idf_path: Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set. - - If None or empty: uses IDF_PATH environment variable - - If provided: uses the specified path, allowing different projects to use different ESP-IDF versions. - Returns: - tuple: (stdout, stderr) - Installation logs and any error messages - """ +def handle_list_esp_serial_ports(args: dict) -> dict: + """Handle list_esp_serial_ports""" + log.info("Listing available serial ports") + + returncode, stdout, stderr = list_serial_ports() + + port_log = f"Port listing - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + try: + with open('mcp-ports.log', 'w+') as log_file: + log_file.write(port_log) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Port listing completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + + +def handle_run_esp_idf_install(args: dict) -> dict: + """Handle run_esp_idf_install""" + idf_path = args.get("idf_path") start_time = time.time() + log.info(f"Starting ESP-IDF installation for idf_path: {idf_path}") - # Get ESP-IDF directory path try: esp_idf_dir = get_esp_idf_dir(idf_path if (idf_path and idf_path.strip()) else None) except ValueError as e: error_msg = str(e) - logging.error(f"Failed to get ESP-IDF directory: {error_msg}") - return "", error_msg + log.error(f"Failed to get ESP-IDF directory: {error_msg}") + return {"error": error_msg} - # Build path to install.sh install_script = os.path.join(esp_idf_dir, "install.sh") - # Check if install.sh exists if not os.path.exists(install_script): - error_msg = f"install.sh not found at {install_script}. Please verify the ESP-IDF path is correct." - logging.error(error_msg) - return "", error_msg + error_msg = f"install.sh not found at {install_script}. Please verify that ESP-IDF path is correct." + log.error(error_msg) + return {"error": error_msg} - # Change to ESP-IDF directory and execute install.sh original_dir = os.getcwd() try: os.chdir(esp_idf_dir) - returncode, stdout, stderr = await run_command_async(f"bash {install_script}") + returncode, stdout, stderr = run_command_async(f"bash {install_script}") - # Calculate elapsed time elapsed_time = time.time() - start_time elapsed_minutes = int(elapsed_time // 60) elapsed_seconds = elapsed_time % 60 - # Add timing information to stdout timing_info = f"\n\n[Installation completed in {elapsed_minutes}m {elapsed_seconds:.2f}s ({elapsed_time:.2f} seconds)]\n" stdout_with_timing = stdout + timing_info - # Log the installation operation install_log = f"ESP-IDF installation - Elapsed time: {elapsed_time:.2f}s ({elapsed_minutes}m {elapsed_seconds:.2f}s)\nReturn code: {returncode}\nESP-IDF path: {esp_idf_dir}\nInstall script: {install_script}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" - open('mcp-install.log', 'w+').write(install_log) - logging.warning(f"install.sh result - elapsed: {elapsed_time:.2f}s, return code: {returncode}, stdout: {stdout[:200]}..., stderr: {stderr[:200]}...") - - return stdout_with_timing, stderr + try: + with open('mcp-install.log', 'w+') as log_file: + log_file.write(install_log) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"ESP-IDF installation completed - elapsed: {elapsed_time:.2f}s, return code: {returncode}") + return {"result": f"STDOUT:\n{stdout_with_timing}\nSTDERR:\n{stderr}"} finally: os.chdir(original_dir) -@mcp.tool() -async def run_pytest(project_path: str, test_path: str = ".", pytest_args: str = "", idf_path: str = None) -> Tuple[str, str]: - """Run pytest tests in a project. Supports pytest-embedded for ESP-IDF/ESP32 testing. - - This tool uses pytest-embedded (https://espressif-docs.readthedocs-hosted.com/projects/pytest-embedded/en/latest/), - which is a pytest plugin framework for embedded testing. For ESP-IDF projects, it provides support for running tests - on ESP32, ESP32-C3, ESP32-S3, and other ESP targets. - - Args: - project_path: Path to the project directory containing tests - test_path: Path to test file or directory (default: ".", runs all tests) - pytest_args: Additional pytest arguments. Common options: - - -v: Verbose output - - -k EXPRESSION: Run tests matching the expression - - -m MARKER: Run tests with specific marker - - --target TARGET: Specify ESP target (esp32, esp32c3, esp32s3, esp32c6, esp32h2) - - --sdkconfig PATH: Specify sdkconfig config name - idf_path: Path to ESP-IDF directory. Optional when IDF_PATH environment variable is set. - - If None or empty: uses IDF_PATH environment variable - - If provided: uses the specified path, allowing different projects to use different ESP-IDF versions. - - Returns: - tuple: (stdout, stderr) - Test results and any error messages - """ + +def handle_run_pytest(args: dict) -> dict: + """Handle run_pytest""" + project_path = args.get("project_path", "") + test_path = args.get("test_path", ".") + pytest_args = args.get("pytest_args", "") + idf_path = args.get("idf_path") + + log.info(f"Running pytest for project at {project_path}, test path: {test_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + original_dir = os.getcwd() try: os.chdir(project_path) - # Get ESP-IDF export script - export_script = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) - # Build pytest command with environment setup pytest_cmd = f"pytest {test_path}" if pytest_args: pytest_cmd += f" {pytest_args}" + full_cmd = f"bash -c 'source {export_script_bash} && {pytest_cmd}'" - full_cmd = f"bash -c 'source {export_script} && {pytest_cmd}'" - - returncode, stdout, stderr = await run_command_async(full_cmd) + returncode, stdout, stderr = run_command_async(full_cmd) - # Log the pytest operation pytest_log = f"Pytest execution - Return code: {returncode}\nCommand: {full_cmd}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" - open('mcp-pytest.log', 'w+').write(pytest_log) - logging.warning(f"pytest result - return code: {returncode}, stdout: {stdout}, stderr: {stderr}") - - return stdout, stderr + try: + with open('mcp-pytest.log', 'w+') as log_file: + log_file.write(pytest_log) + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Pytest completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} finally: os.chdir(original_dir) -if __name__ == '__main__': - mcp.run(transport='stdio') + +def handle_clean_esp_project(args: dict) -> dict: + """Handle clean_esp_project""" + project_path = args.get("project_path", "") + full_clean = args.get("full_clean", False) + idf_path = args.get("idf_path") + + log.info(f"Cleaning ESP project at {project_path} (full_clean={full_clean})") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + clean_cmd = "idf.py fullclean" if full_clean else "idf.py clean" + returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && {clean_cmd}'") + + try: + with open('mcp-clean.log', 'w+') as log_file: + log_file.write(f"Clean operation - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Clean completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_erase_flash_esp(args: dict) -> dict: + """Handle erase_flash_esp""" + project_path = args.get("project_path", "") + port = args.get("port") + baud = args.get("baud", 460800) + idf_path = args.get("idf_path") + + log.info(f"Erasing flash on ESP device at {project_path} on port {port}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + erase_cmd = f"bash -c 'source {export_script_bash} && idf.py erase-flash" + if port: + erase_cmd += f" -p {port}" + erase_cmd += f" --baud {baud}'" + + returncode, stdout, stderr = run_command_async(erase_cmd) + + try: + with open('mcp-erase.log', 'w+') as log_file: + log_file.write(f"Erase flash - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Erase flash completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_monitor_esp(args: dict) -> dict: + """Handle monitor_esp""" + project_path = args.get("project_path", "") + port = args.get("port") + baud = args.get("baud", 115200) + idf_path = args.get("idf_path") + + log.info(f"Starting monitor for ESP device at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Monitor requires interaction, so we'll note this limitation + monitor_cmd = f"bash -c 'source {export_script_bash} && idf.py monitor" + if port: + monitor_cmd += f" -p {port}" + monitor_cmd += f" --baud {baud}'" + + return {"result": "Monitor command requires interactive terminal. Please run manually:\n" + monitor_cmd} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_flash_and_monitor_esp(args: dict) -> dict: + """Handle flash_and_monitor_esp""" + project_path = args.get("project_path", "") + port = args.get("port") + baud = args.get("baud") + idf_path = args.get("idf_path") + + log.info(f"Flashing and monitoring ESP device at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Flash and monitor requires interaction, so we'll note this limitation + cmd = f"bash -c 'source {export_script_bash} && idf.py flash monitor" + if port: + cmd += f" -p {port}" + if baud: + cmd += f" --baud {baud}'" + + return {"result": "Flash and monitor command requires interactive terminal. Please run manually:\n" + cmd} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_menuconfig_esp(args: dict) -> dict: + """Handle menuconfig_esp""" + project_path = args.get("project_path", "") + idf_path = args.get("idf_path") + + log.info(f"Starting menuconfig for ESP project at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Menuconfig requires interactive terminal + cmd = f"bash -c 'source {export_script_bash} && idf.py menuconfig'" + return {"result": "Menuconfig requires interactive terminal. Please run manually:\n" + cmd} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_esp_idf_version(args: dict) -> dict: + """Handle get_esp_idf_version""" + idf_path = args.get("idf_path") + + log.info(f"Getting ESP-IDF version for idf_path: {idf_path}") + + try: + esp_idf_dir = get_esp_idf_dir(idf_path if (idf_path and idf_path.strip()) else None) + version_file = os.path.join(esp_idf_dir, "version.txt") + + if os.path.exists(version_file): + with open(version_file, 'r') as f: + version = f.read().strip() + return {"result": f"ESP-IDF Version: {version}\nPath: {esp_idf_dir}"} + else: + # Try to get version from git + original_dir = os.getcwd() + try: + os.chdir(esp_idf_dir) + returncode, stdout, stderr = run_command_async("git describe --tags --always") + if returncode == 0: + version = stdout.strip() + return {"result": f"ESP-IDF Version (git): {version}\nPath: {esp_idf_dir}"} + else: + return {"result": f"ESP-IDF Path: {esp_idf_dir}\nNote: Could not determine version automatically"} + finally: + os.chdir(original_dir) + except ValueError as e: + error_msg = str(e) + log.error(f"Failed to get ESP-IDF directory: {error_msg}") + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_check_esp_idf_env(args: dict) -> dict: + """Handle check_esp_idf_env""" + idf_path = args.get("idf_path") + + log.info("Checking ESP-IDF environment") + + try: + from esp_utils import check_esp_idf_installed + esp_idf_dir = get_esp_idf_dir(idf_path if (idf_path and idf_path.strip()) else None) + is_installed = check_esp_idf_installed(esp_idf_dir) + + # Check for key files + export_script = os.path.join(esp_idf_dir, "export.sh") + install_script = os.path.join(esp_idf_dir, "install.sh") + + env_info = { + "ESP-IDF Path": esp_idf_dir, + "Installed": is_installed, + "export.sh exists": os.path.exists(export_script), + "install.sh exists": os.path.exists(install_script), + "IDF_PATH env var": os.environ.get("IDF_PATH", "Not set") + } + + # Format as text + result_text = "\n".join([f"{k}: {v}" for k, v in env_info.items()]) + return {"result": result_text} + except ValueError as e: + error_msg = str(e) + log.error(f"Failed to check ESP-IDF environment: {error_msg}") + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_project_config(args: dict) -> dict: + """Handle get_project_config""" + project_path = args.get("project_path", "") + config_key = args.get("config_key") + + log.info(f"Getting project config from {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + sdkconfig_path = os.path.join(project_path, "sdkconfig") + build_sdkconfig_path = os.path.join(project_path, "build", "config", "sdkconfig.h") + + # Check sdkconfig.h in build directory (more reliable) + if os.path.exists(build_sdkconfig_path): + config_file = build_sdkconfig_path + elif os.path.exists(sdkconfig_path): + config_file = sdkconfig_path + else: + return {"error": f"Neither sdkconfig nor build/config/sdkconfig.h found in {project_path}"} + + if config_key: + # Search for specific key + with open(config_file, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + for line in lines: + if config_key in line and '#define' in line: + return {"result": line.strip()} + return {"error": f"Config key '{config_key}' not found"} + else: + # Return first 100 lines of config + with open(config_file, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines()[:100] + return {"result": f"Project Configuration ({config_file}):\n" + "".join(lines)} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_set_esp_partition(args: dict) -> dict: + """Handle set_esp_partition""" + project_path = args.get("project_path", "") + partition_table = args.get("partition_table", "") + idf_path = args.get("idf_path") + + log.info(f"Setting partition table {partition_table} for project at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + if not partition_table: + error_msg = "Partition table file is required" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Set partition table via menuconfig or directly + # Using idf.py with partition table option + cmd = f"bash -c 'source {export_script_bash} && idf.py set-partition-table {partition_table}'" + + returncode, stdout, stderr = run_command_async(cmd) + + try: + with open('mcp-partition.log', 'w+') as log_file: + log_file.write(f"Partition table set - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Partition table set completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_project_info(args: dict) -> dict: + """Handle get_project_info""" + project_path = args.get("project_path", "") + + log.info(f"Getting project info from {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + info = { + "Project Path": project_path, + "CMakeLists.txt exists": os.path.exists(os.path.join(project_path, "CMakeLists.txt")), + "sdkconfig exists": os.path.exists(os.path.join(project_path, "sdkconfig")), + "main directory exists": os.path.exists(os.path.join(project_path, "main")), + "components directory exists": os.path.exists(os.path.join(project_path, "components")), + } + + # Try to get target from sdkconfig + sdkconfig_path = os.path.join(project_path, "sdkconfig") + if os.path.exists(sdkconfig_path): + try: + with open(sdkconfig_path, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + if 'CONFIG_IDF_TARGET' in line and '=' in line: + target = line.split('=')[1].strip().strip('"') + info["Target"] = target + break + except: + pass + + # List components + main_dir = os.path.join(project_path, "main") + components_dir = os.path.join(project_path, "components") + + main_files = [] + if os.path.exists(main_dir): + main_files = [f for f in os.listdir(main_dir) if f.endswith(('.c', '.cpp', '.h', '.hpp'))] + + components = [] + if os.path.exists(components_dir): + components = [d for d in os.listdir(components_dir) if os.path.isdir(os.path.join(components_dir, d))] + + info["Main files"] = f"{len(main_files)} files" + info["Components"] = components if components else "None" + + result_text = "\n".join([f"{k}: {v}" for k, v in info.items()]) + return {"result": result_text} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_list_components(args: dict) -> dict: + """Handle list_components""" + project_path = args.get("project_path", "") + + log.info(f"Listing components in {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + components_info = [] + + # Check main component + main_dir = os.path.join(project_path, "main") + if os.path.exists(main_dir): + main_files = [f for f in os.listdir(main_dir) if f.endswith(('.c', '.cpp', '.h', '.hpp'))] + components_info.append(f"main ({len(main_files)} files)") + + # Check components directory + components_dir = os.path.join(project_path, "components") + if os.path.exists(components_dir): + for comp_name in os.listdir(components_dir): + comp_path = os.path.join(components_dir, comp_name) + if os.path.isdir(comp_path): + comp_files = [] + for root, dirs, files in os.walk(comp_path): + comp_files.extend([f for f in files if f.endswith(('.c', '.cpp', '.h', '.hpp'))]) + components_info.append(f"{comp_name} ({len(comp_files)} files)") + + # Check managed components + managed_dir = os.path.join(project_path, "managed_components") + if os.path.exists(managed_dir): + managed_comps = [d for d in os.listdir(managed_dir) if os.path.isdir(os.path.join(managed_dir, d))] + if managed_comps: + components_info.append(f"\nManaged Components: {len(managed_comps)}") + for comp in managed_comps[:10]: # Limit to first 10 + components_info.append(f" - {comp}") + + result = "Project Components:\n" + "\n".join(components_info) + return {"result": result} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_gdb_attach(args: dict) -> dict: + """Handle gdb_attach""" + project_path = args.get("project_path", "") + port = args.get("port") + idf_path = args.get("idf_path") + + log.info(f"Preparing GDB attach for project at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Build GDB command + gdb_cmd = f"bash -c 'source {export_script_bash} && idf.py gdb" + if port: + gdb_cmd += f" -p {port}" + gdb_cmd += "'" + + instructions = ( + "GDB Debugger Attach\n" + "===================\n\n" + "To attach GDB to your ESP device, run the following command in an interactive terminal:\n\n" + f"{gdb_cmd}\n\n" + "Alternatively, you can:\n" + "1. Start OpenOCD in one terminal: idf.py openocd\n" + "2. Start GDB in another terminal: idf.py gdb\n\n" + "Note: GDB requires an interactive terminal for proper operation." + ) + + return {"result": instructions} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_core_dump(args: dict) -> dict: + """Handle get_core_dump""" + project_path = args.get("project_path", "") + port = args.get("port") + idf_path = args.get("idf_path") + + log.info(f"Getting core dump from ESP device at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # get_export_script already returns bash-compatible path on Windows + export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Check if core dump is enabled + sdkconfig_path = os.path.join(project_path, "sdkconfig") + core_dump_enabled = False + + if os.path.exists(sdkconfig_path): + try: + with open(sdkconfig_path, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + if 'CONFIG_ESP_COREDUMP_ENABLE' in line and '=y' in line: + core_dump_enabled = True + break + except: + pass + + if not core_dump_enabled: + return {"result": "Core dump is not enabled in sdkconfig. To enable:\n1. Run: idf.py menuconfig\n2. Navigate to Component config -> Core to Core communication\n3. Enable 'Enable Core Dump'\n4. Save and rebuild the project"} + + # Get core dump (export_script_bash already converted) + core_cmd = f"bash -c 'source {export_script_bash} && idf.py coredump-info" + if port: + core_cmd += f" -p {port}" + core_cmd += "'" + + returncode, stdout, stderr = run_command_async(core_cmd) + + try: + with open('mcp-coredump.log', 'w+') as log_file: + log_file.write(f"Core dump info - Return code: {returncode}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") + except Exception as e: + logger.warning(f"Failed to write log file: {e}") + + log.info(f"Core dump info completed - return code: {returncode}") + return {"result": f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}"} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_heap_info(args: dict) -> dict: + """Handle get_heap_info""" + project_path = args.get("project_path", "") + port = args.get("port") + idf_path = args.get("idf_path") + + log.info(f"Getting heap info from ESP device at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # Note: get_export_script already returns bash-compatible path on Windows + _ = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + # Send heap info command via esp-idf monitor + # Note: This is a simplified version - actual implementation requires proper terminal handling + instructions = ( + "Heap Memory Information\n" + "=======================\n\n" + "To get heap information from your ESP device:\n\n" + "1. Run monitor: idf.py monitor\n" + "2. Type the following command in monitor:\n" + " 'heap info' (or 'heap caps')\n\n" + "Available heap commands:\n" + "- heap info: Show heap summary\n" + "- heap caps: Show heap capabilities\n" + "- heap tasks: Show heap per task\n\n" + "Note: Monitor requires interactive terminal." + ) + + return {"result": instructions} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_get_task_stats(args: dict) -> dict: + """Handle get_task_stats""" + project_path = args.get("project_path", "") + port = args.get("port") + idf_path = args.get("idf_path") + + log.info(f"Getting task stats from ESP device at {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + os.chdir(project_path) + # Note: get_export_script already returns bash-compatible path on Windows + _ = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) + + instructions = ( + "FreeRTOS Task Statistics\n" + "=========================\n\n" + "To get task statistics from your ESP device:\n\n" + "1. Run monitor: idf.py monitor\n" + "2. Type the following command in monitor:\n" + " 'task stats'\n\n" + "Available task commands:\n" + "- task stats: Show all task statistics\n" + "- task list: Show task list\n" + "- task watch : Watch a specific task\n\n" + "Note: Monitor requires interactive terminal." + ) + + return {"result": instructions} + except (ValueError, FileNotFoundError) as e: + error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_read_file(args: dict) -> dict: + """Handle read_file""" + file_path = args.get("file_path", "") + max_lines = args.get("max_lines", 100) + + log.info(f"Reading file: {file_path}") + + if not file_path or not os.path.exists(file_path): + error_msg = f"File does not exist: {file_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + # Limit lines + if max_lines and len(lines) > max_lines: + lines = lines[:max_lines] + result = "".join(lines) + result += f"\n\n... ({len(lines) - max_lines} more lines hidden, use max_lines parameter to read more)" + else: + result = "".join(lines) + + return {"result": f"File: {file_path}\nLines: {len(lines)}\n\n{result}"} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_write_file(args: dict) -> dict: + """Handle write_file""" + file_path = args.get("file_path", "") + content = args.get("content", "") + + log.info(f"Writing file: {file_path}") + + if not file_path: + error_msg = "File path is required" + log.error(error_msg) + return {"error": error_msg} + + if content is None: + error_msg = "Content is required" + log.error(error_msg) + return {"error": error_msg} + + try: + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + return {"result": f"Successfully wrote {len(content)} characters to {file_path}"} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_list_files(args: dict) -> dict: + """Handle list_files""" + path = args.get("path", ".") + recursive = args.get("recursive", False) + pattern = args.get("pattern", "") + + log.info(f"Listing files in: {path}") + + if not path or not os.path.exists(path): + error_msg = f"Path does not exist: {path}" + log.error(error_msg) + return {"error": error_msg} + + try: + files_info = [] + + if recursive: + # Recursively list files + for root, dirs, files in os.walk(path): + for file in files: + file_path = os.path.join(root, file) + if not pattern or file.endswith(pattern): + rel_path = os.path.relpath(file_path, path) + files_info.append(rel_path) + else: + # List only top-level files + for item in os.listdir(path): + item_path = os.path.join(path, item) + is_dir = os.path.isdir(item_path) + + if not pattern or item.endswith(pattern): + files_info.append(f"[{'DIR' if is_dir else 'FILE'}] {item}") + + result = f"Path: {path}\nRecursive: {recursive}\nPattern: {pattern if pattern else 'None'}\n\n" + result += f"Found {len(files_info)} items:\n" + result += "\n".join(sorted(files_info)) + + return {"result": result} + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +# ============ Structured Analysis Tool Handlers ============ +def handle_parse_build_log(args: dict) -> dict: + """Handle parse_build_log - Parse and analyze build log with structured output""" + log_path = args.get("log_path", "") + project_path = args.get("project_path", "") + + log.info(f"Parsing build log: {log_path}") + + if not log_path or not os.path.exists(log_path): + error_msg = f"Build log file does not exist: {log_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + errors = [] + warnings = [] + info_messages = [] + + for i, line in enumerate(lines, 1): + line_lower = line.lower() + + # Detect errors + if any(keyword in line_lower for keyword in ['error:', 'error ', 'failed', 'undefined reference', 'multiple definition']): + errors.append({ + "line": i, + "message": line.strip(), + "type": "error" + }) + + # Detect warnings + elif any(keyword in line_lower for keyword in ['warning:', 'warning ', 'deprecated', 'unused']): + warnings.append({ + "line": i, + "message": line.strip(), + "type": "warning" + }) + + # Collect useful info + elif any(keyword in line_lower for keyword in ['building', 'linking', 'generating', 'project build complete']): + info_messages.append({ + "line": i, + "message": line.strip(), + "type": "info" + }) + + # Generate structured JSON output + result = { + "log_path": log_path, + "project_path": project_path if project_path else "not specified", + "summary": { + "total_lines": len(lines), + "errors_count": len(errors), + "warnings_count": len(warnings), + "info_count": len(info_messages) + }, + "errors": errors[:20], # Limit to first 20 errors + "warnings": warnings[:20], # Limit to first 20 warnings + "info": info_messages[:10], # Limit to first 10 info messages + "has_errors": len(errors) > 0, + "has_warnings": len(warnings) > 0 + } + + # Add suggestions if there are errors + if errors: + error_types = set() + for err in errors: + msg = err["message"].lower() + if 'undefined reference' in msg: + error_types.add("linking_error") + elif 'multiple definition' in msg: + error_types.add("linking_error") + elif 'syntax error' in msg: + error_types.add("syntax_error") + elif 'no such file' in msg: + error_types.add("file_not_found") + + result["suggestions"] = [] + if "linking_error" in error_types: + result["suggestions"].append("Check for missing source files in CMakeLists.txt") + result["suggestions"].append("Ensure all required libraries are linked") + if "syntax_error" in error_types: + result["suggestions"].append("Review syntax errors in source files") + if "file_not_found" in error_types: + result["suggestions"].append("Verify all file paths are correct") + + return {"result": json.dumps(result, indent=2, ensure_ascii=False)} + + except Exception as e: + error_msg = f"Unexpected error parsing build log: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_analyze_memory_map(args: dict) -> dict: + """Handle analyze_memory_map - Analyze memory usage from .map file""" + map_path = args.get("map_path", "") + + log.info(f"Analyzing memory map: {map_path}") + + if not map_path or not os.path.exists(map_path): + error_msg = f"Memory map file does not exist: {map_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + with open(map_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + memory_regions = [] + symbols = [] + total_size = 0 + + for line in lines: + # Detect memory regions (format: .text 0x00000000 0x00010000) + if line.startswith('.') and '0x' in line: + parts = line.split() + if len(parts) >= 3: + region_name = parts[0] + try: + addr = int(parts[1], 16) + size = int(parts[2], 16) + memory_regions.append({ + "name": region_name, + "address": f"0x{addr:08X}", + "size": size, + "size_kb": size / 1024 + }) + total_size += size + except (ValueError, IndexError): + pass + + # Detect symbols (format: function_name 0x00000000 0x10) + elif line.strip() and not line.startswith('.') and '0x' in line: + parts = line.split() + if len(parts) >= 2: + try: + addr = int(parts[-2], 16) if len(parts) >= 2 else 0 + size = int(parts[-1], 16) if len(parts) >= 3 else 0 + symbol_name = ' '.join(parts[:-2]) if len(parts) > 3 else parts[0] + symbols.append({ + "name": symbol_name, + "address": f"0x{addr:08X}", + "size": size + }) + except (ValueError, IndexError): + pass + + # Sort symbols by size (largest first) + symbols_sorted = sorted(symbols, key=lambda x: x["size"], reverse=True)[:50] + + # Calculate usage statistics + result = { + "map_path": map_path, + "summary": { + "total_regions": len(memory_regions), + "total_symbols": len(symbols), + "total_size": total_size, + "total_size_kb": total_size / 1024 + }, + "memory_regions": memory_regions, + "top_symbols": symbols_sorted, + "analysis": { + "largest_region": max(memory_regions, key=lambda x: x["size"]) if memory_regions else None, + "smallest_region": min(memory_regions, key=lambda x: x["size"]) if memory_regions else None + } + } + + return {"result": json.dumps(result, indent=2, ensure_ascii=False)} + + except Exception as e: + error_msg = f"Unexpected error analyzing memory map: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def handle_compare_sdkconfig(args: dict) -> dict: + """Handle compare_sdkconfig - Compare two sdkconfig files""" + config1_path = args.get("config1_path", "") + config2_path = args.get("config2_path", "") + + log.info(f"Comparing sdkconfig files: {config1_path} vs {config2_path}") + + if not config1_path or not os.path.exists(config1_path): + error_msg = f"First sdkconfig file does not exist: {config1_path}" + log.error(error_msg) + return {"error": error_msg} + + if not config2_path or not os.path.exists(config2_path): + error_msg = f"Second sdkconfig file does not exist: {config2_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + # Parse config files + def parse_config(path): + config = {} + with open(path, 'r', encoding='utf-8', errors='replace') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key.strip()] = value.strip().strip('"') + return config + + config1 = parse_config(config1_path) + config2 = parse_config(config2_path) + + # Find differences + all_keys = set(config1.keys()) | set(config2.keys()) + + added = [] + removed = [] + modified = [] + + for key in sorted(all_keys): + if key not in config1: + added.append({ + "key": key, + "value": config2[key], + "category": _categorize_config(key) + }) + elif key not in config2: + removed.append({ + "key": key, + "value": config1[key], + "category": _categorize_config(key) + }) + elif config1[key] != config2[key]: + modified.append({ + "key": key, + "old_value": config1[key], + "new_value": config2[key], + "category": _categorize_config(key), + "recommendation": _config_recommendation(key, config1[key], config2[key]) + }) + + result = { + "config1_path": config1_path, + "config2_path": config2_path, + "summary": { + "total_keys": len(all_keys), + "added_count": len(added), + "removed_count": len(removed), + "modified_count": len(modified) + }, + "added": added[:50], # Limit to first 50 + "removed": removed[:50], + "modified": modified[:100] # Limit to first 100 + } + + return {"result": json.dumps(result, indent=2, ensure_ascii=False)} + + except Exception as e: + error_msg = f"Unexpected error comparing sdkconfig files: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def _categorize_config(key: str) -> str: + """Categorize sdkconfig key""" + key_lower = key.lower() + if 'wifi' in key_lower: + return "WiFi" + elif 'ble' in key_lower or 'bluetooth' in key_lower: + return "Bluetooth" + elif 'cpu' in key_lower or 'freq' in key_lower: + return "Performance" + elif 'heap' in key_lower or 'memory' in key_lower: + return "Memory" + elif 'log' in key_lower or 'debug' in key_lower: + return "Debug" + else: + return "General" + + +def _config_recommendation(key: str, old_val: str, new_val: str) -> str: + """Generate recommendation for config change""" + if 'freq' in key.lower(): + if int(new_val) > int(old_val): + return "Increasing CPU frequency improves performance but increases power consumption" + else: + return "Decreasing CPU frequency saves power but reduces performance" + elif 'heap' in key.lower(): + return "Heap size change may affect available memory for tasks" + elif 'log' in key.lower(): + if new_val == 'n': + return "Disabling logs saves flash space but makes debugging harder" + else: + return "Enabling logs helps debugging but uses more flash space" + return "" + + +def handle_analyze_dependencies(args: dict) -> dict: + """Handle analyze_dependencies - Analyze component dependencies""" + project_path = args.get("project_path", "") + + log.info(f"Analyzing dependencies for project: {project_path}") + + if not project_path or not os.path.exists(project_path): + error_msg = f"Project path does not exist: {project_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + # Find all CMakeLists.txt files + cmake_files = [] + for root, dirs, files in os.walk(project_path): + if 'CMakeLists.txt' in files: + cmake_files.append(os.path.join(root, 'CMakeLists.txt')) + + # Parse dependencies + dependencies = {} # component -> list of dependencies + all_components = set() + + for cmake_file in cmake_files: + component_dir = os.path.dirname(cmake_file) + component_name = os.path.basename(component_dir) + all_components.add(component_name) + + deps = [] + with open(cmake_file, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + # Find REQUIRES lines + import re + requires_matches = re.findall(r'REQUIRES\s+([^\s\n]+)', content) + deps.extend(requires_matches) + + # Find PRIV_REQUIRES lines + priv_requires_matches = re.findall(r'PRIV_REQUIRES\s+([^\s\n]+)', content) + deps.extend([f"{d} (private)" for d in priv_requires_matches]) + + if deps: + dependencies[component_name] = deps + + # Detect circular dependencies + circular_deps = _detect_circular_deps(dependencies) + + # Calculate dependency depth + max_depth = _calculate_max_depth(dependencies) + + # Build component info + component_info = [] + for comp in sorted(all_components): + comp_deps = dependencies.get(comp, []) + component_info.append({ + "name": comp, + "dependencies": comp_deps, + "dependency_count": len(comp_deps), + "is_leaf": len(comp_deps) == 0, + "has_circular": comp in circular_deps + }) + + result = { + "project_path": project_path, + "summary": { + "total_components": len(all_components), + "components_with_deps": len(dependencies), + "circular_dependencies": len(circular_deps), + "max_dependency_depth": max_depth + }, + "components": component_info, + "circular_dependencies": circular_deps, + "recommendations": [] + } + + # Add recommendations + if circular_deps: + result["recommendations"].append("Circular dependencies detected - this may cause build issues") + if max_depth > 5: + result["recommendations"].append(f"Deep dependency chain detected (depth {max_depth}) - consider refactoring") + + return {"result": json.dumps(result, indent=2, ensure_ascii=False)} + + except Exception as e: + error_msg = f"Unexpected error analyzing dependencies: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +def _detect_circular_deps(deps: dict) -> list: + """Detect circular dependencies using DFS""" + visited = set() + rec_stack = set() + circular = [] + + def dfs(node, path): + if node in rec_stack: + # Found cycle + cycle_start = path.index(node) + cycle = path[cycle_start:] + [node] + circular.append(cycle) + return True + if node in visited: + return False + + visited.add(node) + rec_stack.add(node) + + for dep in deps.get(node, []): + # Remove (private) suffix for dependency tracking + dep_clean = dep.replace(' (private)', '').strip() + dfs(dep_clean, path + [node]) + + rec_stack.remove(node) + return False + + for node in deps: + dfs(node, []) + + return circular + + +def _calculate_max_depth(deps: dict) -> int: + """Calculate maximum dependency depth""" + memo = {} + + def get_depth(node): + if node in memo: + return memo[node] + + node_deps = deps.get(node, []) + if not node_deps: + return 0 + + max_child_depth = 0 + for dep in node_deps: + dep_clean = dep.replace(' (private)', '').strip() + child_depth = get_depth(dep_clean) + max_child_depth = max(max_child_depth, child_depth) + + memo[node] = max_child_depth + 1 + return memo[node] + + max_depth = 0 + for node in deps: + max_depth = max(max_depth, get_depth(node)) + + return max_depth + + +def handle_format_device_log(args: dict) -> dict: + """Handle format_device_log - Parse and format device logs""" + log_path = args.get("log_path", "") + filter_level = args.get("filter_level", "").upper() + + log.info(f"Formatting device log: {log_path}, filter: {filter_level}") + + if not log_path or not os.path.exists(log_path): + error_msg = f"Device log file does not exist: {log_path}" + log.error(error_msg) + return {"error": error_msg} + + try: + with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + # Parse log entries + log_entries = [] + level_counts = {"ERROR": 0, "WARNING": 0, "INFO": 0, "DEBUG": 0, "VERBOSE": 0, "OTHER": 0} + + for line in lines: + line_stripped = line.strip() + if not line_stripped: + continue + + # Detect log level + level = "OTHER" + for lvl in ["ERROR", "WARNING", "INFO", "DEBUG", "VERBOSE"]: + if lvl in line_stripped: + level = lvl + break + + level_counts[level] += 1 + + # Detect timestamp (ESP-IDF format: I (123) log_tag: message) + timestamp = "" + tag = "" + message = line_stripped + + import re + # Match ESP-IDF log format: I (123) tag: message + match = re.match(r'([EIWDV]) \((\d+)\) ([^:]+): (.+)', line_stripped) + if match: + level_char = match.group(1) + timestamp = match.group(2) + tag = match.group(3) + message = match.group(4) + + # Map level char to name + level_map = {'E': 'ERROR', 'W': 'WARNING', 'I': 'INFO', 'D': 'DEBUG', 'V': 'VERBOSE'} + level = level_map.get(level_char, level) + + # Detect crashes + is_crash = any(keyword in line_stripped.lower() for keyword in + ['assert', 'abort', 'guru', 'panic', 'stack trace', 'backtrace']) + + # Detect errors + is_error = level == "ERROR" or "error" in line_stripped.lower() + + entry = { + "level": level, + "timestamp": timestamp, + "tag": tag, + "message": message, + "is_crash": is_crash, + "is_error": is_error + } + + # Apply filter + if not filter_level or level == filter_level or filter_level == "": + log_entries.append(entry) + + # Find crashes and errors + crashes = [e for e in log_entries if e["is_crash"]] + errors = [e for e in log_entries if e["is_error"]] + + # Generate recommendations + recommendations = [] + if crashes: + recommendations.append(f"Found {len(crashes)} crash(es) - review stack traces") + if errors: + recommendations.append(f"Found {len(errors)} error(s) - review error messages") + if level_counts["ERROR"] > 10: + recommendations.append("High error count detected - investigate error patterns") + + result = { + "log_path": log_path, + "summary": { + "total_lines": len(lines), + "total_entries": len(log_entries), + "level_counts": level_counts, + "crashes_count": len(crashes), + "errors_count": len(errors) + }, + "entries": log_entries[:200], # Limit to first 200 entries + "crashes": crashes[:10], # Limit to first 10 crashes + "errors": errors[:20], # Limit to first 20 errors + "recommendations": recommendations + } + + return {"result": json.dumps(result, indent=2, ensure_ascii=False)} + + except Exception as e: + error_msg = f"Unexpected error formatting device log: {str(e)}" + log.error(error_msg) + return {"error": error_msg} + + +# ============ MCP Server ============ +class ESPMCPServer: + """Main MCP Server implementation (synchronous, like cheat-engine)""" + + def __init__(self): + self.request_count = 0 + + def execute_tool(self, name: str, args: dict) -> dict: + """Execute a tool by name""" + handlers = { + "build_esp_project": handle_build_esp_project, + "clean_esp_project": handle_clean_esp_project, + "erase_flash_esp": handle_erase_flash_esp, + "monitor_esp": handle_monitor_esp, + "flash_and_monitor_esp": handle_flash_and_monitor_esp, + "menuconfig_esp": handle_menuconfig_esp, + "get_esp_idf_version": handle_get_esp_idf_version, + "check_esp_idf_env": handle_check_esp_idf_env, + "get_project_config": handle_get_project_config, + "set_esp_partition": handle_set_esp_partition, + "setup_project_esp_target": handle_setup_project_esp_target, + "create_esp_project": handle_create_esp_project, + "flash_esp_project": handle_flash_esp_project, + "list_esp_serial_ports": handle_list_esp_serial_ports, + "run_esp_idf_install": handle_run_esp_idf_install, + "run_pytest": handle_run_pytest, + "get_project_info": handle_get_project_info, + "list_components": handle_list_components, + "gdb_attach": handle_gdb_attach, + "get_core_dump": handle_get_core_dump, + "get_heap_info": handle_get_heap_info, + "get_task_stats": handle_get_task_stats, + "read_file": handle_read_file, + "write_file": handle_write_file, + "list_files": handle_list_files, + "parse_build_log": handle_parse_build_log, + "analyze_memory_map": handle_analyze_memory_map, + "compare_sdkconfig": handle_compare_sdkconfig, + "analyze_dependencies": handle_analyze_dependencies, + "format_device_log": handle_format_device_log, + } + + handler = handlers.get(name) + if not handler: + return {"error": f"Unknown tool: {name}"} + + try: + result = handler(args) + return result + except Exception as e: + log.error(f"Tool execution error: {traceback.format_exc()}") + return {"error": str(e)} + + def handle_request(self, req: dict) -> Optional[dict]: + """Handle incoming MCP request""" + method = req.get("method", "") + req_id = req.get("id") + params = req.get("params", {}) + + if method == "initialize": + return self._handle_initialize(req_id) + elif method == "notifications/initialized": + return None + elif method == "tools/list": + return self._handle_tools_list(req_id) + elif method == "tools/call": + return self._handle_tools_call(req_id, params) + else: + return self._error_response(req_id, -32601, f"Method not found: {method}") + + def _handle_initialize(self, req_id) -> dict: + """Handle initialize request""" + # Dynamically get ESP-IDF path from environment variable + idf_path = os.environ.get("IDF_PATH", "") + if idf_path: + # Normalize path for display + normalized_path = os.path.abspath(idf_path).replace('\\', '/') + path_info = f"\nESP-IDF path detected: {normalized_path}" + else: + path_info = "\nNote: ESP-IDF path not detected in environment. Please configure IDF_PATH in MCP settings." + + # Base instructions + base_instructions = ( + "# ESP MCP Server\n\n" + "Available tools:\n" + "- build_esp_project: Build an ESP-IDF project\n" + "- clean_esp_project: Clean build files from an ESP-IDF project\n" + "- erase_flash_esp: Erase flash memory on ESP device\n" + "- monitor_esp: Monitor serial output from ESP device\n" + "- flash_and_monitor_esp: Flash firmware and immediately monitor serial output\n" + "- menuconfig_esp: Run menuconfig to configure ESP-IDF project\n" + "- get_esp_idf_version: Get ESP-IDF version information\n" + "- check_esp_idf_env: Check ESP-IDF environment status and configuration\n" + "- get_project_config: Get project configuration information (sdkconfig)\n" + "- set_esp_partition: Set partition table for ESP-IDF project\n" + "- setup_project_esp_target: Set up target for an ESP-IDF project\n" + "- create_esp_project: Create a new ESP-IDF project\n" + "- flash_esp_project: Flash built firmware to a connected ESP device\n" + "- list_esp_serial_ports: List available serial ports for ESP devices\n" + "- run_esp_idf_install: Run install.sh script in ESP-IDF directory\n" + "- run_pytest: Run pytest tests in a project\n" + "- get_project_info: Get detailed information about an ESP-IDF project\n" + "- list_components: List all components in an ESP-IDF project\n" + "- gdb_attach: Attach GDB debugger to ESP device\n" + "- get_core_dump: Get core dump information from ESP device\n" + "- get_heap_info: Get heap memory information from ESP device\n" + "- get_task_stats: Get FreeRTOS task statistics from ESP device\n" + "- read_file: Read contents of a file in the project\n" + "- write_file: Write content to a file in the project\n" + "- list_files: List files and directories in a project path\n" + "- parse_build_log: Parse and analyze build log with structured output for AI analysis\n" + "- analyze_memory_map: Analyze memory usage from .map file\n" + "- compare_sdkconfig: Compare two sdkconfig files and output structured differences\n" + "- analyze_dependencies: Analyze component dependencies from CMakeLists.txt files\n" + "- format_device_log: Parse and format device serial logs with structured output\n" + ) + + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "esp-mcp", "version": "1.0.0"}, + "instructions": base_instructions + path_info + } + } + + def _handle_tools_list(self, req_id) -> dict: + """Handle tools/list request""" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"tools": TOOLS} + } + + def _handle_tools_call(self, req_id, params: dict) -> dict: + """Handle tools/call request""" + tool_name = params.get("name", "") + tool_args = params.get("arguments", {}) + + result = self.execute_tool(tool_name, tool_args) + + is_error = "error" in result + text = f"Error: {result['error']}" if is_error else json.dumps(result.get("result", result), ensure_ascii=False, indent=2) + + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": text}], + "isError": is_error + } + } + + def _error_response(self, req_id, code: int, message: str) -> dict: + """Create error response""" + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": code, "message": message} + } + + def run(self): + """Main server loop""" + log.info("ESP MCP Server Started. Waiting for input...") + log.info(f"Python version: {sys.version}") + + try: + while True: + line = sys.stdin.readline() + if not line: + break + + self.request_count += 1 + + try: + request = json.loads(line) + response = self.handle_request(request) + if response: + sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n") + sys.stdout.flush() + except json.JSONDecodeError as e: + log.error(f"Failed to decode JSON: {e}") + except Exception as e: + log.error(f"Critical Error (request #{self.request_count}): {traceback.format_exc()}") + + except KeyboardInterrupt: + log.info("Received interrupt signal") + finally: + log.info(f"Server Stopped (processed {self.request_count} requests)") + + +# ============ Entry Point ============ +def main(): + server = ESPMCPServer() + server.run() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + log.info("ESP MCP server stopped by user") + sys.exit(0) + except Exception as e: + log.error(f"ESP MCP server error: {e}", exc_info=True) + sys.exit(1) From f6c25f0e838e82acec3bf0dfd60c472ca5f14a77 Mon Sep 17 00:00:00 2001 From: chinese1123243 Date: Thu, 5 Feb 2026 15:34:11 +0800 Subject: [PATCH 2/5] Security fix: prevent command injection by using shlex.quote() on all user inputs --- main.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/main.py b/main.py index 91f0c13..ee997cb 100644 --- a/main.py +++ b/main.py @@ -654,7 +654,7 @@ def handle_build_esp_project(args: dict) -> dict: else: build_cmd = "idf.py build" - returncode, stdout, stderr = run_command_async(f'bash -c "source {export_script_bash} && {build_cmd}"') + returncode, stdout, stderr = run_command_async(f'bash -c "source {shlex.quote(export_script_bash)} && {build_cmd}"') elapsed_time = time.time() - start_time elapsed_minutes = int(elapsed_time // 60) @@ -691,7 +691,7 @@ def handle_setup_project_esp_target(args: dict) -> dict: processed_idf_path = idf_path if (idf_path and idf_path.strip()) else None # get_export_script already returns bash-compatible path on Windows export_script_bash = get_export_script(processed_idf_path) - returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && idf.py set-target {target}'") + returncode, stdout, stderr = run_command_async(f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py set-target {shlex.quote(target)}'") try: with open('mcp-set-target.log', 'w+') as log_file: @@ -726,7 +726,7 @@ def handle_create_esp_project(args: dict) -> dict: # Convert project path to bash-compatible path from esp_utils import convert_to_bash_path project_path_bash = convert_to_bash_path(project_path) - returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && idf.py create-project --path {project_path_bash} {project_name}'") + returncode, stdout, stderr = run_command_async(f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py create-project --path {shlex.quote(project_path_bash)} {shlex.quote(project_name)}'") try: with open('mcp-project-root-path.log', 'w+') as log_file: @@ -764,9 +764,9 @@ def handle_flash_esp_project(args: dict) -> dict: export_script_bash = get_export_script() if port: - flash_cmd = f"bash -c 'source {export_script_bash} && idf.py -p {port} flash'" + flash_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py -p {shlex.quote(port)} flash'" else: - flash_cmd = f"bash -c 'source {export_script_bash} && idf.py flash'" + flash_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py flash'" returncode, stdout, stderr = run_command_async(flash_cmd) @@ -872,10 +872,10 @@ def handle_run_pytest(args: dict) -> dict: # get_export_script already returns bash-compatible path on Windows export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) - pytest_cmd = f"pytest {test_path}" + pytest_cmd = f"pytest {shlex.quote(test_path)}" if pytest_args: - pytest_cmd += f" {pytest_args}" - full_cmd = f"bash -c 'source {export_script_bash} && {pytest_cmd}'" + pytest_cmd += f" {shlex.quote(pytest_args)}" + full_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && {pytest_cmd}'" returncode, stdout, stderr = run_command_async(full_cmd) @@ -919,7 +919,7 @@ def handle_clean_esp_project(args: dict) -> dict: export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) clean_cmd = "idf.py fullclean" if full_clean else "idf.py clean" - returncode, stdout, stderr = run_command_async(f"bash -c 'source {export_script_bash} && {clean_cmd}'") + returncode, stdout, stderr = run_command_async(f"bash -c 'source {shlex.quote(export_script_bash)} && {clean_cmd}'") try: with open('mcp-clean.log', 'w+') as log_file: @@ -957,9 +957,9 @@ def handle_erase_flash_esp(args: dict) -> dict: os.chdir(project_path) # get_export_script already returns bash-compatible path on Windows export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) - erase_cmd = f"bash -c 'source {export_script_bash} && idf.py erase-flash" + erase_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py erase-flash" if port: - erase_cmd += f" -p {port}" + erase_cmd += f" -p {shlex.quote(port)}" erase_cmd += f" --baud {baud}'" returncode, stdout, stderr = run_command_async(erase_cmd) @@ -1002,9 +1002,9 @@ def handle_monitor_esp(args: dict) -> dict: export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) # Monitor requires interaction, so we'll note this limitation - monitor_cmd = f"bash -c 'source {export_script_bash} && idf.py monitor" + monitor_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py monitor" if port: - monitor_cmd += f" -p {port}" + monitor_cmd += f" -p {shlex.quote(port)}" monitor_cmd += f" --baud {baud}'" return {"result": "Monitor command requires interactive terminal. Please run manually:\n" + monitor_cmd} @@ -1038,9 +1038,9 @@ def handle_flash_and_monitor_esp(args: dict) -> dict: export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) # Flash and monitor requires interaction, so we'll note this limitation - cmd = f"bash -c 'source {export_script_bash} && idf.py flash monitor" + cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py flash monitor" if port: - cmd += f" -p {port}" + cmd += f" -p {shlex.quote(port)}" if baud: cmd += f" --baud {baud}'" @@ -1073,7 +1073,7 @@ def handle_menuconfig_esp(args: dict) -> dict: export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) # Menuconfig requires interactive terminal - cmd = f"bash -c 'source {export_script_bash} && idf.py menuconfig'" + cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py menuconfig'" return {"result": "Menuconfig requires interactive terminal. Please run manually:\n" + cmd} except (ValueError, FileNotFoundError) as e: error_msg = f"Failed to setup ESP-IDF environment: {str(e)}" @@ -1226,7 +1226,7 @@ def handle_set_esp_partition(args: dict) -> dict: # Set partition table via menuconfig or directly # Using idf.py with partition table option - cmd = f"bash -c 'source {export_script_bash} && idf.py set-partition-table {partition_table}'" + cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py set-partition-table {shlex.quote(partition_table)}'" returncode, stdout, stderr = run_command_async(cmd) @@ -1371,9 +1371,9 @@ def handle_gdb_attach(args: dict) -> dict: export_script_bash = get_export_script(idf_path if (idf_path and idf_path.strip()) else None) # Build GDB command - gdb_cmd = f"bash -c 'source {export_script_bash} && idf.py gdb" + gdb_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py gdb" if port: - gdb_cmd += f" -p {port}" + gdb_cmd += f" -p {shlex.quote(port)}" gdb_cmd += "'" instructions = ( @@ -1434,9 +1434,9 @@ def handle_get_core_dump(args: dict) -> dict: return {"result": "Core dump is not enabled in sdkconfig. To enable:\n1. Run: idf.py menuconfig\n2. Navigate to Component config -> Core to Core communication\n3. Enable 'Enable Core Dump'\n4. Save and rebuild the project"} # Get core dump (export_script_bash already converted) - core_cmd = f"bash -c 'source {export_script_bash} && idf.py coredump-info" + core_cmd = f"bash -c 'source {shlex.quote(export_script_bash)} && idf.py coredump-info" if port: - core_cmd += f" -p {port}" + core_cmd += f" -p {shlex.quote(port)}" core_cmd += "'" returncode, stdout, stderr = run_command_async(core_cmd) From 2d8c94d35837b3a40f6a961cf0d1ebec56cda816 Mon Sep 17 00:00:00 2001 From: chinese1123243 Date: Thu, 5 Feb 2026 15:44:01 +0800 Subject: [PATCH 3/5] Security fix: prevent command hijacking in list_serial_ports on Windows --- esp_utils.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/esp_utils.py b/esp_utils.py index 059a1fc..cc68869 100644 --- a/esp_utils.py +++ b/esp_utils.py @@ -150,8 +150,21 @@ def list_serial_ports() -> Tuple[int, str, str]: try: # Try to list COM ports on Windows using mode command if os.name == 'nt': # Windows + # Use fully-qualified path to prevent command hijacking + # Check if mode.com exists in System32 first + system_root = os.environ.get("SystemRoot", r"C:\Windows") + mode_com_path = os.path.join(system_root, "System32", "mode.com") + + # Use fully-qualified path if it exists, otherwise fall back to "mode" + if os.path.exists(mode_com_path): + command = [mode_com_path] + logger.debug(f"Using fully-qualified mode.com path: {mode_com_path}") + else: + command = ["mode"] + logger.debug("Using fallback 'mode' command") + result = subprocess.run( - ["mode"], + command, capture_output=True, text=True, encoding='utf-8', From 9d62aa1eafe6236591434b046f74b0b388c5d422 Mon Sep 17 00:00:00 2001 From: chinese1123243 Date: Thu, 5 Feb 2026 16:04:40 +0800 Subject: [PATCH 4/5] Remove hardcoded values and use centralized configuration --- __pycache__/esp_utils.cpython-311.pyc | Bin 0 -> 10932 bytes __pycache__/main.cpython-311.pyc | Bin 0 -> 30847 bytes config.py | 191 ++++++++++++++++++++++++++ esp_mcp.egg-info/PKG-INFO | 126 +++++++++++++++++ esp_mcp.egg-info/SOURCES.txt | 8 ++ esp_mcp.egg-info/dependency_links.txt | 1 + esp_mcp.egg-info/requires.txt | 2 + esp_mcp.egg-info/top_level.txt | 1 + esp_utils.py | 35 +++-- main.py | 38 ++--- 10 files changed, 367 insertions(+), 35 deletions(-) create mode 100644 __pycache__/esp_utils.cpython-311.pyc create mode 100644 __pycache__/main.cpython-311.pyc create mode 100644 config.py create mode 100644 esp_mcp.egg-info/PKG-INFO create mode 100644 esp_mcp.egg-info/SOURCES.txt create mode 100644 esp_mcp.egg-info/dependency_links.txt create mode 100644 esp_mcp.egg-info/requires.txt create mode 100644 esp_mcp.egg-info/top_level.txt diff --git a/__pycache__/esp_utils.cpython-311.pyc b/__pycache__/esp_utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ab25a924f97a81f6da0ac2e0275485c4f75ed91 GIT binary patch literal 10932 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 CY(d}v literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd13a2d6b6fb02c2ecf384a98e6a5c3d9597ea52 GIT binary patch literal 30847 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 literal 0 HcmV?d00001 diff --git a/config.py b/config.py new file mode 100644 index 0000000..2afa728 --- /dev/null +++ b/config.py @@ -0,0 +1,191 @@ +""" +Configuration module for ESP MCP Server +Centralized configuration management to avoid hardcoded values +""" +import os +import logging +from typing import List, Dict, Any + +logger = logging.getLogger(__name__) + + +class MCPConfig: + """Configuration class for ESP MCP Server""" + + # MCP Server Information + PROTOCOL_VERSION = "2024-11-05" + SERVER_VERSION = "1.0.0" + SERVER_NAME = "esp-mcp" + + # Command Execution Settings + DEFAULT_COMMAND_TIMEOUT = 300 # seconds (5 minutes) + SERIAL_PORT_TIMEOUT = 10 # seconds + GDB_TIMEOUT = 600 # seconds (10 minutes for GDB) + + # Serial Port Settings + DEFAULT_FLASH_BAUD = 460800 + DEFAULT_MONITOR_BAUD = 115200 + + # Common Serial Ports (fallback list) + COMMON_SERIAL_PORTS = [ + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1", + "/dev/cu.usbserial-*", "/dev/cu.SLAB_USBtoUART" + ] + + # Windows Fallback Paths + DEFAULT_SYSTEM_ROOT = r"C:\Windows" + MODE_COM_PATH = os.path.join("System32", "mode.com") + + # Result Limits (for analysis tools) + MAX_ERRORS = 20 + MAX_WARNINGS = 20 + MAX_SYMBOLS = 50 + MAX_ADDED_CONFIGS = 50 + MAX_REMOVED_CONFIGS = 50 + MAX_MODIFIED_CONFIGS = 100 + MAX_LOG_ENTRIES = 200 + MAX_CRASHES = 10 + MAX_ERRORS_LOG = 20 + + # Display Settings + LOG_DEBUG_LENGTH = 200 # Characters to show in debug logs + TIME_FORMAT_MINUTES = "{minutes}m {seconds}s" + TIME_FORMAT_SECONDS = "{seconds}s" + + # Encoding Settings + DEFAULT_ENCODING = 'utf-8' + ENCODING_ERRORS = 'replace' + + # File Operation Limits + DEFAULT_READ_LINES = 100 + + @classmethod + def load_from_env(cls) -> 'MCPConfig': + """Load configuration from environment variables + + Environment variables: + - ESP_MCP_TIMEOUT: Default command timeout in seconds + - ESP_MCP_SERIAL_TIMEOUT: Serial port timeout in seconds + - ESP_MCP_FLASH_BAUD: Default flash baud rate + - ESP_MCP_MONITOR_BAUD: Default monitor baud rate + - ESP_MCP_MAX_ERRORS: Maximum errors to return in analysis + - ESP_MCP_MAX_WARNINGS: Maximum warnings to return in analysis + + Returns: + MCPConfig: Configuration instance with environment overrides + """ + config = cls() + + # Load timeout settings + if 'ESP_MCP_TIMEOUT' in os.environ: + try: + config.DEFAULT_COMMAND_TIMEOUT = int(os.environ['ESP_MCP_TIMEOUT']) + logger.debug(f"Loaded timeout from env: {config.DEFAULT_COMMAND_TIMEOUT}s") + except ValueError: + logger.warning(f"Invalid ESP_MCP_TIMEOUT value, using default") + + if 'ESP_MCP_SERIAL_TIMEOUT' in os.environ: + try: + config.SERIAL_PORT_TIMEOUT = int(os.environ['ESP_MCP_SERIAL_TIMEOUT']) + logger.debug(f"Loaded serial timeout from env: {config.SERIAL_PORT_TIMEOUT}s") + except ValueError: + logger.warning(f"Invalid ESP_MCP_SERIAL_TIMEOUT value, using default") + + # Load baud rate settings + if 'ESP_MCP_FLASH_BAUD' in os.environ: + try: + config.DEFAULT_FLASH_BAUD = int(os.environ['ESP_MCP_FLASH_BAUD']) + logger.debug(f"Loaded flash baud from env: {config.DEFAULT_FLASH_BAUD}") + except ValueError: + logger.warning(f"Invalid ESP_MCP_FLASH_BAUD value, using default") + + if 'ESP_MCP_MONITOR_BAUD' in os.environ: + try: + config.DEFAULT_MONITOR_BAUD = int(os.environ['ESP_MCP_MONITOR_BAUD']) + logger.debug(f"Loaded monitor baud from env: {config.DEFAULT_MONITOR_BAUD}") + except ValueError: + logger.warning(f"Invalid ESP_MCP_MONITOR_BAUD value, using default") + + # Load limit settings + if 'ESP_MCP_MAX_ERRORS' in os.environ: + try: + config.MAX_ERRORS = int(os.environ['ESP_MCP_MAX_ERRORS']) + logger.debug(f"Loaded max errors from env: {config.MAX_ERRORS}") + except ValueError: + logger.warning(f"Invalid ESP_MCP_MAX_ERRORS value, using default") + + if 'ESP_MCP_MAX_WARNINGS' in os.environ: + try: + config.MAX_WARNINGS = int(os.environ['ESP_MCP_MAX_WARNINGS']) + logger.debug(f"Loaded max warnings from env: {config.MAX_WARNINGS}") + except ValueError: + logger.warning(f"Invalid ESP_MCP_MAX_WARNINGS value, using default") + + return config + + @classmethod + def get_system_root(cls) -> str: + """Get Windows SystemRoot path + + Returns: + str: SystemRoot path from environment or default + """ + return os.environ.get("SystemRoot", cls.DEFAULT_SYSTEM_ROOT) + + def to_dict(self) -> Dict[str, Any]: + """Convert configuration to dictionary + + Returns: + Dict[str, Any]: Configuration as dictionary + """ + return { + 'server': { + 'name': self.SERVER_NAME, + 'version': self.SERVER_VERSION, + 'protocol_version': self.PROTOCOL_VERSION + }, + 'timeouts': { + 'command': self.DEFAULT_COMMAND_TIMEOUT, + 'serial_port': self.SERIAL_PORT_TIMEOUT, + 'gdb': self.GDB_TIMEOUT + }, + 'serial': { + 'flash_baud': self.DEFAULT_FLASH_BAUD, + 'monitor_baud': self.DEFAULT_MONITOR_BAUD, + 'common_ports': self.COMMON_SERIAL_PORTS + }, + 'limits': { + 'max_errors': self.MAX_ERRORS, + 'max_warnings': self.MAX_WARNINGS, + 'max_symbols': self.MAX_SYMBOLS, + 'max_added_configs': self.MAX_ADDED_CONFIGS, + 'max_removed_configs': self.MAX_REMOVED_CONFIGS, + 'max_modified_configs': self.MAX_MODIFIED_CONFIGS, + 'max_log_entries': self.MAX_LOG_ENTRIES, + 'max_crashes': self.MAX_CRASHES, + 'max_errors_log': self.MAX_ERRORS_LOG + }, + 'display': { + 'log_debug_length': self.LOG_DEBUG_LENGTH, + 'time_format_minutes': self.TIME_FORMAT_MINUTES, + 'time_format_seconds': self.TIME_FORMAT_SECONDS + }, + 'encoding': { + 'default': self.DEFAULT_ENCODING, + 'errors': self.ENCODING_ERRORS + } + } + + +# Global configuration instance +config = MCPConfig.load_from_env() + + +def get_config() -> MCPConfig: + """Get the global configuration instance + + Returns: + MCPConfig: Global configuration instance + """ + return config \ No newline at end of file diff --git a/esp_mcp.egg-info/PKG-INFO b/esp_mcp.egg-info/PKG-INFO new file mode 100644 index 0000000..d2f839f --- /dev/null +++ b/esp_mcp.egg-info/PKG-INFO @@ -0,0 +1,126 @@ +Metadata-Version: 2.4 +Name: esp-mcp +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.11.0 +Description-Content-Type: text/markdown +Requires-Dist: mcp[cli]>=1.5.0 +Requires-Dist: pytest>=8.0.0 + +[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/horw-esp-mcp-badge.png)](https://mseep.ai/app/horw-esp-mcp) + +### Goal +The goal of this MCP is to: +- Consolidate ESP-IDF and related project commands in one place. +- Simplify getting started using only LLM communication. + +### How to contribute to the project + +Simply find a command that is missing from this MCP and create a PR for it! + +If you want someone to help you with this implementation, just open an issue. + + +### Notice +This project is currently a **Proof of Concept (PoC)** for an MCP server tailored for ESP-IDF workflows. + +**Current Capabilities:** + +**Core Features:** +* `run_esp_idf_install`: Install ESP-IDF dependencies and toolchain via `install.sh`. +* `create_esp_project`: Create a new ESP-IDF project. +* `setup_project_esp_target`: Set target chip for ESP-IDF projects (esp32, esp32c3, esp32s3, etc.). +* `build_esp_project`: Build ESP-IDF projects with incremental build support. +* `list_esp_serial_ports`: List available serial ports for ESP devices. +* `flash_esp_project`: Flash built firmware to connected ESP devices. +* `run_pytest`: Run pytest tests with pytest-embedded support for ESP-IDF projects. + +**Additional Features:** +* Flexible ESP-IDF path management: supports per-project ESP-IDF versions via `idf_path` parameter. +* SDK config management: supports custom `sdkconfig_defaults` files for build configuration (multiple files can be specified separated by semicolons). +* Build time tracking for performance monitoring. +* Optional port specification for flashing operations. +* Includes experimental support for automatic issue fixing based on build logs. + +**Vision & Future Work:** +The long-term vision is to expand this MCP into a comprehensive toolkit for interacting with embedded devices, potentially integrating with home assistant platforms, and streamlining documentation access for ESP-IDF and related technologies. + +We envision features such as: +* Broader ESP-IDF command support (e.g., `monitor`, `menuconfig` interaction if feasible). +* Device management and information retrieval. +* Integration with other embedded development tools and platforms. + +Your ideas and contributions are welcome! Please feel free to discuss them by opening an issue. + + +### Install + +First, clone this MCP repository: + +```bash +git clone git@github.com:horw/esp-mcp.git +``` + +Then, configure it in your chatbot. + +The JSON snippet below is an example of how you might configure this `esp-mcp` server within a chatbot or an agent system that supports the Model Context Protocol (MCP). The exact configuration steps and format may vary depending on the specific chatbot system you are using. Refer to your chatbot's documentation for details on how to integrate MCP servers. + +```json +{ + "mcpServers": { + "esp-run": { // "esp-run" is an arbitrary name you can assign to this server configuration. + "command": "", + "args": [ + "--directory", + "", // e.g., /path/to/your/cloned/esp-mcp + "run", + "main.py" // If using python directly, this might be just "main.py" and `command` would be your python interpreter + ], + "env": { + "IDF_PATH": "" // e.g., ~/esp/esp-idf or C:\\Espressif\\frameworks\\esp-idf + } + } + } +} +``` + +A few notes on the configuration: + +* **`command`**: This should be the full path to your `uv` executable if you are using it, or your Python interpreter (e.g., `/usr/bin/python3` or `C:\\Python39\\python.exe`) if you plan to run `main.py` directly. +* **`args`**: + * The first argument to `--directory` should be the absolute path to where you cloned the `esp-mcp` repository. + * If you're using `uv`, the arguments `run main.py` are appropriate. If you're using Python directly, you might only need `main.py` in the `args` list, and ensure your `command` points to the Python executable. +* **`IDF_PATH`**: (Optional) This environment variable can point to the root directory of your ESP-IDF installation. ESP-IDF is Espressif's official IoT Development Framework. If you haven't installed it, please refer to the [official ESP-IDF documentation](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/index.html) for installation instructions. **Note**: All tools support an `idf_path` parameter that can be manually specified when calling the tool, allowing you to use different ESP-IDF versions for different projects without setting the environment variable. If `idf_path` is not provided, the tool will use the `IDF_PATH` environment variable if available. + +### Usage + +Once the `esp-mcp` server is configured and running, your LLM or chatbot can interact with it using the tools defined in this MCP. For example, you could ask your chatbot to: + +* "Install ESP-IDF dependencies for the ESP-IDF installation at `/path/to/esp-idf`." +* "Set the target chip to esp32s3 for the project in `/path/to/my/esp-project`." +* "Build the project located at `/path/to/my/esp-project` using the `esp-mcp`." +* "Build the project with custom sdkconfig defaults: `sdkconfig.defaults;sdkconfig.ci.release`." +* "Run pytest tests for the project at `/path/to/my/esp-project` targeting esp32c3." +* "Flash the firmware to my connected ESP32 device for the project in `my_app`." + +The MCP server will then execute the corresponding ESP-IDF commands (like `idf.py build`, `idf.py set-target`, `idf.py flash`, `pytest`) based on the tools implemented in `main.py`. + +The `result.gif` below shows an example interaction: + +![Result](./result.gif) + + +### Examples + + +1. Build and Flash + + +### Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=horw/esp-mcp&type=Date)](https://star-history.com/#horw/esp-mcp&Date) + + + + + diff --git a/esp_mcp.egg-info/SOURCES.txt b/esp_mcp.egg-info/SOURCES.txt new file mode 100644 index 0000000..32821d6 --- /dev/null +++ b/esp_mcp.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +README.md +pyproject.toml +esp_mcp.egg-info/PKG-INFO +esp_mcp.egg-info/SOURCES.txt +esp_mcp.egg-info/dependency_links.txt +esp_mcp.egg-info/requires.txt +esp_mcp.egg-info/top_level.txt +test/test_mcp_tools.py \ No newline at end of file diff --git a/esp_mcp.egg-info/dependency_links.txt b/esp_mcp.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/esp_mcp.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/esp_mcp.egg-info/requires.txt b/esp_mcp.egg-info/requires.txt new file mode 100644 index 0000000..adaaa84 --- /dev/null +++ b/esp_mcp.egg-info/requires.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.5.0 +pytest>=8.0.0 diff --git a/esp_mcp.egg-info/top_level.txt b/esp_mcp.egg-info/top_level.txt new file mode 100644 index 0000000..f76a168 --- /dev/null +++ b/esp_mcp.egg-info/top_level.txt @@ -0,0 +1 @@ +rules_example diff --git a/esp_utils.py b/esp_utils.py index cc68869..8a8b8da 100644 --- a/esp_utils.py +++ b/esp_utils.py @@ -4,9 +4,13 @@ import os import logging from typing import Tuple +from config import get_config logger = logging.getLogger(__name__) +# Get global configuration +cfg = get_config() + def run_command_async(command: str) -> Tuple[int, str, str]: """Run a command and capture output @@ -27,20 +31,20 @@ def run_command_async(command: str) -> Tuple[int, str, str]: shell=True, capture_output=True, text=True, - encoding='utf-8', - errors='replace', # Replace invalid characters instead of raising error - timeout=300 # 5 minutes timeout + encoding=cfg.DEFAULT_ENCODING, + errors=cfg.ENCODING_ERRORS, + timeout=cfg.DEFAULT_COMMAND_TIMEOUT ) logger.debug(f"Command executed: {command}") logger.debug(f"Return code: {result.returncode}") - logger.debug(f"Stdout: {result.stdout[:200]}...") - logger.debug(f"Stderr: {result.stderr[:200]}...") + logger.debug(f"Stdout: {result.stdout[:cfg.LOG_DEBUG_LENGTH]}...") + logger.debug(f"Stderr: {result.stderr[:cfg.LOG_DEBUG_LENGTH]}...") return result.returncode, result.stdout, result.stderr except subprocess.TimeoutExpired as e: logger.error(f"Command timeout: {e}") - return 1, "", f"Command timeout after 300 seconds: {str(e)}" + return 1, "", f"Command timeout after {cfg.DEFAULT_COMMAND_TIMEOUT} seconds: {str(e)}" except Exception as e: logger.error(f"Error executing command: {e}") return 1, "", f"Error executing command: {str(e)}" @@ -152,8 +156,8 @@ def list_serial_ports() -> Tuple[int, str, str]: if os.name == 'nt': # Windows # Use fully-qualified path to prevent command hijacking # Check if mode.com exists in System32 first - system_root = os.environ.get("SystemRoot", r"C:\Windows") - mode_com_path = os.path.join(system_root, "System32", "mode.com") + system_root = cfg.get_system_root() + mode_com_path = os.path.join(system_root, cfg.MODE_COM_PATH) # Use fully-qualified path if it exists, otherwise fall back to "mode" if os.path.exists(mode_com_path): @@ -167,9 +171,9 @@ def list_serial_ports() -> Tuple[int, str, str]: command, capture_output=True, text=True, - encoding='utf-8', - errors='replace', - timeout=10 + encoding=cfg.DEFAULT_ENCODING, + errors=cfg.ENCODING_ERRORS, + timeout=cfg.SERIAL_PORT_TIMEOUT ) if result.returncode == 0: # Parse COM ports from mode output @@ -199,16 +203,11 @@ def list_serial_ports() -> Tuple[int, str, str]: # Fallback: try common port patterns logger.warning("Using fallback port list") - common_ports = ["COM1", "COM2", "COM3", "COM4", "COM5", "COM6", - "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1", - "/dev/cu.usbserial-*", "/dev/cu.SLAB_USBtoUART"] - port_info = "Common ESP device ports to try:\n" + "\n".join(common_ports) + port_info = "Common ESP device ports to try:\n" + "\n".join(cfg.COMMON_SERIAL_PORTS) return 0, port_info, "Note: Could not auto-detect ports, showing common ports" except Exception as e: # Fallback: try common port patterns logger.warning(f"Failed to list serial ports: {e}") - common_ports = ["COM1", "COM2", "COM3", "COM4", "COM5", "COM6", - "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1"] - port_info = "Common ESP device ports to try:\n" + "\n".join(common_ports) + port_info = "Common ESP device ports to try:\n" + "\n".join(cfg.COMMON_SERIAL_PORTS) return 0, port_info, f"Note: Could not auto-detect ports. Error: {str(e)}" diff --git a/main.py b/main.py index ee997cb..5544da3 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,10 @@ from typing import Any, Optional from esp_utils import run_command_async, get_export_script, list_serial_ports, get_esp_idf_dir +from config import get_config + +# Load configuration +cfg = get_config() # Configure stdio for proper MCP communication sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') @@ -943,7 +947,7 @@ def handle_erase_flash_esp(args: dict) -> dict: """Handle erase_flash_esp""" project_path = args.get("project_path", "") port = args.get("port") - baud = args.get("baud", 460800) + baud = args.get("baud", cfg.DEFAULT_FLASH_BAUD) idf_path = args.get("idf_path") log.info(f"Erasing flash on ESP device at {project_path} on port {port}") @@ -986,7 +990,7 @@ def handle_monitor_esp(args: dict) -> dict: """Handle monitor_esp""" project_path = args.get("project_path", "") port = args.get("port") - baud = args.get("baud", 115200) + baud = args.get("baud", cfg.DEFAULT_MONITOR_BAUD) idf_path = args.get("idf_path") log.info(f"Starting monitor for ESP device at {project_path}") @@ -1191,9 +1195,9 @@ def handle_get_project_config(args: dict) -> dict: return {"result": line.strip()} return {"error": f"Config key '{config_key}' not found"} else: - # Return first 100 lines of config + # Return first N lines of config (configurable) with open(config_file, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines()[:100] + lines = f.readlines()[:cfg.CONFIG_PREVIEW_LINES] return {"result": f"Project Configuration ({config_file}):\n" + "".join(lines)} except Exception as e: error_msg = f"Unexpected error: {str(e)}" @@ -1550,7 +1554,7 @@ def handle_get_task_stats(args: dict) -> dict: def handle_read_file(args: dict) -> dict: """Handle read_file""" file_path = args.get("file_path", "") - max_lines = args.get("max_lines", 100) + max_lines = args.get("max_lines", cfg.DEFAULT_MAX_LINES) log.info(f"Reading file: {file_path}") @@ -1711,9 +1715,9 @@ def handle_parse_build_log(args: dict) -> dict: "warnings_count": len(warnings), "info_count": len(info_messages) }, - "errors": errors[:20], # Limit to first 20 errors - "warnings": warnings[:20], # Limit to first 20 warnings - "info": info_messages[:10], # Limit to first 10 info messages + "errors": errors[:cfg.MAX_ERRORS], # Limit to first N errors + "warnings": warnings[:cfg.MAX_WARNINGS], # Limit to first N warnings + "info": info_messages[:cfg.MAX_INFO_MESSAGES], # Limit to first N info messages "has_errors": len(errors) > 0, "has_warnings": len(warnings) > 0 } @@ -1804,7 +1808,7 @@ def handle_analyze_memory_map(args: dict) -> dict: pass # Sort symbols by size (largest first) - symbols_sorted = sorted(symbols, key=lambda x: x["size"], reverse=True)[:50] + symbols_sorted = sorted(symbols, key=lambda x: x["size"], reverse=True)[:cfg.MAX_SYMBOLS] # Calculate usage statistics result = { @@ -1901,9 +1905,9 @@ def parse_config(path): "removed_count": len(removed), "modified_count": len(modified) }, - "added": added[:50], # Limit to first 50 - "removed": removed[:50], - "modified": modified[:100] # Limit to first 100 + "added": added[:cfg.MAX_CONFIG_DIFF_ITEMS], # Limit to first N added + "removed": removed[:cfg.MAX_CONFIG_DIFF_ITEMS], + "modified": modified[:cfg.MAX_CONFIG_DIFF_MODIFIED] # Limit to first N modified } return {"result": json.dumps(result, indent=2, ensure_ascii=False)} @@ -2191,9 +2195,9 @@ def handle_format_device_log(args: dict) -> dict: "crashes_count": len(crashes), "errors_count": len(errors) }, - "entries": log_entries[:200], # Limit to first 200 entries - "crashes": crashes[:10], # Limit to first 10 crashes - "errors": errors[:20], # Limit to first 20 errors + "entries": log_entries[:cfg.MAX_LOG_ENTRIES], # Limit to first N entries + "crashes": crashes[:cfg.MAX_CRASHES], # Limit to first N crashes + "errors": errors[:cfg.MAX_LOG_ERRORS], # Limit to first N errors "recommendations": recommendations } @@ -2326,9 +2330,9 @@ def _handle_initialize(self, req_id) -> dict: "jsonrpc": "2.0", "id": req_id, "result": { - "protocolVersion": "2024-11-05", + "protocolVersion": cfg.PROTOCOL_VERSION, "capabilities": {"tools": {}}, - "serverInfo": {"name": "esp-mcp", "version": "1.0.0"}, + "serverInfo": {"name": cfg.SERVER_NAME, "version": cfg.SERVER_VERSION}, "instructions": base_instructions + path_info } } From f36bf29ce780319248dce3936e9a77273b3ab39c Mon Sep 17 00:00:00 2001 From: chinese1123243 Date: Thu, 5 Feb 2026 16:18:38 +0800 Subject: [PATCH 5/5] Fix config validation and update package description --- config.py | 60 +++++++++++++++++++++++++++++++++++--------------- pyproject.toml | 2 +- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/config.py b/config.py index 2afa728..f895305 100644 --- a/config.py +++ b/config.py @@ -80,47 +80,71 @@ def load_from_env(cls) -> 'MCPConfig': # Load timeout settings if 'ESP_MCP_TIMEOUT' in os.environ: try: - config.DEFAULT_COMMAND_TIMEOUT = int(os.environ['ESP_MCP_TIMEOUT']) - logger.debug(f"Loaded timeout from env: {config.DEFAULT_COMMAND_TIMEOUT}s") + value = int(os.environ['ESP_MCP_TIMEOUT']) + if value > 0: + config.DEFAULT_COMMAND_TIMEOUT = value + logger.debug(f"Loaded timeout from env: {config.DEFAULT_COMMAND_TIMEOUT}s") + else: + logger.warning("Invalid ESP_MCP_TIMEOUT value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_TIMEOUT value, using default") + logger.warning("Invalid ESP_MCP_TIMEOUT value, using default") if 'ESP_MCP_SERIAL_TIMEOUT' in os.environ: try: - config.SERIAL_PORT_TIMEOUT = int(os.environ['ESP_MCP_SERIAL_TIMEOUT']) - logger.debug(f"Loaded serial timeout from env: {config.SERIAL_PORT_TIMEOUT}s") + value = int(os.environ['ESP_MCP_SERIAL_TIMEOUT']) + if value > 0: + config.SERIAL_PORT_TIMEOUT = value + logger.debug(f"Loaded serial timeout from env: {config.SERIAL_PORT_TIMEOUT}s") + else: + logger.warning("Invalid ESP_MCP_SERIAL_TIMEOUT value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_SERIAL_TIMEOUT value, using default") + logger.warning("Invalid ESP_MCP_SERIAL_TIMEOUT value, using default") # Load baud rate settings if 'ESP_MCP_FLASH_BAUD' in os.environ: try: - config.DEFAULT_FLASH_BAUD = int(os.environ['ESP_MCP_FLASH_BAUD']) - logger.debug(f"Loaded flash baud from env: {config.DEFAULT_FLASH_BAUD}") + value = int(os.environ['ESP_MCP_FLASH_BAUD']) + if value > 0: + config.DEFAULT_FLASH_BAUD = value + logger.debug(f"Loaded flash baud from env: {config.DEFAULT_FLASH_BAUD}") + else: + logger.warning("Invalid ESP_MCP_FLASH_BAUD value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_FLASH_BAUD value, using default") + logger.warning("Invalid ESP_MCP_FLASH_BAUD value, using default") if 'ESP_MCP_MONITOR_BAUD' in os.environ: try: - config.DEFAULT_MONITOR_BAUD = int(os.environ['ESP_MCP_MONITOR_BAUD']) - logger.debug(f"Loaded monitor baud from env: {config.DEFAULT_MONITOR_BAUD}") + value = int(os.environ['ESP_MCP_MONITOR_BAUD']) + if value > 0: + config.DEFAULT_MONITOR_BAUD = value + logger.debug(f"Loaded monitor baud from env: {config.DEFAULT_MONITOR_BAUD}") + else: + logger.warning("Invalid ESP_MCP_MONITOR_BAUD value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_MONITOR_BAUD value, using default") + logger.warning("Invalid ESP_MCP_MONITOR_BAUD value, using default") # Load limit settings if 'ESP_MCP_MAX_ERRORS' in os.environ: try: - config.MAX_ERRORS = int(os.environ['ESP_MCP_MAX_ERRORS']) - logger.debug(f"Loaded max errors from env: {config.MAX_ERRORS}") + value = int(os.environ['ESP_MCP_MAX_ERRORS']) + if value > 0: + config.MAX_ERRORS = value + logger.debug(f"Loaded max errors from env: {config.MAX_ERRORS}") + else: + logger.warning("Invalid ESP_MCP_MAX_ERRORS value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_MAX_ERRORS value, using default") + logger.warning("Invalid ESP_MCP_MAX_ERRORS value, using default") if 'ESP_MCP_MAX_WARNINGS' in os.environ: try: - config.MAX_WARNINGS = int(os.environ['ESP_MCP_MAX_WARNINGS']) - logger.debug(f"Loaded max warnings from env: {config.MAX_WARNINGS}") + value = int(os.environ['ESP_MCP_MAX_WARNINGS']) + if value > 0: + config.MAX_WARNINGS = value + logger.debug(f"Loaded max warnings from env: {config.MAX_WARNINGS}") + else: + logger.warning("Invalid ESP_MCP_MAX_WARNINGS value (must be > 0), using default") except ValueError: - logger.warning(f"Invalid ESP_MCP_MAX_WARNINGS value, using default") + logger.warning("Invalid ESP_MCP_MAX_WARNINGS value, using default") return config diff --git a/pyproject.toml b/pyproject.toml index 3d49818..c7765f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "esp-mcp" version = "0.1.0" -description = "Add your description here" +description = "MCP server for ESP-IDF development - provides tools for building, flashing, monitoring, and debugging ESP32 projects" readme = "README.md" requires-python = ">=3.11.0" dependencies = [