mcesptool/src/mcp_esptool_server/middleware/logger_interceptor.py
Ryan Malloy 64c1505a00 Add QEMU ESP32 emulation support
Integrate Espressif's QEMU fork for virtual ESP device management:

- QemuManager component with 5 MCP tools (start/stop/list/status/flash)
- Config auto-detects QEMU binaries from ~/.espressif/tools/
- Supports esp32, esp32s2, esp32s3, esp32c3 chip emulation
- Virtual serial over TCP (socket://localhost:PORT) transparent to esptool
- Scan integration: QEMU instances appear in esp_scan_ports results
- Blank flash images initialized to 0xFF (erased NOR flash state)
- 38 unit tests covering lifecycle, port allocation, flash writes
2026-01-28 15:35:22 -07:00

291 lines
10 KiB
Python

"""
Logger Interceptor Base Class
Abstract base class for intercepting and redirecting CLI tool logging to MCP context.
Provides the foundation for bidirectional communication with any CLI tool.
"""
import logging
import time
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager
from typing import Any
from fastmcp import Context
logger = logging.getLogger(__name__)
class LoggerInterceptor(ABC):
"""Abstract base class for CLI tool logger interception"""
def __init__(self, context: Context, operation_id: str):
"""
Initialize logger interceptor
Args:
context: FastMCP context for logging and user interaction
operation_id: Unique identifier for this operation
"""
self.context = context
self.operation_id = operation_id
self.operation_start_time = time.time()
# Detect MCP client capabilities
self.capabilities = self._detect_mcp_capabilities()
# Operation state
self.progress_history: list[dict[str, Any]] = []
self.user_confirmations: dict[str, bool] = {}
self.active_stages: list[str] = []
logger.debug(f"Logger interceptor initialized for operation: {operation_id}")
def _detect_mcp_capabilities(self) -> dict[str, bool]:
"""Detect available MCP client capabilities"""
capabilities = {
"logging": hasattr(self.context, "log") and callable(self.context.log),
"progress": hasattr(self.context, "progress") and callable(self.context.progress),
"elicitation": hasattr(self.context, "request_user_input")
and callable(self.context.request_user_input),
"sampling": hasattr(self.context, "sample") and callable(self.context.sample),
}
logger.debug(f"Detected MCP capabilities: {capabilities}")
return capabilities
@abstractmethod
async def install_hooks(self) -> None:
"""Install middleware hooks into the target tool"""
pass
@abstractmethod
async def remove_hooks(self) -> None:
"""Remove middleware hooks from the target tool"""
pass
@abstractmethod
def get_interaction_points(self) -> list[str]:
"""Return list of operations that require user interaction"""
pass
@asynccontextmanager
async def activate(self):
"""Context manager for middleware lifecycle"""
try:
await self.install_hooks()
await self._log_operation_start()
yield self
except Exception as e:
await self._log_error(f"Middleware activation failed: {e}")
raise
finally:
await self._log_operation_end()
await self.remove_hooks()
# Enhanced logging methods
async def _log_info(self, message: str, **kwargs) -> None:
"""Log informational message to MCP context"""
if self.capabilities["logging"]:
try:
await self.context.log(level="info", message=message, **kwargs)
except Exception as e:
logger.warning(f"Failed to log info message: {e}")
async def _log_warning(self, message: str, **kwargs) -> None:
"""Log warning message to MCP context"""
if self.capabilities["logging"]:
try:
await self.context.log(level="warning", message=f"⚠️ {message}", **kwargs)
except Exception as e:
logger.warning(f"Failed to log warning message: {e}")
async def _log_error(self, message: str, **kwargs) -> None:
"""Log error message to MCP context"""
if self.capabilities["logging"]:
try:
await self.context.log(level="error", message=f"{message}", **kwargs)
except Exception as e:
logger.error(f"Failed to log error message: {e}")
async def _log_success(self, message: str, **kwargs) -> None:
"""Log success message to MCP context"""
if self.capabilities["logging"]:
try:
await self.context.log(level="info", message=f"{message}", **kwargs)
except Exception as e:
logger.warning(f"Failed to log success message: {e}")
async def _update_progress(
self,
percentage: float,
message: str = "",
current: int | None = None,
total: int | None = None,
) -> None:
"""Update operation progress"""
if self.capabilities["progress"]:
try:
await self.context.progress(
operation_id=self.operation_id,
progress=percentage,
total=total or 100,
current=current or int(percentage),
message=message,
)
# Store progress history
self.progress_history.append(
{
"timestamp": time.time(),
"percentage": percentage,
"message": message,
"current": current,
"total": total,
}
)
except Exception as e:
logger.warning(f"Failed to update progress: {e}")
async def _request_user_confirmation(
self, prompt: str, default: bool = True, cache_key: str | None = None
) -> bool:
"""Request user confirmation with optional caching"""
# Use cache key or prompt as key
confirmation_key = cache_key or prompt
# Check cache first
if confirmation_key in self.user_confirmations:
logger.debug(f"Using cached confirmation for: {confirmation_key}")
return self.user_confirmations[confirmation_key]
if self.capabilities["elicitation"]:
try:
response = await self.context.request_user_input(
prompt=prompt, input_type="confirmation", additional_data={"default": default}
)
confirmed = response.get("confirmed", default)
self.user_confirmations[confirmation_key] = confirmed
await self._log_info(
f"User confirmation: {prompt} -> {'Yes' if confirmed else 'No'}"
)
return confirmed
except Exception as e:
await self._log_warning(f"User confirmation failed: {e}")
return default
else:
# No elicitation support, use default
await self._log_info(
f"Auto-confirming (no elicitation): {prompt} -> {'Yes' if default else 'No'}"
)
return default
async def _handle_stage_start(self, stage_message: str) -> None:
"""Handle stage start with potential user interaction"""
self.active_stages.append(stage_message)
await self._log_info(f"🔄 Starting: {stage_message}")
# Check if this stage requires user confirmation
if self._requires_user_interaction(stage_message):
confirmed = await self._request_user_confirmation(
f"🤔 About to: {stage_message}. Continue?",
default=True,
cache_key=f"stage_{stage_message}",
)
if not confirmed:
await self._log_error(f"Operation cancelled by user: {stage_message}")
raise RuntimeError(f"User cancelled operation: {stage_message}")
async def _handle_stage_end(self, stage_message: str | None = None) -> None:
"""Handle stage completion"""
if self.active_stages:
completed_stage = stage_message or self.active_stages.pop()
await self._log_success(f"Completed: {completed_stage}")
elif stage_message:
await self._log_success(f"Completed: {stage_message}")
def _requires_user_interaction(self, operation: str) -> bool:
"""Determine if operation requires user confirmation"""
critical_keywords = [
"erase",
"burn",
"encrypt",
"secure",
"factory",
"reset",
"delete",
"remove",
"clear",
"format",
"destroy",
]
operation_lower = operation.lower()
return any(keyword in operation_lower for keyword in critical_keywords)
def _format_message(self, message: str, *args) -> str:
"""Format message with optional arguments"""
try:
return message % args if args else message
except (TypeError, ValueError):
return f"{message} {' '.join(map(str, args))}" if args else message
async def _log_operation_start(self) -> None:
"""Log operation start"""
await self._log_info(f"🔧 Operation started: {self.operation_id}")
async def _log_operation_end(self) -> None:
"""Log operation completion with statistics"""
duration = time.time() - self.operation_start_time
await self._log_info(
f"⏱️ Operation completed: {self.operation_id} "
f"(duration: {duration:.2f}s, "
f"progress_updates: {len(self.progress_history)}, "
f"confirmations: {len(self.user_confirmations)})"
)
def get_operation_statistics(self) -> dict[str, Any]:
"""Get operation statistics for analysis"""
duration = time.time() - self.operation_start_time
return {
"operation_id": self.operation_id,
"duration_seconds": round(duration, 2),
"progress_updates": len(self.progress_history),
"user_confirmations": len(self.user_confirmations),
"stages_completed": len(self.active_stages),
"capabilities_used": [cap for cap, available in self.capabilities.items() if available],
"start_time": self.operation_start_time,
"end_time": time.time(),
}
class MiddlewareError(Exception):
"""Base exception for middleware-related errors"""
pass
class ToolNotFoundError(MiddlewareError):
"""Raised when target CLI tool is not found or available"""
pass
class HookInstallationError(MiddlewareError):
"""Raised when middleware hooks cannot be installed"""
pass
class UserCancellationError(MiddlewareError):
"""Raised when user cancels an operation"""
pass