mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
415 lines
14 KiB
Python
415 lines
14 KiB
Python
"""Unified tool management command.
|
|
|
|
Combines install, uninstall, upgrade, enable, disable, and list into a single tool.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Literal, Optional, TypedDict, Unpack, final, override
|
|
|
|
# Import async I/O utilities
|
|
from hanzo_async import mkdir, path_exists, read_json, write_json
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
from pydantic import Field
|
|
|
|
from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
|
from hanzo_mcp.tools.common.base import BaseTool
|
|
from hanzo_mcp.tools.common.context import create_tool_context
|
|
|
|
Action = Annotated[
|
|
Literal[
|
|
"install", # Install a tool package
|
|
"uninstall", # Remove a tool package
|
|
"upgrade", # Upgrade package(s)
|
|
"reload", # Hot-reload a package
|
|
"enable", # Enable a tool
|
|
"disable", # Disable a tool
|
|
"list", # List installed tools
|
|
"status", # Show tool status
|
|
"self_update", # Update hanzo-mcp itself
|
|
],
|
|
Field(description="Action to perform"),
|
|
]
|
|
|
|
|
|
class ToolParams(TypedDict, total=False):
|
|
"""Parameters for unified tool command."""
|
|
|
|
action: str
|
|
name: Optional[str] # Tool or package name
|
|
source: str # For install: pypi, git, local
|
|
version: Optional[str] # Version constraint
|
|
persist: bool # Persist enable/disable changes
|
|
category: Optional[str] # Filter list by category
|
|
disabled: bool # Show only disabled tools
|
|
enabled: bool # Show only enabled tools
|
|
|
|
|
|
@final
|
|
class UnifiedToolTool(BaseTool):
|
|
"""Unified tool management.
|
|
|
|
Combines package installation, tool enable/disable, and listing
|
|
into a single coherent command.
|
|
"""
|
|
|
|
# Tool states stored here
|
|
_tool_states: dict = {}
|
|
_config_file = Path.home() / ".hanzo" / "mcp" / "tool_states.json"
|
|
_initialized = False
|
|
|
|
def __init__(self):
|
|
"""Initialize the tool."""
|
|
# Note: Async initialization happens on first use via _ensure_initialized
|
|
pass
|
|
|
|
@classmethod
|
|
async def _ensure_initialized(cls):
|
|
"""Ensure states are loaded (async)."""
|
|
if not cls._initialized:
|
|
await cls._load_states_async()
|
|
cls._initialized = True
|
|
|
|
@classmethod
|
|
async def _load_states_async(cls):
|
|
"""Load tool states from config file (async)."""
|
|
if await path_exists(cls._config_file):
|
|
try:
|
|
cls._tool_states = await read_json(cls._config_file)
|
|
except Exception:
|
|
cls._tool_states = {}
|
|
else:
|
|
cls._tool_states = {}
|
|
|
|
@classmethod
|
|
async def _save_states_async(cls):
|
|
"""Save tool states to config file (async)."""
|
|
await mkdir(cls._config_file.parent, parents=True, exist_ok=True)
|
|
await write_json(cls._config_file, cls._tool_states)
|
|
|
|
@classmethod
|
|
def is_tool_enabled(cls, tool_name: str) -> bool:
|
|
"""Check if a tool is enabled (sync, uses cached state)."""
|
|
# Uses cached state - call _ensure_initialized first in async contexts
|
|
return cls._tool_states.get(tool_name, True)
|
|
|
|
@property
|
|
@override
|
|
def name(self) -> str:
|
|
return "tool"
|
|
|
|
@property
|
|
@override
|
|
def description(self) -> str:
|
|
return """Unified tool management command.
|
|
|
|
Actions:
|
|
- list: List all tools and their status
|
|
- enable: Enable a disabled tool
|
|
- disable: Disable a tool
|
|
- status: Check status of a specific tool
|
|
- install: Install a tool package from PyPI/git
|
|
- uninstall: Remove a tool package
|
|
- upgrade: Upgrade package(s) to latest
|
|
- reload: Hot-reload a package without restart
|
|
- self_update: Update hanzo-mcp itself
|
|
|
|
Examples:
|
|
tool list # List all tools
|
|
tool list --category=shell # List shell tools
|
|
tool list --disabled # List disabled tools
|
|
tool enable --name=browser # Enable browser tool
|
|
tool disable --name=grep # Disable grep
|
|
tool status --name=llm # Check llm status
|
|
tool install --name=hanzo-tools-data # Install package
|
|
tool upgrade # Upgrade all
|
|
tool self_update # Update hanzo-mcp
|
|
"""
|
|
|
|
@override
|
|
@auto_timeout("tool")
|
|
async def call(
|
|
self,
|
|
ctx: MCPContext,
|
|
**params: Unpack[ToolParams],
|
|
) -> str:
|
|
"""Execute tool management action."""
|
|
# Ensure async initialization
|
|
await self._ensure_initialized()
|
|
|
|
tool_ctx = create_tool_context(ctx)
|
|
await tool_ctx.set_tool_info(self.name)
|
|
|
|
action = params.get("action", "list")
|
|
name = params.get("name")
|
|
source = params.get("source", "pypi")
|
|
version = params.get("version")
|
|
persist = params.get("persist", True)
|
|
category = params.get("category")
|
|
show_disabled = params.get("disabled", False)
|
|
show_enabled = params.get("enabled", False)
|
|
|
|
# Route to appropriate handler
|
|
if action == "list":
|
|
return await self._handle_list(category, show_disabled, show_enabled)
|
|
elif action == "status":
|
|
return await self._handle_status(name)
|
|
elif action == "enable":
|
|
return await self._handle_enable(name, persist)
|
|
elif action == "disable":
|
|
return await self._handle_disable(name, persist)
|
|
elif action == "install":
|
|
return await self._handle_install(name, source, version, tool_ctx)
|
|
elif action == "uninstall":
|
|
return await self._handle_uninstall(name, tool_ctx)
|
|
elif action == "upgrade":
|
|
return await self._handle_upgrade(name, tool_ctx)
|
|
elif action == "reload":
|
|
return await self._handle_reload(name, tool_ctx)
|
|
elif action == "self_update":
|
|
return await self._handle_self_update(tool_ctx)
|
|
else:
|
|
return f"Unknown action: {action}. Use: list, enable, disable, status, install, uninstall, upgrade, reload, self_update"
|
|
|
|
async def _handle_list(
|
|
self,
|
|
category: Optional[str],
|
|
show_disabled: bool,
|
|
show_enabled: bool,
|
|
) -> str:
|
|
"""List all tools."""
|
|
from hanzo_mcp.config.tool_config import DynamicToolRegistry
|
|
|
|
DynamicToolRegistry.initialize()
|
|
all_tools = DynamicToolRegistry.list_all()
|
|
|
|
lines = []
|
|
if show_disabled:
|
|
lines.append("=== Disabled Tools ===\n")
|
|
elif show_enabled:
|
|
lines.append("=== Enabled Tools ===\n")
|
|
else:
|
|
lines.append("=== All Tools ===\n")
|
|
|
|
# Group by category
|
|
by_category: dict = {}
|
|
for tool_name, config in all_tools.items():
|
|
cat = config.category.value if config.category else "other"
|
|
if category and cat != category:
|
|
continue
|
|
if cat not in by_category:
|
|
by_category[cat] = []
|
|
|
|
is_enabled = self.is_tool_enabled(tool_name)
|
|
if show_disabled and is_enabled:
|
|
continue
|
|
if show_enabled and not is_enabled:
|
|
continue
|
|
|
|
by_category[cat].append((tool_name, config, is_enabled))
|
|
|
|
# Output by category
|
|
for cat, tools in sorted(by_category.items()):
|
|
if not tools:
|
|
continue
|
|
lines.append(f"[{cat}]")
|
|
for tool_name, config, is_enabled in sorted(tools, key=lambda x: x[0]):
|
|
status = "✓" if is_enabled else "○"
|
|
desc = (
|
|
config.description[:50] + "..."
|
|
if len(config.description) > 50
|
|
else config.description
|
|
)
|
|
lines.append(f" {status} {tool_name}: {desc}")
|
|
lines.append("")
|
|
|
|
# Summary
|
|
total = sum(len(t) for t in by_category.values())
|
|
enabled = sum(1 for tools in by_category.values() for _, _, e in tools if e)
|
|
lines.append(
|
|
f"Total: {total} | Enabled: {enabled} | Disabled: {total - enabled}"
|
|
)
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_status(self, name: Optional[str]) -> str:
|
|
"""Check status of a specific tool."""
|
|
if not name:
|
|
return "Error: name required for status action"
|
|
|
|
from hanzo_mcp.config.tool_config import DynamicToolRegistry
|
|
|
|
DynamicToolRegistry.initialize()
|
|
config = DynamicToolRegistry.get(name)
|
|
|
|
if not config:
|
|
return f"Tool '{name}' not found"
|
|
|
|
is_enabled = self.is_tool_enabled(name)
|
|
status = "enabled" if is_enabled else "disabled"
|
|
|
|
return f"""Tool: {name}
|
|
Status: {status}
|
|
Category: {config.category.value if config.category else "unknown"}
|
|
Package: {config.package or "built-in"}
|
|
Description: {config.description}"""
|
|
|
|
async def _handle_enable(self, name: Optional[str], persist: bool) -> str:
|
|
"""Enable a tool."""
|
|
if not name:
|
|
return "Error: name required for enable action"
|
|
|
|
if self.is_tool_enabled(name):
|
|
return f"Tool '{name}' is already enabled"
|
|
|
|
self._tool_states[name] = True
|
|
if persist:
|
|
await self._save_states_async()
|
|
|
|
return f"✓ Enabled tool '{name}'" + ("" if persist else " (temporary)")
|
|
|
|
async def _handle_disable(self, name: Optional[str], persist: bool) -> str:
|
|
"""Disable a tool."""
|
|
if not name:
|
|
return "Error: name required for disable action"
|
|
|
|
# Prevent disabling critical tools
|
|
critical = {"tool", "version", "stats", "config", "mode", "llm", "consensus"}
|
|
if name in critical:
|
|
return f"Error: Cannot disable critical tool '{name}'"
|
|
|
|
if not self.is_tool_enabled(name):
|
|
return f"Tool '{name}' is already disabled"
|
|
|
|
self._tool_states[name] = False
|
|
if persist:
|
|
await self._save_states_async()
|
|
|
|
return f"○ Disabled tool '{name}'" + ("" if persist else " (temporary)")
|
|
|
|
async def _handle_install(
|
|
self,
|
|
name: Optional[str],
|
|
source: str,
|
|
version: Optional[str],
|
|
tool_ctx: Any,
|
|
) -> str:
|
|
"""Install a tool package."""
|
|
if not name:
|
|
return "Error: package name required for install"
|
|
|
|
from hanzo_mcp.tools.common.tool_registry import get_registry
|
|
|
|
await tool_ctx.info(f"Installing {name} from {source}...")
|
|
registry = await get_registry()
|
|
result = await registry.install(package=name, source=source, version=version)
|
|
|
|
if result["success"]:
|
|
tools = result.get("tools", [])
|
|
return (
|
|
f"✓ Installed {name} v{result.get('version', 'latest')}\n"
|
|
f"Tools: {', '.join(tools) if tools else 'none detected'}"
|
|
)
|
|
return f"✗ Failed: {result.get('error', 'unknown error')}"
|
|
|
|
async def _handle_uninstall(self, name: Optional[str], tool_ctx: Any) -> str:
|
|
"""Uninstall a tool package."""
|
|
if not name:
|
|
return "Error: package name required for uninstall"
|
|
|
|
from hanzo_mcp.tools.common.tool_registry import get_registry
|
|
|
|
await tool_ctx.info(f"Uninstalling {name}...")
|
|
registry = await get_registry()
|
|
result = await registry.uninstall(name)
|
|
|
|
if result["success"]:
|
|
return f"✓ Uninstalled {name}"
|
|
return f"✗ Failed: {result.get('error', 'unknown error')}"
|
|
|
|
async def _handle_upgrade(self, name: Optional[str], tool_ctx: Any) -> str:
|
|
"""Upgrade tool package(s)."""
|
|
from hanzo_mcp.tools.common.tool_registry import get_registry
|
|
|
|
await tool_ctx.info(f"Upgrading {name or 'all packages'}...")
|
|
registry = await get_registry()
|
|
result = await registry.upgrade(name)
|
|
|
|
if result["success"]:
|
|
upgraded = [
|
|
r["package"] for r in result.get("results", []) if r.get("success")
|
|
]
|
|
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
|
|
return "✗ Upgrade failed"
|
|
|
|
async def _handle_reload(self, name: Optional[str], tool_ctx: Any) -> str:
|
|
"""Hot-reload a tool package."""
|
|
if not name:
|
|
return "Error: package name required for reload"
|
|
|
|
from hanzo_mcp.tools.common.tool_registry import get_registry
|
|
|
|
await tool_ctx.info(f"Reloading {name}...")
|
|
registry = await get_registry()
|
|
result = await registry.reload_package(name)
|
|
|
|
if result["success"]:
|
|
tools = result.get("tools", [])
|
|
return f"✓ Reloaded {name}\nTools: {', '.join(tools) if tools else 'none'}"
|
|
return f"✗ Failed: {result.get('error', 'unknown error')}"
|
|
|
|
async def _handle_self_update(self, tool_ctx: Any) -> str:
|
|
"""Update hanzo-mcp itself."""
|
|
from hanzo_mcp.tools.common.tool_registry import get_registry
|
|
|
|
await tool_ctx.info("Checking for hanzo-mcp updates...")
|
|
registry = await get_registry()
|
|
result = await registry.self_update()
|
|
|
|
if result["success"]:
|
|
return f"✓ Updated hanzo-mcp from v{result.get('current_version')}\n{result.get('message', '')}"
|
|
return f"✗ Update failed: {result.get('error', 'unknown error')}"
|
|
|
|
def register(self, mcp_server) -> None:
|
|
"""Register this tool with the MCP server."""
|
|
tool_instance = self
|
|
|
|
@mcp_server.tool()
|
|
async def tool(
|
|
action: Action = "list",
|
|
name: Annotated[
|
|
Optional[str], Field(description="Tool or package name")
|
|
] = None,
|
|
source: Annotated[
|
|
str, Field(description="Source: pypi, git, local")
|
|
] = "pypi",
|
|
version: Annotated[
|
|
Optional[str], Field(description="Version constraint")
|
|
] = None,
|
|
persist: Annotated[bool, Field(description="Persist changes")] = True,
|
|
category: Annotated[
|
|
Optional[str], Field(description="Filter by category")
|
|
] = None,
|
|
disabled: Annotated[bool, Field(description="Show only disabled")] = False,
|
|
enabled: Annotated[bool, Field(description="Show only enabled")] = False,
|
|
ctx: MCPContext = None,
|
|
) -> str:
|
|
"""Unified tool management: install, enable, disable, list, upgrade."""
|
|
return await tool_instance.call(
|
|
ctx,
|
|
action=action,
|
|
name=name,
|
|
source=source,
|
|
version=version,
|
|
persist=persist,
|
|
category=category,
|
|
disabled=disabled,
|
|
enabled=enabled,
|
|
)
|
|
|
|
|
|
def register_unified_tool(mcp_server) -> list:
|
|
"""Register the unified tool command."""
|
|
tool = UnifiedToolTool()
|
|
tool.register(mcp_server)
|
|
return [tool]
|