Files
zeekay 2cb7eb03fc feat(hanzo-mcp): unified tool command and essential tools system
Major improvements to mode and tool management:

1. Essential System Tools - Critical tools (llm, consensus, config, mode,
   tool, version, stats) are now ALWAYS enabled regardless of active mode.
   Previously, mode system disabled all tools then only enabled mode-specific
   ones, causing critical tools to be unavailable.

2. Unified 'tool' Command - Consolidates tool_install, tool_enable,
   tool_disable, tool_list into single 'tool' command with actions:
   - list: List all tools by category
   - enable/disable: Toggle tool state
   - install/uninstall: Package management
   - upgrade/reload: Update tools
   - self_update: Update hanzo-mcp

3. Fixed Permission Manager - Made permission_manager optional in:
   - FileSystemTool base class
   - ComputerTool
   - ConfigTool
   - ReadTool, WriteTool, EditTool
   Now creates default PermissionManager if not provided.

4. Fixed Mode System - Builtin modes (hanzo, minimal, fullstack, devops,
   security) are now always registered alongside hanzo-persona profiles.
   Previously they were skipped when hanzo-persona was available.

5. Updated ESSENTIAL_TOOLS constant with modern tool names and added
   llm, consensus, config, mode to ensure they're in all default modes.

Version bump: 0.10.19 -> 0.10.20
2025-12-26 11:09:45 -08:00

336 lines
12 KiB
Python

"""Configuration tool for Hanzo AI.
Git-style config tool for managing settings.
"""
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from pathlib import Path
from pydantic import Field
from hanzo_mcp.config import load_settings, save_settings
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, PermissionManager, auto_timeout
from .index_config import IndexScope, IndexConfig
# Parameter types
Action = Annotated[
str,
Field(
description="Action: get (default), set, list, toggle",
default="get",
),
]
Key = Annotated[
Optional[str],
Field(
description="Configuration key (e.g., tools.write.enabled, enabled_tools.write, index.scope)",
default=None,
),
]
Value = Annotated[
Optional[str],
Field(
description="Configuration value",
default=None,
),
]
Scope = Annotated[
str,
Field(
description="Config scope: local (project) or global",
default="local",
),
]
ConfigPath = Annotated[
Optional[str],
Field(
description="Path for project-specific config",
default=None,
),
]
class ConfigParams(TypedDict, total=False):
"""Parameters for config tool."""
action: str
key: Optional[str]
value: Optional[str]
scope: str
path: Optional[str]
def _parse_bool(value: str) -> Optional[bool]:
v = value.strip().lower()
if v in {"true", "1", "yes", "on"}:
return True
if v in {"false", "0", "no", "off"}:
return False
return None
@final
class ConfigTool(BaseTool):
"""Git-style configuration management tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
"""Initialize config tool."""
super().__init__()
if permission_manager is None:
permission_manager = PermissionManager()
self.permission_manager = permission_manager
self.index_config = IndexConfig()
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "config"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Git-style configuration. Actions: get (default), set, list, toggle.
Usage:
config index.scope
config --action set index.scope project
config --action set tools.write.enabled false
config --action list
config --action toggle index.scope --path ./project"""
@override
@auto_timeout("config")
async def call(
self,
ctx: MCPContext,
**params: Unpack[ConfigParams],
) -> str:
"""Execute config operation."""
tool_ctx = self.create_tool_context(ctx)
# Extract parameters
action = params.get("action", "get")
key = params.get("key")
value = params.get("value")
scope = params.get("scope", "local")
path = params.get("path")
# Route to handler
if action == "get":
return await self._handle_get(key, scope, path, tool_ctx)
elif action == "set":
return await self._handle_set(key, value, scope, path, tool_ctx)
elif action == "list":
return await self._handle_list(scope, path, tool_ctx)
elif action == "toggle":
return await self._handle_toggle(key, scope, path, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: get, set, list, toggle"
async def _handle_get(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
"""Get configuration value."""
if not key:
return "Error: key required for get action"
# Handle index scope
if key == "index.scope":
current_scope = self.index_config.get_scope(path)
return f"index.scope={current_scope.value}"
# tools.<name>.enabled → enabled_tools lookup
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
settings = load_settings(project_dir=path if scope == "local" else None)
return f"{key}={settings.is_tool_enabled(tool_name)}"
# enabled_tools.<name>
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
settings = load_settings(project_dir=path if scope == "local" else None)
val = settings.enabled_tools.get(tool_name)
return f"{key}={val if val is not None else 'unset'}"
# Indexing (legacy) per-tool setting: <tool>.enabled
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
enabled = self.index_config.is_indexing_enabled(tool)
return f"{key}={enabled}"
return f"Unknown key: {key}"
def _save_project_settings(self, settings, project_dir: Optional[str]) -> Path:
"""Save to project config if path provided; else global."""
if project_dir:
project_path = Path(project_dir)
project_path.mkdir(parents=True, exist_ok=True)
cfg = project_path / ".hanzo-mcp.json"
cfg.write_text(
__import__("json").dumps(settings.__dict__ if hasattr(settings, "__dict__") else {}, indent=2)
)
return cfg
# Fallback to global handler
return save_settings(settings, global_config=True)
async def _handle_set(
self,
key: Optional[str],
value: Optional[str],
scope: str,
path: Optional[str],
tool_ctx,
) -> str:
"""Set configuration value."""
if not key:
return "Error: key required for set action"
if value is None:
return "Error: value required for set action"
# Handle index scope
if key == "index.scope":
try:
new_scope = IndexScope(value)
self.index_config.set_scope(new_scope, path if scope == "local" else None)
return f"Set {key}={value} ({'project' if path else 'global'})"
except ValueError:
return f"Error: Invalid scope value '{value}'. Valid: project, global, auto"
# tools.<name>.enabled → enabled_tools mapping
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
settings = load_settings(project_dir=path if scope == "local" else None)
et = dict(settings.enabled_tools)
et[tool_name] = parsed
settings.enabled_tools = et
# Save
if scope == "local" and path:
# Write a project .hanzo-mcp.json adjacent to the path
# Note: save_settings(local) saves to CWD; we target specific path here
out = self._save_project_settings(settings, path)
return f"Set {key}={parsed} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Set {key}={parsed} (global: {out})"
# enabled_tools.<name>
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
settings = load_settings(project_dir=path if scope == "local" else None)
et = dict(settings.enabled_tools)
et[tool_name] = parsed
settings.enabled_tools = et
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Set {key}={parsed} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Set {key}={parsed} (global: {out})"
# Indexing (legacy) per-tool setting: <tool>.enabled (search indexers)
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
self.index_config.set_indexing_enabled(tool, parsed)
return f"Set {key}={parsed}"
return f"Unknown key: {key}"
async def _handle_list(self, scope: str, path: Optional[str], tool_ctx) -> str:
"""List all configuration."""
status = self.index_config.get_status()
output = ["=== Configuration ==="]
output.append(f"\nDefault scope: {status['default_scope']}")
if path:
current_scope = self.index_config.get_scope(path)
output.append(f"Current path scope: {current_scope.value}")
output.append(f"\nProjects with custom config: {status['project_count']}")
output.append("\nTool settings (indexing):")
for tool, settings in status["tools"].items():
output.append(f" {tool}:")
output.append(f" enabled: {settings['enabled']}")
output.append(f" per_project: {settings['per_project']}")
# Also show enabled_tools snapshot
settings_snapshot = load_settings(project_dir=path if scope == "local" else None)
output.append("\nEnabled tools (execution):")
for tool_name, enabled in sorted(settings_snapshot.enabled_tools.items()):
output.append(f" {tool_name}: {enabled}")
return "\n".join(output)
async def _handle_toggle(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
"""Toggle configuration value."""
if not key:
return "Error: key required for toggle action"
# Handle index scope toggle
if key == "index.scope":
new_scope = self.index_config.toggle_scope(path if scope == "local" else None)
return f"Toggled index.scope to {new_scope.value}"
# Handle execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
settings = load_settings(project_dir=path if scope == "local" else None)
current = bool(settings.enabled_tools.get(tool_name, True))
settings.enabled_tools[tool_name] = not current
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Toggled {key} to {not current} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Toggled {key} to {not current} (global: {out})"
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
settings = load_settings(project_dir=path if scope == "local" else None)
current = bool(settings.enabled_tools.get(tool_name, True))
settings.enabled_tools[tool_name] = not current
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Toggled {key} to {not current} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Toggled {key} to {not current} (global: {out})"
# Handle indexing toggles (legacy)
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
current = self.index_config.is_indexing_enabled(tool)
self.index_config.set_indexing_enabled(tool, not current)
return f"Toggled {key} to {not current}"
return f"Cannot toggle key: {key}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass