Files
zeekay 6db5cf2d7e feat: Add GitHub Actions workflow for Docker builds
- Add docker-publish.yml workflow for ghcr.io
- Add production-ready Dockerfile (if missing)
- Enable automated builds on push to main
- Support for semantic versioning tags
2025-07-23 23:06:26 -05:00

305 lines
9.4 KiB
Python

"""Base classes for process execution tools."""
import asyncio
import subprocess
import tempfile
import uuid
from abc import abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional, override
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.permissions import PermissionManager
class ProcessManager:
"""Singleton manager for background processes."""
_instance = None
_processes: Dict[str, subprocess.Popen] = {}
_logs: Dict[str, List[str]] = {}
_log_dir = Path(tempfile.gettempdir()) / "hanzo_mcp_logs"
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._log_dir.mkdir(exist_ok=True)
return cls._instance
def add_process(self, process_id: str, process: subprocess.Popen, log_file: Path) -> None:
"""Add a process to track."""
self._processes[process_id] = process
self._logs[process_id] = str(log_file)
def get_process(self, process_id: str) -> Optional[subprocess.Popen]:
"""Get a tracked process."""
return self._processes.get(process_id)
def remove_process(self, process_id: str) -> None:
"""Remove a process from tracking."""
self._processes.pop(process_id, None)
self._logs.pop(process_id, None)
def list_processes(self) -> Dict[str, Dict[str, Any]]:
"""List all tracked processes."""
result = {}
for pid, proc in list(self._processes.items()):
if proc.poll() is None:
result[pid] = {
"pid": proc.pid,
"running": True,
"log_file": self._logs.get(pid)
}
else:
result[pid] = {
"pid": proc.pid,
"running": False,
"return_code": proc.returncode,
"log_file": self._logs.get(pid)
}
# Clean up finished processes
self.remove_process(pid)
return result
def get_log_file(self, process_id: str) -> Optional[Path]:
"""Get log file path for a process."""
log_path = self._logs.get(process_id)
return Path(log_path) if log_path else None
@property
def log_dir(self) -> Path:
"""Get the log directory."""
return self._log_dir
class BaseProcessTool(BaseTool):
"""Base class for all process execution tools."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
"""Initialize the process tool.
Args:
permission_manager: Optional permission manager for access control
"""
super().__init__()
self.permission_manager = permission_manager
self.process_manager = ProcessManager()
@abstractmethod
def get_command_args(self, command: str, **kwargs) -> List[str]:
"""Get the command arguments for subprocess.
Args:
command: The command or script to run
**kwargs: Additional arguments specific to the tool
Returns:
List of command arguments for subprocess
"""
pass
@abstractmethod
def get_tool_name(self) -> str:
"""Get the name of the tool being used (e.g., 'bash', 'uvx', 'npx')."""
pass
async def execute_sync(
self,
command: str,
cwd: Optional[Path] = None,
env: Optional[Dict[str, str]] = None,
timeout: Optional[int] = None,
**kwargs
) -> str:
"""Execute a command synchronously and return output.
Args:
command: Command to execute
cwd: Working directory
env: Environment variables
timeout: Timeout in seconds
**kwargs: Additional tool-specific arguments
Returns:
Command output
Raises:
RuntimeError: If command fails
"""
# Check permissions if manager is available
if self.permission_manager and cwd:
if not self.permission_manager.is_path_allowed(str(cwd)):
raise PermissionError(f"Access denied to path: {cwd}")
# Get command arguments
cmd_args = self.get_command_args(command, **kwargs)
# Prepare environment
process_env = os.environ.copy()
if env:
process_env.update(env)
try:
result = subprocess.run(
cmd_args,
cwd=cwd,
env=process_env,
capture_output=True,
text=True,
timeout=timeout,
check=True
)
return result.stdout
except subprocess.TimeoutExpired:
raise RuntimeError(f"{self.get_tool_name()} command timed out after {timeout} seconds")
except subprocess.CalledProcessError as e:
error_msg = f"{self.get_tool_name()} command failed with exit code {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr}"
raise RuntimeError(error_msg)
async def execute_background(
self,
command: str,
cwd: Optional[Path] = None,
env: Optional[Dict[str, str]] = None,
**kwargs
) -> Dict[str, Any]:
"""Execute a command in the background.
Args:
command: Command to execute
cwd: Working directory
env: Environment variables
**kwargs: Additional tool-specific arguments
Returns:
Dict with process_id and log_file
"""
# Check permissions if manager is available
if self.permission_manager and cwd:
if not self.permission_manager.is_path_allowed(str(cwd)):
raise PermissionError(f"Access denied to path: {cwd}")
# Generate process ID and log file
process_id = f"{self.get_tool_name()}_{uuid.uuid4().hex[:8]}"
log_file = self.process_manager.log_dir / f"{process_id}.log"
# Get command arguments
cmd_args = self.get_command_args(command, **kwargs)
# Prepare environment
process_env = os.environ.copy()
if env:
process_env.update(env)
# Start process with output to log file
with open(log_file, "w") as f:
process = subprocess.Popen(
cmd_args,
cwd=cwd,
env=process_env,
stdout=f,
stderr=subprocess.STDOUT,
text=True
)
# Track the process
self.process_manager.add_process(process_id, process, log_file)
return {
"process_id": process_id,
"pid": process.pid,
"log_file": str(log_file),
"status": "started"
}
class BaseBinaryTool(BaseProcessTool):
"""Base class for binary execution tools (like npx, uvx)."""
@abstractmethod
def get_binary_name(self) -> str:
"""Get the name of the binary to execute."""
pass
@override
def get_command_args(self, command: str, **kwargs) -> List[str]:
"""Get command arguments for binary execution.
Args:
command: The package or command to run
**kwargs: Additional arguments (args, flags, etc.)
Returns:
List of command arguments
"""
cmd_args = [self.get_binary_name()]
# Add any binary-specific flags
if "flags" in kwargs:
cmd_args.extend(kwargs["flags"])
# Add the command/package
cmd_args.append(command)
# Add any additional arguments
if "args" in kwargs:
if isinstance(kwargs["args"], str):
cmd_args.extend(kwargs["args"].split())
else:
cmd_args.extend(kwargs["args"])
return cmd_args
@override
def get_tool_name(self) -> str:
"""Get the tool name (same as binary name by default)."""
return self.get_binary_name()
class BaseScriptTool(BaseProcessTool):
"""Base class for script execution tools (like bash, python)."""
@abstractmethod
def get_interpreter(self) -> str:
"""Get the interpreter to use."""
pass
@abstractmethod
def get_script_flags(self) -> List[str]:
"""Get default flags for the interpreter."""
pass
@override
def get_command_args(self, command: str, **kwargs) -> List[str]:
"""Get command arguments for script execution.
Args:
command: The script content to execute
**kwargs: Additional arguments
Returns:
List of command arguments
"""
cmd_args = [self.get_interpreter()]
cmd_args.extend(self.get_script_flags())
# For inline scripts, use -c flag
if not kwargs.get("is_file", False):
cmd_args.extend(["-c", command])
else:
cmd_args.append(command)
return cmd_args
@override
def get_tool_name(self) -> str:
"""Get the tool name (interpreter name by default)."""
return self.get_interpreter()
# Import os at the top of the file
import os