feat(hanzo-tools): complete modular tool packages v0.10.7

- Add hanzo-tools-llm (4 tools: llm, unified_llm, consensus, llm_manage)
- Add hanzo-tools-vector (3 tools: index, vector_index, vector_search)
- Add hanzo-tools-config (2 tools: config, mode)
- Add hanzo-tools-mcp (4 tools: mcp, mcp_add, mcp_remove, mcp_stats)
- Update hanzo-tools meta package with all 17 packages
- Bump hanzo-mcp to v0.10.7

Total: 63 tools across 17 modular packages

Bundle extras:
- [core]: filesystem, shell, todo, reasoning, config
- [dev]: core + editor, lsp, refactor
- [ai]: llm, agent, memory
- [all]: everything
This commit is contained in:
2025-12-26 03:51:15 -08:00
parent 08970ba62c
commit c32eef2fa0
35 changed files with 8691 additions and 32 deletions
+1 -1
View File
@@ -26,4 +26,4 @@ if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
except ImportError:
pass
__version__ = "0.9.24"
__version__ = "0.10.7"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.10.6"
version = "0.10.7"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md"
requires-python = ">=3.12"
+1 -1
View File
@@ -1271,7 +1271,7 @@ wheels = [
[[package]]
name = "hanzo-mcp"
version = "0.10.4"
version = "0.10.6"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
@@ -0,0 +1,3 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,59 @@
"""Configuration tools for Hanzo AI.
Tools:
- config: Configuration management
- mode: Development mode switching
Install:
pip install hanzo-tools-config
"""
import logging
logger = logging.getLogger(__name__)
_tools = []
try:
from .config_tool import ConfigTool
_tools.append(ConfigTool)
except ImportError as e:
logger.debug(f"ConfigTool not available: {e}")
ConfigTool = None
try:
from .mode_tool import ModeTool
_tools.append(ModeTool)
except ImportError as e:
logger.debug(f"ModeTool not available: {e}")
ModeTool = None
TOOLS = _tools
__all__ = [
"TOOLS",
"ConfigTool",
"ModeTool",
"register_tools",
]
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
"""Register config tools with MCP server."""
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
if tool_class is None:
continue
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
if enabled.get(tool_name, True):
try:
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except Exception as e:
logger.warning(f"Failed to register {tool_name}: {e}")
return registered
@@ -0,0 +1,333 @@
"""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 mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.config import load_settings, save_settings
from hanzo_tools.core import BaseTool
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import 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: PermissionManager):
"""Initialize config tool."""
super().__init__(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
@@ -0,0 +1,177 @@
"""Index configuration for per-project vs global indexing.
This module manages indexing configuration for different scopes.
"""
import json
from enum import Enum
from typing import Any, Dict, Optional
from pathlib import Path
class IndexScope(Enum):
"""Indexing scope options."""
PROJECT = "project" # Per-project indexing
GLOBAL = "global" # Global indexing
AUTO = "auto" # Auto-detect based on git root
class IndexConfig:
"""Manages indexing configuration."""
def __init__(self, config_dir: Optional[Path] = None):
"""Initialize index configuration."""
self.config_dir = config_dir or Path.home() / ".hanzo" / "mcp"
self.config_file = self.config_dir / "index_config.json"
self._config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from disk."""
if self.config_file.exists():
try:
with open(self.config_file, "r") as f:
return json.load(f)
except Exception:
pass
# Default configuration
return {
"default_scope": IndexScope.AUTO.value,
"project_configs": {},
"global_index_paths": [],
"index_settings": {
"vector": {
"enabled": True,
"auto_index": True,
"include_git_history": True,
},
"symbols": {
"enabled": True,
"auto_index": False,
},
"sql": {
"enabled": True,
"per_project": True,
},
"graph": {
"enabled": True,
"per_project": True,
},
},
}
def save_config(self) -> None:
"""Save configuration to disk."""
self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.config_file, "w") as f:
json.dump(self._config, f, indent=2)
def get_scope(self, path: Optional[str] = None) -> IndexScope:
"""Get indexing scope for a path."""
if not path:
return IndexScope(self._config["default_scope"])
# Check project-specific config
project_root = self._find_project_root(path)
if project_root:
project_config = self._config["project_configs"].get(str(project_root))
if project_config and "scope" in project_config:
return IndexScope(project_config["scope"])
# Use default
scope = IndexScope(self._config["default_scope"])
# Handle auto mode
if scope == IndexScope.AUTO:
if project_root:
return IndexScope.PROJECT
else:
return IndexScope.GLOBAL
return scope
def set_scope(self, scope: IndexScope, path: Optional[str] = None) -> None:
"""Set indexing scope."""
if path:
# Set for specific project
project_root = self._find_project_root(path)
if project_root:
if str(project_root) not in self._config["project_configs"]:
self._config["project_configs"][str(project_root)] = {}
self._config["project_configs"][str(project_root)]["scope"] = scope.value
else:
# Set global default
self._config["default_scope"] = scope.value
self.save_config()
def get_index_path(self, tool: str, path: Optional[str] = None) -> Path:
"""Get index path for a tool and location."""
scope = self.get_scope(path)
if scope == IndexScope.PROJECT and path:
project_root = self._find_project_root(path)
if project_root:
return Path(project_root) / ".hanzo" / "index" / tool
# Global index
return self.config_dir / "index" / tool
def is_indexing_enabled(self, tool: str) -> bool:
"""Check if indexing is enabled for a tool."""
return self._config["index_settings"].get(tool, {}).get("enabled", True)
def set_indexing_enabled(self, tool: str, enabled: bool) -> None:
"""Enable/disable indexing for a tool."""
if tool not in self._config["index_settings"]:
self._config["index_settings"][tool] = {}
self._config["index_settings"][tool]["enabled"] = enabled
self.save_config()
def toggle_scope(self, path: Optional[str] = None) -> IndexScope:
"""Toggle between project and global scope."""
current = self.get_scope(path)
if current == IndexScope.PROJECT:
new_scope = IndexScope.GLOBAL
elif current == IndexScope.GLOBAL:
new_scope = IndexScope.PROJECT
else: # AUTO
# Determine what auto resolves to and toggle
if path and self._find_project_root(path):
new_scope = IndexScope.GLOBAL
else:
new_scope = IndexScope.PROJECT
self.set_scope(new_scope, path)
return new_scope
def _find_project_root(self, path: str) -> Optional[Path]:
"""Find project root (git root or similar)."""
current = Path(path).resolve()
# Walk up looking for markers
markers = [".git", ".hg", "pyproject.toml", "package.json", "Cargo.toml"]
while current != current.parent:
for marker in markers:
if (current / marker).exists():
return current
current = current.parent
return None
def get_status(self) -> Dict[str, Any]:
"""Get current configuration status."""
return {
"default_scope": self._config["default_scope"],
"project_count": len(self._config["project_configs"]),
"tools": {
tool: {
"enabled": settings.get("enabled", True),
"per_project": settings.get("per_project", True),
}
for tool, settings in self._config["index_settings"].items()
},
}
@@ -0,0 +1,321 @@
"""Tool for managing development modes with programmer personalities."""
from typing import Optional, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_mcp.tools.common.mode import ModeRegistry, register_default_modes
from hanzo_tools.core import auto_timeout
class ModeTool(BaseTool):
"""Tool for managing development modes."""
name = "mode"
def __init__(self):
"""Initialize the mode tool."""
super().__init__()
# Register default modes on initialization
register_default_modes()
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Manage development modes (programmer personalities). Actions: list (default), activate, show, current.
Usage:
mode
mode --action list
mode --action activate guido
mode --action show linus
mode --action current"""
@override
async def run(
self,
ctx: MCPContext,
action: str = "list",
name: Optional[str] = None,
) -> str:
"""Manage development modes.
Args:
ctx: MCP context
action: Action to perform (list, activate, show, current)
name: Mode name (for activate/show actions)
Returns:
Action result
"""
if action == "list":
modes = ModeRegistry.list()
if not modes:
return "No modes registered"
output = ["Available development modes (100 programmer personalities):"]
active = ModeRegistry.get_active()
# Group modes by category
categories = {
"Language Creators": [
"guido",
"matz",
"brendan",
"dennis",
"bjarne",
"james",
"anders",
"larry",
"rasmus",
"rich",
],
"Systems & Infrastructure": [
"linus",
"rob",
"ken",
"bill",
"richard",
"brian",
"donald",
"graydon",
"ryan",
"mitchell",
],
"Web & Frontend": [
"tim",
"douglas",
"john",
"evan",
"jordan",
"jeremy",
"david",
"taylor",
"adrian",
"matt",
],
"Database & Data": [
"michael_s",
"michael_w",
"salvatore",
"dwight",
"edgar",
"jim_gray",
"jeff_dean",
"sanjay",
"mike",
"matei",
],
"AI & Machine Learning": [
"yann",
"geoffrey",
"yoshua",
"andrew",
"demis",
"ilya",
"andrej",
"chris",
"francois",
"jeremy_howard",
],
"Security & Cryptography": [
"bruce",
"phil",
"whitfield",
"ralph",
"daniel_b",
"moxie",
"theo",
"dan_kaminsky",
"katie",
"matt_blaze",
],
"Gaming & Graphics": [
"john_carmack",
"sid",
"shigeru",
"gabe",
"markus",
"jonathan",
"casey",
"tim_sweeney",
"hideo",
"will",
],
"Open Source Leaders": [
"miguel",
"nat",
"patrick",
"ian",
"mark_shuttleworth",
"lennart",
"bram",
"daniel_r",
"judd",
"fabrice",
],
"Modern Innovators": [
"vitalik",
"satoshi",
"chris_lattner",
"joe",
"jose",
"sebastian",
"palmer",
"dylan",
"guillermo",
"tom",
],
"Special Configurations": [
"fullstack",
"minimal",
"data_scientist",
"devops",
"security",
"academic",
"startup",
"enterprise",
"creative",
"hanzo",
],
}
for category, mode_names in categories.items():
output.append(f"\n{category}:")
for mode_name in mode_names:
mode = next((m for m in modes if m.name == mode_name), None)
if mode:
marker = " (active)" if active and active.name == mode.name else ""
output.append(f" {mode.name}{marker}: {mode.programmer} - {mode.description}")
output.append("\nUse 'mode --action activate <name>' to activate a mode")
return "\n".join(output)
elif action == "activate":
if not name:
return "Error: Mode name required for activate action"
try:
ModeRegistry.set_active(name)
mode = ModeRegistry.get(name)
output = [f"Activated mode: {mode.name}"]
output.append(f"Programmer: {mode.programmer}")
output.append(f"Description: {mode.description}")
if mode.philosophy:
output.append(f"Philosophy: {mode.philosophy}")
output.append(f"\nEnabled tools ({len(mode.tools)}):")
# Group tools by category
core_tools = []
package_tools = []
ai_tools = []
search_tools = []
other_tools = []
for tool in sorted(mode.tools):
if tool in [
"read",
"write",
"edit",
"multi_edit",
"bash",
"tree",
"grep",
]:
core_tools.append(tool)
elif tool in ["npx", "uvx", "pip", "cargo", "gem"]:
package_tools.append(tool)
elif tool in ["agent", "consensus", "critic", "think"]:
ai_tools.append(tool)
elif tool in ["search", "symbols", "git_search"]:
search_tools.append(tool)
else:
other_tools.append(tool)
if core_tools:
output.append(f" Core: {', '.join(core_tools)}")
if package_tools:
output.append(f" Package managers: {', '.join(package_tools)}")
if ai_tools:
output.append(f" AI tools: {', '.join(ai_tools)}")
if search_tools:
output.append(f" Search: {', '.join(search_tools)}")
if other_tools:
output.append(f" Specialized: {', '.join(other_tools)}")
if mode.environment:
output.append("\nEnvironment variables:")
for key, value in mode.environment.items():
output.append(f" {key}={value}")
output.append("\nNote: Restart MCP session for changes to take full effect")
return "\n".join(output)
except ValueError as e:
return str(e)
elif action == "show":
if not name:
return "Error: Mode name required for show action"
mode = ModeRegistry.get(name)
if not mode:
return f"Mode '{name}' not found"
output = [f"Mode: {mode.name}"]
output.append(f"Programmer: {mode.programmer}")
output.append(f"Description: {mode.description}")
if mode.philosophy:
output.append(f"Philosophy: {mode.philosophy}")
output.append(f"\nTools ({len(mode.tools)}):")
for tool in sorted(mode.tools):
output.append(f" - {tool}")
if mode.environment:
output.append("\nEnvironment:")
for key, value in mode.environment.items():
output.append(f" {key}={value}")
return "\n".join(output)
elif action == "current":
active = ModeRegistry.get_active()
if not active:
return "No mode currently active\nUse 'mode --action activate <name>' to activate one"
output = [f"Current mode: {active.name}"]
output.append(f"Programmer: {active.programmer}")
output.append(f"Description: {active.description}")
if active.philosophy:
output.append(f"Philosophy: {active.philosophy}")
output.append(f"Enabled tools: {len(active.tools)}")
return "\n".join(output)
else:
return f"Unknown action: {action}. Use 'list', 'activate', 'show', or 'current'"
def register(self, server: FastMCP) -> None:
"""Register the tool with the MCP server."""
tool_self = self
@server.tool(name=self.name, description=self.description)
async def mode_handler(ctx: MCPContext, action: str = "list", name: Optional[str] = None) -> str:
"""Handle mode tool calls."""
return await tool_self.run(ctx, action=action, name=name)
@auto_timeout("mode")
async def call(self, ctx: MCPContext, **params) -> str:
"""Call the tool with arguments."""
return await self.run(ctx, action=params.get("action", "list"), name=params.get("name"))
# Create tool instance
mode_tool = ModeTool()
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-config"
version = "0.1.0"
description = "Configuration tools for Hanzo AI - mode, settings management"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "config", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.entry-points."hanzo.tools"]
config = "hanzo_tools.config:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
+72 -11
View File
@@ -1,18 +1,79 @@
"""LLM provider tools.
"""LLM tools for Hanzo AI.
Tools:
- llm: Unified LLM interface supporting 100+ providers
- consensus: Multi-model consensus for improved accuracy
- llm: Unified LLM interface
- llm_manage: Model management
- consensus: Multi-model consensus
Install: pip install hanzo-tools-llm
Install:
pip install hanzo-tools-llm[full]
"""
# Placeholder - full implementation to be migrated from hanzo-mcp
TOOLS = []
import logging
logger = logging.getLogger(__name__)
__all__ = ["TOOLS", "register_tools"]
# Lazy imports for heavy dependencies
_tools = []
def register_tools(mcp_server, enabled_tools=None):
"""Register LLM tools with the MCP server."""
# TODO: Migrate from hanzo_mcp.tools.llm
return []
try:
from .llm_tool import LLMTool
_tools.append(LLMTool)
except ImportError as e:
logger.debug(f"LLMTool not available: {e}")
LLMTool = None
try:
from .llm_unified import UnifiedLLMTool
_tools.append(UnifiedLLMTool)
except ImportError as e:
logger.debug(f"UnifiedLLMTool not available: {e}")
UnifiedLLMTool = None
try:
from .consensus_tool import ConsensusTool
_tools.append(ConsensusTool)
except ImportError as e:
logger.debug(f"ConsensusTool not available: {e}")
ConsensusTool = None
try:
from .llm_manage import LLMManageTool
_tools.append(LLMManageTool)
except ImportError as e:
logger.debug(f"LLMManageTool not available: {e}")
LLMManageTool = None
TOOLS = _tools
LLM_AVAILABLE = len(_tools) > 0
__all__ = [
"TOOLS",
"LLM_AVAILABLE",
"LLMTool",
"UnifiedLLMTool",
"ConsensusTool",
"LLMManageTool",
"register_tools",
]
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
"""Register LLM tools with MCP server."""
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
if tool_class is None:
continue
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
if enabled.get(tool_name, True):
try:
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except Exception as e:
logger.warning(f"Failed to register {tool_name}: {e}")
return registered
@@ -0,0 +1,357 @@
"""Consensus tool for querying multiple LLMs in parallel."""
import asyncio
from typing import (
Dict,
List,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from .llm_tool import LLMTool
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
Prompt = Annotated[
str,
Field(
description="The prompt to send to all models",
min_length=1,
),
]
Models = Annotated[
Optional[List[str]],
Field(
description="List of models to query (defaults to a diverse set)",
default=None,
),
]
SystemPrompt = Annotated[
Optional[str],
Field(
description="System prompt for all models",
default=None,
),
]
Temperature = Annotated[
float,
Field(
description="Temperature for all models",
default=0.7,
),
]
MaxTokens = Annotated[
Optional[int],
Field(
description="Maximum tokens per response",
default=None,
),
]
AggregationModel = Annotated[
Optional[str],
Field(
description="Model to use for aggregating responses (defaults to gpt-4)",
default="gpt-4",
),
]
IncludeRaw = Annotated[
bool,
Field(
description="Include raw responses from each model",
default=False,
),
]
Timeout = Annotated[
int,
Field(
description="Timeout in seconds for each model",
default=30,
),
]
class ConsensusToolParams(TypedDict, total=False):
"""Parameters for consensus tool."""
prompt: str
models: Optional[List[str]]
system_prompt: Optional[str]
temperature: float
max_tokens: Optional[int]
aggregation_model: Optional[str]
include_raw: bool
timeout: int
@final
class ConsensusTool(BaseTool):
"""Tool for getting consensus from multiple LLMs."""
# Default models to use if none specified - mix of fast and powerful models
DEFAULT_MODELS = [
"gpt-4o-mini", # OpenAI's fast model
"claude-3-opus-20240229", # Claude's most capable model
"gemini/gemini-1.5-pro", # Google's largest model
"groq/llama3-70b-8192", # Fast inference via Groq
"mistral/mistral-large-latest", # Mistral's best model
]
def __init__(self):
"""Initialize the consensus tool."""
self.llm_tool = LLMTool()
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "consensus"
@property
@override
def description(self) -> str:
"""Get the tool description."""
available_providers = list(self.llm_tool.available_providers.keys())
return f"""Query multiple LLMs in parallel and get a consensus response.
Queries multiple models simultaneously, then uses another model to
synthesize and analyze the responses for consensus, disagreements, and insights.
Available providers: {", ".join(available_providers)}
Default models (if available):
- GPT-4 (OpenAI)
- Claude 3 Sonnet (Anthropic)
- Gemini Pro (Google)
- Mixtral 8x7B (Groq)
- Mistral Medium (Mistral)
Examples:
- consensus --prompt "What are the key principles of good software design?"
- consensus --prompt "Analyze this code for security issues" --models '["gpt-4", "claude-3-opus-20240229"]'
- consensus --prompt "Is this implementation correct?" --include-raw
- consensus --prompt "What's the best approach?" --aggregation-model "claude-3-opus-20240229"
The tool will:
1. Query all specified models in parallel
2. Collect and analyze responses
3. Use the aggregation model to synthesize findings
4. Highlight areas of agreement and disagreement
5. Provide a balanced consensus view
"""
@override
@auto_timeout("consensus")
async def call(
self,
ctx: MCPContext,
**params: Unpack[ConsensusToolParams],
) -> str:
"""Get consensus from multiple LLMs.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Consensus analysis
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required"
models = params.get("models") or self.DEFAULT_MODELS
system_prompt = params.get("system_prompt")
temperature = params.get("temperature", 0.7)
max_tokens = params.get("max_tokens")
aggregation_model = params.get("aggregation_model", "gpt-4")
include_raw = params.get("include_raw", False)
timeout = params.get("timeout", 30)
# Filter models to only those with available API keys
available_models = []
skipped_models = []
for model in models:
provider = self.llm_tool._get_provider_for_model(model)
if provider in self.llm_tool.available_providers:
available_models.append(model)
else:
skipped_models.append((model, provider))
if not available_models:
return "Error: No models available with configured API keys. Please set API keys for at least one provider."
await tool_ctx.info(f"Querying {len(available_models)} models in parallel...")
if skipped_models:
skipped_info = ", ".join([f"{m[0]} ({m[1]})" for m in skipped_models])
await tool_ctx.info(f"Skipping models without API keys: {skipped_info}")
# Query all models in parallel
results = await self._query_models_parallel(
available_models, prompt, system_prompt, temperature, max_tokens, timeout
)
# Prepare summary of results
successful_responses = [(m, r) for m, r in results.items() if not r.startswith("Error:")]
failed_responses = [(m, r) for m, r in results.items() if r.startswith("Error:")]
if not successful_responses:
return "Error: All model queries failed:\n\n" + "\n".join([f"{m}: {r}" for m, r in failed_responses])
# Use aggregation model to synthesize responses
consensus = await self._aggregate_responses(successful_responses, prompt, aggregation_model)
# Format output
output = ["=== LLM Consensus Analysis ==="]
output.append(f"Query: {prompt}")
output.append(f"Models queried: {len(available_models)}")
output.append(f"Successful responses: {len(successful_responses)}")
if failed_responses:
output.append(f"Failed responses: {len(failed_responses)}")
output.append("")
output.append("=== Consensus Summary ===")
output.append(consensus)
if include_raw:
output.append("\n=== Individual Responses ===")
for model, response in successful_responses:
output.append(f"\n--- {model} ---")
output.append(response[:500] + "..." if len(response) > 500 else response)
if failed_responses:
output.append("\n=== Failed Queries ===")
for model, error in failed_responses:
output.append(f"{model}: {error}")
return "\n".join(output)
async def _query_models_parallel(
self,
models: List[str],
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
timeout: int,
) -> Dict[str, str]:
"""Query multiple models in parallel."""
async def query_with_timeout(model: str) -> tuple[str, str]:
try:
params = {
"model": model,
"prompt": prompt,
"temperature": temperature,
}
if system_prompt:
params["system_prompt"] = system_prompt
if max_tokens:
params["max_tokens"] = max_tokens
# Create a proper context for the LLM tool
tool_ctx = create_tool_context("consensus", self.server)
result = await asyncio.wait_for(self.llm_tool.call(tool_ctx, **params), timeout=timeout)
return (model, result)
except asyncio.TimeoutError:
return (model, f"Error: Timeout after {timeout} seconds")
except Exception as e:
return (model, f"Error: {str(e)}")
# Run all queries in parallel
tasks = [query_with_timeout(model) for model in models]
results = await asyncio.gather(*tasks)
return dict(results)
async def _aggregate_responses(
self,
responses: List[tuple[str, str]],
original_prompt: str,
aggregation_model: str,
) -> str:
"""Use an LLM to aggregate and analyze responses."""
# Prepare the aggregation prompt
response_summary = "\n\n".join([f"Model: {model}\nResponse: {response}" for model, response in responses])
aggregation_prompt = f"""You are analyzing responses from multiple AI models to the following prompt:
<original_prompt>
{original_prompt}
</original_prompt>
<model_responses>
{response_summary}
</model_responses>
Please provide a comprehensive analysis that includes:
1. **Consensus Points**: What do most or all models agree on?
2. **Divergent Views**: Where do the models disagree or offer different perspectives?
3. **Key Insights**: What are the most valuable insights across all responses?
4. **Unique Contributions**: Did any model provide unique valuable information?
5. **Synthesis**: Provide a balanced, synthesized answer that incorporates the best elements from all responses.
Be concise but thorough. Focus on providing actionable insights."""
try:
# Use the LLM tool to get the aggregation
tool_ctx = create_tool_context("consensus_aggregation", self.server)
aggregation_params = {
"model": aggregation_model,
"prompt": aggregation_prompt,
"temperature": 0.3, # Lower temperature for more consistent analysis
"system_prompt": "You are an expert at analyzing and synthesizing multiple AI responses to provide balanced, insightful consensus.",
}
result = await self.llm_tool.call(tool_ctx, **aggregation_params)
return result
except Exception:
# Fallback to simple aggregation if LLM fails
return self._simple_aggregate(responses)
def _simple_aggregate(self, responses: List[tuple[str, str]]) -> str:
"""Simple fallback aggregation without LLM."""
output = []
output.append("Summary of responses:")
output.append("")
# Find common themes (very basic)
all_text = " ".join([r[1] for r in responses]).lower()
output.append("Response lengths:")
for model, response in responses:
output.append(f"- {model}: {len(response)} characters")
output.append("\nNote: Advanced consensus analysis unavailable. Showing basic summary only.")
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,452 @@
"""LLM management tool for enabling/disabling LLM providers."""
import json
from typing import 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
from .llm_tool import LLMTool
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
Action = Annotated[
str,
Field(
description="Action to perform: list, enable, disable, test",
min_length=1,
),
]
Provider = Annotated[
Optional[str],
Field(
description="Provider name (for enable/disable/test actions)",
default=None,
),
]
Model = Annotated[
Optional[str],
Field(
description="Model to test (for test action)",
default=None,
),
]
class LLMManageParams(TypedDict, total=False):
"""Parameters for LLM management tool."""
action: str
provider: Optional[str]
model: Optional[str]
@final
class LLMManageTool(BaseTool):
"""Tool for managing LLM providers."""
def __init__(self):
"""Initialize the LLM management tool."""
self.llm_tool = LLMTool()
self.config_file = Path.home() / ".hanzo" / "llm" / "providers.json"
self.config_file.parent.mkdir(parents=True, exist_ok=True)
self._load_config()
def _load_config(self):
"""Load provider configuration."""
if self.config_file.exists():
try:
with open(self.config_file, "r") as f:
self.config = json.load(f)
except Exception:
self.config = {"disabled_providers": []}
else:
self.config = {"disabled_providers": []}
def _save_config(self):
"""Save provider configuration."""
with open(self.config_file, "w") as f:
json.dump(self.config, f, indent=2)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "llm_manage"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Manage LLM providers and test configurations.
Actions:
- list: Show all providers and their status
- models: List all available models (optionally filtered by provider)
- enable: Enable a provider's tools
- disable: Disable a provider's tools
- test: Test a model to verify it works
Examples:
- llm_manage --action list
- llm_manage --action models
- llm_manage --action models --provider openai
- llm_manage --action enable --provider openai
- llm_manage --action disable --provider perplexity
- llm_manage --action test --model "gpt-4"
- llm_manage --action test --provider groq --model "mixtral"
Providers are automatically detected based on environment variables:
- OpenAI: OPENAI_API_KEY
- Anthropic: ANTHROPIC_API_KEY or CLAUDE_API_KEY
- Google: GOOGLE_API_KEY or GEMINI_API_KEY
- Groq: GROQ_API_KEY
- Mistral: MISTRAL_API_KEY
- Perplexity: PERPLEXITY_API_KEY
- And many more...
"""
@override
@auto_timeout("llm_manage")
async def call(
self,
ctx: MCPContext,
**params: Unpack[LLMManageParams],
) -> str:
"""Manage LLM providers.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of the management action
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
action = params.get("action")
if not action:
return "Error: action is required (list, enable, disable, test, models)"
provider = params.get("provider")
model = params.get("model")
# Handle different actions
if action == "list":
return self._list_providers()
elif action == "models":
return self._list_all_models(provider)
elif action == "enable":
return self._enable_provider(provider)
elif action == "disable":
return self._disable_provider(provider)
elif action == "test":
return await self._test_model(ctx, provider, model)
else:
return f"Error: Invalid action '{action}'. Must be one of: list, models, enable, disable, test"
def _list_providers(self) -> str:
"""List all providers and their status."""
output = ["=== LLM Providers ==="]
output.append("")
# Get all possible providers
all_providers = sorted(LLMTool.API_KEY_ENV_VARS.keys())
available_providers = self.llm_tool.available_providers
disabled_providers = self.config.get("disabled_providers", [])
# Categorize providers
active = []
available_but_disabled = []
no_api_key = []
for provider in all_providers:
if provider in available_providers:
if provider in disabled_providers:
available_but_disabled.append(provider)
else:
active.append(provider)
else:
no_api_key.append(provider)
# Show active providers
if active:
output.append("✅ Active Providers (API key found, enabled):")
for provider in active:
env_vars = available_providers.get(provider, [])
output.append(f" - {provider}: {', '.join(env_vars)}")
# Show example models
examples = self._get_example_models(provider)
if examples:
output.append(f" Models: {', '.join(examples[:3])}")
output.append("")
# Show disabled providers
if available_but_disabled:
output.append("⚠️ Available but Disabled (API key found, disabled):")
for provider in available_but_disabled:
env_vars = available_providers.get(provider, [])
output.append(f" - {provider}: {', '.join(env_vars)}")
output.append(f" Use: llm_manage --action enable --provider {provider}")
output.append("")
# Show providers without API keys
if no_api_key:
output.append("❌ No API Key Found:")
for provider in no_api_key[:10]: # Show first 10
env_vars = LLMTool.API_KEY_ENV_VARS.get(provider, [])
output.append(f" - {provider}: Set one of {', '.join(env_vars)}")
if len(no_api_key) > 10:
output.append(f" ... and {len(no_api_key) - 10} more")
output.append("")
# Summary
output.append("=== Summary ===")
output.append(f"Total providers: {len(all_providers)}")
output.append(f"Active: {len(active)}")
output.append(f"Disabled: {len(available_but_disabled)}")
output.append(f"No API key: {len(no_api_key)}")
# Show available tools
if active:
output.append("\n=== Available LLM Tools ===")
output.append("- llm: Universal LLM tool (all providers)")
output.append("- consensus: Query multiple models in parallel")
provider_tools = []
for provider in active:
if provider in [
"openai",
"anthropic",
"google",
"groq",
"mistral",
"perplexity",
]:
tool_name = "gemini" if provider == "google" else provider
provider_tools.append(tool_name)
if provider_tools:
output.append(f"- Provider tools: {', '.join(provider_tools)}")
return "\n".join(output)
def _enable_provider(self, provider: Optional[str]) -> str:
"""Enable a provider."""
if not provider:
return "Error: provider is required for enable action"
if provider not in self.llm_tool.available_providers:
env_vars = LLMTool.API_KEY_ENV_VARS.get(provider, [])
if env_vars:
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
else:
return f"Error: Unknown provider '{provider}'"
disabled = self.config.get("disabled_providers", [])
if provider in disabled:
disabled.remove(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully enabled {provider}"
else:
return f"{provider} is already enabled"
def _disable_provider(self, provider: Optional[str]) -> str:
"""Disable a provider."""
if not provider:
return "Error: provider is required for disable action"
disabled = self.config.get("disabled_providers", [])
if provider not in disabled:
disabled.append(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully disabled {provider}. Its tools will no longer be available."
else:
return f"{provider} is already disabled"
def _list_all_models(self, provider: Optional[str] = None) -> str:
"""List all available models from LiteLLM."""
try:
from .llm_tool import LLMTool
all_models = LLMTool.get_all_models()
if not all_models:
return "No models available or LiteLLM not installed"
output = ["=== Available LLM Models ==="]
if provider:
# Show models for specific provider
provider_lower = provider.lower()
models = all_models.get(provider_lower, [])
if not models:
return f"No models found for provider '{provider}'"
output.append(f"\n{provider.upper()} ({len(models)} models):")
output.append("-" * 40)
# Show first 50 models
for model in models[:50]:
output.append(f" {model}")
if len(models) > 50:
output.append(f" ... and {len(models) - 50} more")
else:
# Show summary of all providers
total_models = sum(len(models) for models in all_models.values())
output.append(f"Total models available: {total_models}")
output.append("")
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
output.append(f"{provider_name}: {len(models)} models")
output.append(
"\nUse 'llm_manage --action models --provider <name>' to see models for a specific provider"
)
# Show recommended models
output.append("\n=== Recommended Models ===")
recommended = {
"OpenAI": ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"],
"Anthropic": [
"claude-3-opus-20240229",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
],
"Google": ["gemini/gemini-1.5-pro", "gemini/gemini-1.5-flash"],
"Groq": [
"groq/llama3-70b-8192",
"groq/llama3-8b-8192",
"groq/gemma2-9b-it",
],
"Mistral": [
"mistral/mistral-large-latest",
"mistral/mistral-medium",
],
}
for provider_name, models in recommended.items():
available = LLMTool().available_providers
provider_key = provider_name.lower()
if provider_key in available:
output.append(f"\n{provider_name} (✅ API key found):")
for model in models:
output.append(f" - {model}")
else:
output.append(f"\n{provider_name} (❌ No API key):")
for model in models:
output.append(f" - {model}")
return "\n".join(output)
except Exception as e:
return f"Error listing models: {str(e)}"
async def _test_model(self, ctx: MCPContext, provider: Optional[str], model: Optional[str]) -> str:
"""Test a model to verify it works."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
# Determine model to test
if model:
test_model = model
else:
# Use default model for provider
default_models = {
"openai": "gpt-3.5-turbo",
"anthropic": "claude-3-haiku-20240307",
"google": "gemini/gemini-pro",
"groq": "groq/mixtral-8x7b-32768",
"mistral": "mistral/mistral-tiny",
"perplexity": "perplexity/sonar-small-online",
}
test_model = default_models.get(provider)
if not test_model:
return f"Error: No default model for provider '{provider}'. Please specify a model."
# Test the model
test_prompt = "Hello! Please respond with 'OK' if you can hear me."
output = [f"Testing model: {test_model}"]
output.append(f"Prompt: {test_prompt}")
output.append("")
try:
# Call the LLM
params = {
"model": test_model,
"prompt": test_prompt,
"max_tokens": 10,
"temperature": 0,
}
response = await self.llm_tool.call(ctx, **params)
if response.startswith("Error:"):
output.append("❌ Test failed:")
output.append(response)
else:
output.append("✅ Test successful!")
output.append(f"Response: {response}")
output.append("")
output.append(f"Model '{test_model}' is working correctly.")
# Show provider info
detected_provider = self.llm_tool._get_provider_for_model(test_model)
if detected_provider:
output.append(f"Provider: {detected_provider}")
except Exception as e:
output.append("❌ Test failed with exception:")
output.append(str(e))
return "\n".join(output)
def _get_example_models(self, provider: str) -> list[str]:
"""Get example models for a provider."""
examples = {
"openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo", "o1-preview"],
"anthropic": [
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
"google": [
"gemini/gemini-pro",
"gemini/gemini-1.5-pro",
"gemini/gemini-1.5-flash",
],
"groq": [
"groq/mixtral-8x7b-32768",
"groq/llama3-70b-8192",
"groq/llama3-8b-8192",
],
"mistral": [
"mistral/mistral-large-latest",
"mistral/mistral-medium",
"mistral/mistral-small",
],
"perplexity": [
"perplexity/sonar-medium-online",
"perplexity/sonar-small-online",
],
}
return examples.get(provider, [])
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,885 @@
"""Unified LLM tool with multiple actions including consensus mode."""
import os
import json
import asyncio
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
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
# Check if litellm is available
try:
import litellm
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
# Parameter types
Action = Annotated[
str,
Field(
description="Action to perform: query, consensus, list, models, enable, disable, test",
default="query",
),
]
Model = Annotated[
Optional[str],
Field(
description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
default=None,
),
]
Models = Annotated[
Optional[List[str]],
Field(
description="List of models for consensus mode",
default=None,
),
]
Prompt = Annotated[
Optional[str],
Field(
description="The prompt to send to the LLM",
default=None,
),
]
SystemPrompt = Annotated[
Optional[str],
Field(
description="System prompt to set context",
default=None,
),
]
Temperature = Annotated[
float,
Field(
description="Temperature for response randomness (0-2)",
default=0.7,
),
]
MaxTokens = Annotated[
Optional[int],
Field(
description="Maximum tokens in response",
default=None,
),
]
JsonMode = Annotated[
bool,
Field(
description="Request JSON formatted response",
default=False,
),
]
Stream = Annotated[
bool,
Field(
description="Stream the response",
default=False,
),
]
Provider = Annotated[
Optional[str],
Field(
description="Provider name for list/enable/disable actions",
default=None,
),
]
IncludeRaw = Annotated[
bool,
Field(
description="Include raw responses in consensus mode",
default=False,
),
]
JudgeModel = Annotated[
Optional[str],
Field(
description="Model to use as judge/aggregator in consensus",
default=None,
),
]
DevilsAdvocate = Annotated[
bool,
Field(
description="Enable devil's advocate mode (10th model critiques others)",
default=False,
),
]
ConsensusSize = Annotated[
Optional[int],
Field(
description="Number of models to use in consensus (default: 3)",
default=None,
),
]
class LLMParams(TypedDict, total=False):
"""Parameters for LLM tool."""
action: str
model: Optional[str]
models: Optional[List[str]]
prompt: Optional[str]
system_prompt: Optional[str]
temperature: float
max_tokens: Optional[int]
json_mode: bool
stream: bool
provider: Optional[str]
include_raw: bool
judge_model: Optional[str]
devils_advocate: bool
consensus_size: Optional[int]
@final
class LLMTool(BaseTool):
"""Unified LLM tool with multiple actions."""
# Config file for settings
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
# Default consensus models in order of preference
DEFAULT_CONSENSUS_MODELS = [
"gpt-4o", # OpenAI's latest
"claude-3-opus-20240229", # Claude's most capable
"gemini/gemini-1.5-pro", # Google's best
"groq/llama3-70b-8192", # Fast Groq
"mistral/mistral-large-latest", # Mistral's best
"perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
]
# API key environment variables
API_KEY_ENV_VARS = {
"openai": ["OPENAI_API_KEY"],
"anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"groq": ["GROQ_API_KEY"],
"mistral": ["MISTRAL_API_KEY"],
"perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
"together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
"cohere": ["COHERE_API_KEY"],
"replicate": ["REPLICATE_API_KEY"],
"huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
"vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
"azure": ["AZURE_API_KEY"],
"voyage": ["VOYAGE_API_KEY"],
"deepseek": ["DEEPSEEK_API_KEY"],
}
def __init__(self):
"""Initialize the unified LLM tool."""
self.available_providers = self._detect_available_providers()
self.config = self._load_config()
def _detect_available_providers(self) -> Dict[str, List[str]]:
"""Detect which providers have API keys configured."""
available = {}
for provider, env_vars in self.API_KEY_ENV_VARS.items():
for var in env_vars:
if os.getenv(var):
available[provider] = env_vars
break
return available
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file."""
if self.CONFIG_FILE.exists():
try:
with open(self.CONFIG_FILE, "r") as f:
return json.load(f)
except Exception:
pass
# Default config
return {
"disabled_providers": [],
"consensus_models": None, # Use defaults if None
"default_judge_model": "gpt-4o",
"consensus_size": 3,
}
def _save_config(self):
"""Save configuration to file."""
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)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "llm"
@property
@override
def description(self) -> str:
"""Get the tool description."""
available = list(self.available_providers.keys())
return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
Usage:
llm "What is the capital of France?"
llm "Explain this code" --model gpt-4o
llm --action consensus "Is this approach correct?" --devils-advocate
llm --action models --provider openai
Available: {", ".join(available) if available else "None"}"""
@override
@auto_timeout("llm")
async def call(
self,
ctx: MCPContext,
**params: Unpack[LLMParams],
) -> str:
"""Execute LLM 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:
# Running in test mode without MCP context
pass
# Fast test path: allow offline deterministic responses
if os.getenv("HANZO_MCP_FAST_TESTS") == "1":
action = params.get("action", "query")
if action == "query":
prompt = params.get("prompt", "") or ""
# Simple keyword routing for tests
if "generate tasks" in prompt.lower():
return '{"project": "Demo", "tasks": [{"title": "Init repo", "slug": "init-repo"}, {"title": "Add CI", "slug": "add-ci"}]}'
return "Architecture Proposal: minimal service layers and tests"
elif action == "consensus":
return "Consensus: aligned"
# Other actions can fall through to regular path if available
if not LITELLM_AVAILABLE:
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
# Extract action
action = params.get("action", "query")
# Route to appropriate handler
if action == "query":
return await self._handle_query(tool_ctx, params)
elif action == "consensus":
return await self._handle_consensus(tool_ctx, params)
elif action == "list":
return self._handle_list()
elif action == "models":
return self._handle_models(params.get("provider"))
elif action == "enable":
return self._handle_enable(params.get("provider"))
elif action == "disable":
return self._handle_disable(params.get("provider"))
elif action == "test":
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
else:
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle single model query."""
model = params.get("model")
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for query action"
# Auto-select model if not specified
if not model:
if self.available_providers:
# Use first available model
if "openai" in self.available_providers:
model = "gpt-4o-mini"
elif "anthropic" in self.available_providers:
model = "claude-3-haiku-20240307"
elif "google" in self.available_providers:
model = "gemini/gemini-1.5-flash"
else:
# Use first provider's default
provider = list(self.available_providers.keys())[0]
model = f"{provider}/default"
else:
return "Error: No model specified and no API keys found"
# Check if we have API key for this model
provider = self._get_provider_for_model(model)
if provider and provider not in self.available_providers:
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
# Build messages
messages = []
if params.get("system_prompt"):
messages.append({"role": "system", "content": params["system_prompt"]})
messages.append({"role": "user", "content": prompt})
# Build kwargs
kwargs = {
"model": model,
"messages": messages,
"temperature": params.get("temperature", 0.7),
}
if params.get("max_tokens"):
kwargs["max_tokens"] = params["max_tokens"]
if params.get("json_mode"):
kwargs["response_format"] = {"type": "json_object"}
if params.get("stream"):
kwargs["stream"] = True
try:
if tool_ctx:
await tool_ctx.info(f"Querying {model}...")
if kwargs.get("stream"):
# Handle streaming response
response_text = ""
async for chunk in await litellm.acompletion(**kwargs):
if chunk.choices[0].delta.content:
response_text += chunk.choices[0].delta.content
return response_text
else:
# Regular response
response = await litellm.acompletion(**kwargs)
return response.choices[0].message.content
except Exception as e:
error_msg = str(e)
if "model_not_found" in error_msg or "does not exist" in error_msg:
return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
else:
return f"Error calling LLM: {error_msg}"
async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle consensus mode with multiple models."""
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for consensus action"
# Determine models to use
models = params.get("models")
if not models:
# Use configured or default models
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
models = self._get_consensus_models(consensus_size)
if not models:
return "Error: No models available for consensus. Set API keys for at least 2 providers."
if len(models) < 2:
return "Error: Consensus requires at least 2 models"
# Check for devil's advocate mode
devils_advocate = params.get("devils_advocate", False)
if devils_advocate and len(models) < 3:
return "Error: Devil's advocate mode requires at least 3 models"
if tool_ctx:
await tool_ctx.info(f"Running consensus with {len(models)} models...")
# Query models in parallel
system_prompt = params.get("system_prompt")
temperature = params.get("temperature", 0.7)
max_tokens = params.get("max_tokens")
# Split models if using devil's advocate
if devils_advocate:
consensus_models = models[:-1]
devil_model = models[-1]
else:
consensus_models = models
devil_model = None
# Query consensus models
responses = await self._query_models_parallel(
consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
)
# Get devil's advocate response if enabled
devil_response = None
if devil_model:
# Create devil's advocate prompt
responses_text = "\n\n".join(
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
)
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
Original Question: {prompt}
Responses from other models:
{responses_text}
Provide your critical analysis:"""
devil_result = await self._query_single_model(
devil_model, devil_prompt, system_prompt, temperature, max_tokens
)
if devil_result["success"]:
devil_response = {
"model": devil_model,
"response": devil_result["response"],
"time_ms": devil_result["time_ms"],
}
# Aggregate responses
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
include_raw = params.get("include_raw", False)
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
def _handle_list(self) -> str:
"""List available providers."""
output = ["=== LLM Providers ==="]
# Get all possible providers
all_providers = sorted(self.API_KEY_ENV_VARS.keys())
disabled = self.config.get("disabled_providers", [])
output.append(f"Total providers: {len(all_providers)}")
output.append(f"Available: {len(self.available_providers)}")
output.append(f"Disabled: {len(disabled)}\n")
for provider in all_providers:
status_parts = []
# Check if API key exists
if provider in self.available_providers:
status_parts.append("✅ API key found")
else:
status_parts.append("❌ No API key")
# Check if disabled
if provider in disabled:
status_parts.append("🚫 Disabled")
# Show environment variables
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
status = " | ".join(status_parts)
output.append(f"{provider}: {status}")
output.append(f" Environment variables: {', '.join(env_vars)}")
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
return "\n".join(output)
def _handle_models(self, provider: Optional[str] = None) -> str:
"""List available models."""
try:
all_models = self._get_all_models()
if not all_models:
return "No models available or LiteLLM not properly initialized"
output = ["=== Available LLM Models ==="]
if provider:
# Show models for specific provider
provider_lower = provider.lower()
models = all_models.get(provider_lower, [])
if not models:
return f"No models found for provider '{provider}'"
output.append(f"\n{provider.upper()} ({len(models)} models):")
output.append("-" * 40)
# Show first 50 models
for model in models[:50]:
output.append(f" {model}")
if len(models) > 50:
output.append(f" ... and {len(models) - 50} more")
else:
# Show summary of all providers
total_models = sum(len(models) for models in all_models.values())
output.append(f"Total models available: {total_models}")
output.append("")
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
available = "✅" if provider_name in self.available_providers else "❌"
output.append(f"{available} {provider_name}: {len(models)} models")
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
return "\n".join(output)
except Exception as e:
return f"Error listing models: {str(e)}"
def _handle_enable(self, provider: Optional[str]) -> str:
"""Enable a provider."""
if not provider:
return "Error: provider is required for enable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider in disabled:
disabled.remove(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully enabled {provider}"
else:
return f"{provider} is already enabled"
def _handle_disable(self, provider: Optional[str]) -> str:
"""Disable a provider."""
if not provider:
return "Error: provider is required for disable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider not in disabled:
disabled.append(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully disabled {provider}"
else:
return f"{provider} is already disabled"
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
"""Test a model or provider."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
# If provider specified, test its default model
if provider and not model:
provider = provider.lower()
if provider == "openai":
model = "gpt-3.5-turbo"
elif provider == "anthropic":
model = "claude-3-haiku-20240307"
elif provider == "google":
model = "gemini/gemini-1.5-flash"
elif provider == "groq":
model = "groq/llama3-8b-8192"
else:
model = f"{provider}/default"
# Test the model
test_prompt = "Say 'Hello from Hanzo AI!' in exactly 5 words."
try:
if tool_ctx:
await tool_ctx.info(f"Testing {model}...")
response = await litellm.acompletion(
model=model,
messages=[{"role": "user", "content": test_prompt}],
temperature=0,
max_tokens=20,
)
result = response.choices[0].message.content
return f"✅ {model} is working!\nResponse: {result}"
except Exception as e:
return f"❌ {model} failed: {str(e)}"
def _get_consensus_models(self, size: int) -> List[str]:
"""Get models for consensus based on availability."""
# Use configured models if set
configured = self.config.get("consensus_models")
if configured:
return configured[:size]
# Otherwise, build list from available providers
models = []
disabled = self.config.get("disabled_providers", [])
# Try default models first
for model in self.DEFAULT_CONSENSUS_MODELS:
if len(models) >= size:
break
provider = self._get_provider_for_model(model)
if provider and provider in self.available_providers and provider not in disabled:
models.append(model)
# If still need more, add from available providers
if len(models) < size:
for provider in self.available_providers:
if provider in disabled:
continue
if provider == "openai" and "gpt-4o" not in models:
models.append("gpt-4o")
elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
models.append("claude-3-opus-20240229")
elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
models.append("gemini/gemini-1.5-pro")
if len(models) >= size:
break
return models
async def _query_models_parallel(
self,
models: List[str],
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
tool_ctx,
) -> List[Dict[str, Any]]:
"""Query multiple models in parallel."""
async def query_with_info(model: str) -> Dict[str, Any]:
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
return {
"model": model,
"response": result.get("response"),
"success": result.get("success", False),
"error": result.get("error"),
"time_ms": result.get("time_ms", 0),
}
# Run all queries in parallel
tasks = [query_with_info(model) for model in models]
results = await asyncio.gather(*tasks)
# Report results
successful = sum(1 for r in results if r["success"])
if tool_ctx:
await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
return results
async def _query_single_model(
self,
model: str,
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
) -> Dict[str, Any]:
"""Query a single model and return result with metadata."""
import time
start_time = time.time()
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
response = await litellm.acompletion(**kwargs)
return {
"success": True,
"response": response.choices[0].message.content,
"time_ms": int((time.time() - start_time) * 1000),
}
except Exception as e:
return {
"success": False,
"error": str(e),
"time_ms": int((time.time() - start_time) * 1000),
}
async def _aggregate_consensus(
self,
responses: List[Dict[str, Any]],
original_prompt: str,
judge_model: str,
include_raw: bool,
devil_response: Optional[Dict[str, Any]],
tool_ctx,
) -> str:
"""Aggregate consensus responses using a judge model."""
# Prepare response data
successful_responses = [r for r in responses if r["success"]]
if not successful_responses:
return "Error: All models failed to respond"
# Format responses for aggregation
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
if devil_response:
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
# Create aggregation prompt
aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
<original_question>
{original_prompt}
</original_question>
<model_responses>
{responses_text}
</model_responses>
Please provide:
1. A synthesis of the key points where models agree
2. Notable differences or disagreements between responses
3. A balanced conclusion incorporating the best insights
{f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
Be concise and highlight the most important findings."""
# Get aggregation
try:
if tool_ctx:
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
if not judge_result["success"]:
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
# Format output
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
output.append(judge_result["response"])
# Add model list
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
if devil_response:
output.append(f"Devil's Advocate: {devil_response['model']}")
# Add timing info
avg_time = sum(r["time_ms"] for r in responses) / len(responses)
output.append(f"\nAverage response time: {avg_time:.0f}ms")
# Include raw responses if requested
if include_raw:
output.append("\n\n=== Raw Responses ===")
for r in successful_responses:
output.append(f"\n{r['model']}:")
output.append("-" * 40)
output.append(r["response"])
if devil_response:
output.append(f"\nDevil's Advocate ({devil_response['model']}):")
output.append("-" * 40)
output.append(devil_response["response"])
return "\n".join(output)
except Exception as e:
return f"Error during aggregation: {str(e)}"
def _get_provider_for_model(self, model: str) -> Optional[str]:
"""Determine the provider for a given model."""
model_lower = model.lower()
# Check explicit provider prefix
if "/" in model:
return model.split("/")[0]
# Check model prefixes
if model_lower.startswith("gpt"):
return "openai"
elif model_lower.startswith("claude"):
return "anthropic"
elif model_lower.startswith("gemini"):
return "google"
elif model_lower.startswith("command"):
return "cohere"
# Default to OpenAI
return "openai"
def _get_all_models(self) -> Dict[str, List[str]]:
"""Get all available models from LiteLLM."""
try:
import litellm
# Get all models
all_models = litellm.model_list
# Organize by provider
providers = {}
for model in all_models:
# Extract provider
if "/" in model:
provider = model.split("/")[0]
elif model.startswith("gpt"):
provider = "openai"
elif model.startswith("claude"):
provider = "anthropic"
elif model.startswith("gemini"):
provider = "google"
elif model.startswith("command"):
provider = "cohere"
else:
provider = "other"
if provider not in providers:
providers[provider] = []
providers[provider].append(model)
# Sort models within each provider
for provider in providers:
providers[provider] = sorted(providers[provider])
return providers
except Exception:
return {}
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,872 @@
"""Unified LLM tool with multiple actions including consensus mode."""
import os
import json
import asyncio
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
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
# Check if litellm is available
try:
import litellm
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
# Parameter types
Action = Annotated[
str,
Field(
description="Action to perform: query, consensus, list, models, enable, disable, test",
default="query",
),
]
Model = Annotated[
Optional[str],
Field(
description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
default=None,
),
]
Models = Annotated[
Optional[List[str]],
Field(
description="List of models for consensus mode",
default=None,
),
]
Prompt = Annotated[
Optional[str],
Field(
description="The prompt to send to the LLM",
default=None,
),
]
SystemPrompt = Annotated[
Optional[str],
Field(
description="System prompt to set context",
default=None,
),
]
Temperature = Annotated[
float,
Field(
description="Temperature for response randomness (0-2)",
default=0.7,
),
]
MaxTokens = Annotated[
Optional[int],
Field(
description="Maximum tokens in response",
default=None,
),
]
JsonMode = Annotated[
bool,
Field(
description="Request JSON formatted response",
default=False,
),
]
Stream = Annotated[
bool,
Field(
description="Stream the response",
default=False,
),
]
Provider = Annotated[
Optional[str],
Field(
description="Provider name for list/enable/disable actions",
default=None,
),
]
IncludeRaw = Annotated[
bool,
Field(
description="Include raw responses in consensus mode",
default=False,
),
]
JudgeModel = Annotated[
Optional[str],
Field(
description="Model to use as judge/aggregator in consensus",
default=None,
),
]
DevilsAdvocate = Annotated[
bool,
Field(
description="Enable devil's advocate mode (10th model critiques others)",
default=False,
),
]
ConsensusSize = Annotated[
Optional[int],
Field(
description="Number of models to use in consensus (default: 3)",
default=None,
),
]
class LLMParams(TypedDict, total=False):
"""Parameters for LLM tool."""
action: str
model: Optional[str]
models: Optional[List[str]]
prompt: Optional[str]
system_prompt: Optional[str]
temperature: float
max_tokens: Optional[int]
json_mode: bool
stream: bool
provider: Optional[str]
include_raw: bool
judge_model: Optional[str]
devils_advocate: bool
consensus_size: Optional[int]
@final
class UnifiedLLMTool(BaseTool):
"""Unified LLM tool with multiple actions."""
# Config file for settings
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
# Default consensus models in order of preference
DEFAULT_CONSENSUS_MODELS = [
"gpt-4o", # OpenAI's latest
"claude-3-opus-20240229", # Claude's most capable
"gemini/gemini-1.5-pro", # Google's best
"groq/llama3-70b-8192", # Fast Groq
"mistral/mistral-large-latest", # Mistral's best
"perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
]
# API key environment variables
API_KEY_ENV_VARS = {
"openai": ["OPENAI_API_KEY"],
"anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"groq": ["GROQ_API_KEY"],
"mistral": ["MISTRAL_API_KEY"],
"perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
"together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
"cohere": ["COHERE_API_KEY"],
"replicate": ["REPLICATE_API_KEY"],
"huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
"vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
"azure": ["AZURE_API_KEY"],
"voyage": ["VOYAGE_API_KEY"],
"deepseek": ["DEEPSEEK_API_KEY"],
}
def __init__(self):
"""Initialize the unified LLM tool."""
self.available_providers = self._detect_available_providers()
self.config = self._load_config()
def _detect_available_providers(self) -> Dict[str, List[str]]:
"""Detect which providers have API keys configured."""
available = {}
for provider, env_vars in self.API_KEY_ENV_VARS.items():
for var in env_vars:
if os.getenv(var):
available[provider] = env_vars
break
return available
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file."""
if self.CONFIG_FILE.exists():
try:
with open(self.CONFIG_FILE, "r") as f:
return json.load(f)
except Exception:
pass
# Default config
return {
"disabled_providers": [],
"consensus_models": None, # Use defaults if None
"default_judge_model": "gpt-4o",
"consensus_size": 3,
}
def _save_config(self):
"""Save configuration to file."""
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)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "llm"
@property
@override
def description(self) -> str:
"""Get the tool description."""
available = list(self.available_providers.keys())
return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
Usage:
llm "What is the capital of France?"
llm "Explain this code" --model gpt-4o
llm --action consensus "Is this approach correct?" --devils-advocate
llm --action models --provider openai
Available: {", ".join(available) if available else "None"}"""
@override
@auto_timeout("llm_unified")
async def call(
self,
ctx: MCPContext,
**params: Unpack[LLMParams],
) -> str:
"""Execute LLM 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:
# Running in test mode without MCP context
pass
if not LITELLM_AVAILABLE:
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
# Extract action
action = params.get("action", "query")
# Route to appropriate handler
if action == "query":
return await self._handle_query(tool_ctx, params)
elif action == "consensus":
return await self._handle_consensus(tool_ctx, params)
elif action == "list":
return self._handle_list()
elif action == "models":
return self._handle_models(params.get("provider"))
elif action == "enable":
return self._handle_enable(params.get("provider"))
elif action == "disable":
return self._handle_disable(params.get("provider"))
elif action == "test":
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
else:
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle single model query."""
model = params.get("model")
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for query action"
# Auto-select model if not specified
if not model:
if self.available_providers:
# Use first available model
if "openai" in self.available_providers:
model = "gpt-4o-mini"
elif "anthropic" in self.available_providers:
model = "claude-3-haiku-20240307"
elif "google" in self.available_providers:
model = "gemini/gemini-1.5-flash"
else:
# Use first provider's default
provider = list(self.available_providers.keys())[0]
model = f"{provider}/default"
else:
return "Error: No model specified and no API keys found"
# Check if we have API key for this model
provider = self._get_provider_for_model(model)
if provider and provider not in self.available_providers:
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
# Build messages
messages = []
if params.get("system_prompt"):
messages.append({"role": "system", "content": params["system_prompt"]})
messages.append({"role": "user", "content": prompt})
# Build kwargs
kwargs = {
"model": model,
"messages": messages,
"temperature": params.get("temperature", 0.7),
}
if params.get("max_tokens"):
kwargs["max_tokens"] = params["max_tokens"]
if params.get("json_mode"):
kwargs["response_format"] = {"type": "json_object"}
if params.get("stream"):
kwargs["stream"] = True
try:
if tool_ctx:
await tool_ctx.info(f"Querying {model}...")
if kwargs.get("stream"):
# Handle streaming response
response_text = ""
async for chunk in await litellm.acompletion(**kwargs):
if chunk.choices[0].delta.content:
response_text += chunk.choices[0].delta.content
return response_text
else:
# Regular response
response = await litellm.acompletion(**kwargs)
return response.choices[0].message.content
except Exception as e:
error_msg = str(e)
if "model_not_found" in error_msg or "does not exist" in error_msg:
return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
else:
return f"Error calling LLM: {error_msg}"
async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle consensus mode with multiple models."""
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for consensus action"
# Determine models to use
models = params.get("models")
if not models:
# Use configured or default models
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
models = self._get_consensus_models(consensus_size)
if not models:
return "Error: No models available for consensus. Set API keys for at least 2 providers."
if len(models) < 2:
return "Error: Consensus requires at least 2 models"
# Check for devil's advocate mode
devils_advocate = params.get("devils_advocate", False)
if devils_advocate and len(models) < 3:
return "Error: Devil's advocate mode requires at least 3 models"
if tool_ctx:
await tool_ctx.info(f"Running consensus with {len(models)} models...")
# Query models in parallel
system_prompt = params.get("system_prompt")
temperature = params.get("temperature", 0.7)
max_tokens = params.get("max_tokens")
# Split models if using devil's advocate
if devils_advocate:
consensus_models = models[:-1]
devil_model = models[-1]
else:
consensus_models = models
devil_model = None
# Query consensus models
responses = await self._query_models_parallel(
consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
)
# Get devil's advocate response if enabled
devil_response = None
if devil_model:
# Create devil's advocate prompt
responses_text = "\n\n".join(
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
)
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
Original Question: {prompt}
Responses from other models:
{responses_text}
Provide your critical analysis:"""
devil_result = await self._query_single_model(
devil_model, devil_prompt, system_prompt, temperature, max_tokens
)
if devil_result["success"]:
devil_response = {
"model": devil_model,
"response": devil_result["response"],
"time_ms": devil_result["time_ms"],
}
# Aggregate responses
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
include_raw = params.get("include_raw", False)
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
def _handle_list(self) -> str:
"""List available providers."""
output = ["=== LLM Providers ==="]
# Get all possible providers
all_providers = sorted(self.API_KEY_ENV_VARS.keys())
disabled = self.config.get("disabled_providers", [])
output.append(f"Total providers: {len(all_providers)}")
output.append(f"Available: {len(self.available_providers)}")
output.append(f"Disabled: {len(disabled)}\n")
for provider in all_providers:
status_parts = []
# Check if API key exists
if provider in self.available_providers:
status_parts.append("✅ API key found")
else:
status_parts.append("❌ No API key")
# Check if disabled
if provider in disabled:
status_parts.append("🚫 Disabled")
# Show environment variables
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
status = " | ".join(status_parts)
output.append(f"{provider}: {status}")
output.append(f" Environment variables: {', '.join(env_vars)}")
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
return "\n".join(output)
def _handle_models(self, provider: Optional[str] = None) -> str:
"""List available models."""
try:
all_models = self._get_all_models()
if not all_models:
return "No models available or LiteLLM not properly initialized"
output = ["=== Available LLM Models ==="]
if provider:
# Show models for specific provider
provider_lower = provider.lower()
models = all_models.get(provider_lower, [])
if not models:
return f"No models found for provider '{provider}'"
output.append(f"\n{provider.upper()} ({len(models)} models):")
output.append("-" * 40)
# Show first 50 models
for model in models[:50]:
output.append(f" {model}")
if len(models) > 50:
output.append(f" ... and {len(models) - 50} more")
else:
# Show summary of all providers
total_models = sum(len(models) for models in all_models.values())
output.append(f"Total models available: {total_models}")
output.append("")
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
available = "✅" if provider_name in self.available_providers else "❌"
output.append(f"{available} {provider_name}: {len(models)} models")
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
return "\n".join(output)
except Exception as e:
return f"Error listing models: {str(e)}"
def _handle_enable(self, provider: Optional[str]) -> str:
"""Enable a provider."""
if not provider:
return "Error: provider is required for enable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider in disabled:
disabled.remove(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully enabled {provider}"
else:
return f"{provider} is already enabled"
def _handle_disable(self, provider: Optional[str]) -> str:
"""Disable a provider."""
if not provider:
return "Error: provider is required for disable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider not in disabled:
disabled.append(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully disabled {provider}"
else:
return f"{provider} is already disabled"
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
"""Test a model or provider."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
# If provider specified, test its default model
if provider and not model:
provider = provider.lower()
if provider == "openai":
model = "gpt-3.5-turbo"
elif provider == "anthropic":
model = "claude-3-haiku-20240307"
elif provider == "google":
model = "gemini/gemini-1.5-flash"
elif provider == "groq":
model = "groq/llama3-8b-8192"
else:
model = f"{provider}/default"
# Test the model
test_prompt = "Say 'Hello from Hanzo MCP!' in exactly 5 words."
try:
if tool_ctx:
await tool_ctx.info(f"Testing {model}...")
response = await litellm.acompletion(
model=model,
messages=[{"role": "user", "content": test_prompt}],
temperature=0,
max_tokens=20,
)
result = response.choices[0].message.content
return f"✅ {model} is working!\nResponse: {result}"
except Exception as e:
return f"❌ {model} failed: {str(e)}"
def _get_consensus_models(self, size: int) -> List[str]:
"""Get models for consensus based on availability."""
# Use configured models if set
configured = self.config.get("consensus_models")
if configured:
return configured[:size]
# Otherwise, build list from available providers
models = []
disabled = self.config.get("disabled_providers", [])
# Try default models first
for model in self.DEFAULT_CONSENSUS_MODELS:
if len(models) >= size:
break
provider = self._get_provider_for_model(model)
if provider and provider in self.available_providers and provider not in disabled:
models.append(model)
# If still need more, add from available providers
if len(models) < size:
for provider in self.available_providers:
if provider in disabled:
continue
if provider == "openai" and "gpt-4o" not in models:
models.append("gpt-4o")
elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
models.append("claude-3-opus-20240229")
elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
models.append("gemini/gemini-1.5-pro")
if len(models) >= size:
break
return models
async def _query_models_parallel(
self,
models: List[str],
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
tool_ctx,
) -> List[Dict[str, Any]]:
"""Query multiple models in parallel."""
async def query_with_info(model: str) -> Dict[str, Any]:
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
return {
"model": model,
"response": result.get("response"),
"success": result.get("success", False),
"error": result.get("error"),
"time_ms": result.get("time_ms", 0),
}
# Run all queries in parallel
tasks = [query_with_info(model) for model in models]
results = await asyncio.gather(*tasks)
# Report results
successful = sum(1 for r in results if r["success"])
if tool_ctx:
await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
return results
async def _query_single_model(
self,
model: str,
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
) -> Dict[str, Any]:
"""Query a single model and return result with metadata."""
import time
start_time = time.time()
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
response = await litellm.acompletion(**kwargs)
return {
"success": True,
"response": response.choices[0].message.content,
"time_ms": int((time.time() - start_time) * 1000),
}
except Exception as e:
return {
"success": False,
"error": str(e),
"time_ms": int((time.time() - start_time) * 1000),
}
async def _aggregate_consensus(
self,
responses: List[Dict[str, Any]],
original_prompt: str,
judge_model: str,
include_raw: bool,
devil_response: Optional[Dict[str, Any]],
tool_ctx,
) -> str:
"""Aggregate consensus responses using a judge model."""
# Prepare response data
successful_responses = [r for r in responses if r["success"]]
if not successful_responses:
return "Error: All models failed to respond"
# Format responses for aggregation
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
if devil_response:
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
# Create aggregation prompt
aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
<original_question>
{original_prompt}
</original_question>
<model_responses>
{responses_text}
</model_responses>
Please provide:
1. A synthesis of the key points where models agree
2. Notable differences or disagreements between responses
3. A balanced conclusion incorporating the best insights
{f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
Be concise and highlight the most important findings."""
# Get aggregation
try:
if tool_ctx:
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
if not judge_result["success"]:
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
# Format output
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
output.append(judge_result["response"])
# Add model list
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
if devil_response:
output.append(f"Devil's Advocate: {devil_response['model']}")
# Add timing info
avg_time = sum(r["time_ms"] for r in responses) / len(responses)
output.append(f"\nAverage response time: {avg_time:.0f}ms")
# Include raw responses if requested
if include_raw:
output.append("\n\n=== Raw Responses ===")
for r in successful_responses:
output.append(f"\n{r['model']}:")
output.append("-" * 40)
output.append(r["response"])
if devil_response:
output.append(f"\nDevil's Advocate ({devil_response['model']}):")
output.append("-" * 40)
output.append(devil_response["response"])
return "\n".join(output)
except Exception as e:
return f"Error during aggregation: {str(e)}"
def _get_provider_for_model(self, model: str) -> Optional[str]:
"""Determine the provider for a given model."""
model_lower = model.lower()
# Check explicit provider prefix
if "/" in model:
return model.split("/")[0]
# Check model prefixes
if model_lower.startswith("gpt"):
return "openai"
elif model_lower.startswith("claude"):
return "anthropic"
elif model_lower.startswith("gemini"):
return "google"
elif model_lower.startswith("command"):
return "cohere"
# Default to OpenAI
return "openai"
def _get_all_models(self) -> Dict[str, List[str]]:
"""Get all available models from LiteLLM."""
try:
import litellm
# Get all models
all_models = litellm.model_list
# Organize by provider
providers = {}
for model in all_models:
# Extract provider
if "/" in model:
provider = model.split("/")[0]
elif model.startswith("gpt"):
provider = "openai"
elif model.startswith("claude"):
provider = "anthropic"
elif model.startswith("gemini"):
provider = "google"
elif model.startswith("command"):
provider = "cohere"
else:
provider = "other"
if provider not in providers:
providers[provider] = []
providers[provider].append(model)
# Sort models within each provider
for provider in providers:
providers[provider] = sorted(providers[provider])
return providers
except Exception:
return {}
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,411 @@
"""Provider-specific LLM tools."""
from typing import Dict, Unpack, Optional, Annotated, TypedDict, final, override
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from .llm_tool import LLMTool
from hanzo_tools.core import auto_timeout
Prompt = Annotated[
str,
Field(
description="The prompt or question to send to the model",
min_length=1,
),
]
Model = Annotated[
Optional[str],
Field(
description="Specific model variant (defaults to provider's best model)",
default=None,
),
]
SystemPrompt = Annotated[
Optional[str],
Field(
description="System prompt to set context",
default=None,
),
]
Temperature = Annotated[
float,
Field(
description="Temperature for response randomness (0.0-2.0)",
default=0.7,
),
]
MaxTokens = Annotated[
Optional[int],
Field(
description="Maximum tokens in response",
default=None,
),
]
JsonMode = Annotated[
bool,
Field(
description="Request JSON formatted response",
default=False,
),
]
class ProviderToolParams(TypedDict, total=False):
"""Parameters for provider-specific tools."""
prompt: str
model: Optional[str]
system_prompt: Optional[str]
temperature: float
max_tokens: Optional[int]
json_mode: bool
class BaseProviderTool(BaseTool):
"""Base class for provider-specific LLM tools."""
def __init__(self, provider: str, default_model: str, model_variants: Dict[str, str]):
"""Initialize provider tool.
Args:
provider: Provider name
default_model: Default model to use
model_variants: Map of short names to full model names
"""
self.provider = provider
self.default_model = default_model
self.model_variants = model_variants
self.llm_tool = LLMTool()
self.is_available = provider in self.llm_tool.available_providers
def get_full_model_name(self, model: Optional[str]) -> str:
"""Get full model name from short name or default."""
if not model:
return self.default_model
# Check if it's a short name
if model in self.model_variants:
return self.model_variants[model]
# Return as-is if not found (assume full name)
return model
@override
@auto_timeout("provider_tools")
async def call(
self,
ctx: MCPContext,
**params: Unpack[ProviderToolParams],
) -> str:
"""Call the provider's LLM."""
if not self.is_available:
env_vars = LLMTool.API_KEY_ENV_VARS.get(self.provider, [])
return f"Error: {self.provider.title()} API key not found. Set one of: {', '.join(env_vars)}"
# Get full model name
model = self.get_full_model_name(params.get("model"))
# Prepare LLM tool parameters
llm_params = {
"model": model,
"prompt": params["prompt"],
}
# Add optional parameters
if "system_prompt" in params:
llm_params["system_prompt"] = params["system_prompt"]
if "temperature" in params:
llm_params["temperature"] = params["temperature"]
if "max_tokens" in params:
llm_params["max_tokens"] = params["max_tokens"]
if "json_mode" in params:
llm_params["json_mode"] = params["json_mode"]
# Call the LLM tool
return await self.llm_tool.call(ctx, **llm_params)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@final
class OpenAITool(BaseProviderTool):
"""OpenAI-specific LLM tool."""
def __init__(self):
super().__init__(
provider="openai",
default_model="gpt-4o",
model_variants={
"4o": "gpt-4o",
"4": "gpt-4",
"3.5": "gpt-3.5-turbo",
"o1": "o1-preview",
"o1-mini": "o1-mini",
"4-turbo": "gpt-4-turbo-preview",
"4-vision": "gpt-4-vision-preview",
},
)
@property
@override
def name(self) -> str:
return "openai"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query OpenAI models directly ({status}).
Models:
- 4o (default): GPT-4o - Latest and most capable
- 4: GPT-4 - Advanced reasoning
- 3.5: GPT-3.5 Turbo - Fast and efficient
- o1: O1 Preview - Chain of thought reasoning
- o1-mini: O1 Mini - Smaller reasoning model
Examples:
- openai --prompt "Explain quantum computing"
- openai --model 4 --prompt "Write a Python function"
- openai --model o1 --prompt "Solve this step by step"
"""
@final
class AnthropicTool(BaseProviderTool):
"""Anthropic-specific LLM tool."""
def __init__(self):
super().__init__(
provider="anthropic",
default_model="claude-3-sonnet-20240229",
model_variants={
"opus": "claude-3-opus-20240229",
"sonnet": "claude-3-sonnet-20240229",
"haiku": "claude-3-haiku-20240307",
"2.1": "claude-2.1",
"2": "claude-2",
"instant": "claude-instant-1.2",
},
)
@property
@override
def name(self) -> str:
return "anthropic"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query Anthropic Claude models directly ({status}).
Models:
- sonnet (default): Claude 3 Sonnet - Balanced performance
- opus: Claude 3 Opus - Most capable
- haiku: Claude 3 Haiku - Fast and efficient
- 2.1: Claude 2.1 - Previous generation
- instant: Claude Instant - Very fast
Examples:
- anthropic --prompt "Analyze this code"
- anthropic --model opus --prompt "Write a detailed essay"
- anthropic --model haiku --prompt "Quick question"
"""
@final
class GeminiTool(BaseProviderTool):
"""Google Gemini-specific LLM tool."""
def __init__(self):
super().__init__(
provider="google",
default_model="gemini/gemini-pro",
model_variants={
"pro": "gemini/gemini-pro",
"pro-vision": "gemini/gemini-pro-vision",
"1.5-pro": "gemini/gemini-1.5-pro-latest",
"1.5-flash": "gemini/gemini-1.5-flash-latest",
"ultra": "gemini/gemini-ultra",
},
)
@property
@override
def name(self) -> str:
return "gemini"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query Google Gemini models directly ({status}).
Models:
- pro (default): Gemini Pro - Balanced model
- 1.5-pro: Gemini 1.5 Pro - Advanced with long context
- 1.5-flash: Gemini 1.5 Flash - Fast and efficient
- pro-vision: Gemini Pro Vision - Multimodal
- ultra: Gemini Ultra - Most capable (if available)
Examples:
- gemini --prompt "Explain this concept"
- gemini --model 1.5-pro --prompt "Analyze this long document"
- gemini --model 1.5-flash --prompt "Quick task"
"""
@final
class GroqTool(BaseProviderTool):
"""Groq-specific LLM tool."""
def __init__(self):
super().__init__(
provider="groq",
default_model="groq/mixtral-8x7b-32768",
model_variants={
"mixtral": "groq/mixtral-8x7b-32768",
"llama3-70b": "groq/llama3-70b-8192",
"llama3-8b": "groq/llama3-8b-8192",
"llama2-70b": "groq/llama2-70b-4096",
"gemma-7b": "groq/gemma-7b-it",
},
)
@property
@override
def name(self) -> str:
return "groq"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query Groq LPU models - ultra-fast inference ({status}).
Models:
- mixtral (default): Mixtral 8x7B - High quality
- llama3-70b: Llama 3 70B - Very capable
- llama3-8b: Llama 3 8B - Fast and efficient
- llama2-70b: Llama 2 70B - Previous gen
- gemma-7b: Google Gemma 7B - Efficient
Examples:
- groq --prompt "Fast response needed"
- groq --model llama3-70b --prompt "Complex reasoning"
- groq --model gemma-7b --prompt "Quick task"
"""
@final
class MistralTool(BaseProviderTool):
"""Mistral-specific LLM tool."""
def __init__(self):
super().__init__(
provider="mistral",
default_model="mistral/mistral-medium",
model_variants={
"tiny": "mistral/mistral-tiny",
"small": "mistral/mistral-small-latest",
"medium": "mistral/mistral-medium-latest",
"large": "mistral/mistral-large-latest",
"embed": "mistral/mistral-embed",
},
)
@property
@override
def name(self) -> str:
return "mistral"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query Mistral AI models directly ({status}).
Models:
- medium (default): Mistral Medium - Balanced
- large: Mistral Large - Most capable
- small: Mistral Small - Efficient
- tiny: Mistral Tiny - Very fast
Examples:
- mistral --prompt "Explain this"
- mistral --model large --prompt "Complex analysis"
- mistral --model tiny --prompt "Quick response"
"""
@final
class PerplexityTool(BaseProviderTool):
"""Perplexity-specific LLM tool."""
def __init__(self):
super().__init__(
provider="perplexity",
default_model="perplexity/sonar-medium-online",
model_variants={
"sonar-small": "perplexity/sonar-small-online",
"sonar-medium": "perplexity/sonar-medium-online",
"sonar-small-chat": "perplexity/sonar-small-chat",
"sonar-medium-chat": "perplexity/sonar-medium-chat",
},
)
@property
@override
def name(self) -> str:
return "perplexity"
@property
@override
def description(self) -> str:
status = "✓ Available" if self.is_available else "✗ No API key"
return f"""Query Perplexity models with internet access ({status}).
Models:
- sonar-medium (default): Online search + reasoning
- sonar-small: Faster online search
- sonar-medium-chat: Chat without search
- sonar-small-chat: Fast chat without search
Examples:
- perplexity --prompt "Latest news about AI"
- perplexity --model sonar-small --prompt "Quick fact check"
- perplexity --model sonar-medium-chat --prompt "Explain without search"
"""
# Export all provider tools
PROVIDER_TOOLS = [
OpenAITool,
AnthropicTool,
GeminiTool,
GroqTool,
MistralTool,
PerplexityTool,
]
def create_provider_tools() -> list[BaseTool]:
"""Create instances of all provider tools."""
tools = []
for tool_class in PROVIDER_TOOLS:
tool = tool_class()
# Only include tools with available API keys
if tool.is_available:
tools.append(tool)
return tools
+6 -5
View File
@@ -5,20 +5,21 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-llm"
version = "0.1.0"
description = "LLM provider tools - unified interface to 100+ providers"
readme = "README.md"
description = "LLM tools for Hanzo AI - model management, consensus, providers"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "llm", "openai", "anthropic", "mcp", "ai"]
keywords = ["hanzo", "tools", "llm", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
"litellm>=1.80.0",
"openai>=1.82.0",
]
[project.optional-dependencies]
full = ["litellm>=1.0.0", "openai>=1.0.0", "anthropic>=0.40.0"]
[project.entry-points."hanzo.tools"]
llm = "hanzo_tools.llm:TOOLS"
@@ -0,0 +1,3 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,77 @@
"""MCP management tools for Hanzo AI.
Tools:
- mcp: MCP server management
- mcp_add: Add MCP servers
- mcp_remove: Remove MCP servers
- mcp_stats: MCP statistics
Install:
pip install hanzo-tools-mcp
"""
import logging
logger = logging.getLogger(__name__)
_tools = []
try:
from .mcp_tool import MCPTool
_tools.append(MCPTool)
except ImportError as e:
logger.debug(f"MCPTool not available: {e}")
MCPTool = None
try:
from .mcp_add import McpAddTool
_tools.append(McpAddTool)
except ImportError as e:
logger.debug(f"McpAddTool not available: {e}")
McpAddTool = None
try:
from .mcp_remove import McpRemoveTool
_tools.append(McpRemoveTool)
except ImportError as e:
logger.debug(f"McpRemoveTool not available: {e}")
McpRemoveTool = None
try:
from .mcp_stats import McpStatsTool
_tools.append(McpStatsTool)
except ImportError as e:
logger.debug(f"McpStatsTool not available: {e}")
McpStatsTool = None
TOOLS = _tools
__all__ = [
"TOOLS",
"MCPTool",
"McpAddTool",
"McpRemoveTool",
"McpStatsTool",
"register_tools",
]
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
"""Register MCP tools with MCP server."""
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
if tool_class is None:
continue
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
if enabled.get(tool_name, True):
try:
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except Exception as e:
logger.warning(f"Failed to register {tool_name}: {e}")
return registered
@@ -0,0 +1,265 @@
"""Add MCP servers dynamically."""
import json
import shutil
from typing import Any, Dict, 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
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
ServerCommand = Annotated[
str,
Field(
description="Server command (e.g., 'uvx mcp-server-git', 'npx @modelcontextprotocol/server-filesystem')",
min_length=1,
),
]
ServerName = Annotated[
str,
Field(
description="Unique name for the server",
min_length=1,
),
]
Args = Annotated[
Optional[str],
Field(
description="Additional arguments for the server",
default=None,
),
]
Env = Annotated[
Optional[Dict[str, str]],
Field(
description="Environment variables for the server",
default=None,
),
]
AutoStart = Annotated[
bool,
Field(
description="Automatically start the server after adding",
default=True,
),
]
class McpAddParams(TypedDict, total=False):
"""Parameters for MCP add tool."""
command: str
name: str
args: Optional[str]
env: Optional[Dict[str, str]]
auto_start: bool
@final
class McpAddTool(BaseTool):
"""Tool for adding MCP servers dynamically."""
# Class variable to store added servers
_mcp_servers: Dict[str, Dict[str, Any]] = {}
_config_file = Path.home() / ".hanzo" / "mcp" / "servers.json"
def __init__(self):
"""Initialize the MCP add tool."""
# Load existing servers from config
self._load_servers()
@classmethod
def _load_servers(cls):
"""Load servers from config file."""
if cls._config_file.exists():
try:
with open(cls._config_file, "r") as f:
cls._mcp_servers = json.load(f)
except Exception:
cls._mcp_servers = {}
@classmethod
def _save_servers(cls):
"""Save servers to config file."""
cls._config_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._config_file, "w") as f:
json.dump(cls._mcp_servers, f, indent=2)
@classmethod
def get_servers(cls) -> Dict[str, Dict[str, Any]]:
"""Get all registered MCP servers."""
return cls._mcp_servers.copy()
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "mcp_add"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Add MCP (Model Context Protocol) servers dynamically.
This allows adding new MCP servers that provide additional tools.
Servers can be from npm packages or Python packages.
Common MCP servers:
- @modelcontextprotocol/server-filesystem - File system access
- @modelcontextprotocol/server-github - GitHub integration
- @modelcontextprotocol/server-gitlab - GitLab integration
- @modelcontextprotocol/server-postgres - PostgreSQL access
- @modelcontextprotocol/server-sqlite - SQLite access
- mcp-server-git - Git operations
- mcp-server-docker - Docker management
- mcp-server-kubernetes - K8s management
Examples:
- mcp_add --command "npx @modelcontextprotocol/server-filesystem" --name filesystem --args "/path/to/allow"
- mcp_add --command "uvx mcp-server-git" --name git --args "--repository /path/to/repo"
- mcp_add --command "npx @modelcontextprotocol/server-github" --name github --env '{"GITHUB_TOKEN": "..."}'
Use 'mcp_stats' to see all added servers and their status.
"""
@override
@auto_timeout("mcp_add")
async def call(
self,
ctx: MCPContext,
**params: Unpack[McpAddParams],
) -> str:
"""Add an MCP server.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of adding the server
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
command = params.get("command")
if not command:
return "Error: command is required"
name = params.get("name")
if not name:
return "Error: name is required"
args = params.get("args")
env = params.get("env", {})
auto_start = params.get("auto_start", True)
# Check if server already exists
if name in self._mcp_servers:
return f"Error: Server '{name}' already exists. Use mcp_remove to remove it first."
# Parse command to determine type
server_type = "unknown"
if command.startswith("npx"):
server_type = "node"
elif command.startswith("uvx") or command.startswith("python"):
server_type = "python"
elif command.startswith("node"):
server_type = "node"
# Build full command
full_command = [command]
if args:
import shlex
# If command contains spaces, split it first
if " " in command:
full_command = shlex.split(command)
full_command.extend(shlex.split(args))
else:
if " " in command:
import shlex
full_command = shlex.split(command)
await tool_ctx.info(f"Adding MCP server '{name}' with command: {' '.join(full_command)}")
# Create server configuration
server_config = {
"command": full_command,
"name": name,
"type": server_type,
"env": env,
"status": "stopped",
"process_id": None,
"tools": [],
"resources": [],
"prompts": [],
}
# Test if command is valid
if auto_start:
try:
# Try to start the server briefly to validate
test_env = {**env} if env else {}
# Quick test to see if command exists
test_cmd = full_command[0]
if test_cmd == "npx":
if not shutil.which("npx"):
return "Error: npx not found. Install Node.js first."
elif test_cmd == "uvx":
if not shutil.which("uvx"):
return "Error: uvx not found. Install uv first."
# Server is validated and ready to be used
# The actual connection happens when tools are invoked
server_config["status"] = "ready"
except Exception as e:
await tool_ctx.error(f"Failed to validate server: {str(e)}")
server_config["status"] = "error"
server_config["error"] = str(e)
# Add server to registry
self._mcp_servers[name] = server_config
self._save_servers()
output = [
f"Successfully added MCP server '{name}':",
f" Type: {server_type}",
f" Command: {' '.join(full_command)}",
f" Status: {server_config['status']}",
]
if env:
output.append(f" Environment: {list(env.keys())}")
output.extend(
[
"",
"Use 'mcp_stats' to see server details.",
f"Use 'mcp_remove --name {name}' to remove this server.",
]
)
# Note: In a real implementation, we would:
# 1. Start the MCP server process
# 2. Connect to it via stdio/HTTP
# 3. Query its capabilities (tools, resources, prompts)
# 4. Register those with our MCP server
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,137 @@
"""Remove MCP servers."""
from typing import Unpack, Annotated, TypedDict, final, override
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from .mcp_add import McpAddTool
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
ServerName = Annotated[
str,
Field(
description="Name of the server to remove",
min_length=1,
),
]
Force = Annotated[
bool,
Field(
description="Force removal even if server is running",
default=False,
),
]
class McpRemoveParams(TypedDict, total=False):
"""Parameters for MCP remove tool."""
name: str
force: bool
@final
class McpRemoveTool(BaseTool):
"""Tool for removing MCP servers."""
def __init__(self):
"""Initialize the MCP remove tool."""
pass
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "mcp_remove"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Remove previously added MCP servers.
This removes MCP servers that were added with mcp_add.
If the server is running, it will be stopped first.
Examples:
- mcp_remove --name filesystem
- mcp_remove --name github --force
Use 'mcp_stats' to see all servers before removing.
"""
@override
@auto_timeout("mcp_remove")
async def call(
self,
ctx: MCPContext,
**params: Unpack[McpRemoveParams],
) -> str:
"""Remove an MCP server.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of removing the server
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
name = params.get("name")
if not name:
return "Error: name is required"
force = params.get("force", False)
# Get current servers
servers = McpAddTool.get_servers()
if name not in servers:
return f"Error: Server '{name}' not found. Use 'mcp_stats' to see available servers."
server = servers[name]
await tool_ctx.info(f"Removing MCP server '{name}'")
# Check if server is running
if server.get("status") == "running" and server.get("process_id"):
if not force:
return f"Error: Server '{name}' is currently running. Use --force to remove anyway."
else:
# Stop the server process if it's running
process_id = server.get("process_id")
if process_id:
try:
import os
import signal
os.kill(process_id, signal.SIGTERM)
await tool_ctx.info(f"Stopped running server '{name}' (PID: {process_id})")
except ProcessLookupError:
await tool_ctx.info(f"Server '{name}' process not found (already stopped)")
# Remove from registry
del McpAddTool._mcp_servers[name]
McpAddTool._save_servers()
output = [
f"Successfully removed MCP server '{name}'",
f" Type: {server.get('type', 'unknown')}",
f" Command: {' '.join(server.get('command', []))}",
]
if server.get("tools"):
output.append(f" Tools removed: {len(server['tools'])}")
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,166 @@
"""MCP server statistics."""
from typing import Unpack, TypedDict, final, override
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from .mcp_add import McpAddTool
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
class McpStatsParams(TypedDict, total=False):
"""Parameters for MCP stats tool."""
pass
@final
class McpStatsTool(BaseTool):
"""Tool for showing MCP server statistics."""
def __init__(self):
"""Initialize the MCP stats tool."""
pass
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "mcp_stats"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Show statistics about added MCP servers.
Displays:
- Total number of servers
- Server types (Python, Node.js)
- Server status (running, stopped, error)
- Available tools from each server
- Resource usage per server
Example:
- mcp_stats
"""
@override
@auto_timeout("mcp_stats")
async def call(
self,
ctx: MCPContext,
**params: Unpack[McpStatsParams],
) -> str:
"""Get MCP server statistics.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
MCP server statistics
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Get all servers
servers = McpAddTool.get_servers()
if not servers:
return "No MCP servers have been added yet.\n\nUse 'mcp_add' to add servers."
output = []
output.append("=== MCP Server Statistics ===")
output.append(f"Total Servers: {len(servers)}")
output.append("")
# Count by type
type_counts = {}
status_counts = {}
total_tools = 0
total_resources = 0
for server in servers.values():
# Count types
server_type = server.get("type", "unknown")
type_counts[server_type] = type_counts.get(server_type, 0) + 1
# Count status
status = server.get("status", "unknown")
status_counts[status] = status_counts.get(status, 0) + 1
# Count tools and resources
total_tools += len(server.get("tools", []))
total_resources += len(server.get("resources", []))
# Server types
output.append("Server Types:")
for stype, count in sorted(type_counts.items()):
output.append(f" {stype}: {count}")
output.append("")
# Server status
output.append("Server Status:")
for status, count in sorted(status_counts.items()):
output.append(f" {status}: {count}")
output.append("")
# Tools and resources
output.append(f"Total Tools Available: {total_tools}")
output.append(f"Total Resources Available: {total_resources}")
output.append("")
# Individual server details
output.append("=== Server Details ===")
for name, server in sorted(servers.items()):
output.append(f"\n{name}:")
output.append(f" Type: {server.get('type', 'unknown')}")
output.append(f" Status: {server.get('status', 'unknown')}")
output.append(f" Command: {' '.join(server.get('command', []))}")
if server.get("process_id"):
output.append(f" Process ID: {server['process_id']}")
if server.get("error"):
output.append(f" Error: {server['error']}")
tools = server.get("tools", [])
if tools:
output.append(f" Tools ({len(tools)}):")
for tool in tools[:5]: # Show first 5
output.append(f" - {tool}")
if len(tools) > 5:
output.append(f" ... and {len(tools) - 5} more")
resources = server.get("resources", [])
if resources:
output.append(f" Resources ({len(resources)}):")
for resource in resources[:5]: # Show first 5
output.append(f" - {resource}")
if len(resources) > 5:
output.append(f" ... and {len(resources) - 5} more")
if server.get("env"):
output.append(f" Environment vars: {list(server['env'].keys())}")
# Common MCP servers hint
output.append("\n=== Available MCP Servers ===")
output.append("Common servers you can add:")
output.append(" - @modelcontextprotocol/server-filesystem")
output.append(" - @modelcontextprotocol/server-github")
output.append(" - mcp-server-git")
output.append(" - @modelcontextprotocol/server-postgres")
output.append(" - @modelcontextprotocol/server-browser-use")
output.append(" - @modelcontextprotocol/server-iterm2")
output.append(" - @modelcontextprotocol/server-linear")
output.append(" - @modelcontextprotocol/server-slack")
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,522 @@
"""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
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import auto_timeout
# 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
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-mcp"
version = "0.1.0"
description = "MCP management tools for Hanzo AI - server add/remove/stats"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.entry-points."hanzo.tools"]
mcp_tools = "hanzo_tools.mcp_tools:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
@@ -1,16 +1,62 @@
"""Vector search and embedding tools.
"""Vector/embedding tools for Hanzo AI.
Tools:
- vector_index: Index files for semantic search
- vector_search: Search indexed content
- vector_index: Index documents for semantic search
- vector_search: Search indexed documents
- index: Project indexing
Install: pip install hanzo-tools-vector
Install:
pip install hanzo-tools-vector[full]
"""
TOOLS = []
import logging
logger = logging.getLogger(__name__)
__all__ = ["TOOLS", "register_tools"]
_tools = []
VECTOR_AVAILABLE = False
def register_tools(mcp_server, permission_manager=None, enabled_tools=None):
"""Register vector tools with the MCP server."""
return []
try:
from .index_tool import IndexTool
_tools.append(IndexTool)
from .vector_index import VectorIndexTool
_tools.append(VectorIndexTool)
from .vector_search import VectorSearchTool
_tools.append(VectorSearchTool)
VECTOR_AVAILABLE = True
except ImportError as e:
logger.debug(f"Vector tools not available: {e}")
TOOLS = _tools
__all__ = [
"TOOLS",
"VECTOR_AVAILABLE",
"register_tools",
]
if VECTOR_AVAILABLE:
__all__.extend(["IndexTool", "VectorIndexTool", "VectorSearchTool"])
def register_tools(mcp_server, permission_manager=None, enabled_tools: dict[str, bool] | None = None):
"""Register vector tools with MCP server."""
if not VECTOR_AVAILABLE:
logger.warning("Vector tools not available - missing dependencies")
return []
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
if enabled.get(tool_name, True):
try:
tool = tool_class(permission_manager) if permission_manager else tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except Exception as e:
logger.warning(f"Failed to register {tool_name}: {e}")
return registered
@@ -0,0 +1,486 @@
"""AST analysis and symbol extraction for code understanding."""
import ast
import hashlib
from typing import Any, Dict, List, Optional
from pathlib import Path
from dataclasses import asdict, dataclass
try:
import tree_sitter
import tree_sitter_python as tspython
TREE_SITTER_AVAILABLE = True
except ImportError:
TREE_SITTER_AVAILABLE = False
@dataclass
class Symbol:
"""Represents a code symbol (function, class, variable, etc.)."""
name: str
type: str # function, class, variable, import, etc.
file_path: str
line_start: int
line_end: int
column_start: int
column_end: int
scope: str # global, class, function
parent: Optional[str] = None # parent class/function
docstring: Optional[str] = None
signature: Optional[str] = None
references: List[str] = None # Files that reference this symbol
def __post_init__(self):
if self.references is None:
self.references = []
@dataclass
class ASTNode:
"""Represents an AST node with metadata."""
type: str
name: Optional[str]
line_start: int
line_end: int
column_start: int
column_end: int
children: List["ASTNode"] = None
parent: Optional[str] = None
def __post_init__(self):
if self.children is None:
self.children = []
@dataclass
class FileAST:
"""Complete AST representation of a file."""
file_path: str
file_hash: str
language: str
symbols: List[Symbol]
ast_nodes: List[ASTNode]
imports: List[str]
exports: List[str]
dependencies: List[str] # Files this file depends on
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for storage."""
return {
"file_path": self.file_path,
"file_hash": self.file_hash,
"language": self.language,
"symbols": [asdict(s) for s in self.symbols],
"ast_nodes": [asdict(n) for n in self.ast_nodes],
"imports": self.imports,
"exports": self.exports,
"dependencies": self.dependencies,
}
class ASTAnalyzer:
"""Analyzes code files and extracts AST information and symbols."""
def __init__(self):
"""Initialize the AST analyzer."""
self.parsers = {}
self._setup_parsers()
def _setup_parsers(self):
"""Set up tree-sitter parsers for different languages."""
if TREE_SITTER_AVAILABLE:
try:
# Python parser
self.parsers["python"] = tree_sitter.Language(tspython.language())
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not initialize Python parser: {e}")
def analyze_file(self, file_path: str) -> Optional[FileAST]:
"""Analyze a file and extract AST information and symbols.
Args:
file_path: Path to the file to analyze
Returns:
FileAST object with extracted information, or None if analysis fails
"""
path = Path(file_path)
if not path.exists():
return None
# Determine language
language = self._detect_language(path)
if not language:
return None
try:
# Read file content
content = path.read_text(encoding="utf-8")
file_hash = hashlib.sha256(content.encode()).hexdigest()
# Extract symbols and AST
if language == "python":
return self._analyze_python_file(file_path, content, file_hash)
else:
# Generic analysis for other languages
return self._analyze_generic_file(file_path, content, file_hash, language)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error analyzing file {file_path}: {e}")
return None
def _detect_language(self, path: Path) -> Optional[str]:
"""Detect programming language from file extension."""
extension = path.suffix.lower()
language_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".jsx": "javascript",
".tsx": "typescript",
".java": "java",
".cpp": "cpp",
".c": "c",
".h": "c",
".hpp": "cpp",
".rs": "rust",
".go": "go",
".rb": "ruby",
".php": "php",
".cs": "csharp",
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".clj": "clojure",
".hs": "haskell",
".ml": "ocaml",
".elm": "elm",
".dart": "dart",
".lua": "lua",
".r": "r",
".m": "objective-c",
".mm": "objective-cpp",
}
return language_map.get(extension)
def _analyze_python_file(self, file_path: str, content: str, file_hash: str) -> FileAST:
"""Analyze Python file using both AST and tree-sitter."""
symbols = []
ast_nodes = []
imports = []
exports = []
dependencies = []
try:
# Parse with Python AST
tree = ast.parse(content)
# Extract symbols using AST visitor
visitor = PythonSymbolExtractor(file_path)
visitor.visit(tree)
symbols.extend(visitor.symbols)
imports.extend(visitor.imports)
exports.extend(visitor.exports)
dependencies.extend(visitor.dependencies)
# If tree-sitter is available, get more detailed AST
if TREE_SITTER_AVAILABLE and "python" in self.parsers:
parser = tree_sitter.Parser(self.parsers["python"])
ts_tree = parser.parse(content.encode())
ast_nodes = self._extract_tree_sitter_nodes(ts_tree.root_node, content)
except SyntaxError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Syntax error in {file_path}: {e}")
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error parsing Python file {file_path}: {e}")
return FileAST(
file_path=file_path,
file_hash=file_hash,
language="python",
symbols=symbols,
ast_nodes=ast_nodes,
imports=imports,
exports=exports,
dependencies=dependencies,
)
def _analyze_generic_file(self, file_path: str, content: str, file_hash: str, language: str) -> FileAST:
"""Generic analysis for non-Python files."""
# For now, just basic line-based analysis
# Could be enhanced with language-specific parsers
symbols = []
ast_nodes = []
imports = []
exports = []
dependencies = []
# Basic pattern matching for common constructs
lines = content.split("\n")
for i, line in enumerate(lines, 1):
line = line.strip()
# Basic function detection (works for many C-style languages)
if language in ["javascript", "typescript", "java", "cpp", "c"]:
if "function " in line or line.startswith("def ") or " function(" in line:
# Extract function name
parts = line.split()
for j, part in enumerate(parts):
if part == "function" and j + 1 < len(parts):
func_name = parts[j + 1].split("(")[0]
symbols.append(
Symbol(
name=func_name,
type="function",
file_path=file_path,
line_start=i,
line_end=i,
column_start=0,
column_end=len(line),
scope="global",
)
)
break
# Basic import detection
if "import " in line or "#include " in line or "require(" in line:
imports.append(line)
return FileAST(
file_path=file_path,
file_hash=file_hash,
language=language,
symbols=symbols,
ast_nodes=ast_nodes,
imports=imports,
exports=exports,
dependencies=dependencies,
)
def _extract_tree_sitter_nodes(self, node, content: str) -> List[ASTNode]:
"""Extract AST nodes from tree-sitter parse tree."""
nodes = []
def traverse(ts_node, parent_name=None):
node_name = None
# Try to extract node name for named nodes
if ts_node.type in [
"function_definition",
"class_definition",
"identifier",
]:
for child in ts_node.children:
if child.type == "identifier":
start_byte = child.start_byte
end_byte = child.end_byte
node_name = content[start_byte:end_byte]
break
ast_node = ASTNode(
type=ts_node.type,
name=node_name,
line_start=ts_node.start_point[0] + 1,
line_end=ts_node.end_point[0] + 1,
column_start=ts_node.start_point[1],
column_end=ts_node.end_point[1],
parent=parent_name,
)
nodes.append(ast_node)
# Recursively process children
for child in ts_node.children:
traverse(child, node_name or parent_name)
traverse(node)
return nodes
class PythonSymbolExtractor(ast.NodeVisitor):
"""AST visitor for extracting Python symbols."""
def __init__(self, file_path: str):
self.file_path = file_path
self.symbols = []
self.imports = []
self.exports = []
self.dependencies = []
self.scope_stack = ["global"]
def visit_FunctionDef(self, node):
"""Visit function definitions."""
scope = ".".join(self.scope_stack)
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
# Extract docstring
docstring = None
if (
node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)
):
docstring = node.body[0].value.value
# Create function signature
args = [arg.arg for arg in node.args.args]
signature = f"{node.name}({', '.join(args)})"
symbol = Symbol(
name=node.name,
type="function",
file_path=self.file_path,
line_start=node.lineno,
line_end=node.end_lineno or node.lineno,
column_start=node.col_offset,
column_end=node.end_col_offset or 0,
scope=scope,
parent=parent if parent != "global" else None,
docstring=docstring,
signature=signature,
)
self.symbols.append(symbol)
# Enter function scope
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_AsyncFunctionDef(self, node):
"""Visit async function definitions."""
self.visit_FunctionDef(node) # Same logic
def visit_ClassDef(self, node):
"""Visit class definitions."""
scope = ".".join(self.scope_stack)
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
# Extract docstring
docstring = None
if (
node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)
):
docstring = node.body[0].value.value
# Extract base classes
bases = [self._get_name(base) for base in node.bases]
signature = f"class {node.name}({', '.join(bases)})" if bases else f"class {node.name}"
symbol = Symbol(
name=node.name,
type="class",
file_path=self.file_path,
line_start=node.lineno,
line_end=node.end_lineno or node.lineno,
column_start=node.col_offset,
column_end=node.end_col_offset or 0,
scope=scope,
parent=parent if parent != "global" else None,
docstring=docstring,
signature=signature,
)
self.symbols.append(symbol)
# Enter class scope
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_Import(self, node):
"""Visit import statements."""
for alias in node.names:
import_name = alias.name
self.imports.append(import_name)
if "." not in import_name: # Top-level module
self.dependencies.append(import_name)
def visit_ImportFrom(self, node):
"""Visit from...import statements."""
if node.module:
self.imports.append(node.module)
if "." not in node.module: # Top-level module
self.dependencies.append(node.module)
for alias in node.names:
if alias.name != "*":
import_item = f"{node.module}.{alias.name}" if node.module else alias.name
self.imports.append(import_item)
def visit_Assign(self, node):
"""Visit variable assignments."""
scope = ".".join(self.scope_stack)
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
for target in node.targets:
if isinstance(target, ast.Name):
symbol = Symbol(
name=target.id,
type="variable",
file_path=self.file_path,
line_start=node.lineno,
line_end=node.end_lineno or node.lineno,
column_start=node.col_offset,
column_end=node.end_col_offset or 0,
scope=scope,
parent=parent if parent != "global" else None,
)
self.symbols.append(symbol)
self.generic_visit(node)
def _get_name(self, node):
"""Extract name from AST node."""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
return f"{self._get_name(node.value)}.{node.attr}"
elif isinstance(node, ast.Constant):
return str(node.value)
else:
return str(node)
def create_symbol_embedding_text(symbol: Symbol) -> str:
"""Create text representation of symbol for vector embedding."""
parts = [
f"Symbol: {symbol.name}",
f"Type: {symbol.type}",
f"Scope: {symbol.scope}",
]
if symbol.parent:
parts.append(f"Parent: {symbol.parent}")
if symbol.signature:
parts.append(f"Signature: {symbol.signature}")
if symbol.docstring:
parts.append(f"Documentation: {symbol.docstring}")
return " | ".join(parts)
@@ -0,0 +1,456 @@
"""Git repository ingester for comprehensive code indexing.
This module provides functionality to ingest entire git repositories including:
- Full git history and commit metadata
- File contents at different points in time
- AST analysis via tree-sitter
- Symbol extraction and cross-references
- Blame information for line-level attribution
"""
import logging
import subprocess
from typing import Any, Dict, List, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from .ast_analyzer import ASTAnalyzer
from .infinity_store import InfinityVectorStore
logger = logging.getLogger(__name__)
@dataclass
class GitCommit:
"""Represents a git commit."""
hash: str
author: str
author_email: str
timestamp: int
message: str
files: List[Dict[str, str]] # [{'status': 'M', 'filename': 'main.py'}]
parent_hashes: List[str]
@dataclass
class GitFileHistory:
"""History of a single file."""
file_path: str
commits: List[GitCommit]
current_content: Optional[str]
line_blame: Dict[int, Dict[str, Any]] # line_number -> blame info
class GitIngester:
"""Ingests git repositories into vector store."""
def __init__(self, vector_store: InfinityVectorStore):
"""Initialize the git ingester.
Args:
vector_store: The vector store to ingest into
"""
self.vector_store = vector_store
self.ast_analyzer = ASTAnalyzer()
self._commit_cache: Dict[str, GitCommit] = {}
def ingest_repository(
self,
repo_path: str,
branch: str = "HEAD",
include_history: bool = True,
include_diffs: bool = True,
include_blame: bool = True,
file_patterns: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Ingest an entire git repository.
Args:
repo_path: Path to the git repository
branch: Branch to ingest (default: HEAD)
include_history: Whether to include commit history
include_diffs: Whether to include diff information
include_blame: Whether to include blame information
file_patterns: List of file patterns to include (e.g., ["*.py", "*.js"])
Returns:
Summary of ingestion results
"""
repo_path = Path(repo_path)
if not (repo_path / ".git").exists():
raise ValueError(f"Not a git repository: {repo_path}")
logger.info(f"Starting ingestion of repository: {repo_path}")
results = {
"repository": str(repo_path),
"branch": branch,
"commits_processed": 0,
"commits_indexed": 0,
"files_indexed": 0,
"symbols_extracted": 0,
"diffs_indexed": 0,
"blame_entries": 0,
"errors": [],
}
try:
# Get current branch/commit
current_commit = self._get_current_commit(repo_path)
results["current_commit"] = current_commit
# Get list of files to process
files = self._get_repository_files(repo_path, file_patterns)
logger.info(f"Found {len(files)} files to process")
# Process each file
for file_path in files:
try:
self._process_file(
repo_path,
file_path,
include_history=include_history,
include_blame=include_blame,
results=results,
)
except Exception as e:
logger.error(f"Error processing {file_path}: {e}")
results["errors"].append(f"{file_path}: {str(e)}")
# Process commit history if requested
if include_history:
commits = self._get_commit_history(repo_path, branch)
results["commits_processed"] = len(commits)
for commit in commits:
self._index_commit(commit, include_diffs=include_diffs)
results["commits_indexed"] = results.get("commits_indexed", 0) + 1
if include_diffs:
results["diffs_indexed"] += len(commit.files)
# Create repository metadata document
self._index_repository_metadata(repo_path, results)
except Exception as e:
logger.error(f"Repository ingestion failed: {e}")
results["errors"].append(f"Fatal error: {str(e)}")
logger.info(f"Ingestion complete: {results}")
return results
def _get_current_commit(self, repo_path: Path) -> str:
"""Get the current commit hash."""
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _get_repository_files(self, repo_path: Path, patterns: Optional[List[str]] = None) -> List[Path]:
"""Get list of files in repository matching patterns."""
# Use git ls-files to respect .gitignore
cmd = ["git", "ls-files"]
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=True)
files = []
for line in result.stdout.strip().split("\n"):
if line:
file_path = repo_path / line
if file_path.exists():
# Apply pattern filtering if specified
if patterns:
if any(file_path.match(pattern) for pattern in patterns):
files.append(file_path)
else:
files.append(file_path)
return files
def _get_commit_history(self, repo_path: Path, branch: str = "HEAD", max_commits: int = 1000) -> List[GitCommit]:
"""Get commit history for the repository."""
# Get commit list with basic info
result = subprocess.run(
[
"git",
"log",
branch,
f"--max-count={max_commits}",
"--pretty=format:%H|%P|%an|%ae|%at|%s",
],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
commits = []
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("|", 5)
if len(parts) >= 6:
commit_hash = parts[0]
parent_hashes = parts[1].split() if parts[1] else []
# Get file changes for this commit
files = self._get_commit_files(repo_path, commit_hash)
commit = GitCommit(
hash=commit_hash,
parent_hashes=parent_hashes,
author=parts[2],
author_email=parts[3],
timestamp=int(parts[4]),
message=parts[5],
files=files,
)
commits.append(commit)
self._commit_cache[commit_hash] = commit
return commits
def _get_commit_files(self, repo_path: Path, commit_hash: str) -> List[Dict[str, str]]:
"""Get list of files changed in a commit."""
result = subprocess.run(
["git", "show", "--name-status", "--format=", commit_hash],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
files = []
for line in result.stdout.strip().split("\n"):
if line and "\t" in line:
parts = line.split("\t", 1)
if len(parts) == 2:
files.append({"status": parts[0], "filename": parts[1]})
return files
def _process_file(
self,
repo_path: Path,
file_path: Path,
include_history: bool,
include_blame: bool,
results: Dict[str, Any],
):
"""Process a single file."""
relative_path = file_path.relative_to(repo_path)
# Read current content
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = file_path.read_text(encoding="latin-1")
# Get file metadata
metadata = {
"repository": str(repo_path),
"relative_path": str(relative_path),
"file_type": file_path.suffix,
"size": file_path.stat().st_size,
}
# Add git history metadata if requested
if include_history:
history = self._get_file_history(repo_path, relative_path)
metadata["commit_count"] = len(history)
if history:
metadata["first_commit"] = history[-1]["hash"]
metadata["last_commit"] = history[0]["hash"]
metadata["last_modified"] = datetime.fromtimestamp(history[0]["timestamp"]).isoformat()
# Add blame information if requested
if include_blame:
blame_data = self._get_file_blame(repo_path, relative_path)
metadata["unique_authors"] = len(set(b["author"] for b in blame_data.values()))
# Index the file content
doc_ids = self.vector_store.add_file(str(file_path), chunk_size=1000, chunk_overlap=200, metadata=metadata)
results["files_indexed"] += 1
# Perform AST analysis for supported languages
if file_path.suffix in [".py", ".js", ".ts", ".java", ".cpp", ".c"]:
try:
file_ast = self.ast_analyzer.analyze_file(str(file_path))
if file_ast:
# Store complete AST
self.vector_store._store_file_ast(file_ast)
# Store individual symbols
self.vector_store._store_symbols(file_ast.symbols)
results["symbols_extracted"] += len(file_ast.symbols)
# Store cross-references
self.vector_store._store_references(file_ast)
except Exception as e:
logger.warning(f"AST analysis failed for {file_path}: {e}")
def _get_file_history(self, repo_path: Path, file_path: Path) -> List[Dict[str, Any]]:
"""Get commit history for a specific file."""
result = subprocess.run(
[
"git",
"log",
"--follow",
"--pretty=format:%H|%at|%an|%s",
"--",
str(file_path),
],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
history = []
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("|", 3)
if len(parts) >= 4:
history.append(
{
"hash": parts[0],
"timestamp": int(parts[1]),
"author": parts[2],
"message": parts[3],
}
)
return history
def _get_file_blame(self, repo_path: Path, file_path: Path) -> Dict[int, Dict[str, Any]]:
"""Get blame information for a file."""
result = subprocess.run(
["git", "blame", "--line-porcelain", "--", str(file_path)],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
return {}
blame_data = {}
current_commit = None
current_line = None
author = None
timestamp = None
for line in result.stdout.strip().split("\n"):
if line and not line.startswith("\t"):
parts = line.split(" ")
if len(parts) >= 3 and len(parts[0]) == 40: # SHA-1 hash
current_commit = parts[0]
current_line = int(parts[2])
elif line.startswith("author "):
author = line[7:]
elif line.startswith("author-time "):
timestamp = int(line[12:])
# We have all the data for this line
if current_line and author:
blame_data[current_line] = {
"commit": current_commit,
"author": author,
"timestamp": timestamp,
}
return blame_data
def _index_commit(self, commit: GitCommit, include_diffs: bool = True):
"""Index a single commit."""
# Create commit document
commit_doc = f"""Git Commit: {commit.hash}
Author: {commit.author} <{commit.author_email}>
Date: {datetime.fromtimestamp(commit.timestamp).isoformat()}
Message: {commit.message}
Files changed: {len(commit.files)}
"""
for file_info in commit.files:
commit_doc += f"\n{file_info['status']}\t{file_info['filename']}"
# Index commit
metadata = {
"type": "git_commit",
"commit_hash": commit.hash,
"author": commit.author,
"timestamp": commit.timestamp,
"file_count": len(commit.files),
}
self.vector_store.add_document(commit_doc, metadata)
# Index diffs if requested
if include_diffs:
for file_info in commit.files:
self._index_commit_diff(commit, file_info["filename"])
def _index_commit_diff(self, commit: GitCommit, filename: str):
"""Index the diff for a specific file in a commit."""
# This is a simplified version - in practice you'd want to
# parse the actual diff and store meaningful chunks
metadata = {
"type": "git_diff",
"commit_hash": commit.hash,
"filename": filename,
"author": commit.author,
"timestamp": commit.timestamp,
}
# Create a document representing this change
diff_doc = f"""File: {filename}
Commit: {commit.hash}
Author: {commit.author}
Message: {commit.message}
"""
self.vector_store.add_document(diff_doc, metadata)
def _index_repository_metadata(self, repo_path: Path, results: Dict[str, Any]):
"""Index overall repository metadata."""
# Get repository info
remote_result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=repo_path,
capture_output=True,
text=True,
)
remote_url = remote_result.stdout.strip() if remote_result.returncode == 0 else None
# Create repository summary document
repo_doc = f"""Repository: {repo_path.name}
Path: {repo_path}
Remote: {remote_url or "No remote"}
Current Commit: {results.get("current_commit", "Unknown")}
Statistics:
- Files indexed: {results["files_indexed"]}
- Commits processed: {results["commits_processed"]}
- Symbols extracted: {results["symbols_extracted"]}
- Diffs indexed: {results["diffs_indexed"]}
"""
metadata = {
"type": "repository",
"name": repo_path.name,
"path": str(repo_path),
"remote_url": remote_url,
**results,
}
self.vector_store.add_document(repo_doc, metadata)
@@ -0,0 +1,406 @@
"""Index tool for managing vector store indexing."""
import os
import time
from typing import Unpack, 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
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .git_ingester import GitIngester
from .project_manager import ProjectVectorManager
Path_str = Annotated[
str,
Field(
description="Path to index (defaults to current working directory)",
min_length=1,
),
]
IncludeGitHistory = Annotated[
bool,
Field(
description="Include git history in the index",
default=True,
),
]
FilePatterns = Annotated[
list[str] | None,
Field(
description="File patterns to include (e.g., ['*.py', '*.js'])",
default=None,
),
]
ShowStats = Annotated[
bool,
Field(
description="Show detailed statistics after indexing",
default=True,
),
]
Force = Annotated[
bool,
Field(
description="Force re-indexing even if already indexed",
default=False,
),
]
class IndexToolParams(TypedDict, total=False):
"""Parameters for the index tool."""
path: str
include_git_history: bool
file_patterns: list[str] | None
show_stats: bool
force: bool
@final
class IndexTool(BaseTool):
"""Tool for indexing files and git history into vector store."""
def __init__(self, permission_manager: PermissionManager):
"""Initialize the index tool.
Args:
permission_manager: Permission manager for access control
"""
self.permission_manager = permission_manager
self.project_manager = ProjectVectorManager(permission_manager)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "index"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Index files and git history into the vector store for semantic search.
This tool:
- Indexes all project files into a vector database
- Includes git history (commits, diffs, blame) when available
- Supports incremental updates
- Shows statistics about indexed content
- Automatically creates project-specific databases
Usage:
- index: Index the current directory
- index --path /path/to/project: Index a specific path
- index --file-patterns "*.py" "*.js": Index only specific file types
- index --no-git-history: Skip git history indexing
- index --force: Force re-indexing of all files"""
@override
@auto_timeout("index")
async def call(
self,
ctx: MCPContext,
**params: Unpack[IndexToolParams],
) -> str:
"""Execute the index tool.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Indexing result and statistics
"""
start_time = time.time()
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
path = params.get("path", os.getcwd())
include_git_history = params.get("include_git_history", True)
file_patterns = params.get("file_patterns")
show_stats = params.get("show_stats", True)
force = params.get("force", False)
# Resolve absolute path
abs_path = os.path.abspath(path)
# Check permissions
if not self.permission_manager.is_path_allowed(abs_path):
return f"Permission denied: {abs_path}"
# Check if path exists
if not os.path.exists(abs_path):
return f"Path does not exist: {abs_path}"
await tool_ctx.info(f"Starting indexing of {abs_path}")
try:
# Get or create vector store for this project
vector_store = self.project_manager.get_project_store(abs_path)
# Check if already indexed (unless force)
if not force:
stats = await vector_store.get_stats()
if stats and stats.get("document_count", 0) > 0:
await tool_ctx.info("Project already indexed, use --force to re-index")
if show_stats:
return self._format_stats(stats, abs_path, time.time() - start_time)
return "Project is already indexed. Use --force to re-index."
# Prepare file patterns
if file_patterns is None:
# Default patterns for code files
file_patterns = [
"*.py",
"*.js",
"*.ts",
"*.jsx",
"*.tsx",
"*.java",
"*.cpp",
"*.c",
"*.h",
"*.hpp",
"*.go",
"*.rs",
"*.rb",
"*.php",
"*.swift",
"*.kt",
"*.scala",
"*.cs",
"*.vb",
"*.fs",
"*.sh",
"*.bash",
"*.zsh",
"*.fish",
"*.md",
"*.rst",
"*.txt",
"*.json",
"*.yaml",
"*.yml",
"*.toml",
"*.ini",
"*.cfg",
"*.conf",
"*.html",
"*.css",
"*.scss",
"*.sass",
"*.less",
"*.sql",
"*.graphql",
"*.proto",
"Dockerfile",
"Makefile",
"*.mk",
".gitignore",
".dockerignore",
"requirements.txt",
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
]
# Clear existing index if force
if force:
await tool_ctx.info("Clearing existing index...")
await vector_store.clear()
# Index files
await tool_ctx.info("Indexing files...")
indexed_files = 0
total_size = 0
errors = []
for pattern in file_patterns:
pattern_files = await self._find_files(abs_path, pattern)
for file_path in pattern_files:
try:
# Check file size (skip very large files)
file_size = os.path.getsize(file_path)
if file_size > 10 * 1024 * 1024: # 10MB
await tool_ctx.warning(f"Skipping large file: {file_path}")
continue
# Read file content
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except UnicodeDecodeError:
# Skip binary files
continue
# Index the file
rel_path = os.path.relpath(file_path, abs_path)
await vector_store.index_document(
content=content,
metadata={
"type": "file",
"path": rel_path,
"absolute_path": file_path,
"size": file_size,
"extension": Path(file_path).suffix,
},
)
indexed_files += 1
total_size += file_size
if indexed_files % 100 == 0:
await tool_ctx.info(f"Indexed {indexed_files} files...")
except Exception as e:
errors.append(f"{file_path}: {str(e)}")
await tool_ctx.info(f"Indexed {indexed_files} files ({total_size / 1024 / 1024:.1f} MB)")
# Index git history if requested
git_stats = {}
if include_git_history and os.path.exists(os.path.join(abs_path, ".git")):
await tool_ctx.info("Indexing git history...")
git_ingester = GitIngester(vector_store)
git_stats = await git_ingester.ingest_repository(
repo_path=abs_path,
include_history=True,
include_diffs=True,
include_blame=True,
file_patterns=file_patterns,
)
await tool_ctx.info(
f"Indexed {git_stats.get('commits_indexed', 0)} commits, {git_stats.get('diffs_indexed', 0)} diffs"
)
# Get final statistics
if show_stats:
stats = await vector_store.get_stats()
stats.update(
{
"files_indexed": indexed_files,
"total_size_mb": total_size / 1024 / 1024,
"errors": len(errors),
**git_stats,
}
)
result = self._format_stats(stats, abs_path, time.time() - start_time)
if errors:
result += f"\n\nErrors ({len(errors)}):\n"
result += "\n".join(errors[:10]) # Show first 10 errors
if len(errors) > 10:
result += f"\n... and {len(errors) - 10} more errors"
return result
else:
return f"Successfully indexed {indexed_files} files"
except Exception as e:
await tool_ctx.error(f"Indexing failed: {str(e)}")
return f"Error during indexing: {str(e)}"
async def _find_files(self, base_path: str, pattern: str) -> list[str]:
"""Find files matching a pattern.
Args:
base_path: Base directory to search
pattern: File pattern to match
Returns:
List of matching file paths
"""
import glob
# Use glob to find files
if pattern.startswith("*."):
# Extension pattern
files = glob.glob(
os.path.join(base_path, "**", pattern),
recursive=True,
)
else:
# Exact filename
files = glob.glob(
os.path.join(base_path, "**", pattern),
recursive=True,
)
# Filter out hidden directories and common ignore patterns
filtered_files = []
ignore_dirs = {
".git",
"__pycache__",
"node_modules",
".venv",
"venv",
"dist",
"build",
}
for file_path in files:
# Check if any parent directory is in ignore list
parts = Path(file_path).parts
if any(part in ignore_dirs for part in parts):
continue
if any(part.startswith(".") and part != "." for part in parts[:-1]):
continue # Skip hidden directories (but allow hidden files like .gitignore)
filtered_files.append(file_path)
return filtered_files
def _format_stats(self, stats: dict, path: str, elapsed_time: float) -> str:
"""Format statistics for display.
Args:
stats: Statistics dictionary
path: Indexed path
elapsed_time: Time taken for indexing
Returns:
Formatted statistics string
"""
result = f"=== Index Statistics for {path} ===\n\n"
# Basic stats
result += f"Indexing completed in {elapsed_time:.1f} seconds\n\n"
result += "Content Statistics:\n"
result += f" Documents: {stats.get('document_count', 0):,}\n"
result += f" Files indexed: {stats.get('files_indexed', 0):,}\n"
result += f" Total size: {stats.get('total_size_mb', 0):.1f} MB\n"
if stats.get("commits_indexed", 0) > 0:
result += f"\nGit History:\n"
result += f" Commits: {stats.get('commits_indexed', 0):,}\n"
result += f" Diffs: {stats.get('diffs_indexed', 0):,}\n"
result += f" Blame entries: {stats.get('blame_entries', 0):,}\n"
# Vector store info
result += f"\nVector Store:\n"
result += f" Database: {stats.get('database_name', 'default')}\n"
result += f" Table: {stats.get('table_name', 'documents')}\n"
result += f" Vectors: {stats.get('vector_count', stats.get('document_count', 0)):,}\n"
if stats.get("errors", 0) > 0:
result += f"\nErrors: {stats.get('errors', 0)}\n"
return result
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
# Tool registration is handled by the ToolRegistry
pass
@@ -0,0 +1,870 @@
"""Infinity vector database integration for Hanzo AI."""
import json
import hashlib
from typing import Any, Dict, List, Tuple, Optional
from pathlib import Path
from dataclasses import dataclass
try:
import infinity_embedded
INFINITY_AVAILABLE = True
except ImportError:
# Use mock implementation when infinity_embedded is not available
from . import mock_infinity as infinity_embedded
INFINITY_AVAILABLE = True # Mock is always available
from .ast_analyzer import Symbol, FileAST, ASTAnalyzer, create_symbol_embedding_text
@dataclass
class Document:
"""Document representation for vector storage."""
id: str
content: str
metadata: Dict[str, Any]
file_path: Optional[str] = None
chunk_index: Optional[int] = None
@dataclass
class SearchResult:
"""Search result from vector database."""
document: Document
score: float
distance: float
@dataclass
class SymbolSearchResult:
"""Search result for symbols."""
symbol: Symbol
score: float
context_document: Optional[Document] = None
@dataclass
class UnifiedSearchResult:
"""Search result combining text, vector, and symbol search."""
type: str # 'document', 'symbol', 'reference'
content: str
file_path: str
line_start: int
line_end: int
score: float
search_type: str # 'text', 'vector', 'symbol', 'ast'
metadata: Dict[str, Any]
class InfinityVectorStore:
"""Local vector database using Infinity."""
def __init__(
self,
data_path: Optional[str] = None,
embedding_model: str = "text-embedding-3-small",
dimension: int = 1536, # Default for OpenAI text-embedding-3-small
):
"""Initialize the Infinity vector store.
Args:
data_path: Path to store vector database (default: ~/.config/hanzo/vector-store)
embedding_model: Embedding model to use
dimension: Vector dimension (must match embedding model)
"""
if not INFINITY_AVAILABLE:
raise ImportError("infinity_embedded is required for vector store functionality")
# Set up data path
if data_path:
self.data_path = Path(data_path)
else:
from hanzo_mcp.config.settings import get_config_dir
self.data_path = get_config_dir() / "vector-store"
self.data_path.mkdir(parents=True, exist_ok=True)
self.embedding_model = embedding_model
self.dimension = dimension
# Initialize AST analyzer
self.ast_analyzer = ASTAnalyzer()
# Connect to Infinity
self.infinity = infinity_embedded.connect(str(self.data_path))
self.db = self.infinity.get_database("hanzo_mcp")
# Initialize tables
self._initialize_tables()
def _initialize_tables(self):
"""Initialize database tables if they don't exist."""
# Documents table
try:
self.documents_table = self.db.get_table("documents")
except Exception:
self.documents_table = self.db.create_table(
"documents",
{
"id": {"type": "varchar"},
"content": {"type": "varchar"},
"file_path": {"type": "varchar"},
"chunk_index": {"type": "integer"},
"metadata": {"type": "varchar"}, # JSON string
"embedding": {"type": f"vector,{self.dimension},float"},
},
)
# Symbols table for code symbols
try:
self.symbols_table = self.db.get_table("symbols")
except Exception:
self.symbols_table = self.db.create_table(
"symbols",
{
"id": {"type": "varchar"},
"name": {"type": "varchar"},
"type": {"type": "varchar"}, # function, class, variable, etc.
"file_path": {"type": "varchar"},
"line_start": {"type": "integer"},
"line_end": {"type": "integer"},
"scope": {"type": "varchar"},
"parent": {"type": "varchar"},
"signature": {"type": "varchar"},
"docstring": {"type": "varchar"},
"metadata": {"type": "varchar"}, # JSON string
"embedding": {"type": f"vector,{self.dimension},float"},
},
)
# AST table for storing complete file ASTs
try:
self.ast_table = self.db.get_table("ast_files")
except Exception:
self.ast_table = self.db.create_table(
"ast_files",
{
"file_path": {"type": "varchar"},
"file_hash": {"type": "varchar"},
"language": {"type": "varchar"},
"ast_data": {"type": "varchar"}, # JSON string of complete AST
"last_updated": {"type": "varchar"}, # ISO timestamp
},
)
# References table for cross-file references
try:
self.references_table = self.db.get_table("references")
except Exception:
self.references_table = self.db.create_table(
"references",
{
"id": {"type": "varchar"},
"source_file": {"type": "varchar"},
"target_file": {"type": "varchar"},
"symbol_name": {"type": "varchar"},
"reference_type": {"type": "varchar"}, # import, call, inheritance, etc.
"line_number": {"type": "integer"},
"metadata": {"type": "varchar"}, # JSON string
},
)
def _generate_doc_id(self, content: str, file_path: str = "", chunk_index: int = 0) -> str:
"""Generate a unique document ID."""
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
path_hash = hashlib.sha256(file_path.encode()).hexdigest()[:8]
return f"doc_{path_hash}_{chunk_index}_{content_hash}"
def add_document(
self,
content: str,
metadata: Dict[str, Any] = None,
file_path: Optional[str] = None,
chunk_index: int = 0,
embedding: Optional[List[float]] = None,
) -> str:
"""Add a document to the vector store.
Args:
content: Document content
metadata: Additional metadata
file_path: Source file path
chunk_index: Chunk index if document is part of larger file
embedding: Pre-computed embedding (if None, will compute)
Returns:
Document ID
"""
doc_id = self._generate_doc_id(content, file_path or "", chunk_index)
# Generate embedding if not provided
if embedding is None:
embedding = self._generate_embedding(content)
# Prepare metadata
metadata = metadata or {}
metadata_json = json.dumps(metadata)
# Insert document
self.documents_table.insert(
[
{
"id": doc_id,
"content": content,
"file_path": file_path or "",
"chunk_index": chunk_index,
"metadata": metadata_json,
"embedding": embedding,
}
]
)
return doc_id
def add_file(
self,
file_path: str,
chunk_size: int = 1000,
chunk_overlap: int = 200,
metadata: Dict[str, Any] = None,
) -> List[str]:
"""Add a file to the vector store by chunking it.
Args:
file_path: Path to the file to add
chunk_size: Maximum characters per chunk
chunk_overlap: Characters to overlap between chunks
metadata: Additional metadata for all chunks
Returns:
List of document IDs for all chunks
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
# Read file content
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
# Try with different encoding
content = path.read_text(encoding="latin-1")
# Chunk the content
chunks = self._chunk_text(content, chunk_size, chunk_overlap)
# Add metadata
file_metadata = metadata or {}
file_metadata.update(
{
"file_name": path.name,
"file_extension": path.suffix,
"file_size": path.stat().st_size,
}
)
# Add each chunk
doc_ids = []
for i, chunk in enumerate(chunks):
chunk_metadata = file_metadata.copy()
chunk_metadata["chunk_number"] = i
chunk_metadata["total_chunks"] = len(chunks)
doc_id = self.add_document(
content=chunk,
metadata=chunk_metadata,
file_path=str(path),
chunk_index=i,
)
doc_ids.append(doc_id)
return doc_ids
def add_file_with_ast(
self,
file_path: str,
chunk_size: int = 1000,
chunk_overlap: int = 200,
metadata: Dict[str, Any] = None,
) -> Tuple[List[str], Optional[FileAST]]:
"""Add a file with full AST analysis and symbol extraction.
Args:
file_path: Path to the file to add
chunk_size: Maximum characters per chunk for content
chunk_overlap: Characters to overlap between chunks
metadata: Additional metadata for all chunks
Returns:
Tuple of (document IDs for content chunks, FileAST object)
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
# First add file content using existing method
doc_ids = self.add_file(file_path, chunk_size, chunk_overlap, metadata)
# Analyze AST and symbols
file_ast = self.ast_analyzer.analyze_file(file_path)
if not file_ast:
return doc_ids, None
# Store complete AST
self._store_file_ast(file_ast)
# Store individual symbols with embeddings
self._store_symbols(file_ast.symbols)
# Store cross-references
self._store_references(file_ast)
return doc_ids, file_ast
def _store_file_ast(self, file_ast: FileAST):
"""Store complete file AST information."""
from datetime import datetime
# Remove existing AST for this file
try:
self.ast_table.delete(f"file_path = '{file_ast.file_path}'")
except Exception:
pass
# Insert new AST
self.ast_table.insert(
[
{
"file_path": file_ast.file_path,
"file_hash": file_ast.file_hash,
"language": file_ast.language,
"ast_data": json.dumps(file_ast.to_dict()),
"last_updated": datetime.now().isoformat(),
}
]
)
def _store_symbols(self, symbols: List[Symbol]):
"""Store symbols with vector embeddings."""
if not symbols:
return
# Remove existing symbols for these files
file_paths = list(set(symbol.file_path for symbol in symbols))
for file_path in file_paths:
try:
self.symbols_table.delete(f"file_path = '{file_path}'")
except Exception:
pass
# Insert new symbols
symbol_records = []
for symbol in symbols:
# Create embedding text for symbol
embedding_text = create_symbol_embedding_text(symbol)
embedding = self._generate_embedding(embedding_text)
# Generate symbol ID
symbol_id = self._generate_symbol_id(symbol)
# Prepare metadata
symbol_metadata = {
"references": symbol.references,
"embedding_text": embedding_text,
}
symbol_records.append(
{
"id": symbol_id,
"name": symbol.name,
"type": symbol.type,
"file_path": symbol.file_path,
"line_start": symbol.line_start,
"line_end": symbol.line_end,
"scope": symbol.scope or "",
"parent": symbol.parent or "",
"signature": symbol.signature or "",
"docstring": symbol.docstring or "",
"metadata": json.dumps(symbol_metadata),
"embedding": embedding,
}
)
if symbol_records:
self.symbols_table.insert(symbol_records)
def _store_references(self, file_ast: FileAST):
"""Store cross-file references."""
if not file_ast.dependencies:
return
# Remove existing references for this file
try:
self.references_table.delete(f"source_file = '{file_ast.file_path}'")
except Exception:
pass
# Insert new references
reference_records = []
for i, dependency in enumerate(file_ast.dependencies):
ref_id = f"{file_ast.file_path}_{dependency}_{i}"
reference_records.append(
{
"id": ref_id,
"source_file": file_ast.file_path,
"target_file": dependency,
"symbol_name": dependency,
"reference_type": "import",
"line_number": 0, # Could be enhanced to track actual line numbers
"metadata": json.dumps({}),
}
)
if reference_records:
self.references_table.insert(reference_records)
def _generate_symbol_id(self, symbol: Symbol) -> str:
"""Generate unique symbol ID."""
text = f"{symbol.file_path}_{symbol.type}_{symbol.name}_{symbol.line_start}"
return hashlib.sha256(text.encode()).hexdigest()[:16]
def search_symbols(
self,
query: str,
symbol_type: Optional[str] = None,
file_path: Optional[str] = None,
limit: int = 10,
score_threshold: float = 0.0,
) -> List[SymbolSearchResult]:
"""Search for symbols using vector similarity.
Args:
query: Search query
symbol_type: Filter by symbol type (function, class, variable, etc.)
file_path: Filter by file path
limit: Maximum number of results
score_threshold: Minimum similarity score
Returns:
List of symbol search results
"""
# Generate query embedding
query_embedding = self._generate_embedding(query)
# Build search query
search_query = self.symbols_table.output(["*"]).match_dense(
"embedding",
query_embedding,
"float",
"ip", # Inner product
limit * 2, # Get more results for filtering
)
# Apply filters
if symbol_type:
search_query = search_query.filter(f"type = '{symbol_type}'")
if file_path:
search_query = search_query.filter(f"file_path = '{file_path}'")
search_results = search_query.to_pl()
# Convert to SymbolSearchResult objects
results = []
for row in search_results.iter_rows(named=True):
score = row.get("score", 0.0)
if score >= score_threshold:
# Parse metadata
try:
metadata = json.loads(row["metadata"])
except Exception:
metadata = {}
# Create Symbol object
symbol = Symbol(
name=row["name"],
type=row["type"],
file_path=row["file_path"],
line_start=row["line_start"],
line_end=row["line_end"],
column_start=0, # Not stored in table
column_end=0, # Not stored in table
scope=row["scope"],
parent=row["parent"] if row["parent"] else None,
docstring=row["docstring"] if row["docstring"] else None,
signature=row["signature"] if row["signature"] else None,
references=metadata.get("references", []),
)
results.append(
SymbolSearchResult(
symbol=symbol,
score=score,
)
)
return results[:limit]
def search_ast_nodes(
self,
file_path: str,
node_type: Optional[str] = None,
node_name: Optional[str] = None,
) -> Optional[FileAST]:
"""Search AST nodes within a specific file.
Args:
file_path: File to search in
node_type: Filter by AST node type
node_name: Filter by node name
Returns:
FileAST object if file found, None otherwise
"""
try:
results = self.ast_table.output(["*"]).filter(f"file_path = '{file_path}'").to_pl()
if len(results) == 0:
return None
row = next(results.iter_rows(named=True))
ast_data = json.loads(row["ast_data"])
# Reconstruct FileAST object
file_ast = FileAST(
file_path=ast_data["file_path"],
file_hash=ast_data["file_hash"],
language=ast_data["language"],
symbols=[Symbol(**s) for s in ast_data["symbols"]],
ast_nodes=[], # Would need custom deserialization for ASTNode
imports=ast_data["imports"],
exports=ast_data["exports"],
dependencies=ast_data["dependencies"],
)
return file_ast
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error searching AST nodes: {e}")
return None
def get_file_references(self, file_path: str) -> List[Dict[str, Any]]:
"""Get all files that reference the given file.
Args:
file_path: File to find references for
Returns:
List of reference information
"""
try:
results = self.references_table.output(["*"]).filter(f"target_file = '{file_path}'").to_pl()
references = []
for row in results.iter_rows(named=True):
references.append(
{
"source_file": row["source_file"],
"symbol_name": row["symbol_name"],
"reference_type": row["reference_type"],
"line_number": row["line_number"],
}
)
return references
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error getting file references: {e}")
return []
def search(
self,
query: str,
limit: int = 10,
score_threshold: float = 0.0,
filters: Dict[str, Any] = None,
) -> List[SearchResult]:
"""Search for similar documents.
Args:
query: Search query
limit: Maximum number of results
score_threshold: Minimum similarity score
filters: Metadata filters (not yet implemented)
Returns:
List of search results
"""
# Generate query embedding
query_embedding = self._generate_embedding(query)
# Perform vector search
search_results = (
self.documents_table.output(["*"])
.match_dense(
"embedding",
query_embedding,
"float",
"ip", # Inner product (cosine similarity)
limit,
)
.to_pl()
)
# Convert to SearchResult objects
results = []
for row in search_results.iter_rows(named=True):
# Parse metadata
try:
metadata = json.loads(row["metadata"])
except Exception:
metadata = {}
# Create document
document = Document(
id=row["id"],
content=row["content"],
metadata=metadata,
file_path=row["file_path"] if row["file_path"] else None,
chunk_index=row["chunk_index"],
)
# Score is the similarity (higher is better)
score = row.get("score", 0.0)
distance = 1.0 - score # Convert similarity to distance
if score >= score_threshold:
results.append(
SearchResult(
document=document,
score=score,
distance=distance,
)
)
return results
def delete_document(self, doc_id: str) -> bool:
"""Delete a document by ID.
Args:
doc_id: Document ID to delete
Returns:
True if document was deleted
"""
try:
self.documents_table.delete(f"id = '{doc_id}'")
return True
except Exception:
return False
def delete_file(self, file_path: str) -> int:
"""Delete all documents from a specific file.
Args:
file_path: File path to delete documents for
Returns:
Number of documents deleted
"""
try:
# Get count first
results = self.documents_table.output(["id"]).filter(f"file_path = '{file_path}'").to_pl()
count = len(results)
# Delete all documents for this file
self.documents_table.delete(f"file_path = '{file_path}'")
return count
except Exception:
return 0
def list_files(self) -> List[Dict[str, Any]]:
"""List all indexed files.
Returns:
List of file information
"""
try:
results = self.documents_table.output(["file_path", "metadata"]).to_pl()
files = {}
for row in results.iter_rows(named=True):
file_path = row["file_path"]
if file_path and file_path not in files:
try:
metadata = json.loads(row["metadata"])
files[file_path] = {
"file_path": file_path,
"file_name": metadata.get("file_name", Path(file_path).name),
"file_size": metadata.get("file_size", 0),
"total_chunks": metadata.get("total_chunks", 1),
}
except Exception:
files[file_path] = {
"file_path": file_path,
"file_name": Path(file_path).name,
}
return list(files.values())
except Exception:
return []
def _chunk_text(self, text: str, chunk_size: int, overlap: int) -> List[str]:
"""Split text into overlapping chunks."""
if len(text) <= chunk_size:
return [text]
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
# Try to break at word boundary
if end < len(text):
# Look back for a good break point
break_point = end
for i in range(end - 100, start + 100, -1):
if i > 0 and text[i] in "\n\r.!?":
break_point = i + 1
break
end = break_point
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = max(start + chunk_size - overlap, end)
return chunks
def _generate_embedding(self, text: str) -> List[float]:
"""Generate embedding for text.
For now, this returns a dummy embedding. In a real implementation,
you would call an embedding API (OpenAI, Cohere, etc.) or use a local model.
"""
# This is a placeholder - you would implement actual embedding generation here
# For now, return a random embedding of the correct dimension
import random
return [random.random() for _ in range(self.dimension)]
async def get_stats(self) -> Dict[str, Any]:
"""Get statistics about the vector store.
Returns:
Dictionary with statistics
"""
try:
# Get document count
doc_count_result = self.documents_table.output(["count(*)"]).to_pl()
doc_count = doc_count_result.item(0, 0) if len(doc_count_result) > 0 else 0
# Get unique file count
file_result = self.documents_table.output(["file_path"]).to_pl()
unique_files = set()
for row in file_result.iter_rows():
if row[0]:
unique_files.add(row[0])
# Get symbol count
symbol_count = 0
try:
symbol_result = self.symbols_table.output(["count(*)"]).to_pl()
symbol_count = symbol_result.item(0, 0) if len(symbol_result) > 0 else 0
except Exception:
pass
# Get AST count
ast_count = 0
try:
ast_result = self.ast_table.output(["count(*)"]).to_pl()
ast_count = ast_result.item(0, 0) if len(ast_result) > 0 else 0
except Exception:
pass
return {
"document_count": doc_count,
"vector_count": doc_count, # Each document has a vector
"unique_files": len(unique_files),
"symbol_count": symbol_count,
"ast_count": ast_count,
"database_name": self.db_name,
"table_name": "documents",
"dimension": self.dimension,
}
except Exception as e:
return {
"error": str(e),
"document_count": 0,
"vector_count": 0,
}
async def clear(self) -> bool:
"""Clear all data from the vector store.
Returns:
True if successful
"""
try:
# Delete all records from all tables
self.documents_table.delete()
try:
self.symbols_table.delete()
except Exception:
pass
try:
self.ast_table.delete()
except Exception:
pass
try:
self.references_table.delete()
except Exception:
pass
return True
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error clearing vector store: {e}")
return False
async def index_document(
self,
content: str,
metadata: Dict[str, Any] = None,
) -> str:
"""Async version of add_document for consistency.
Args:
content: Document content
metadata: Additional metadata
Returns:
Document ID
"""
file_path = metadata.get("path") if metadata else None
return self.add_document(content, metadata, file_path)
def close(self):
"""Close the database connection."""
if hasattr(self, "infinity"):
self.infinity.disconnect()
@@ -0,0 +1,159 @@
"""Mock implementation of infinity_embedded for testing on unsupported platforms."""
import random
from typing import Any, Dict, List
from pathlib import Path
class MockTable:
"""Mock implementation of an Infinity table."""
def __init__(self, name: str, schema: Dict[str, Any]):
self.name = name
self.schema = schema
self.data = []
self._id_counter = 0
def insert(self, records: List[Dict[str, Any]]):
"""Insert records into the table."""
for record in records:
# Add an internal ID if not present
if "id" not in record:
record["_internal_id"] = self._id_counter
self._id_counter += 1
self.data.append(record)
def delete(self, condition: str):
"""Delete records matching condition."""
# Simple implementation - just clear for now
self.data = [r for r in self.data if not self._eval_condition(r, condition)]
def output(self, columns: List[str]):
"""Start a query chain."""
return MockQuery(self, columns)
def _eval_condition(self, record: Dict[str, Any], condition: str) -> bool:
"""Evaluate a simple condition."""
# Very basic implementation
if "=" in condition:
field, value = condition.split("=", 1)
field = field.strip()
value = value.strip().strip("'\"")
return str(record.get(field, "")) == value
return False
class MockQuery:
"""Mock query builder."""
def __init__(self, table: MockTable, columns: List[str]):
self.table = table
self.columns = columns
self.filters = []
self.vector_search = None
self.limit_value = None
def filter(self, condition: str):
"""Add a filter condition."""
self.filters.append(condition)
return self
def match_dense(self, column: str, vector: List[float], dtype: str, metric: str, limit: int):
"""Add vector search."""
self.vector_search = {
"column": column,
"vector": vector,
"dtype": dtype,
"metric": metric,
"limit": limit,
}
self.limit_value = limit
return self
def to_pl(self):
"""Execute query and return polars-like result."""
results = self.table.data.copy()
# Apply filters
for condition in self.filters:
results = [r for r in results if self.table._eval_condition(r, condition)]
# Apply vector search (mock similarity)
if self.vector_search:
# Add mock scores
for r in results:
r["score"] = random.uniform(0.5, 1.0)
# Sort by score
results.sort(key=lambda x: x.get("score", 0), reverse=True)
# Limit results
if self.limit_value:
results = results[: self.limit_value]
# Return mock polars DataFrame
return MockDataFrame(results)
class MockDataFrame:
"""Mock polars DataFrame."""
def __init__(self, data: List[Dict[str, Any]]):
self.data = data
def __len__(self):
return len(self.data)
def iter_rows(self, named: bool = False):
"""Iterate over rows."""
if named:
return iter(self.data)
else:
# Return tuples
if not self.data:
return iter([])
keys = list(self.data[0].keys())
return iter([tuple(row.get(k) for k in keys) for row in self.data])
class MockDatabase:
"""Mock implementation of an Infinity database."""
def __init__(self, name: str):
self.name = name
self.tables = {}
def create_table(self, name: str, schema: Dict[str, Any]) -> MockTable:
"""Create a new table."""
table = MockTable(name, schema)
self.tables[name] = table
return table
def get_table(self, name: str) -> MockTable:
"""Get an existing table."""
if name not in self.tables:
raise KeyError(f"Table {name} not found")
return self.tables[name]
class MockInfinity:
"""Mock implementation of Infinity connection."""
def __init__(self, path: str):
self.path = Path(path)
self.databases = {}
# Ensure directory exists
self.path.mkdir(parents=True, exist_ok=True)
def get_database(self, name: str) -> MockDatabase:
"""Get or create a database."""
if name not in self.databases:
self.databases[name] = MockDatabase(name)
return self.databases[name]
def disconnect(self):
"""Disconnect from Infinity."""
pass
def connect(path: str) -> MockInfinity:
"""Connect to Infinity (mock implementation)."""
return MockInfinity(path)
@@ -0,0 +1,387 @@
"""Project-aware vector database management for Hanzo AI."""
import asyncio
from typing import Any, Dict, List, Tuple, Optional
from pathlib import Path
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor
from .index_config import IndexScope, IndexConfig
from .infinity_store import SearchResult, InfinityVectorStore
@dataclass
class ProjectInfo:
"""Information about a detected project."""
root_path: Path
llm_md_path: Path
db_path: Path
name: str
class ProjectVectorManager:
"""Manages project-aware vector databases."""
def __init__(
self,
global_db_path: Optional[str] = None,
embedding_model: str = "text-embedding-3-small",
dimension: int = 1536,
):
"""Initialize the project vector manager.
Args:
global_db_path: Path for global vector store (default: ~/.config/hanzo/db)
embedding_model: Embedding model to use
dimension: Vector dimension
"""
self.embedding_model = embedding_model
self.dimension = dimension
# Set up index configuration
self.index_config = IndexConfig()
# Set up global database path
if global_db_path:
self.global_db_path = Path(global_db_path)
else:
self.global_db_path = self.index_config.get_index_path("vector")
self.global_db_path.mkdir(parents=True, exist_ok=True)
# Cache for project info and vector stores
self.projects: Dict[str, ProjectInfo] = {}
self.vector_stores: Dict[str, InfinityVectorStore] = {}
self._global_store: Optional[InfinityVectorStore] = None
# Thread pool for parallel operations
self.executor = ThreadPoolExecutor(max_workers=4)
def _get_global_store(self) -> InfinityVectorStore:
"""Get or create the global vector store."""
if self._global_store is None:
self._global_store = InfinityVectorStore(
data_path=str(self.global_db_path),
embedding_model=self.embedding_model,
dimension=self.dimension,
)
return self._global_store
def detect_projects(self, search_paths: List[str]) -> List[ProjectInfo]:
"""Detect projects by finding LLM.md files.
Args:
search_paths: List of paths to search for projects
Returns:
List of detected project information
"""
projects = []
for search_path in search_paths:
path = Path(search_path).resolve()
# Search for LLM.md files
for llm_md_path in path.rglob("LLM.md"):
project_root = llm_md_path.parent
project_name = project_root.name
# Create .hanzo/db directory in project
db_path = project_root / ".hanzo" / "db"
db_path.mkdir(parents=True, exist_ok=True)
project_info = ProjectInfo(
root_path=project_root,
llm_md_path=llm_md_path,
db_path=db_path,
name=project_name,
)
projects.append(project_info)
# Cache project info
project_key = str(project_root)
self.projects[project_key] = project_info
return projects
def get_project_for_path(self, file_path: str) -> Optional[ProjectInfo]:
"""Find the project that contains a given file path.
Args:
file_path: File path to check
Returns:
Project info if found, None otherwise
"""
path = Path(file_path).resolve()
# Check each known project
for project_key, project_info in self.projects.items():
try:
# Check if path is within project root
path.relative_to(project_info.root_path)
return project_info
except ValueError:
# Path is not within this project
continue
# Try to find project by walking up the directory tree
current_path = path.parent if path.is_file() else path
while current_path != current_path.parent: # Stop at filesystem root
llm_md_path = current_path / "LLM.md"
if llm_md_path.exists():
# Found a project, create and cache it
db_path = current_path / ".hanzo" / "db"
db_path.mkdir(parents=True, exist_ok=True)
project_info = ProjectInfo(
root_path=current_path,
llm_md_path=llm_md_path,
db_path=db_path,
name=current_path.name,
)
project_key = str(current_path)
self.projects[project_key] = project_info
return project_info
current_path = current_path.parent
return None
def get_vector_store(self, project_info: Optional[ProjectInfo] = None) -> InfinityVectorStore:
"""Get vector store for a project or global store.
Args:
project_info: Project to get store for, None for global store
Returns:
Vector store instance
"""
# Check indexing scope
if project_info:
scope = self.index_config.get_scope(str(project_info.root_path))
if scope == IndexScope.GLOBAL:
# Even for project files, use global store if configured
return self._get_global_store()
else:
return self._get_global_store()
# Use project-specific store
project_key = str(project_info.root_path)
if project_key not in self.vector_stores:
# Get index path based on configuration
index_path = self.index_config.get_index_path("vector", str(project_info.root_path))
index_path.mkdir(parents=True, exist_ok=True)
self.vector_stores[project_key] = InfinityVectorStore(
data_path=str(index_path),
embedding_model=self.embedding_model,
dimension=self.dimension,
)
return self.vector_stores[project_key]
def add_file_to_appropriate_store(
self,
file_path: str,
chunk_size: int = 1000,
chunk_overlap: int = 200,
metadata: Dict[str, Any] = None,
) -> Tuple[List[str], Optional[ProjectInfo]]:
"""Add a file to the appropriate vector store (project or global).
Args:
file_path: Path to file to add
chunk_size: Chunk size for text splitting
chunk_overlap: Overlap between chunks
metadata: Additional metadata
Returns:
Tuple of (document IDs, project info or None for global)
"""
# Check if indexing is enabled
if not self.index_config.is_indexing_enabled("vector"):
return [], None
# Find project for this file
project_info = self.get_project_for_path(file_path)
# Get appropriate vector store based on scope configuration
vector_store = self.get_vector_store(project_info)
# Add file metadata
file_metadata = metadata or {}
if project_info:
file_metadata["project_name"] = project_info.name
file_metadata["project_root"] = str(project_info.root_path)
# Check actual scope used
scope = self.index_config.get_scope(str(project_info.root_path))
file_metadata["index_scope"] = scope.value
else:
file_metadata["project_name"] = "global"
file_metadata["index_scope"] = "global"
# Add file to store
doc_ids = vector_store.add_file(
file_path=file_path,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
metadata=file_metadata,
)
return doc_ids, project_info
async def search_all_projects(
self,
query: str,
limit_per_project: int = 5,
score_threshold: float = 0.0,
include_global: bool = True,
project_filter: Optional[List[str]] = None,
) -> Dict[str, List[SearchResult]]:
"""Search across all projects in parallel.
Args:
query: Search query
limit_per_project: Maximum results per project
score_threshold: Minimum similarity score
include_global: Whether to include global store
project_filter: List of project names to search (None for all)
Returns:
Dictionary mapping project names to search results
"""
search_tasks = []
project_names = []
# Add global store if requested
if include_global:
global_store = self._get_global_store()
search_tasks.append(
asyncio.get_event_loop().run_in_executor(
self.executor,
lambda: global_store.search(query, limit_per_project, score_threshold),
)
)
project_names.append("global")
# Add project stores
for _project_key, project_info in self.projects.items():
# Apply project filter
if project_filter and project_info.name not in project_filter:
continue
vector_store = self.get_vector_store(project_info)
search_tasks.append(
asyncio.get_event_loop().run_in_executor(
self.executor,
lambda vs=vector_store: vs.search(query, limit_per_project, score_threshold),
)
)
project_names.append(project_info.name)
# Execute all searches in parallel
results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Combine results
combined_results = {}
for i, result in enumerate(results):
project_name = project_names[i]
if isinstance(result, Exception):
# Log error but continue
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error searching project {project_name}: {result}")
combined_results[project_name] = []
else:
combined_results[project_name] = result
return combined_results
def search_project_by_path(
self,
file_path: str,
query: str,
limit: int = 10,
score_threshold: float = 0.0,
) -> List[SearchResult]:
"""Search the project containing a specific file path.
Args:
file_path: File path to determine project
query: Search query
limit: Maximum results
score_threshold: Minimum similarity score
Returns:
Search results from the appropriate project store
"""
project_info = self.get_project_for_path(file_path)
vector_store = self.get_vector_store(project_info)
return vector_store.search(
query=query,
limit=limit,
score_threshold=score_threshold,
)
def get_project_stats(self) -> Dict[str, Dict[str, Any]]:
"""Get statistics for all projects.
Returns:
Dictionary mapping project names to stats
"""
stats = {}
# Global store stats
try:
global_store = self._get_global_store()
global_files = global_store.list_files()
stats["global"] = {
"file_count": len(global_files),
"db_path": str(self.global_db_path),
}
except Exception as e:
stats["global"] = {"error": str(e)}
# Project store stats
for _project_key, project_info in self.projects.items():
try:
vector_store = self.get_vector_store(project_info)
project_files = vector_store.list_files()
stats[project_info.name] = {
"file_count": len(project_files),
"db_path": str(project_info.db_path),
"root_path": str(project_info.root_path),
"llm_md_exists": project_info.llm_md_path.exists(),
}
except Exception as e:
stats[project_info.name] = {"error": str(e)}
return stats
def cleanup(self):
"""Close all vector stores and cleanup resources."""
# Close all project stores
for vector_store in self.vector_stores.values():
try:
vector_store.close()
except Exception:
pass
# Close global store
if self._global_store:
try:
self._global_store.close()
except Exception:
pass
# Shutdown executor
self.executor.shutdown(wait=False)
@@ -0,0 +1,326 @@
"""Unified vector store tool."""
from typing import (
Any,
Dict,
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
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .project_manager import ProjectVectorManager
# Parameter types
Action = Annotated[
str,
Field(
description="Action: search (default), index, stats, clear",
default="search",
),
]
Query = Annotated[
Optional[str],
Field(
description="Search query for semantic similarity",
default=None,
),
]
Path = Annotated[
Optional[str],
Field(
description="Path to index or search within",
default=".",
),
]
Include = Annotated[
Optional[str],
Field(
description="File pattern to include (e.g., '*.py')",
default=None,
),
]
Exclude = Annotated[
Optional[str],
Field(
description="File pattern to exclude",
default=None,
),
]
Limit = Annotated[
int,
Field(
description="Maximum results to return",
default=10,
),
]
IncludeGit = Annotated[
bool,
Field(
description="Include git history in indexing",
default=True,
),
]
ForceReindex = Annotated[
bool,
Field(
description="Force reindexing even if up to date",
default=False,
),
]
class VectorParams(TypedDict, total=False):
"""Parameters for vector tool."""
action: str
query: Optional[str]
path: Optional[str]
include: Optional[str]
exclude: Optional[str]
limit: int
include_git: bool
force_reindex: bool
@final
class VectorTool(BaseTool):
"""Unified vector store tool for semantic search."""
def __init__(
self,
permission_manager: PermissionManager,
project_manager: ProjectVectorManager,
):
"""Initialize the vector tool."""
super().__init__(permission_manager)
self.project_manager = project_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "vector"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Semantic search with embeddings. Actions: search (default), index, stats, clear.
Usage:
vector "find authentication logic"
vector --action index --path ./src --include "*.py"
vector --action stats
vector --action clear --path ./old_code
"""
@override
@auto_timeout("vector")
async def call(
self,
ctx: MCPContext,
**params: Unpack[VectorParams],
) -> str:
"""Execute vector operation."""
tool_ctx = self.create_tool_context(ctx)
# Extract action
action = params.get("action", "search")
# Route to appropriate handler
if action == "search":
return await self._handle_search(params, tool_ctx)
elif action == "index":
return await self._handle_index(params, tool_ctx)
elif action == "stats":
return await self._handle_stats(params, tool_ctx)
elif action == "clear":
return await self._handle_clear(params, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: search, index, stats, clear"
async def _handle_search(self, params: Dict[str, Any], tool_ctx) -> str:
"""Handle semantic search."""
query = params.get("query")
if not query:
return "Error: query is required for search action"
path = params.get("path", ".")
limit = params.get("limit", 10)
# Validate path
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
if not allowed:
return error_msg
try:
# Determine search scope
project = self.project_manager.get_project_for_path(path)
if not project:
return "Error: No indexed project found for this path. Run 'vector --action index' first."
# Search
await tool_ctx.info(f"Searching for: {query}")
results = project.search(query, k=limit)
if not results:
return f"No results found for: {query}"
# Format results
output = [f"=== Vector Search Results for '{query}' ==="]
output.append(f"Found {len(results)} matches\n")
for i, result in enumerate(results, 1):
score = result.get("score", 0)
file_path = result.get("file_path", "unknown")
content = result.get("content", "")
chunk_type = result.get("metadata", {}).get("type", "content")
output.append(f"Result {i} - Score: {score:.1%}")
output.append(f"File: {file_path}")
if chunk_type != "content":
output.append(f"Type: {chunk_type}")
output.append("-" * 60)
# Truncate content if too long
if len(content) > 300:
content = content[:300] + "..."
output.append(content)
output.append("")
return "\n".join(output)
except Exception as e:
await tool_ctx.error(f"Search failed: {str(e)}")
return f"Error during search: {str(e)}"
async def _handle_index(self, params: Dict[str, Any], tool_ctx) -> str:
"""Handle indexing files."""
path = params.get("path", ".")
include = params.get("include")
exclude = params.get("exclude")
include_git = params.get("include_git", True)
force = params.get("force_reindex", False)
# Validate path
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
if not allowed:
return error_msg
try:
await tool_ctx.info(f"Indexing {path}...")
# Get or create project
project = self.project_manager.get_or_create_project(path)
# Index files
stats = await project.index_directory(
path,
include_pattern=include,
exclude_pattern=exclude,
force_reindex=force,
)
# Index git history if requested
if include_git and Path(path).joinpath(".git").exists():
await tool_ctx.info("Indexing git history...")
git_stats = await project.index_git_history(path)
stats["git_commits"] = git_stats.get("commits_indexed", 0)
# Format output
output = [f"=== Indexing Complete ==="]
output.append(f"Path: {path}")
output.append(f"Files indexed: {stats.get('files_indexed', 0)}")
output.append(f"Chunks created: {stats.get('chunks_created', 0)}")
if stats.get("git_commits"):
output.append(f"Git commits indexed: {stats['git_commits']}")
output.append(f"Total documents: {project.get_stats().get('total_documents', 0)}")
return "\n".join(output)
except Exception as e:
await tool_ctx.error(f"Indexing failed: {str(e)}")
return f"Error during indexing: {str(e)}"
async def _handle_stats(self, params: Dict[str, Any], tool_ctx) -> str:
"""Get vector store statistics."""
path = params.get("path")
try:
if path:
# Stats for specific project
project = self.project_manager.get_project_for_path(path)
if not project:
return f"No indexed project found for path: {path}"
stats = project.get_stats()
output = [f"=== Vector Store Stats for {project.name} ==="]
else:
# Global stats
stats = self.project_manager.get_global_stats()
output = ["=== Global Vector Store Stats ==="]
output.append(f"Total documents: {stats.get('total_documents', 0)}")
output.append(f"Total size: {stats.get('total_size_mb', 0):.1f} MB")
if stats.get("projects"):
output.append(f"\nProjects indexed: {len(stats['projects'])}")
for proj in stats["projects"]:
output.append(f" - {proj['name']}: {proj['documents']} docs, {proj['size_mb']:.1f} MB")
return "\n".join(output)
except Exception as e:
await tool_ctx.error(f"Failed to get stats: {str(e)}")
return f"Error getting stats: {str(e)}"
async def _handle_clear(self, params: Dict[str, Any], tool_ctx) -> str:
"""Clear vector store."""
path = params.get("path")
if not path:
return "Error: path is required for clear action"
# Validate path
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
if not allowed:
return error_msg
try:
project = self.project_manager.get_project_for_path(path)
if not project:
return f"No indexed project found for path: {path}"
# Get stats before clearing
stats = project.get_stats()
doc_count = stats.get("total_documents", 0)
# Clear
project.clear()
return f"Cleared {doc_count} documents from vector store for {project.name}"
except Exception as e:
await tool_ctx.error(f"Failed to clear: {str(e)}")
return f"Error clearing vector store: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,121 @@
"""Vector indexing tool for adding documents to vector database."""
from typing import Dict, Unpack, Optional, TypedDict, final
from pathlib import Path
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .project_manager import ProjectVectorManager
class VectorIndexParams(TypedDict, total=False):
"""Parameters for vector indexing operations."""
file_path: str
content: Optional[str]
chunk_size: Optional[int]
chunk_overlap: Optional[int]
metadata: Optional[Dict[str, str]]
@final
class VectorIndexTool(BaseTool):
"""Tool for indexing documents in the vector database."""
def __init__(
self,
permission_manager: PermissionManager,
project_manager: ProjectVectorManager,
):
"""Initialize the vector index tool.
Args:
permission_manager: Permission manager for access control
project_manager: Project-aware vector store manager
"""
self.permission_manager = permission_manager
self.project_manager = project_manager
@property
def name(self) -> str:
"""Get the tool name."""
return "vector_index"
@property
def description(self) -> str:
"""Get the tool description."""
return """Index documents in project-aware vector databases for semantic search.
Can index individual text content or entire files. Files are automatically assigned
to the appropriate project database based on LLM.md detection or stored in the global
database. Files are chunked for optimal search performance.
Projects are detected by finding LLM.md files, with databases stored in .hanzo/db
directories alongside them. Use this to build searchable knowledge bases per project."""
@auto_timeout("vector_index")
async def call(
self,
ctx: MCPContext,
**params: Unpack[VectorIndexParams],
) -> str:
"""Index content or files in the vector database.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Indexing result message
"""
file_path = params.get("file_path")
content = params.get("content")
chunk_size = params.get("chunk_size", 1000)
chunk_overlap = params.get("chunk_overlap", 200)
metadata = params.get("metadata", {})
if not file_path and not content:
return "Error: Either file_path or content must be provided"
try:
if file_path:
# Validate file access
# Use permission manager's existing validation
if not self.permission_manager.is_path_allowed(file_path):
return f"Error: Access denied to path {file_path}"
if not Path(file_path).exists():
return f"Error: File does not exist: {file_path}"
# Index file using project-aware manager
doc_ids, project_info = self.project_manager.add_file_to_appropriate_store(
file_path=file_path,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
metadata=metadata,
)
file_name = Path(file_path).name
if project_info:
return (
f"Successfully indexed {file_name} with {len(doc_ids)} chunks in project '{project_info.name}'"
)
else:
return f"Successfully indexed {file_name} with {len(doc_ids)} chunks in global database"
else:
# Index content directly in global store (no project context)
global_store = self.project_manager._get_global_store()
doc_id = global_store.add_document(
content=content,
metadata=metadata,
)
return f"Successfully indexed content as document {doc_id} in global database"
except Exception as e:
return f"Error indexing content: {str(e)}"
@@ -0,0 +1,237 @@
"""Vector search tool for semantic document retrieval."""
import json
from typing import List, Unpack, Optional, TypedDict, final
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .project_manager import ProjectVectorManager
class VectorSearchParams(TypedDict, total=False):
"""Parameters for vector search operations."""
query: str
limit: Optional[int]
score_threshold: Optional[float]
include_content: Optional[bool]
file_filter: Optional[str]
project_filter: Optional[List[str]]
search_scope: Optional[str] # "all", "global", "current", or specific project name
@final
class VectorSearchTool(BaseTool):
"""Tool for semantic search in the vector database."""
def __init__(
self,
permission_manager: PermissionManager,
project_manager: ProjectVectorManager,
):
"""Initialize the vector search tool.
Args:
permission_manager: Permission manager for access control
project_manager: Project-aware vector store manager
"""
self.permission_manager = permission_manager
self.project_manager = project_manager
@property
def name(self) -> str:
"""Get the tool name."""
return "vector_search"
@property
def description(self) -> str:
"""Get the tool description."""
return """Pure semantic/vector search using Infinity embedded database.
Searches indexed documents using vector embeddings to find semantically similar content.
This is NOT keyword search - it finds documents based on meaning and context similarity.
Features:
- Searches across project-specific vector databases
- Returns similarity scores (0-1, higher is better)
- Supports filtering by project or file
- Automatically detects projects via LLM.md files
Use 'grep' for exact text/pattern matching, 'vector_search' for semantic similarity."""
@auto_timeout("vector_search")
async def call(
self,
ctx: MCPContext,
**params: Unpack[VectorSearchParams],
) -> str:
"""Search for similar documents in the vector database.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Search results formatted as text
"""
query = params.get("query")
if not query:
return "Error: query parameter is required"
limit = params.get("limit", 10)
score_threshold = params.get("score_threshold", 0.0)
include_content = params.get("include_content", True)
file_filter = params.get("file_filter")
project_filter = params.get("project_filter")
search_scope = params.get("search_scope", "all")
try:
# Determine search strategy based on scope
if search_scope == "all":
# Search across all projects
project_results = await self.project_manager.search_all_projects(
query=query,
limit_per_project=limit,
score_threshold=score_threshold,
include_global=True,
project_filter=project_filter,
)
# Combine and sort all results
all_results = []
for project_name, results in project_results.items():
for result in results:
# Add project info to metadata
result.document.metadata = result.document.metadata or {}
result.document.metadata["search_project"] = project_name
all_results.append(result)
# Sort by score and limit
all_results.sort(key=lambda x: x.score, reverse=True)
results = all_results[:limit]
elif search_scope == "global":
# Search only global store
global_store = self.project_manager._get_global_store()
results = global_store.search(
query=query,
limit=limit,
score_threshold=score_threshold,
)
for result in results:
result.document.metadata = result.document.metadata or {}
result.document.metadata["search_project"] = "global"
else:
# Search specific project or current context
if search_scope != "current":
# Search specific project by name
project_info = None
for _proj_key, proj_info in self.project_manager.projects.items():
if proj_info.name == search_scope:
project_info = proj_info
break
if project_info:
vector_store = self.project_manager.get_vector_store(project_info)
results = vector_store.search(
query=query,
limit=limit,
score_threshold=score_threshold,
)
for result in results:
result.document.metadata = result.document.metadata or {}
result.document.metadata["search_project"] = project_info.name
else:
return f"Project '{search_scope}' not found"
else:
# For "current", try to detect from working directory
import os
current_dir = os.getcwd()
project_info = self.project_manager.get_project_for_path(current_dir)
if project_info:
vector_store = self.project_manager.get_vector_store(project_info)
results = vector_store.search(
query=query,
limit=limit,
score_threshold=score_threshold,
)
for result in results:
result.document.metadata = result.document.metadata or {}
result.document.metadata["search_project"] = project_info.name
else:
# Fall back to global store
global_store = self.project_manager._get_global_store()
results = global_store.search(
query=query,
limit=limit,
score_threshold=score_threshold,
)
for result in results:
result.document.metadata = result.document.metadata or {}
result.document.metadata["search_project"] = "global"
if not results:
return f"No results found for query: '{query}'"
# Filter by file if requested
if file_filter:
results = [r for r in results if file_filter in (r.document.file_path or "")]
# Format results
output_lines = [f"Found {len(results)} results for query: '{query}'\n"]
for i, result in enumerate(results, 1):
doc = result.document
score_percent = result.score * 100
# Header with score and metadata
project_name = doc.metadata.get("search_project", "unknown")
header = f"Result {i} (Score: {score_percent:.1f}%) - Project: {project_name}"
if doc.file_path:
header += f" - {doc.file_path}"
if doc.chunk_index is not None:
header += f" [Chunk {doc.chunk_index}]"
output_lines.append(header)
output_lines.append("-" * len(header))
# Add metadata if available
if doc.metadata:
relevant_metadata = {
k: v
for k, v in doc.metadata.items()
if k not in ["chunk_number", "total_chunks", "search_project"]
}
if relevant_metadata:
output_lines.append(f"Metadata: {json.dumps(relevant_metadata, indent=2)}")
# Add content if requested
if include_content:
content = doc.content
if len(content) > 500:
content = content[:500] + "..."
output_lines.append(f"Content:\n{content}")
output_lines.append("") # Empty line between results
return "\n".join(output_lines)
except Exception as e:
return f"Error searching vector database: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
# This is a placeholder - the actual registration would happen
# through the MCP server's tool registration mechanism
pass
+9 -4
View File
@@ -5,15 +5,20 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-vector"
version = "0.1.0"
description = "Vector search and embedding tools"
description = "Vector/embedding tools for Hanzo AI - indexing, search, RAG"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
dependencies = ["mcp>=1.25.0", "fastmcp>=2.14.1", "pydantic>=2.12.5"]
keywords = ["hanzo", "tools", "vector", "embeddings", "rag", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
chroma = ["chromadb>=0.5.0"]
openai = ["openai>=1.82.0"]
full = ["sentence-transformers>=2.0.0", "faiss-cpu>=1.7.0"]
[project.entry-points."hanzo.tools"]
vector = "hanzo_tools.vector:TOOLS"
+8
View File
@@ -37,12 +37,17 @@ lsp = ["hanzo-tools-lsp>=0.1.0"]
refactor = ["hanzo-tools-refactor>=0.1.0"]
vector = ["hanzo-tools-vector>=0.1.0"]
todo = ["hanzo-tools-todo>=0.1.0"]
config = ["hanzo-tools-config>=0.1.0"]
mcp_tools = ["hanzo-tools-mcp>=0.1.0"]
reasoning = ["hanzo-tools-reasoning>=0.1.0"]
# Bundles
core = [
"hanzo-tools-filesystem>=0.1.0",
"hanzo-tools-shell>=0.1.0",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
"hanzo-tools-config>=0.1.0",
]
dev = [
"hanzo-tools[core]",
@@ -69,6 +74,9 @@ all = [
"hanzo-tools-refactor>=0.1.0",
"hanzo-tools-vector>=0.1.0",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
"hanzo-tools-config>=0.1.0",
"hanzo-tools-mcp>=0.1.0",
]
[project.urls]