mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""Entry-point based tool loader for hanzo-mcp.
|
|
|
|
Discovers and loads tools from installed hanzo-tools-* packages using
|
|
Python entry points. This enables dynamic tool loading without code duplication.
|
|
|
|
The entry point group is "hanzo.tools" and each package exports:
|
|
[project.entry-points."hanzo.tools"]
|
|
package_name = "hanzo_tools.package:TOOLS"
|
|
|
|
Where TOOLS is a list of BaseTool subclasses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from importlib.metadata import entry_points
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from hanzo_tools.core import BaseTool, PermissionManager
|
|
from mcp.server import FastMCP
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Entry point group name
|
|
TOOLS_ENTRY_POINT_GROUP = "hanzo.tools"
|
|
|
|
# Package to tool prefix mapping (for enable/disable)
|
|
# HIP-0300 Operator Lattice: 16 tools organized by axis
|
|
PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
|
|
# Core operators (HIP-0300)
|
|
"filesystem": ["fs"], # Bytes + Paths axis
|
|
"core": ["id"], # Identity axis (hash, uri, ref, verify)
|
|
"code": ["code"], # Symbols + Structure axis (parse, transform, summarize)
|
|
"shell": ["exec"], # Execution axis
|
|
"vcs": ["git", "repo"], # History + Diffs axis; repo is the Hanzo git side
|
|
"test": ["test"], # Validation axis (check, build, test)
|
|
"net": ["fetch"], # Network axis (search, fetch, download, crawl)
|
|
"plan": ["plan"], # Orchestration axis (intent, route, compose)
|
|
# Control surfaces
|
|
"browser": ["browser"], # Web content control (Playwright/extension)
|
|
"computer": ["computer"], # OS/desktop control (computer use)
|
|
"ui": ["ui"], # UI component registry (browse, search, install)
|
|
# Extended operators
|
|
"lsp": ["lsp"], # Semantic stream (diagnostics, code_actions)
|
|
"memory": ["memory"], # Knowledge persistence
|
|
"todo": ["tasks"], # Task tracking
|
|
"reasoning": ["think", "critic"],
|
|
"refactor": ["refactor"],
|
|
"database": ["sql", "graph"],
|
|
"agent": ["agent", "zen", "review"],
|
|
"jupyter": ["jupyter"],
|
|
"editor": ["neovim_edit", "neovim_command", "neovim_session"],
|
|
"llm": ["llm", "kai_decide"],
|
|
"vector": ["index", "vector_index", "vector_search"],
|
|
"config": ["config", "mode", "workspace"],
|
|
"mcp_tools": ["mcp"],
|
|
"api": ["api", "hanzo"],
|
|
"auth": ["auth"],
|
|
"kms": ["kms"],
|
|
"paas": ["paas"],
|
|
"s3": ["s3"],
|
|
"billing": ["billing"],
|
|
"commerce": ["commerce"],
|
|
"iam_tools": ["iam"],
|
|
"ingress": ["ingress"],
|
|
"mpc": ["mpc"],
|
|
"team": ["team"],
|
|
}
|
|
|
|
|
|
class EntryPointToolLoader:
|
|
"""Loads tools from hanzo-tools-* packages via entry points.
|
|
|
|
This loader discovers installed tool packages and dynamically loads
|
|
and registers their tools with the MCP server.
|
|
"""
|
|
|
|
def __init__(self, permission_manager: "PermissionManager" | None = None):
|
|
"""Initialize the loader.
|
|
|
|
Args:
|
|
permission_manager: Optional permission manager for file tools
|
|
"""
|
|
self.permission_manager = permission_manager
|
|
self._discovered_packages: dict[str, Any] = {}
|
|
self._loaded_tools: dict[str, "BaseTool"] = {}
|
|
|
|
def _get_tool_name(self, tool_class: type) -> str:
|
|
"""Extract tool name from class, handling @property decorators.
|
|
|
|
When 'name' is defined as a @property, we need to instantiate
|
|
the class to get the actual value.
|
|
"""
|
|
# Check if name is a property in the class hierarchy
|
|
for klass in tool_class.__mro__:
|
|
if "name" in getattr(klass, "__dict__", {}):
|
|
attr = klass.__dict__["name"]
|
|
if isinstance(attr, property):
|
|
# Need to instantiate to get property value
|
|
try:
|
|
instance = tool_class()
|
|
name = getattr(instance, "name", None)
|
|
if isinstance(name, str):
|
|
return name
|
|
except Exception:
|
|
pass
|
|
# Fall back to class name
|
|
return tool_class.__name__.lower().replace("tool", "")
|
|
break
|
|
|
|
# Try class-level attribute
|
|
name = getattr(tool_class, "name", None)
|
|
if isinstance(name, str):
|
|
return name
|
|
|
|
# Fall back to class name
|
|
return tool_class.__name__.lower().replace("tool", "")
|
|
|
|
def discover_packages(self) -> dict[str, list[str]]:
|
|
"""Discover installed hanzo-tools-* packages.
|
|
|
|
Returns:
|
|
Dict mapping package name to list of tool names
|
|
"""
|
|
discovered = {}
|
|
|
|
# Get entry points for hanzo.tools group
|
|
try:
|
|
if sys.version_info >= (3, 10):
|
|
eps = entry_points(group=TOOLS_ENTRY_POINT_GROUP)
|
|
else:
|
|
# Python 3.9 compatibility
|
|
eps = entry_points().get(TOOLS_ENTRY_POINT_GROUP, [])
|
|
|
|
for ep in eps:
|
|
try:
|
|
# Load the TOOLS list from the entry point
|
|
tools_list = ep.load()
|
|
|
|
if isinstance(tools_list, list):
|
|
tool_names = []
|
|
for tool_class in tools_list:
|
|
name = self._get_tool_name(tool_class)
|
|
tool_names.append(name)
|
|
|
|
discovered[ep.name] = tool_names
|
|
self._discovered_packages[ep.name] = tools_list
|
|
logger.debug(
|
|
f"Discovered package '{ep.name}' with tools: {tool_names}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load entry point '{ep.name}': {e}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to discover entry points: {e}")
|
|
|
|
return discovered
|
|
|
|
def load_package(
|
|
self,
|
|
package_name: str,
|
|
mcp_server: "FastMCP",
|
|
enabled_tools: dict[str, bool] | None = None,
|
|
**kwargs: Any,
|
|
) -> list["BaseTool"]:
|
|
"""Load tools from a specific package.
|
|
|
|
Args:
|
|
package_name: Name of the package (e.g., "filesystem", "shell")
|
|
mcp_server: The FastMCP server to register tools with
|
|
enabled_tools: Dict of tool_name -> enabled state
|
|
**kwargs: Additional arguments passed to tool registration
|
|
|
|
Returns:
|
|
List of registered BaseTool instances
|
|
"""
|
|
if package_name not in self._discovered_packages:
|
|
logger.warning(f"Package '{package_name}' not discovered")
|
|
return []
|
|
|
|
tools_list = self._discovered_packages[package_name]
|
|
registered = []
|
|
enabled_tools = enabled_tools or {}
|
|
|
|
for tool_class in tools_list:
|
|
tool_name = self._get_tool_name(tool_class)
|
|
|
|
# Check if tool is enabled
|
|
if not enabled_tools.get(tool_name, True):
|
|
logger.debug(f"Skipping disabled tool: {tool_name}")
|
|
continue
|
|
|
|
try:
|
|
# Try different instantiation patterns
|
|
if self.permission_manager and hasattr(tool_class, "__init__"):
|
|
# Check if tool accepts permission_manager
|
|
import inspect
|
|
|
|
sig = inspect.signature(tool_class.__init__)
|
|
params = list(sig.parameters.keys())
|
|
|
|
if "permission_manager" in params:
|
|
tool = tool_class(permission_manager=self.permission_manager)
|
|
else:
|
|
tool = tool_class()
|
|
else:
|
|
tool = tool_class()
|
|
|
|
# Register with MCP server
|
|
if hasattr(tool, "register"):
|
|
tool.register(mcp_server)
|
|
|
|
self._loaded_tools[tool_name] = tool
|
|
registered.append(tool)
|
|
logger.debug(f"Registered tool: {tool_name}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to register tool '{tool_name}': {e}")
|
|
|
|
return registered
|
|
|
|
def load_all(
|
|
self,
|
|
mcp_server: "FastMCP",
|
|
enabled_tools: dict[str, bool] | None = None,
|
|
enabled_packages: dict[str, bool] | None = None,
|
|
**kwargs: Any,
|
|
) -> dict[str, "BaseTool"]:
|
|
"""Load all discovered tools.
|
|
|
|
Args:
|
|
mcp_server: The FastMCP server to register tools with
|
|
enabled_tools: Dict of tool_name -> enabled state
|
|
enabled_packages: Dict of package_name -> enabled state
|
|
**kwargs: Additional arguments passed to tool registration
|
|
|
|
Returns:
|
|
Dict mapping tool name to BaseTool instance
|
|
"""
|
|
if not self._discovered_packages:
|
|
self.discover_packages()
|
|
|
|
enabled_tools = enabled_tools or {}
|
|
enabled_packages = enabled_packages or {}
|
|
|
|
for package_name in self._discovered_packages:
|
|
# Check if package is enabled
|
|
if not enabled_packages.get(package_name, True):
|
|
logger.debug(f"Skipping disabled package: {package_name}")
|
|
continue
|
|
|
|
self.load_package(
|
|
package_name,
|
|
mcp_server,
|
|
# Apply global resolved tool state directly. Package-prefix mapping is
|
|
# advisory for UX/grouping but not authoritative for runtime gating.
|
|
enabled_tools=enabled_tools,
|
|
**kwargs,
|
|
)
|
|
|
|
return self._loaded_tools
|
|
|
|
def get_tool(self, name: str) -> "BaseTool | None":
|
|
"""Get a loaded tool by name."""
|
|
return self._loaded_tools.get(name)
|
|
|
|
def list_tools(self) -> list[str]:
|
|
"""List all loaded tool names."""
|
|
return list(self._loaded_tools.keys())
|
|
|
|
def list_packages(self) -> list[str]:
|
|
"""List all discovered package names."""
|
|
return list(self._discovered_packages.keys())
|
|
|
|
|
|
def discover_tools() -> dict[str, list[str]]:
|
|
"""Discover all available tools from installed packages.
|
|
|
|
Returns:
|
|
Dict mapping package name to list of tool names
|
|
"""
|
|
loader = EntryPointToolLoader()
|
|
return loader.discover_packages()
|
|
|
|
|
|
def register_tools_from_entrypoints(
|
|
mcp_server: "FastMCP",
|
|
permission_manager: "PermissionManager" | None = None,
|
|
enabled_tools: dict[str, bool] | None = None,
|
|
enabled_packages: dict[str, bool] | None = None,
|
|
**kwargs: Any,
|
|
) -> dict[str, "BaseTool"]:
|
|
"""Register tools from all discovered hanzo-tools-* packages.
|
|
|
|
This is the main entry point for loading tools via entry points.
|
|
|
|
Args:
|
|
mcp_server: The FastMCP server to register tools with
|
|
permission_manager: Optional permission manager for file tools
|
|
enabled_tools: Dict of tool_name -> enabled state
|
|
enabled_packages: Dict of package_name -> enabled state
|
|
**kwargs: Additional arguments passed to tool registration
|
|
|
|
Returns:
|
|
Dict mapping tool name to BaseTool instance
|
|
"""
|
|
loader = EntryPointToolLoader(permission_manager=permission_manager)
|
|
return loader.load_all(
|
|
mcp_server,
|
|
enabled_tools=enabled_tools,
|
|
enabled_packages=enabled_packages,
|
|
**kwargs,
|
|
)
|