Files
zooqueenandHanzo Dev b144973bfc fix(lint): resolve all ruff + black errors across all packages
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310,
S306, S110, TID251, UP042, F401) and run black on 526 files.

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-21 23:32:32 -08:00

535 lines
16 KiB
Python

"""Unified MCP tool for managing MCP servers."""
import os
import json
import signal
import subprocess
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from pathlib import Path
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, ToolContext, auto_timeout, create_tool_context
# Parameter types
Action = Annotated[
str,
Field(
description="Action to perform: list, add, remove, enable, disable, restart, config",
default="list",
),
]
Name = Annotated[
Optional[str],
Field(
description="MCP server name",
default=None,
),
]
Command = Annotated[
Optional[str],
Field(
description="Command to run the MCP server",
default=None,
),
]
Args = Annotated[
Optional[List[str]],
Field(
description="Arguments for the MCP server command",
default=None,
),
]
Env = Annotated[
Optional[Dict[str, str]],
Field(
description="Environment variables for the MCP server",
default=None,
),
]
ConfigKey = Annotated[
Optional[str],
Field(
description="Configuration key to get/set",
default=None,
),
]
ConfigValue = Annotated[
Optional[Any],
Field(
description="Configuration value to set",
default=None,
),
]
AutoStart = Annotated[
bool,
Field(
description="Auto-start server when Hanzo AI starts",
default=True,
),
]
class MCPParams(TypedDict, total=False):
"""Parameters for MCP tool."""
action: str
name: Optional[str]
command: Optional[str]
args: Optional[List[str]]
env: Optional[Dict[str, str]]
config_key: Optional[str]
config_value: Optional[Any]
auto_start: bool
@final
class MCPTool(BaseTool):
"""Tool for managing MCP servers."""
# Config file
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "servers.json"
# Running servers tracking
_running_servers: Dict[str, subprocess.Popen] = {}
def __init__(self):
"""Initialize the MCP management tool."""
self.config = self._load_config()
# Auto-start servers if configured
self._auto_start_servers()
def _load_config(self) -> Dict[str, Any]:
"""Load MCP server configuration."""
if self.CONFIG_FILE.exists():
try:
with open(self.CONFIG_FILE, "r") as f:
return json.load(f)
except Exception:
pass
# Default configuration with some examples
return {
"servers": {
# Example configurations (disabled by default)
"filesystem": {
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {},
"enabled": False,
"auto_start": False,
"description": "MCP filesystem server for /tmp access",
},
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"},
"enabled": False,
"auto_start": False,
"description": "GitHub API access via MCP",
},
"postgres": {
"command": "npx",
"args": [
"@modelcontextprotocol/server-postgres",
"postgresql://localhost/db",
],
"env": {},
"enabled": False,
"auto_start": False,
"description": "PostgreSQL database access",
},
},
"global_env": {},
"log_dir": str(Path.home() / ".hanzo" / "mcp" / "logs"),
}
def _save_config(self):
"""Save configuration."""
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(self.CONFIG_FILE, "w") as f:
json.dump(self.config, f, indent=2)
def _auto_start_servers(self):
"""Auto-start servers configured for auto-start."""
for name, server_config in self.config.get("servers", {}).items():
if server_config.get("enabled", False) and server_config.get(
"auto_start", False
):
self._start_server(name, server_config)
def _start_server(self, name: str, config: Dict[str, Any]) -> bool:
"""Start an MCP server."""
if name in self._running_servers:
return False # Already running
try:
# Prepare environment
env = os.environ.copy()
env.update(self.config.get("global_env", {}))
# Process server-specific env vars
server_env = config.get("env", {})
for key, value in server_env.items():
# Replace ${VAR} with actual environment variable
if value.startswith("${") and value.endswith("}"):
var_name = value[2:-1]
if var_name in os.environ:
value = os.environ[var_name]
env[key] = value
# Prepare command
cmd = [config["command"]] + config.get("args", [])
# Create log directory
log_dir = Path(
self.config.get("log_dir", str(Path.home() / ".hanzo" / "mcp" / "logs"))
)
log_dir.mkdir(parents=True, exist_ok=True)
# Start process
log_file = log_dir / f"{name}.log"
with open(log_file, "a") as log:
process = subprocess.Popen(
cmd,
env=env,
stdout=log,
stderr=subprocess.STDOUT,
preexec_fn=os.setsid if os.name != "nt" else None,
)
self._running_servers[name] = process
return True
except Exception:
return False
def _stop_server(self, name: str) -> bool:
"""Stop an MCP server."""
if name not in self._running_servers:
return False
process = self._running_servers[name]
try:
if os.name == "nt":
process.terminate()
else:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait(timeout=5)
except Exception:
# Force kill if needed
try:
if os.name == "nt":
process.kill()
else:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
pass
del self._running_servers[name]
return True
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "mcp"
@property
@override
def description(self) -> str:
"""Get the tool description."""
servers = self.config.get("servers", {})
enabled = sum(1 for s in servers.values() if s.get("enabled", False))
running = len(self._running_servers)
return f"""Manage MCP servers. Actions: list (default), add, remove, enable, disable, restart, config.
Usage:
mcp
mcp --action add --name github --command npx --args '["@modelcontextprotocol/server-github"]'
mcp --action enable --name github
Status: {enabled} enabled, {running} running"""
@override
@auto_timeout("mcp")
async def call(
self,
ctx: MCPContext,
**params: Unpack[MCPParams],
) -> str:
"""Execute MCP management action."""
# Create tool context only if we have a proper MCP context
tool_ctx = None
try:
if hasattr(ctx, "client") and ctx.client and hasattr(ctx.client, "server"):
tool_ctx = create_tool_context(ctx)
if tool_ctx:
await tool_ctx.set_tool_info(self.name)
except Exception:
pass
# Extract action
action = params.get("action", "list")
# Route to appropriate handler
if action == "list":
return self._handle_list()
elif action == "add":
return self._handle_add(params)
elif action == "remove":
return self._handle_remove(params.get("name"))
elif action == "enable":
return self._handle_enable(params.get("name"))
elif action == "disable":
return self._handle_disable(params.get("name"))
elif action == "restart":
return self._handle_restart(params.get("name"))
elif action == "config":
return self._handle_config(
params.get("config_key"), params.get("config_value")
)
else:
return f"Error: Unknown action '{action}'. Valid actions: list, add, remove, enable, disable, restart, config"
def _handle_list(self) -> str:
"""List all MCP servers."""
servers = self.config.get("servers", {})
if not servers:
return "No MCP servers configured. Use 'mcp --action add' to add one."
output = ["=== MCP Servers ==="]
output.append(
f"Total: {len(servers)} | Enabled: {sum(1 for s in servers.values() if s.get('enabled', False))} | Running: {len(self._running_servers)}"
)
output.append("")
for name, config in sorted(servers.items()):
status_parts = []
# Check if enabled
if config.get("enabled", False):
status_parts.append("✅ Enabled")
else:
status_parts.append("❌ Disabled")
# Check if running
if name in self._running_servers:
process = self._running_servers[name]
if process.poll() is None:
status_parts.append("🟢 Running")
else:
status_parts.append("🔴 Stopped")
del self._running_servers[name]
else:
status_parts.append("⚫ Not running")
# Auto-start status
if config.get("auto_start", False):
status_parts.append("🚀 Auto-start")
status = " | ".join(status_parts)
output.append(f"{name}: {status}")
if config.get("description"):
output.append(f" Description: {config['description']}")
output.append(
f" Command: {config['command']} {' '.join(config.get('args', []))}"
)
if config.get("env"):
env_str = ", ".join([f"{k}={v}" for k, v in config["env"].items()])
output.append(f" Environment: {env_str}")
output.append("\nUse 'mcp --action enable --name <server>' to enable a server")
output.append("Use 'mcp --action add' to add a new server")
return "\n".join(output)
def _handle_add(self, params: Dict[str, Any]) -> str:
"""Add a new MCP server."""
name = params.get("name")
command = params.get("command")
if not name:
return "Error: name is required for add action"
if not command:
return "Error: command is required for add action"
servers = self.config.get("servers", {})
if name in servers:
return f"Error: Server '{name}' already exists. Use a different name or remove it first."
# Create server config
server_config = {
"command": command,
"args": params.get("args", []),
"env": params.get("env", {}),
"enabled": False,
"auto_start": params.get("auto_start", True),
"description": params.get("description", ""),
}
servers[name] = server_config
self.config["servers"] = servers
self._save_config()
return f"Successfully added MCP server '{name}'. Use 'mcp --action enable --name {name}' to enable it."
def _handle_remove(self, name: Optional[str]) -> str:
"""Remove an MCP server."""
if not name:
return "Error: name is required for remove action"
servers = self.config.get("servers", {})
if name not in servers:
return f"Error: Server '{name}' not found"
# Stop if running
if name in self._running_servers:
self._stop_server(name)
del servers[name]
self.config["servers"] = servers
self._save_config()
return f"Successfully removed MCP server '{name}'"
def _handle_enable(self, name: Optional[str]) -> str:
"""Enable an MCP server."""
if not name:
return "Error: name is required for enable action"
servers = self.config.get("servers", {})
if name not in servers:
return f"Error: Server '{name}' not found"
servers[name]["enabled"] = True
self.config["servers"] = servers
self._save_config()
# Start if auto-start is enabled
if servers[name].get("auto_start", False):
if self._start_server(name, servers[name]):
return f"Successfully enabled and started MCP server '{name}'"
else:
return f"Enabled MCP server '{name}' but failed to start it. Check the configuration."
return f"Successfully enabled MCP server '{name}'"
def _handle_disable(self, name: Optional[str]) -> str:
"""Disable an MCP server."""
if not name:
return "Error: name is required for disable action"
servers = self.config.get("servers", {})
if name not in servers:
return f"Error: Server '{name}' not found"
# Stop if running
if name in self._running_servers:
self._stop_server(name)
servers[name]["enabled"] = False
self.config["servers"] = servers
self._save_config()
return f"Successfully disabled MCP server '{name}'"
def _handle_restart(self, name: Optional[str]) -> str:
"""Restart an MCP server."""
if not name:
return "Error: name is required for restart action"
servers = self.config.get("servers", {})
if name not in servers:
return f"Error: Server '{name}' not found"
if not servers[name].get("enabled", False):
return f"Error: Server '{name}' is not enabled"
# Stop if running
if name in self._running_servers:
self._stop_server(name)
# Start again
if self._start_server(name, servers[name]):
return f"Successfully restarted MCP server '{name}'"
else:
return f"Failed to restart MCP server '{name}'. Check the configuration."
def _handle_config(self, key: Optional[str], value: Optional[Any]) -> str:
"""Get or set configuration values."""
if not key:
# Show all config
return json.dumps(self.config, indent=2)
# Parse nested keys (e.g., "servers.github.auto_start")
keys = key.split(".")
if value is None:
# Get value
current = self.config
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return f"Configuration key '{key}' not found"
return (
json.dumps(current, indent=2)
if isinstance(current, (dict, list))
else str(current)
)
else:
# Set value
# Navigate to parent
current = self.config
for k in keys[:-1]:
if k not in current:
current[k] = {}
current = current[k]
# Parse value if it looks like JSON
if (
isinstance(value, str)
and value.startswith("{")
or value.startswith("[")
):
try:
value = json.loads(value)
except Exception:
pass
# Set the value
current[keys[-1]] = value
self._save_config()
return f"Successfully set {key} = {json.dumps(value) if isinstance(value, (dict, list)) else value}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass