feat(hanzo-tools): add modular tool packages with 53 tools across 13 packages

Introduces hanzo-tools-* namespace packages for independent tool installation:

Core packages:
- hanzo-tools-core: BaseTool, ToolRegistry, PermissionManager
- hanzo-tools-filesystem: read, write, edit, tree, find, search, ast (7 tools)
- hanzo-tools-shell: dag, ps, zsh, shell, npx, uvx, open (7 tools)

AI/Agent packages:
- hanzo-tools-agent: critic, iching, review, network, CLI agents (12 tools)
- hanzo-tools-memory: recall/create/update/delete memories, facts, KB (9 tools)
- hanzo-tools-reasoning: think, critic (2 tools)

Editor/Dev packages:
- hanzo-tools-lsp: Language Server Protocol tool (1 tool)
- hanzo-tools-refactor: rename, extract, inline, move, change_signature (1 tool)
- hanzo-tools-database: SQL + graph database tools (8 tools)
- hanzo-tools-editor: neovim integration (3 tools)
- hanzo-tools-jupyter: notebook read/edit (1 tool)
- hanzo-tools-browser: Playwright browser automation (1 tool)
- hanzo-tools-todo: unified todo management (1 tool)

Key improvements:
- All subprocess.run calls have timeout protection
- All tool .call() methods are async
- Namespace package pattern for mix-and-match installation
- Entry point discovery via hanzo.tools
- No blocking imports (all < 2s)

Also updates hanzo-mcp:
- Browser tool integration
- Tool install/registry for dynamic tool management
- Auto-background timeout reduced to 60s
This commit is contained in:
2025-12-25 22:38:26 -08:00
parent 8750f1ce8b
commit d5f8e3f2b5
130 changed files with 27106 additions and 9 deletions
+30 -1
View File
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
_LSP_TOOL_AVAILABLE: bool | None = None
_REFACTOR_TOOL_AVAILABLE: bool | None = None
_MEMORY_TOOLS_AVAILABLE: bool | None = None
_BROWSER_TOOL_AVAILABLE: bool | None = None
def _check_lsp_available() -> bool:
@@ -63,6 +64,18 @@ def _check_memory_available() -> bool:
return _MEMORY_TOOLS_AVAILABLE
def _check_browser_available() -> bool:
"""Check if browser tool is available (requires playwright)."""
global _BROWSER_TOOL_AVAILABLE
if _BROWSER_TOOL_AVAILABLE is None:
try:
import playwright # noqa: F401
_BROWSER_TOOL_AVAILABLE = True
except ImportError:
_BROWSER_TOOL_AVAILABLE = False
return _BROWSER_TOOL_AVAILABLE
# Expose availability flags as properties for backward compatibility
@property
def LSP_TOOL_AVAILABLE() -> bool:
@@ -137,7 +150,7 @@ def register_all_tools(
from hanzo_mcp.tools.todo import register_todo_tools
from hanzo_mcp.tools.agent import register_agent_tools
from hanzo_mcp.tools.shell import register_shell_tools
from hanzo_mcp.tools.common import register_batch_tool, register_critic_tool, register_thinking_tool
from hanzo_mcp.tools.common import register_batch_tool, register_critic_tool, register_thinking_tool, register_tool_install
from hanzo_mcp.tools.editor import (
NeovimEditTool,
NeovimCommandTool,
@@ -379,6 +392,12 @@ def register_all_tools(
from hanzo_mcp.tools.common.version_tool import register_version_tool
register_version_tool(mcp_server)
# Tool install tool (for dynamic tool management)
if is_tool_enabled("tool_install", True):
install_tools = register_tool_install(mcp_server)
for tool in install_tools:
all_tools[tool.name] = tool
# Tool enable/disable tools
tool_enable = ToolEnableTool()
tool_enable.register(mcp_server)
@@ -515,6 +534,16 @@ def register_all_tools(
except Exception as e:
logger.warning(f"Failed to register refactor tool: {e}")
# Register browser tool if enabled (requires playwright)
if is_tool_enabled("browser", True) and _check_browser_available():
try:
from hanzo_mcp.tools.browser import create_browser_tool
tool = create_browser_tool()
tool.register(mcp_server)
all_tools[tool.name] = tool
except Exception as e:
logger.warning(f"Failed to register browser tool: {e}")
# Register user plugins last (so they can override built-in tools)
for plugin_name, plugin in plugins.items():
if is_tool_enabled(plugin_name, True):
@@ -0,0 +1,35 @@
"""High-performance async browser automation using Playwright.
Features:
- Async-first: All operations non-blocking
- Shared browser pool: Low latency, reuse across calls
- CDP connection: Cross-MCP browser sharing
- Connection pooling: Multiple pages for parallel work
Basic usage:
tool = create_browser_tool()
tool.register(mcp_server)
Cross-MCP sharing:
# Terminal 1: Launch persistent browser
endpoint = await launch_browser_server(port=9222)
# MCP instances connect via:
# BROWSER_CDP_ENDPOINT=http://localhost:9222
"""
from hanzo_mcp.tools.browser.browser_tool import (
BrowserTool,
BrowserPool,
create_browser_tool,
launch_browser_server,
PLAYWRIGHT_AVAILABLE,
)
__all__ = [
"BrowserTool",
"BrowserPool",
"create_browser_tool",
"launch_browser_server",
"PLAYWRIGHT_AVAILABLE",
]
@@ -0,0 +1,511 @@
"""High-performance async browser automation tool using Playwright.
Design goals:
- Async-first: All operations are non-blocking
- Shared browser instance: Reuse browser across calls (low latency)
- Connection pooling: Multiple pages/contexts for parallel work
- Cross-MCP sharing: Connect to existing browser via CDP endpoint
"""
import asyncio
import base64
import os
from typing import Annotated, Any, Literal, Optional, ClassVar
from pathlib import Path
import logging
from pydantic import Field
# Playwright import with graceful fallback
try:
from playwright.async_api import async_playwright, Browser, Page, BrowserContext, Playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
Browser = Page = BrowserContext = Playwright = None
logger = logging.getLogger(__name__)
Action = Annotated[
Literal[
"navigate", # Go to URL
"click", # Click element
"type", # Type text into element
"fill", # Fill form field (clears first)
"press", # Press key
"screenshot", # Take screenshot
"snapshot", # Get accessibility tree
"evaluate", # Run JavaScript
"wait", # Wait for selector or time
"close", # Close browser
"tabs", # List/switch tabs
"connect", # Connect to existing browser (CDP)
],
Field(description="Browser action to perform"),
]
class BrowserPool:
"""Shared browser instance pool for high-performance automation.
Features:
- Singleton browser instance across all MCP calls
- Connection to remote browser via CDP (cross-MCP sharing)
- Context pooling for parallel operations
- Automatic cleanup on shutdown
"""
_instance: ClassVar[Optional["BrowserPool"]] = None
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
def __init__(self):
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
self._pages: list[Page] = []
self._headless: bool = True
self._cdp_endpoint: Optional[str] = None
self._initialized: bool = False
@classmethod
async def get_instance(cls) -> "BrowserPool":
"""Get or create the singleton browser pool."""
async with cls._lock:
if cls._instance is None:
cls._instance = BrowserPool()
return cls._instance
@classmethod
async def shutdown(cls) -> None:
"""Shutdown the browser pool."""
async with cls._lock:
if cls._instance is not None:
await cls._instance.close()
cls._instance = None
async def ensure_browser(
self,
headless: bool = True,
cdp_endpoint: Optional[str] = None,
) -> Page:
"""Ensure browser is running, return current page.
Args:
headless: Run in headless mode (ignored if connecting via CDP)
cdp_endpoint: Connect to existing browser via CDP endpoint
e.g., "http://localhost:9222"
"""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError(
"Playwright not installed. Run: pip install playwright && playwright install chromium"
)
# Check if we need to reconnect
needs_init = (
not self._initialized or
self._page is None or
self._browser is None or
self._cdp_endpoint != cdp_endpoint
)
if needs_init:
# Close existing if reconnecting
if self._initialized:
await self.close()
self._playwright = await async_playwright().start()
self._headless = headless
self._cdp_endpoint = cdp_endpoint
if cdp_endpoint:
# Connect to existing browser via CDP (for cross-MCP sharing)
logger.info(f"Connecting to browser at {cdp_endpoint}")
self._browser = await self._playwright.chromium.connect_over_cdp(cdp_endpoint)
# Get existing contexts or create new one
contexts = self._browser.contexts
if contexts:
self._context = contexts[0]
pages = self._context.pages
if pages:
self._page = pages[0]
self._pages = list(pages)
else:
self._page = await self._context.new_page()
self._pages = [self._page]
else:
self._context = await self._browser.new_context(
viewport={"width": 1280, "height": 720},
)
self._page = await self._context.new_page()
self._pages = [self._page]
else:
# Launch new browser
self._browser = await self._playwright.chromium.launch(
headless=headless,
args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
self._context = await self._browser.new_context(
viewport={"width": 1280, "height": 720},
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
)
self._page = await self._context.new_page()
self._pages = [self._page]
self._initialized = True
logger.info("Browser initialized")
return self._page
async def new_tab(self) -> Page:
"""Open a new tab and switch to it."""
if not self._context:
raise RuntimeError("Browser not initialized")
page = await self._context.new_page()
self._pages.append(page)
self._page = page
return page
async def switch_tab(self, index: int) -> Page:
"""Switch to tab by index."""
if 0 <= index < len(self._pages):
self._page = self._pages[index]
return self._page
raise ValueError(f"Invalid tab index: {index}")
async def close(self) -> None:
"""Close browser and cleanup."""
if self._browser:
try:
await self._browser.close()
except Exception as e:
logger.warning(f"Error closing browser: {e}")
if self._playwright:
try:
await self._playwright.stop()
except Exception as e:
logger.warning(f"Error stopping playwright: {e}")
self._browser = None
self._context = None
self._page = None
self._pages = []
self._playwright = None
self._initialized = False
logger.info("Browser closed")
@property
def page(self) -> Optional[Page]:
return self._page
@property
def pages(self) -> list[Page]:
return self._pages
class BrowserTool:
"""Unified browser automation tool.
High-performance, async-first browser automation.
Uses shared browser pool for low latency.
"""
name: str = "browser"
description: str = "Unified browser automation with Playwright"
def __init__(self, headless: bool = True, cdp_endpoint: Optional[str] = None):
self.headless = headless
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
self.timeout = 30000
async def _get_page(self) -> Page:
"""Get page from shared pool."""
pool = await BrowserPool.get_instance()
return await pool.ensure_browser(
headless=self.headless,
cdp_endpoint=self.cdp_endpoint,
)
async def execute(
self,
action: str,
url: Optional[str] = None,
selector: Optional[str] = None,
ref: Optional[str] = None,
text: Optional[str] = None,
key: Optional[str] = None,
code: Optional[str] = None,
timeout: Optional[int] = None,
full_page: bool = False,
tab_index: Optional[int] = None,
cdp_endpoint: Optional[str] = None,
) -> dict[str, Any]:
"""Execute browser action.
Args:
action: Action to perform
url: URL for navigate action
selector: CSS selector for element actions
ref: Alternative to selector (for compatibility)
text: Text for type/fill actions
key: Key for press action (e.g., "Enter", "Tab")
code: JavaScript code for evaluate action
timeout: Override default timeout (ms)
full_page: Take full page screenshot
tab_index: Tab index for tabs action
cdp_endpoint: CDP endpoint for connect action
Returns:
Dict with result, varies by action
"""
pool = await BrowserPool.get_instance()
timeout = timeout or self.timeout
sel = selector or ref # Accept either
try:
# Special action: connect to existing browser
if action == "connect":
endpoint = cdp_endpoint or self.cdp_endpoint
if not endpoint:
return {"error": "cdp_endpoint required for connect"}
page = await pool.ensure_browser(
headless=self.headless,
cdp_endpoint=endpoint,
)
return {
"success": True,
"connected": True,
"endpoint": endpoint,
"url": page.url if page else None,
}
page = await self._get_page()
if action == "navigate":
if not url:
return {"error": "url required for navigate"}
response = await page.goto(url, timeout=timeout, wait_until="domcontentloaded")
return {
"success": True,
"url": page.url,
"title": await page.title(),
"status": response.status if response else None,
}
elif action == "click":
if not sel:
return {"error": "selector required for click"}
await page.click(sel, timeout=timeout)
return {"success": True, "clicked": sel}
elif action == "type":
if not sel or text is None:
return {"error": "selector and text required for type"}
await page.type(sel, text, timeout=timeout)
return {"success": True, "typed": len(text), "selector": sel}
elif action == "fill":
if not sel or text is None:
return {"error": "selector and text required for fill"}
await page.fill(sel, text, timeout=timeout)
return {"success": True, "filled": sel}
elif action == "press":
if not key:
return {"error": "key required for press"}
if sel:
await page.press(sel, key, timeout=timeout)
else:
await page.keyboard.press(key)
return {"success": True, "pressed": key}
elif action == "screenshot":
screenshot_bytes = await page.screenshot(
full_page=full_page,
type="png",
)
# Return base64 encoded image
return {
"success": True,
"format": "png",
"base64": base64.b64encode(screenshot_bytes).decode(),
"url": page.url,
}
elif action == "snapshot":
# Get accessibility tree - simpler than full snapshot
tree = await page.accessibility.snapshot()
return {
"success": True,
"url": page.url,
"title": await page.title(),
"snapshot": tree,
}
elif action == "evaluate":
if not code:
return {"error": "code required for evaluate"}
result = await page.evaluate(code)
return {"success": True, "result": result}
elif action == "wait":
if sel:
await page.wait_for_selector(sel, timeout=timeout)
return {"success": True, "found": sel}
elif timeout:
await asyncio.sleep(timeout / 1000)
return {"success": True, "waited_ms": timeout}
else:
return {"error": "selector or timeout required for wait"}
elif action == "close":
await pool.close()
return {"success": True, "closed": True}
elif action == "tabs":
if tab_index is not None:
try:
page = await pool.switch_tab(tab_index)
return {
"success": True,
"switched_to": tab_index,
"url": page.url,
}
except ValueError as e:
return {"error": str(e)}
else:
return {
"success": True,
"count": len(pool.pages),
"tabs": [
{"index": i, "url": p.url}
for i, p in enumerate(pool.pages)
],
}
else:
return {"error": f"Unknown action: {action}"}
except Exception as e:
logger.exception(f"Browser action failed: {action}")
return {"error": str(e), "action": action}
def register(self, mcp_server) -> None:
"""Register the browser tool with an MCP server."""
from mcp.server import FastMCP
tool_instance = self
@mcp_server.tool()
async def browser(
action: Action,
url: Annotated[Optional[str], Field(description="URL for navigate action")] = None,
selector: Annotated[Optional[str], Field(description="CSS selector for element")] = None,
ref: Annotated[Optional[str], Field(description="Element reference (alias for selector)")] = None,
text: Annotated[Optional[str], Field(description="Text for type/fill actions")] = None,
key: Annotated[Optional[str], Field(description="Key for press action")] = None,
code: Annotated[Optional[str], Field(description="JavaScript for evaluate")] = None,
timeout: Annotated[Optional[int], Field(description="Timeout in ms")] = None,
full_page: Annotated[bool, Field(description="Full page screenshot")] = False,
tab_index: Annotated[Optional[int], Field(description="Tab index for tabs action")] = None,
cdp_endpoint: Annotated[Optional[str], Field(description="CDP endpoint for connect action")] = None,
) -> dict[str, Any]:
"""Unified browser automation tool (async, high-performance).
Uses shared browser instance for low latency.
Supports CDP connection for cross-MCP browser sharing.
Actions:
- navigate: Go to URL (requires url)
- click: Click element (requires selector)
- type: Type text (requires selector, text)
- fill: Fill form field (requires selector, text)
- press: Press key (requires key, optional selector)
- screenshot: Take screenshot (optional full_page)
- snapshot: Get accessibility tree
- evaluate: Run JavaScript (requires code)
- wait: Wait for selector or time
- close: Close browser
- tabs: List or switch tabs
- connect: Connect to existing browser via CDP
Examples:
browser(action="navigate", url="https://example.com")
browser(action="click", selector="button#submit")
browser(action="type", selector="input[name=q]", text="hello")
browser(action="screenshot", full_page=True)
browser(action="connect", cdp_endpoint="http://localhost:9222")
"""
return await tool_instance.execute(
action=action,
url=url,
selector=selector,
ref=ref,
text=text,
key=key,
code=code,
timeout=timeout,
full_page=full_page,
tab_index=tab_index,
cdp_endpoint=cdp_endpoint,
)
def create_browser_tool(headless: bool = True, cdp_endpoint: Optional[str] = None) -> BrowserTool:
"""Create a browser tool instance.
Args:
headless: Run browser in headless mode
cdp_endpoint: Connect to existing browser via CDP endpoint
(or set BROWSER_CDP_ENDPOINT env var)
Returns:
BrowserTool instance ready for registration
"""
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint)
async def launch_browser_server(port: int = 9222, headless: bool = False) -> str:
"""Launch a persistent browser server for cross-MCP sharing.
This launches Chrome with remote debugging enabled.
Multiple MCP instances can connect to this browser.
Args:
port: CDP debugging port (default: 9222)
headless: Run in headless mode
Returns:
CDP endpoint URL
Example:
# Terminal 1: Launch browser server
endpoint = await launch_browser_server(port=9222, headless=False)
print(f"Browser server at: {endpoint}")
# MCP instances can now connect:
# BROWSER_CDP_ENDPOINT=http://localhost:9222 hanzo-mcp
"""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError("Playwright not installed")
pw = await async_playwright().start()
browser = await pw.chromium.launch(
headless=headless,
args=[
f"--remote-debugging-port={port}",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
# Keep browser running
endpoint = f"http://localhost:{port}"
logger.info(f"Browser server launched at {endpoint}")
return endpoint
@@ -4,6 +4,10 @@ Core thinking tools:
- think: Structured reasoning
- critic: Critical analysis
Dynamic tool management:
- tool_install: Install, update, reload tools dynamically
- tool_enable/tool_disable: Enable/disable tools at runtime
Note: batch tool is DEPRECATED in 1.0.0 - use `dag` from shell tools instead.
"""
@@ -13,6 +17,7 @@ from mcp.server import FastMCP
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
from hanzo_mcp.tools.common.critic_tool import CriticTool
from hanzo_mcp.tools.common.thinking_tool import ThinkingTool
from hanzo_mcp.tools.common.tool_install import ToolInstallTool, register_tool_install
def register_thinking_tool(mcp_server: FastMCP) -> list[BaseTool]:
@@ -0,0 +1,227 @@
"""Tool installation and self-update tool.
Allows the AI to:
- Install new tool packages dynamically
- Update existing tools
- Self-update hanzo-mcp
- Hot-reload without restart
"""
from typing import Annotated, Any, Literal, TypedDict, Unpack, final, override
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.context import create_tool_context
from hanzo_mcp.tools.common.auto_timeout import auto_timeout
Action = Annotated[
Literal[
"install", # Install a tool package
"uninstall", # Remove a tool package
"upgrade", # Upgrade package(s)
"reload", # Hot-reload a package
"list", # List installed packages
"self_update", # Update hanzo-mcp itself
],
Field(description="Action to perform"),
]
class ToolInstallParams(TypedDict, total=False):
"""Parameters for tool install actions."""
action: str
package: str
source: str
version: str
@final
class ToolInstallTool(BaseTool):
"""Dynamic tool installation and management.
Allows the AI to expand its own capabilities by installing
new tool packages, updating existing tools, and even updating itself.
"""
@property
@override
def name(self) -> str:
return "tool_install"
@property
@override
def description(self) -> str:
return """Install, update, and manage tool packages dynamically.
Actions:
- install: Install a new tool package
- uninstall: Remove a tool package
- upgrade: Upgrade package(s) to latest
- reload: Hot-reload a package without restart
- list: List all installed packages
- self_update: Update hanzo-mcp itself
Examples:
tool_install(action="install", package="hanzo-tools-browser")
tool_install(action="upgrade", package="hanzo-tools-data")
tool_install(action="self_update")
tool_install(action="list")
tool_install(action="reload", package="hanzo-tools-browser")
Sources:
- pypi: Install from PyPI (default)
- git: Install from git URL
- local: Install from local path
"""
@override
@auto_timeout("tool_install")
async def call(
self,
ctx: MCPContext,
**params: Unpack[ToolInstallParams],
) -> str:
"""Execute tool management action."""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
action = params.get("action", "list")
package = params.get("package")
source = params.get("source", "pypi")
version = params.get("version")
# Import registry lazily
from hanzo_mcp.tools.common.tool_registry import get_registry
registry = await get_registry()
if action == "install":
if not package:
return "Error: package name required for install"
await tool_ctx.info(f"Installing {package} from {source}...")
result = await registry.install(
package=package,
source=source,
version=version,
)
if result["success"]:
tools = result.get("tools", [])
return (
f"✓ Installed {package} v{result.get('version', 'latest')}\n"
f"Tools available: {', '.join(tools) if tools else 'none detected'}"
)
else:
return f"✗ Failed to install {package}: {result.get('error', 'unknown error')}"
elif action == "uninstall":
if not package:
return "Error: package name required for uninstall"
await tool_ctx.info(f"Uninstalling {package}...")
result = await registry.uninstall(package)
if result["success"]:
return f"✓ Uninstalled {package}"
else:
return f"✗ Failed to uninstall {package}: {result.get('error', 'unknown error')}"
elif action == "upgrade":
await tool_ctx.info(f"Upgrading {package or 'all packages'}...")
result = await registry.upgrade(package)
if result["success"]:
upgraded = [r["package"] for r in result.get("results", []) if r.get("success")]
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
else:
errors = [f"{r['package']}: {r.get('error')}" for r in result.get("results", []) if not r.get("success")]
return f"✗ Some upgrades failed:\n" + "\n".join(errors)
elif action == "reload":
if not package:
return "Error: package name required for reload"
await tool_ctx.info(f"Reloading {package}...")
result = await registry.reload_package(package)
if result["success"]:
tools = result.get("tools", [])
return (
f"✓ Reloaded {package}\n"
f"Tools: {', '.join(tools) if tools else 'none'}"
)
else:
return f"✗ Failed to reload {package}: {result.get('error', 'unknown error')}"
elif action == "list":
packages = registry.list_packages()
if not packages:
return "No tool packages installed.\n\nUse tool_install(action='install', package='...') to add tools."
lines = ["Installed tool packages:", ""]
for pkg in packages:
status = "✓" if pkg["enabled"] else "○"
lines.append(f"{status} {pkg['name']} v{pkg['version']} ({pkg['source']})")
if pkg["tools"]:
lines.append(f" Tools: {', '.join(pkg['tools'])}")
return "\n".join(lines)
elif action == "self_update":
await tool_ctx.info("Checking for hanzo-mcp updates...")
result = await registry.self_update()
if result["success"]:
return (
f"✓ Updated hanzo-mcp from v{result.get('current_version')}\n"
f"{result.get('message', '')}"
)
else:
return (
f"✗ Update failed: {result.get('error', 'unknown error')}\n"
f"Current version: v{result.get('current_version', 'unknown')}"
)
else:
return f"Unknown action: {action}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
tool_instance = self
@mcp_server.tool()
async def tool_install(
action: Action,
package: Annotated[str | None, Field(description="Package name or URL")] = None,
source: Annotated[str, Field(description="Source: pypi, git, or local")] = "pypi",
version: Annotated[str | None, Field(description="Version constraint")] = None,
ctx: MCPContext = None,
) -> str:
"""Install, update, and manage tool packages dynamically.
The AI can use this to expand its own capabilities by installing
new tools, updating existing ones, or even updating itself.
Examples:
tool_install(action="install", package="hanzo-tools-browser")
tool_install(action="self_update")
tool_install(action="list")
"""
return await tool_instance.call(
ctx,
action=action,
package=package,
source=source,
version=version,
)
def register_tool_install(mcp_server) -> list:
"""Register the tool install tool."""
tool = ToolInstallTool()
tool.register(mcp_server)
return [tool]
@@ -0,0 +1,451 @@
"""Dynamic tool registry with hot-reload and self-update capabilities.
Enables:
- Installing tool packages dynamically
- Hot-reloading tools without server restart
- Self-updating the AI's tooling
- Cross-session tool persistence
"""
import asyncio
import subprocess
import sys
import json
import os
import importlib
import logging
from typing import Any, Optional, Type, Callable
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class ToolPackage:
"""Represents an installed tool package."""
name: str
version: str
source: str # "pypi", "git", "local"
installed_at: datetime
tools: list[str] = field(default_factory=list)
enabled: bool = True
class ToolRegistry:
"""Centralized registry for dynamic tool management.
Features:
- Install tools from PyPI/git/local
- Hot-reload without restart
- Track installed packages
- Enable/disable per tool
"""
_instance: Optional["ToolRegistry"] = None
_lock = asyncio.Lock()
def __init__(self):
self._packages: dict[str, ToolPackage] = {}
self._tools: dict[str, Any] = {} # name -> tool instance
self._tool_classes: dict[str, Type] = {} # name -> tool class
self._config_path = Path.home() / ".hanzo" / "mcp" / "registry.json"
self._tools_path = Path.home() / ".hanzo" / "tools" # Tool packages install here
self._reload_callbacks: list[Callable] = []
self._mcp_server = None
self._tools_path.mkdir(parents=True, exist_ok=True)
self._load_config()
@classmethod
async def get_instance(cls) -> "ToolRegistry":
"""Get singleton instance."""
async with cls._lock:
if cls._instance is None:
cls._instance = ToolRegistry()
return cls._instance
def set_mcp_server(self, mcp_server) -> None:
"""Set the MCP server for tool registration."""
self._mcp_server = mcp_server
def on_reload(self, callback: Callable) -> None:
"""Register callback for reload events."""
self._reload_callbacks.append(callback)
def _load_config(self) -> None:
"""Load registry config from disk."""
if self._config_path.exists():
try:
with open(self._config_path) as f:
data = json.load(f)
for name, pkg_data in data.get("packages", {}).items():
self._packages[name] = ToolPackage(
name=pkg_data["name"],
version=pkg_data["version"],
source=pkg_data["source"],
installed_at=datetime.fromisoformat(pkg_data["installed_at"]),
tools=pkg_data.get("tools", []),
enabled=pkg_data.get("enabled", True),
)
except Exception as e:
logger.warning(f"Failed to load registry config: {e}")
def _save_config(self) -> None:
"""Save registry config to disk."""
self._config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"packages": {
name: {
"name": pkg.name,
"version": pkg.version,
"source": pkg.source,
"installed_at": pkg.installed_at.isoformat(),
"tools": pkg.tools,
"enabled": pkg.enabled,
}
for name, pkg in self._packages.items()
}
}
with open(self._config_path, "w") as f:
json.dump(data, f, indent=2)
async def install(
self,
package: str,
source: str = "pypi",
version: Optional[str] = None,
upgrade: bool = False,
) -> dict[str, Any]:
"""Install a tool package.
Args:
package: Package name or git URL
source: "pypi", "git", or "local"
version: Specific version (optional)
upgrade: Upgrade if already installed
Returns:
Installation result
"""
try:
if source == "pypi":
# Install from PyPI
pkg_spec = f"{package}=={version}" if version else package
cmd = [sys.executable, "-m", "uv", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.extend(["--target", str(self._tools_path), pkg_spec])
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
return {
"success": False,
"error": stderr.decode(),
"package": package,
}
# Get installed version
installed_version = version or "latest"
elif source == "git":
# Install from git
cmd = [
sys.executable, "-m", "uv", "pip", "install",
"--target", str(self._tools_path),
f"git+{package}",
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
return {
"success": False,
"error": stderr.decode(),
"package": package,
}
installed_version = "git"
elif source == "local":
# Install from local path
cmd = [
sys.executable, "-m", "uv", "pip", "install",
"--target", str(self._tools_path),
"-e", package,
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
return {
"success": False,
"error": stderr.decode(),
"package": package,
}
installed_version = "local"
else:
return {"success": False, "error": f"Unknown source: {source}"}
# Record package
pkg_name = package.split("/")[-1] if "/" in package else package
pkg_name = pkg_name.replace(".git", "")
self._packages[pkg_name] = ToolPackage(
name=pkg_name,
version=installed_version,
source=source,
installed_at=datetime.now(),
enabled=True,
)
self._save_config()
# Hot-reload the package
await self.reload_package(pkg_name)
return {
"success": True,
"package": pkg_name,
"version": installed_version,
"source": source,
"tools": self._packages[pkg_name].tools,
}
except Exception as e:
logger.exception(f"Failed to install package: {package}")
return {"success": False, "error": str(e), "package": package}
async def uninstall(self, package: str) -> dict[str, Any]:
"""Uninstall a tool package."""
if package not in self._packages:
return {"success": False, "error": f"Package not found: {package}"}
try:
cmd = [
sys.executable, "-m", "uv", "pip", "uninstall",
"--target", str(self._tools_path),
"-y", package,
]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await result.communicate()
# Remove from registry
pkg = self._packages.pop(package, None)
# Unregister tools
if pkg:
for tool_name in pkg.tools:
self._tools.pop(tool_name, None)
self._tool_classes.pop(tool_name, None)
self._save_config()
return {"success": True, "package": package}
except Exception as e:
return {"success": False, "error": str(e), "package": package}
async def upgrade(self, package: Optional[str] = None) -> dict[str, Any]:
"""Upgrade package(s) to latest version.
Args:
package: Specific package or None for all
"""
results = []
packages_to_upgrade = (
[package] if package else list(self._packages.keys())
)
for pkg_name in packages_to_upgrade:
if pkg_name not in self._packages:
results.append({"package": pkg_name, "error": "Not installed"})
continue
pkg = self._packages[pkg_name]
result = await self.install(
pkg.name,
source=pkg.source,
upgrade=True,
)
results.append(result)
return {
"success": all(r.get("success", False) for r in results),
"results": results,
}
async def reload_package(self, package: str) -> dict[str, Any]:
"""Hot-reload a package without server restart."""
if package not in self._packages:
return {"success": False, "error": f"Package not found: {package}"}
try:
# Add tools path to sys.path if not there
tools_path_str = str(self._tools_path)
if tools_path_str not in sys.path:
sys.path.insert(0, tools_path_str)
# Try to import/reload the package
try:
# Clear existing module from cache
modules_to_remove = [
mod for mod in sys.modules
if mod.startswith(package) or mod.startswith(package.replace("-", "_"))
]
for mod in modules_to_remove:
del sys.modules[mod]
# Import the package
pkg_module = importlib.import_module(package.replace("-", "_"))
# Look for tools
tools_found = []
# Check for TOOLS export
if hasattr(pkg_module, "TOOLS"):
for tool_class in pkg_module.TOOLS:
tool_name = getattr(tool_class, "name", tool_class.__name__)
self._tool_classes[tool_name] = tool_class
tools_found.append(tool_name)
# Register with MCP server if available
if self._mcp_server:
try:
tool_instance = tool_class()
if hasattr(tool_instance, "register"):
tool_instance.register(self._mcp_server)
self._tools[tool_name] = tool_instance
except Exception as e:
logger.warning(f"Failed to register tool {tool_name}: {e}")
# Check for register_* functions
for attr_name in dir(pkg_module):
if attr_name.startswith("register_") and callable(getattr(pkg_module, attr_name)):
register_func = getattr(pkg_module, attr_name)
if self._mcp_server:
try:
registered = register_func(self._mcp_server)
if registered:
for tool in registered:
tool_name = getattr(tool, "name", str(tool))
tools_found.append(tool_name)
except Exception as e:
logger.warning(f"Failed to call {attr_name}: {e}")
# Update package tools list
self._packages[package].tools = tools_found
self._save_config()
# Notify callbacks
for callback in self._reload_callbacks:
try:
callback(package, tools_found)
except Exception:
pass
return {
"success": True,
"package": package,
"tools": tools_found,
}
except ImportError as e:
return {"success": False, "error": f"Import failed: {e}", "package": package}
except Exception as e:
logger.exception(f"Failed to reload package: {package}")
return {"success": False, "error": str(e), "package": package}
async def self_update(self) -> dict[str, Any]:
"""Update hanzo-mcp itself (the AI updates its own tooling)."""
try:
# Get current version
from hanzo_mcp import __version__ as current_version
# Check for updates
cmd = [sys.executable, "-m", "uv", "pip", "install", "--upgrade", "hanzo-mcp"]
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
return {
"success": False,
"error": stderr.decode(),
"current_version": current_version,
}
# Check new version (requires restart to take effect)
output = stdout.decode()
return {
"success": True,
"current_version": current_version,
"message": "Updated successfully. Restart required for changes to take effect.",
"output": output,
}
except Exception as e:
return {"success": False, "error": str(e)}
def list_packages(self) -> list[dict[str, Any]]:
"""List all installed tool packages."""
return [
{
"name": pkg.name,
"version": pkg.version,
"source": pkg.source,
"installed_at": pkg.installed_at.isoformat(),
"tools": pkg.tools,
"enabled": pkg.enabled,
}
for pkg in self._packages.values()
]
def list_tools(self) -> list[str]:
"""List all registered tools."""
return list(self._tools.keys())
def get_tool(self, name: str) -> Optional[Any]:
"""Get a tool instance by name."""
return self._tools.get(name)
# Singleton access
_registry: Optional[ToolRegistry] = None
async def get_registry() -> ToolRegistry:
"""Get the global tool registry."""
global _registry
if _registry is None:
_registry = await ToolRegistry.get_instance()
return _registry
@@ -19,18 +19,18 @@ from hanzo_mcp.tools.shell.base_process import ProcessManager
class AutoBackgroundExecutor:
"""Executor that automatically backgrounds long-running processes."""
# Default timeout before auto-backgrounding (2 minutes)
DEFAULT_TIMEOUT = 120.0
# Default timeout before auto-backgrounding (60 seconds for efficiency)
DEFAULT_TIMEOUT = 60.0
def __init__(self, process_manager: ProcessManager, timeout: float = DEFAULT_TIMEOUT):
"""Initialize the auto-background executor.
Args:
process_manager: Process manager for tracking background processes
timeout: Timeout in seconds before auto-backgrounding (default: 120s)
timeout: Default timeout in seconds before auto-backgrounding (default: 60s)
"""
self.process_manager = process_manager
self.timeout = timeout
self.default_timeout = timeout
async def execute_with_auto_background(
self,
@@ -38,6 +38,7 @@ class AutoBackgroundExecutor:
tool_name: str,
cwd: Optional[Path] = None,
env: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Tuple[str, bool, Optional[str]]:
"""Execute a command with automatic backgrounding if it takes too long.
@@ -46,10 +47,14 @@ class AutoBackgroundExecutor:
tool_name: Name of the tool (for process ID generation)
cwd: Working directory
env: Environment variables
timeout: Override timeout in seconds (uses default if None)
Returns:
Tuple of (output/status, was_backgrounded, process_id)
"""
# Use passed timeout or default
effective_timeout = timeout if timeout is not None else self.default_timeout
# Fast path for tests/offline: run synchronously
import os
@@ -119,7 +124,7 @@ class AutoBackgroundExecutor:
# Wait for either timeout or completion
done, pending = await asyncio.wait(
[read_task, wait_task],
timeout=self.timeout,
timeout=effective_timeout,
return_when=asyncio.FIRST_COMPLETED,
)
@@ -207,6 +207,7 @@ class BaseProcessTool(BaseTool):
tool_name=self.get_tool_name(),
cwd=cwd,
env=process_env,
timeout=float(timeout) if timeout is not None else None,
)
if was_backgrounded:
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.10.4"
version = "0.10.6"
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"
@@ -70,6 +70,7 @@ test = [
agents = ["hanzo-agents>=0.1.0"]
memory = ["hanzo-memory>=1.0.0"]
performance = ["ujson>=5.7.0", "orjson>=3.9.0", "uvloop>=0.21.0; sys_platform != 'win32'"]
browser = ["playwright>=1.49.0"]
publish = ["twine>=4.0.2", "build>=1.0.3"]
[project.scripts]
+76 -2
View File
@@ -1114,6 +1114,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[[package]]
name = "greenlet"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
]
[[package]]
name = "grep-ast"
version = "0.9.0"
@@ -1232,7 +1271,7 @@ wheels = [
[[package]]
name = "hanzo-mcp"
version = "0.10.3"
version = "0.10.4"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
@@ -1263,6 +1302,9 @@ agents = [
analytics = [
{ name = "posthog" },
]
browser = [
{ name = "playwright" },
]
dev = [
{ name = "black" },
{ name = "mypy" },
@@ -1325,6 +1367,7 @@ requires-dist = [
{ name = "nbformat", specifier = ">=5.10.4" },
{ name = "openai", specifier = ">=1.82.0" },
{ name = "orjson", marker = "extra == 'performance'", specifier = ">=3.9.0" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.49.0" },
{ name = "posthog", marker = "extra == 'analytics'", specifier = ">=3.0.0" },
{ name = "psutil", specifier = ">=6.0.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
@@ -1354,7 +1397,7 @@ requires-dist = [
{ name = "uvloop", marker = "sys_platform != 'win32' and extra == 'performance'", specifier = ">=0.21.0" },
{ name = "watchdog", specifier = ">=6.0.0" },
]
provides-extras = ["dev", "docs", "analytics", "test", "agents", "memory", "performance", "publish"]
provides-extras = ["dev", "docs", "analytics", "test", "agents", "memory", "performance", "browser", "publish"]
[[package]]
name = "hanzo-memory"
@@ -3021,6 +3064,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "playwright"
version = "1.57.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
{ url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
{ url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
{ url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -3597,6 +3659,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" },
]
[[package]]
name = "pyee"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,114 @@
"""Agent tools for Hanzo AI.
This package provides agent orchestration and multi-agent tools:
- agent: Agent spawning and orchestration
- swarm: Multi-agent swarm coordination
- CLI agents: Claude, Codex, Gemini, Grok CLI tools
- critic: Critical analysis tool
- review: Code review tool
Install:
pip install hanzo-tools-agent
Usage:
from hanzo_tools.agent import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
Optional dependencies:
pip install hanzo-tools-agent[full] # For LLM provider support
"""
import logging
logger = logging.getLogger(__name__)
# Core tools that don't have external dependencies
from .critic_tool import CriticTool
from .iching_tool import IChingTool
from .review_tool import ReviewTool
from .clarification_tool import ClarificationTool
from .network_tool import NetworkTool
from .code_auth_tool import CodeAuthTool
# CLI agent tools
from .cli_agent_base import CLIAgentBase
from .claude_cli_tool import ClaudeCLITool
from .codex_cli_tool import CodexCLITool
from .gemini_cli_tool import GeminiCLITool
from .grok_cli_tool import GrokCLITool
# Tools with optional dependencies - imported lazily
_optional_tools = []
try:
from .agent_tool import AgentTool
_optional_tools.append(AgentTool)
except ImportError as e:
logger.debug(f"AgentTool not available: {e}")
try:
from .swarm_tool import SwarmTool
_optional_tools.append(SwarmTool)
except ImportError as e:
logger.debug(f"SwarmTool not available: {e}")
# Export list for tool discovery
TOOLS = [
CriticTool,
IChingTool,
ReviewTool,
ClarificationTool,
NetworkTool,
CodeAuthTool,
ClaudeCLITool,
CodexCLITool,
GeminiCLITool,
GrokCLITool,
] + _optional_tools
__all__ = [
"register_tools",
"TOOLS",
"CriticTool",
"IChingTool",
"ReviewTool",
"ClarificationTool",
"NetworkTool",
"CodeAuthTool",
"CLIAgentBase",
"ClaudeCLITool",
"CodexCLITool",
"GeminiCLITool",
"GrokCLITool",
]
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
"""Register all agent tools with the MCP server.
Args:
mcp_server: FastMCP server instance
enabled_tools: Dict of tool_name -> enabled state
Returns:
List of registered tool instances
"""
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
if enabled.get(tool_name, True): # Enabled by default
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,404 @@
"""Unified agent tool implementation.
This module provides the AgentTool for delegating tasks to sub-agents,
supporting both one-off and long-running RPC modes, including A2A communication.
"""
import re
import json
import time
import uuid
import asyncio
# Import litellm with warnings suppressed
import warnings
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from pydantic import Field
from openai.types.chat import ChatCompletionMessageParam
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.jupyter import get_read_only_jupyter_tools
from hanzo_mcp.tools.filesystem import get_read_only_filesystem_tools
from hanzo_tools.core import BaseTool
from .prompt import (
get_default_model,
get_system_prompt,
get_allowed_agent_tools,
)
from hanzo_mcp.tools.common.context import (
ToolContext,
create_tool_context,
)
from hanzo_mcp.tools.common.batch_tool import BatchTool
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
# Parameter types
Action = Annotated[
str,
Field(
description="Action: run (default), start, call, stop, list",
default="run",
),
]
Prompts = Annotated[
Optional[str | List[str]],
Field(
description="Task(s) for agent (must include absolute paths starting with /)",
default=None,
),
]
Mode = Annotated[
str,
Field(
description="Execution mode: oneoff (default) or rpc",
default="oneoff",
),
]
AgentId = Annotated[
Optional[str],
Field(
description="Agent ID for RPC mode",
default=None,
),
]
Method = Annotated[
Optional[str],
Field(
description="Method to call on RPC agent",
default=None,
),
]
Args = Annotated[
Optional[Dict[str, Any]],
Field(
description="Arguments for RPC method call",
default=None,
),
]
Model = Annotated[
Optional[str],
Field(
description="Model to use (e.g., lm-studio/local-model, openai/gpt-4o)",
default=None,
),
]
class AgentParams(TypedDict, total=False):
"""Parameters for agent tool."""
action: str
prompts: Optional[str | List[str]]
mode: str
agent_id: Optional[str]
method: Optional[str]
args: Optional[Dict[str, Any]]
model: Optional[str]
class RPCAgent:
"""Long-running RPC agent."""
def __init__(self, agent_id: str, model: str, system_prompt: str, tools: List[BaseTool]):
self.agent_id = agent_id
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.messages: List[ChatCompletionMessageParam] = [{"role": "system", "content": system_prompt}]
self.created_at = time.time()
self.last_used = time.time()
self.call_count = 0
async def call_method(self, method: str, args: Dict[str, Any], tool_ctx: ToolContext) -> str:
"""Call a method on the RPC agent."""
self.last_used = time.time()
self.call_count += 1
# Build prompt based on method
if method == "search":
prompt = f"Search for: {args.get('query', 'unknown')}"
elif method == "analyze":
prompt = f"Analyze: {args.get('target', 'unknown')}"
elif method == "execute":
prompt = f"Execute: {args.get('command', 'unknown')}"
else:
# Generic method call
prompt = f"Method: {method}, Args: {json.dumps(args)}"
# Add to conversation
self.messages.append({"role": "user", "content": prompt})
# Get response
# (simplified - would integrate with full agent execution logic)
response = f"Executed {method} with args {args}"
self.messages.append({"role": "assistant", "content": response})
return response
@final
class AgentTool(BaseTool):
"""Unified agent tool with one-off and RPC modes."""
def __init__(
self,
permission_manager: PermissionManager,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_tokens: int | None = None,
max_iterations: int = 10,
max_tool_uses: int = 30,
):
"""Initialize the agent tool."""
self.permission_manager = permission_manager
self.model_override = model
self.api_key_override = api_key
self.base_url_override = base_url
self.max_tokens_override = max_tokens
self.max_iterations = max_iterations
self.max_tool_uses = max_tool_uses
# RPC agent registry
self._rpc_agents: Dict[str, RPCAgent] = {}
# Available tools
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "agent"
@property
@override
def description(self) -> str:
"""Get the tool description."""
tools = [t.name for t in self.available_tools]
return f"""AI agents with tools: {", ".join(tools)}. Actions: run (default), start, call, stop, list.
Usage:
agent "Search for config files in /project"
agent --action start --mode rpc --model lm-studio/local-model
agent --action call --agent-id abc123 --method search --args '{{"query": "database"}}'
agent --action list
Modes:
- oneoff: Single task execution (default)
- rpc: Long-running agent for multiple calls (A2A support)"""
@override
@auto_timeout("agent")
async def call(
self,
ctx: MCPContext,
**params: Unpack[AgentParams],
) -> str:
"""Execute agent operation."""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract action
action = params.get("action", "run")
# Route to appropriate handler
if action == "run":
return await self._handle_run(params, tool_ctx)
elif action == "start":
return await self._handle_start(params, tool_ctx)
elif action == "call":
return await self._handle_call(params, tool_ctx)
elif action == "stop":
return await self._handle_stop(params, tool_ctx)
elif action == "list":
return await self._handle_list(tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: run, start, call, stop, list"
async def _handle_run(self, params: Dict[str, Any], tool_ctx: ToolContext) -> str:
"""Handle one-off agent run (default action)."""
prompts = params.get("prompts")
if not prompts:
return "Error: prompts required for run action"
# Convert to list
if isinstance(prompts, str):
prompt_list = [prompts]
else:
prompt_list = prompts
# Validate prompts
for prompt in prompt_list:
if not self._validate_prompt(prompt):
return f"Error: Prompt must contain absolute paths starting with /: {prompt[:50]}..."
# Execute agents
start_time = time.time()
if len(prompt_list) == 1:
await tool_ctx.info("Launching agent")
result = await self._execute_agent(prompt_list[0], params.get("model"), tool_ctx)
else:
await tool_ctx.info(f"Launching {len(prompt_list)} agents in parallel")
result = await self._execute_multiple_agents(prompt_list, params.get("model"), tool_ctx)
execution_time = time.time() - start_time
return f"""Agent execution completed in {execution_time:.2f} seconds.
AGENT RESPONSE:
{result}"""
async def _handle_start(self, params: Dict[str, Any], tool_ctx: ToolContext) -> str:
"""Start a new RPC agent."""
mode = params.get("mode", "oneoff")
if mode != "rpc":
return "Error: start action only valid for rpc mode"
# Generate agent ID
agent_id = str(uuid.uuid4())[:8]
# Get model
model = params.get("model") or get_default_model(self.model_override)
# Get available tools
agent_tools = get_allowed_agent_tools(
self.available_tools,
self.permission_manager,
)
# Create system prompt
system_prompt = get_system_prompt(
agent_tools,
self.permission_manager,
)
# Create RPC agent
agent = RPCAgent(agent_id, model, system_prompt, agent_tools)
self._rpc_agents[agent_id] = agent
await tool_ctx.info(f"Started RPC agent {agent_id} with model {model}")
return f"""Started RPC agent:
- ID: {agent_id}
- Model: {model}
- Tools: {len(agent_tools)}
Use 'agent --action call --agent-id {agent_id} --method <method> --args <args>' to interact."""
async def _handle_call(self, params: Dict[str, Any], tool_ctx: ToolContext) -> str:
"""Call method on RPC agent."""
agent_id = params.get("agent_id")
if not agent_id:
return "Error: agent_id required for call action"
if agent_id not in self._rpc_agents:
return f"Error: Agent {agent_id} not found. Use 'agent --action list' to see active agents."
method = params.get("method")
if not method:
return "Error: method required for call action"
args = params.get("args", {})
# Call agent method
agent = self._rpc_agents[agent_id]
await tool_ctx.info(f"Calling {method} on agent {agent_id}")
try:
result = await agent.call_method(method, args, tool_ctx)
return f"Agent {agent_id} response:\n{result}"
except Exception as e:
await tool_ctx.error(f"Error calling agent: {str(e)}")
return f"Error calling agent: {str(e)}"
async def _handle_stop(self, params: Dict[str, Any], tool_ctx: ToolContext) -> str:
"""Stop an RPC agent."""
agent_id = params.get("agent_id")
if not agent_id:
return "Error: agent_id required for stop action"
if agent_id not in self._rpc_agents:
return f"Error: Agent {agent_id} not found"
agent = self._rpc_agents.pop(agent_id)
await tool_ctx.info(f"Stopped agent {agent_id}")
return f"""Stopped agent {agent_id}:
- Runtime: {time.time() - agent.created_at:.2f} seconds
- Calls: {agent.call_count}"""
async def _handle_list(self, tool_ctx: ToolContext) -> str:
"""List active RPC agents."""
if not self._rpc_agents:
return "No active RPC agents"
output = ["=== Active RPC Agents ==="]
for agent_id, agent in self._rpc_agents.items():
runtime = time.time() - agent.created_at
idle = time.time() - agent.last_used
output.append(f"\nAgent {agent_id}:")
output.append(f" Model: {agent.model}")
output.append(f" Runtime: {runtime:.2f}s")
output.append(f" Idle: {idle:.2f}s")
output.append(f" Calls: {agent.call_count}")
return "\n".join(output)
def _validate_prompt(self, prompt: str) -> bool:
"""Validate that prompt contains absolute paths."""
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
return bool(re.search(absolute_path_pattern, prompt))
async def _execute_agent(self, prompt: str, model: Optional[str], tool_ctx: ToolContext) -> str:
"""Execute a single agent (simplified - would use full logic from agent_tool.py)."""
# This would integrate the full agent execution logic from agent_tool.py
# For now, return a placeholder
return f"Executed agent with prompt: {prompt[:100]}..."
async def _execute_multiple_agents(self, prompts: List[str], model: Optional[str], tool_ctx: ToolContext) -> str:
"""Execute multiple agents in parallel."""
tasks = []
for prompt in prompts:
task = self._execute_agent(prompt, model, tool_ctx)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
formatted_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
formatted_results.append(f"Agent {i + 1} Error:\n{str(result)}")
else:
formatted_results.append(f"Agent {i + 1} Result:\n{result}")
return "\n\n---\n\n".join(formatted_results)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,531 @@
"""Agent tool implementation using hanzo-agents SDK.
This module implements the AgentTool that leverages the hanzo-agents SDK
for sophisticated agent orchestration and execution.
"""
import re
import time
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
TypedDict,
final,
override,
)
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import auto_timeout
# Import hanzo-agents SDK
try:
from hanzo_agents import (
Tool,
Agent,
State,
History,
Network,
InferenceResult,
create_memory_kv,
sequential_router,
create_memory_vector,
)
from hanzo_agents.core.cli_agent import (
GrokAgent,
GeminiAgent,
ClaudeCodeAgent,
OpenAICodexAgent,
)
HANZO_AGENTS_AVAILABLE = True
except ImportError:
HANZO_AGENTS_AVAILABLE = False
# Define stub classes when hanzo-agents is not available
class State:
"""Stub State class when hanzo-agents is not available."""
def __init__(self):
pass
def to_dict(self):
return {}
@classmethod
def from_dict(cls, data):
return cls()
class Tool:
"""Stub Tool class when hanzo-agents is not available."""
pass
class Agent:
"""Stub Agent class when hanzo-agents is not available."""
pass
class Network:
"""Stub Network class when hanzo-agents is not available."""
pass
class History:
"""Stub History class when hanzo-agents is not available."""
pass
class InferenceResult:
"""Stub InferenceResult class when hanzo-agents is not available."""
def __init__(self, agent=None, content=None, metadata=None):
self.agent = agent
self.content = content
self.metadata = metadata or {}
from hanzo_mcp.tools.jupyter import get_read_only_jupyter_tools
from hanzo_mcp.tools.filesystem import Edit, MultiEdit, get_read_only_filesystem_tools
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from .critic_tool import CriticTool
from .iching_tool import IChingTool
from .review_tool import ReviewTool
from hanzo_mcp.tools.common.batch_tool import BatchTool
from hanzo_tools.core import PermissionManager
from .clarification_tool import ClarificationTool
from .clarification_protocol import (
AgentClarificationMixin,
)
class AgentToolParams(TypedDict, total=False):
"""Parameters for the AgentTool."""
prompts: str | list[str]
model: Optional[str]
use_memory: Optional[bool]
memory_backend: Optional[str]
concurrency: Optional[int]
class MCPAgentState(State):
"""State for MCP agents."""
def __init__(self, prompts: List[str], context: Dict[str, Any]):
"""Initialize agent state."""
super().__init__()
self.prompts = prompts
self.context = context
self.current_prompt_index = 0
self.results = []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
base_dict = super().to_dict()
base_dict.update(
{
"prompts": self.prompts,
"context": self.context,
"current_prompt_index": self.current_prompt_index,
"results": self.results,
}
)
return base_dict
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MCPAgentState":
"""Create from dictionary."""
state = cls(prompts=data.get("prompts", []), context=data.get("context", {}))
state.current_prompt_index = data.get("current_prompt_index", 0)
state.results = data.get("results", [])
for k, v in data.items():
if k not in ["prompts", "context", "current_prompt_index", "results"]:
state[k] = v
return state
class MCPToolAdapter(Tool):
"""Adapter to wrap MCP tools for hanzo-agents."""
def __init__(self, mcp_tool: BaseTool, ctx: MCPContext):
"""Initialize adapter."""
self.mcp_tool = mcp_tool
self.ctx = ctx
# Set required Tool class attributes
self.name = mcp_tool.name
self.description = mcp_tool.description
def handle(self, **kwargs) -> Any:
"""Execute the tool synchronously (required by Tool ABC).
Note: This is called via Tool.__call__ which handles async properly.
We return the coroutine and let the framework await it.
"""
return self.mcp_tool.call(self.ctx, **kwargs)
async def execute(self, **kwargs) -> str:
"""Execute the MCP tool asynchronously."""
return await self.mcp_tool.call(self.ctx, **kwargs)
class MCPAgent(Agent):
"""Agent that executes MCP tasks."""
name = "mcp_agent"
description = "Agent for executing MCP tasks"
def __init__(
self,
available_tools: List[BaseTool],
permission_manager: PermissionManager,
ctx: MCPContext,
model: str = "model://anthropic/claude-3-5-sonnet-20241022",
**kwargs,
):
"""Initialize MCP agent."""
super().__init__(model=model, **kwargs)
self.available_tools = available_tools
self.permission_manager = permission_manager
self.ctx = ctx
# Set up agent tools by wrapping MCP tools
self.tools = [MCPToolAdapter(mcp_tool, ctx) for mcp_tool in available_tools]
async def run(self, state: MCPAgentState, history: History, network: Network) -> InferenceResult:
"""Execute the agent."""
# Get current prompt
if state.current_prompt_index >= len(state.prompts):
return InferenceResult(
agent=self.name,
content="All prompts completed",
metadata={"completed": True},
)
prompt = state.prompts[state.current_prompt_index]
# Execute with tools
messages = [
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt},
]
# Add history context
for entry in history[-10:]:
if entry.role == "assistant":
messages.append({"role": "assistant", "content": entry.content})
elif entry.role == "user":
messages.append({"role": "user", "content": entry.content})
# Call model
from hanzo_agents import ModelRegistry
adapter = ModelRegistry.get_adapter(self.model)
response = await adapter.chat(messages)
# Update state
state.current_prompt_index += 1
state.results.append(response)
# Return result
return InferenceResult(
agent=self.name,
content=response,
metadata={
"prompt_index": state.current_prompt_index - 1,
"total_prompts": len(state.prompts),
},
)
def _get_system_prompt(self) -> str:
"""Get system prompt for the agent."""
tool_descriptions = []
for tool in self.tools:
tool_descriptions.append(f"- {tool.name}: {tool.description}")
return f"""You are an AI assistant with access to the following tools:
{chr(10).join(tool_descriptions)}
When you need to use a tool, respond with:
TOOL: tool_name(arg1="value1", arg2="value2")
Important guidelines:
- Always include absolute paths starting with / when working with files
- Be thorough in your searches and analysis
- Provide clear, actionable results
- Edit files when requested to make changes
"""
@final
class AgentTool(AgentClarificationMixin, BaseTool):
"""Tool for delegating tasks to sub-agents using hanzo-agents SDK."""
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "agent"
@property
@override
def description(self) -> str:
"""Get the tool description."""
if not HANZO_AGENTS_AVAILABLE:
return "Agent tool (hanzo-agents SDK not available - using fallback)"
at = [t.name for t in self.available_tools]
return f"""Launch a new agent that has access to the following tools: {at}.
When to use the Agent tool:
- If you are searching for a keyword like "config" or "logger"
- When you need to perform edits across multiple files
- When you need to delegate complex file modification tasks
When NOT to use the Agent tool:
- If you want to read a specific file path
- If you are searching for a specific class definition
- Writing code and running bash commands
- Other tasks that are not related to searching
Usage notes:
1. Launch multiple agents concurrently whenever possible
2. Agent results are not visible to the user - summarize them
3. Each agent invocation is stateless
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just do research"""
def __init__(
self,
permission_manager: PermissionManager,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_tokens: int | None = None,
max_iterations: int = 10,
max_tool_uses: int = 30,
) -> None:
"""Initialize the agent tool."""
self.permission_manager = permission_manager
self.model_override = model
self.api_key_override = api_key
self.base_url_override = base_url
self.max_tokens_override = max_tokens
self.max_iterations = max_iterations
self.max_tool_uses = max_tool_uses
# Set up available tools
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
# Add edit tools
self.available_tools.append(Edit(self.permission_manager))
self.available_tools.append(MultiEdit(self.permission_manager))
# Add special tools
self.available_tools.append(ClarificationTool())
self.available_tools.append(CriticTool())
self.available_tools.append(ReviewTool())
self.available_tools.append(IChingTool())
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
@override
@auto_timeout("agent")
async def call(
self,
ctx: MCPContext,
**params: Unpack[AgentToolParams],
) -> str:
"""Execute the tool with the given parameters."""
start_time = time.time()
# Create tool context
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
prompts = params.get("prompts")
if prompts is None:
await tool_ctx.error("No prompts provided")
return "Error: At least one prompt must be provided."
# Handle both string and list inputs
if isinstance(prompts, str):
prompt_list = [prompts]
elif isinstance(prompts, list):
if not prompts:
await tool_ctx.error("Empty prompts list provided")
return "Error: At least one prompt must be provided."
prompt_list = prompts
else:
await tool_ctx.error("Invalid prompts parameter type")
return "Error: Parameter 'prompts' must be a string or list of strings."
# Validate absolute paths
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
for prompt in prompt_list:
if not re.search(absolute_path_pattern, prompt):
await tool_ctx.error(f"Prompt missing absolute path: {prompt[:50]}...")
return "Error: All prompts must contain at least one absolute path."
# Require hanzo-agents SDK
if not HANZO_AGENTS_AVAILABLE:
await tool_ctx.error("hanzo-agents SDK is required but not available")
return "Error: hanzo-agents SDK is required for agent tool functionality. Please install it with: pip install hanzo-agents"
# Determine concurrency (parallel agents)
concurrency = params.get("concurrency")
if concurrency is not None and isinstance(concurrency, int) and concurrency > 0:
# Expand prompt list to match concurrency
if len(prompt_list) == 1:
prompt_list = prompt_list * concurrency
elif len(prompt_list) < concurrency:
# Repeat prompts to reach concurrency
times = (concurrency + len(prompt_list) - 1) // len(prompt_list)
prompt_list = (prompt_list * times)[:concurrency]
await tool_ctx.info(f"Launching {len(prompt_list)} agent(s) using hanzo-agents SDK")
# Determine model and agent type
model = params.get("model", self.model_override)
use_memory = params.get("use_memory", False)
memory_backend = params.get("memory_backend", "sqlite")
# Get appropriate agent class
agent_class = self._get_agent_class(model, ctx)
# Create state
state = MCPAgentState(
prompts=prompt_list,
context={
"permission_manager": self.permission_manager,
"api_key": self.api_key_override,
"base_url": self.base_url_override,
"max_tokens": self.max_tokens_override,
},
)
# Create memory if requested
memory_kv = None
memory_vector = None
if use_memory:
memory_kv = create_memory_kv(memory_backend)
memory_vector = create_memory_vector("simple")
# Create network
network = Network(
state=state,
agents=[agent_class],
router=sequential_router([agent_class] * len(prompt_list)),
memory_kv=memory_kv,
memory_vector=memory_vector,
max_steps=self.max_iterations * len(prompt_list),
)
# Execute
try:
final_state = await network.run()
execution_time = time.time() - start_time
# Format results
results = final_state.results
if len(results) == 1:
formatted_result = f"""Agent execution completed in {execution_time:.2f} seconds.
AGENT RESPONSE:
{results[0]}"""
else:
formatted_results = []
for i, result in enumerate(results):
formatted_results.append(f"Agent {i + 1} Result:\n{result}")
formatted_result = f"""Multi-agent execution completed in {execution_time:.2f} seconds ({len(results)} agents).
AGENT RESPONSES:
{chr(10).join(formatted_results)}"""
await tool_ctx.info(f"Execution completed in {execution_time:.2f}s")
return formatted_result
except Exception as e:
await tool_ctx.error(f"Agent execution failed: {str(e)}")
return f"Error: {str(e)}"
def _get_agent_class(self, model: Optional[str], ctx: MCPContext) -> type[Agent]:
"""Get appropriate agent class based on model.
Args:
model: Model identifier
ctx: MCP context for the agent
Returns:
Agent class type
"""
if not model:
model = "model://anthropic/claude-3-5-sonnet-20241022"
# Check for CLI agents
cli_agents = {
"claude_cli": ClaudeCodeAgent,
"codex_cli": OpenAICodexAgent,
"gemini_cli": GeminiAgent,
"grok_cli": GrokAgent,
}
if model in cli_agents:
return cli_agents[model]
# Capture values from outer scope for the dynamic class
available_tools = self.available_tools
permission_manager = self.permission_manager
captured_model = model
captured_ctx = ctx
# Return generic MCP agent with proper closure
def dynamic_init(agent_self):
MCPAgent.__init__(
agent_self,
available_tools=available_tools,
permission_manager=permission_manager,
ctx=captured_ctx,
model=captured_model,
)
return type(
"DynamicMCPAgent",
(MCPAgent,),
{
"model": captured_model,
"__init__": dynamic_init,
},
)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this agent tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def dispatch_agent(
prompts: str | list[str],
ctx: MCPContext,
model: Optional[str] = None,
use_memory: bool = False,
memory_backend: str = "sqlite",
) -> str:
return await tool_self.call(
ctx,
prompts=prompts,
model=model,
use_memory=use_memory,
memory_backend=memory_backend,
)
@@ -0,0 +1,661 @@
"""Agent tool implementation for Hanzo AI.
This module implements the AgentTool that allows Claude to delegate tasks to sub-agents,
enabling concurrent execution of multiple operations and specialized processing.
"""
import re
import json
import time
import asyncio
# Import litellm with warnings suppressed
import warnings
from typing import Unpack, Annotated, TypedDict, final, override
from collections.abc import Iterable
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import litellm
from pydantic import Field
from mcp.server import FastMCP
from openai.types.chat import ChatCompletionToolParam, ChatCompletionMessageParam
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.jupyter import get_read_only_jupyter_tools
from hanzo_mcp.tools.filesystem import Edit, MultiEdit, get_read_only_filesystem_tools
from hanzo_tools.core import BaseTool
from .prompt import (
get_default_model,
get_system_prompt,
get_model_parameters,
get_allowed_agent_tools,
)
from hanzo_mcp.tools.common.context import (
ToolContext,
create_tool_context,
)
from .critic_tool import CriticTool, CriticProtocol
from .iching_tool import IChingTool
from .review_tool import ReviewTool, ReviewProtocol
from hanzo_mcp.tools.common.batch_tool import BatchTool
from .tool_adapter import (
convert_tools_to_openai_functions,
)
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .clarification_tool import ClarificationTool
from .clarification_protocol import (
ClarificationType,
AgentClarificationMixin,
)
Prompt = Annotated[
str,
Field(
description="Task for the agent to perform (must include absolute paths starting with /)",
min_length=1,
),
]
class AgentToolParams(TypedDict, total=False):
"""Parameters for the AgentTool.
Attributes:
prompts: Task(s) for the agent to perform (must include absolute paths starting with /)
"""
prompts: str | list[str]
@final
class AgentTool(AgentClarificationMixin, BaseTool):
"""Tool for delegating tasks to sub-agents.
The AgentTool allows Claude to create and manage sub-agents for performing
specialized tasks concurrently, such as code search, analysis, and more.
Agents can request clarification from the main loop up to once per task.
"""
@property
@override
def name(self) -> str:
"""Get the tool name.
Returns:
Tool name
"""
return "agent"
@property
@override
def description(self) -> str:
"""Get the tool description.
Returns:
Tool description
"""
# Note: Consider adding glob pattern support in future
at = [t.name for t in self.available_tools]
return f"""Launch a new agent that has access to the following tools: {at}. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you.
When to use the Agent tool:
- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended
- When you need to perform edits across multiple files based on search results
- When you need to delegate complex file modification tasks
When NOT to use the Agent tool:
- If you want to read a specific file path, use the read or glob tool instead of the Agent tool, to find the match more quickly
- If you are searching for a specific class definition like \"class Foo\", use the glob tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the read tool instead of the Agent tool, to find the match more quickly
- Writing code and running bash commands (use other tools for that)
- Other tasks that are not related to searching for a keyword or file
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent"""
def __init__(
self,
permission_manager: PermissionManager,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_tokens: int | None = None,
max_iterations: int = 10,
max_tool_uses: int = 30,
) -> None:
"""Initialize the agent tool.
Args:
permission_manager: Permission manager for access control
model: Optional model name override in LiteLLM format (e.g., "openai/gpt-4o")
api_key: Optional API key for the model provider
base_url: Optional base URL for the model provider API endpoint
max_tokens: Optional maximum tokens for model responses
max_iterations: Maximum number of iterations for agent (default: 10)
max_tool_uses: Maximum number of total tool uses for agent (default: 30)
"""
self.permission_manager = permission_manager
self.model_override = model
self.api_key_override = api_key
self.base_url_override = base_url
self.max_tokens_override = max_tokens
self.max_iterations = max_iterations
self.max_tool_uses = max_tool_uses
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
# Always add edit tools - agents should have edit access
self.available_tools.append(Edit(self.permission_manager))
self.available_tools.append(MultiEdit(self.permission_manager))
# Add clarification tool for agents
self.available_tools.append(ClarificationTool())
# Add critic tool for agents (devil's advocate)
self.available_tools.append(CriticTool())
# Add review tool for agents (balanced review)
self.available_tools.append(ReviewTool())
# Add I Ching tool for creative guidance
self.available_tools.append(IChingTool())
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
# Initialize protocols
self.critic_protocol = CriticProtocol()
self.review_protocol = ReviewProtocol()
@override
@auto_timeout("agent_tool_v1_deprecated")
async def call(
self,
ctx: MCPContext,
**params: Unpack[AgentToolParams],
) -> str:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Tool execution result
"""
start_time = time.time()
# Create tool context
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract and validate parameters
prompts = params.get("prompts")
if prompts is None:
await tool_ctx.error("No prompts provided")
return """Error: At least one prompt must be provided.
IMPORTANT REMINDER FOR CLAUDE:
The dispatch_agent tool requires prompts parameter. Please provide either:
- A single prompt as a string
- Multiple prompts as an array of strings
Each prompt must contain absolute paths starting with /.
Example of correct usage:
- prompts: "Search for all instances of the 'config' variable in /Users/bytedance/project/hanzo-mcp"
- prompts: ["Find files in /path/to/project", "Search code in /path/to/src"]"""
# Handle both string and list inputs
if isinstance(prompts, str):
prompt_list = [prompts]
elif isinstance(prompts, list):
if not prompts:
await tool_ctx.error("Empty prompts list provided")
return "Error: At least one prompt must be provided when using a list."
if not all(isinstance(p, str) for p in prompts):
await tool_ctx.error("All prompts must be strings")
return "Error: All prompts in the list must be strings."
prompt_list = prompts
else:
await tool_ctx.error("Invalid prompts parameter type")
return "Error: Parameter 'prompts' must be a string or an array of strings."
# Validate absolute paths in all prompts
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
for prompt in prompt_list:
if not re.search(absolute_path_pattern, prompt):
await tool_ctx.error(f"Prompt does not contain absolute path: {prompt[:50]}...")
return """Error: All prompts must contain at least one absolute path.
IMPORTANT REMINDER FOR CLAUDE:
When using the dispatch_agent tool, always include absolute paths in your prompts.
Example of correct usage:
- "Search for all instances of the 'config' variable in /Users/bytedance/project/hanzo-mcp"
- "Find files that import the database module in /Users/bytedance/project/hanzo-mcp/src"
The agent cannot access files without knowing their absolute locations."""
# Execute agent(s) - always use _execute_multiple_agents for list inputs
if len(prompt_list) == 1:
await tool_ctx.info("Launching agent")
result = await self._execute_multiple_agents(prompt_list, tool_ctx)
execution_time = time.time() - start_time
formatted_result = f"""Agent execution completed in {execution_time:.2f} seconds.
AGENT RESPONSE:
{result}"""
await tool_ctx.info(f"Agent execution completed in {execution_time:.2f}s")
return formatted_result
else:
await tool_ctx.info(f"Launching {len(prompt_list)} agents in parallel")
result = await self._execute_multiple_agents(prompt_list, tool_ctx)
execution_time = time.time() - start_time
formatted_result = f"""Multi-agent execution completed in {execution_time:.2f} seconds ({len(prompt_list)} agents).
AGENT RESPONSES:
{result}"""
await tool_ctx.info(f"Multi-agent execution completed in {execution_time:.2f}s")
return formatted_result
async def _execute_agent(self, prompt: str, tool_ctx: ToolContext) -> str:
"""Execute a single agent with the given prompt.
Args:
prompt: The task prompt for the agent
tool_ctx: Tool context for logging
Returns:
Agent execution result
"""
# Get available tools for the agent
agent_tools = get_allowed_agent_tools(
self.available_tools,
self.permission_manager,
)
# Convert tools to OpenAI format
openai_tools = convert_tools_to_openai_functions(agent_tools)
# Log execution start
await tool_ctx.info("Starting agent execution")
# Create a result container
result = ""
try:
# Create system prompt for this agent
system_prompt = get_system_prompt(
agent_tools,
self.permission_manager,
)
# Execute agent
await tool_ctx.info(f"Executing agent task: {prompt[:50]}...")
result = await self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
except Exception as e:
# Log and return error result
error_message = f"Error executing agent: {str(e)}"
await tool_ctx.error(error_message)
return f"Error: {error_message}"
return result if result else "No results returned from agent"
async def _execute_multiple_agents(self, prompts: list[str], tool_ctx: ToolContext) -> str:
"""Execute multiple agents concurrently.
Args:
prompts: List of prompts for the agents
tool_ctx: Tool context for logging
Returns:
Combined results from all agents
"""
# Get available tools for the agents
agent_tools = get_allowed_agent_tools(
self.available_tools,
self.permission_manager,
)
# Convert tools to OpenAI format
openai_tools = convert_tools_to_openai_functions(agent_tools)
# Create system prompt for the agents
system_prompt = get_system_prompt(
agent_tools,
self.permission_manager,
)
# Create tasks for parallel execution
tasks = []
for i, prompt in enumerate(prompts):
await tool_ctx.info(f"Creating agent task {i + 1}: {prompt[:50]}...")
task = self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
tasks.append(task)
# Execute all agents concurrently
await tool_ctx.info(f"Executing {len(tasks)} agents in parallel")
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle single agent case
if len(results) == 1:
if isinstance(results[0], Exception):
await tool_ctx.error(f"Agent execution failed: {str(results[0])}")
return f"Error: {str(results[0])}"
return results[0]
# Format results for multiple agents
formatted_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
formatted_results.append(f"Agent {i + 1} Error:\n{str(result)}")
await tool_ctx.error(f"Agent {i + 1} failed: {str(result)}")
else:
formatted_results.append(f"Agent {i + 1} Result:\n{result}")
return "\n\n---\n\n".join(formatted_results)
async def _execute_agent_with_tools(
self,
system_prompt: str,
user_prompt: str,
available_tools: list[BaseTool],
openai_tools: list[ChatCompletionToolParam],
tool_ctx: ToolContext,
) -> str:
"""Execute agent with tool handling.
Args:
system_prompt: System prompt for the agent
user_prompt: User prompt for the agent
available_tools: List of available tools
openai_tools: List of tools in OpenAI format
tool_ctx: Tool context for logging
Returns:
Agent execution result
"""
# Get model parameters and name
model = get_default_model(self.model_override)
params = get_model_parameters(max_tokens=self.max_tokens_override)
# Initialize messages
messages: Iterable[ChatCompletionMessageParam] = []
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_prompt})
# Track tool usage for metrics
tool_usage = {}
total_tool_use_count = 0
iteration_count = 0
max_tool_uses = self.max_tool_uses # Safety limit to prevent infinite loops
max_iterations = self.max_iterations # Add a maximum number of iterations for safety
# Execute until the agent completes or reaches the limit
while total_tool_use_count < max_tool_uses and iteration_count < max_iterations:
iteration_count += 1
await tool_ctx.info(f"Calling model (iteration {iteration_count})...")
try:
# Configure model parameters based on capabilities
completion_params = {
"model": model,
"messages": messages,
"tools": openai_tools,
"tool_choice": "auto",
"temperature": params["temperature"],
"timeout": params["timeout"],
}
if self.api_key_override:
completion_params["api_key"] = self.api_key_override
# Add max_tokens if provided
if params.get("max_tokens"):
completion_params["max_tokens"] = params.get("max_tokens")
# Add base_url if provided
if self.base_url_override:
completion_params["base_url"] = self.base_url_override
# Make the model call
response = litellm.completion(**completion_params) # pyright: ignore
if len(response.choices) == 0: # pyright: ignore
raise ValueError("No response choices returned")
message = response.choices[0].message # pyright: ignore
# Add message to conversation history
messages.append(message) # pyright: ignore
# If no tool calls, we're done
if not message.tool_calls:
return message.content or "Agent completed with no response."
# Process tool calls
tool_call_count = len(message.tool_calls)
await tool_ctx.info(f"Processing {tool_call_count} tool calls")
# Track which tool calls we've processed to ensure all get results
tool_results_to_add = []
for tool_call in message.tool_calls:
total_tool_use_count += 1
function_name = tool_call.function.name
# Track usage
tool_usage[function_name] = tool_usage.get(function_name, 0) + 1
# Log tool usage
await tool_ctx.info(f"Agent using tool: {function_name}")
# Parse the arguments
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
function_args = {}
# Find the matching tool
tool = next((t for t in available_tools if t.name == function_name), None)
if not tool:
tool_result = f"Error: Tool '{function_name}' not found"
# Special handling for clarification requests
elif function_name == "request_clarification":
try:
# Extract clarification parameters
request_type = function_args.get("type", "ADDITIONAL_INFO")
question = function_args.get("question", "")
context = function_args.get("context", {})
options = function_args.get("options")
# Convert string type to enum
clarification_type = ClarificationType[request_type]
# Request clarification
answer = await self.request_clarification(
request_type=clarification_type,
question=question,
context=context,
options=options,
)
tool_result = self.format_clarification_in_output(question, answer)
except Exception as e:
tool_result = f"Error processing clarification: {str(e)}"
# Special handling for critic requests
elif function_name == "critic":
try:
# Extract critic parameters
review_type = function_args.get("review_type", "GENERAL")
work_description = function_args.get("work_description", "")
code_snippets = function_args.get("code_snippets")
file_paths = function_args.get("file_paths")
specific_concerns = function_args.get("specific_concerns")
# Request critical review
tool_result = self.critic_protocol.request_review(
review_type=review_type,
work_description=work_description,
code_snippets=code_snippets,
file_paths=file_paths,
specific_concerns=specific_concerns,
)
except Exception as e:
tool_result = f"Error processing critic review: {str(e)}"
# Special handling for review requests
elif function_name == "review":
try:
# Extract review parameters
focus = function_args.get("focus", "GENERAL")
work_description = function_args.get("work_description", "")
code_snippets = function_args.get("code_snippets")
file_paths = function_args.get("file_paths")
context = function_args.get("context")
# Request balanced review
tool_result = self.review_protocol.request_review(
focus=focus,
work_description=work_description,
code_snippets=code_snippets,
file_paths=file_paths,
context=context,
)
except Exception as e:
tool_result = f"Error processing review: {str(e)}"
else:
try:
tool_result = await tool.call(ctx=tool_ctx.mcp_context, **function_args)
except Exception as e:
tool_result = f"Error executing {function_name}: {str(e)}"
await tool_ctx.info(
f"tool {function_name} run with args {function_args} and return {tool_result[: min(100, len(tool_result))]}"
)
# Add the tool result to the list
tool_results_to_add.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"name": function_name,
"content": tool_result,
}
)
# Add all tool results to messages atomically
messages.extend(tool_results_to_add)
# Log progress
await tool_ctx.info(f"Processed {len(message.tool_calls)} tool calls. Total: {total_tool_use_count}")
except Exception as e:
await tool_ctx.error(f"Error in model call: {str(e)}")
# Avoid trying to JSON serialize message objects
await tool_ctx.error(f"Message count: {len(messages)}")
# CRITICAL FIX: Ensure we add tool_result messages for any tool_use blocks that were added
# Get the last assistant message (which has the tool_calls)
last_assistant_msg = None
for msg in reversed(messages):
if msg.get("role") == "assistant" and hasattr(msg, "tool_calls"):
last_assistant_msg = msg
break
# If there are orphaned tool calls, add error results for them
if last_assistant_msg and hasattr(last_assistant_msg, "tool_calls"):
# Count how many tool results we already have after this message
tool_results_count = 0
found_assistant = False
for msg in reversed(messages):
if msg == last_assistant_msg:
found_assistant = True
break
if found_assistant and msg.get("role") == "tool":
tool_results_count += 1
# Add error results for any missing tool calls
expected_results = len(last_assistant_msg.tool_calls)
if tool_results_count < expected_results:
for i in range(tool_results_count, expected_results):
tool_call = last_assistant_msg.tool_calls[i]
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.function.name,
"content": f"Error: Tool execution interrupted - {str(e)}",
}
)
return f"Error in agent execution: {str(e)}"
# If we've reached the limit, add a warning and get final response
if total_tool_use_count >= max_tool_uses or iteration_count >= max_iterations:
messages.append(
{
"role": "system",
"content": "You have reached the maximum iteration. Please provide your final response.",
}
)
try:
# Make a final call to get the result
final_response = litellm.completion(
model=model,
messages=messages,
temperature=params["temperature"],
timeout=params["timeout"],
max_tokens=params.get("max_tokens"),
)
return (
final_response.choices[0].message.content or "Agent reached max iteration limit without a response."
) # pyright: ignore
except Exception as e:
await tool_ctx.error(f"Error in final model call: {str(e)}")
return f"Error in final response: {str(e)}"
# Should not reach here but just in case
return "Agent execution completed after maximum iterations."
def _format_result(self, result: str, execution_time: float) -> str:
"""Format agent result with metrics.
Args:
result: Raw result from agent
execution_time: Execution time in seconds
Returns:
Formatted result with metrics
"""
return f"""Agent execution completed in {execution_time:.2f} seconds.
AGENT RESPONSE:
{result}
"""
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this agent tool with the MCP server.
Creates a wrapper function with explicitly defined parameters that match
the tool's parameter schema and registers it with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def dispatch_agent(prompts: str | list[str], ctx: MCPContext) -> str:
return await tool_self.call(ctx, prompts=prompts)
@@ -0,0 +1,225 @@
"""Clarification protocol for agent-to-mainloop communication.
This module provides a protocol for agents to request clarification
from the main loop without human intervention.
"""
import json
from enum import Enum
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
class ClarificationType(Enum):
"""Types of clarification requests."""
AMBIGUOUS_INSTRUCTION = "ambiguous_instruction"
MISSING_CONTEXT = "missing_context"
MULTIPLE_OPTIONS = "multiple_options"
CONFIRMATION_NEEDED = "confirmation_needed"
ADDITIONAL_INFO = "additional_info"
@dataclass
class ClarificationRequest:
"""A request for clarification from an agent."""
agent_id: str
request_type: ClarificationType
question: str
context: Dict[str, Any]
options: Optional[List[str]] = None
def to_json(self) -> str:
"""Convert to JSON for transport."""
return json.dumps(
{
"agent_id": self.agent_id,
"request_type": self.request_type.value,
"question": self.question,
"context": self.context,
"options": self.options,
}
)
@classmethod
def from_json(cls, data: str) -> "ClarificationRequest":
"""Create from JSON string."""
obj = json.loads(data)
return cls(
agent_id=obj["agent_id"],
request_type=ClarificationType(obj["request_type"]),
question=obj["question"],
context=obj["context"],
options=obj.get("options"),
)
@dataclass
class ClarificationResponse:
"""A response to a clarification request."""
request_id: str
answer: str
additional_context: Optional[Dict[str, Any]] = None
def to_json(self) -> str:
"""Convert to JSON for transport."""
return json.dumps(
{
"request_id": self.request_id,
"answer": self.answer,
"additional_context": self.additional_context,
}
)
class ClarificationHandler:
"""Handles clarification requests from agents."""
def __init__(self):
self.pending_requests: Dict[str, ClarificationRequest] = {}
self.request_counter = 0
def create_request(
self,
agent_id: str,
request_type: ClarificationType,
question: str,
context: Dict[str, Any],
options: Optional[List[str]] = None,
) -> str:
"""Create a new clarification request.
Returns:
Request ID for tracking
"""
request = ClarificationRequest(
agent_id=agent_id,
request_type=request_type,
question=question,
context=context,
options=options,
)
request_id = f"clarify_{self.request_counter}"
self.request_counter += 1
self.pending_requests[request_id] = request
return request_id
def handle_request(self, request: ClarificationRequest) -> ClarificationResponse:
"""Handle a clarification request automatically.
This method implements automatic clarification resolution
based on context and common patterns.
"""
request_id = f"clarify_{len(self.pending_requests)}"
# Handle different types of clarification
if request.request_type == ClarificationType.AMBIGUOUS_INSTRUCTION:
# Try to clarify based on context
if "file_path" in request.context:
if request.context["file_path"].endswith(".go"):
answer = "For Go files, ensure you add imports in the correct format and handle both single import and import block cases."
elif request.context["file_path"].endswith(".py"):
answer = "For Python files, add imports at the top of the file after any module docstring."
else:
answer = "Add imports according to the language's conventions."
else:
answer = "Proceed with the most reasonable interpretation based on the context."
elif request.request_type == ClarificationType.MISSING_CONTEXT:
# Provide additional context based on what's missing
if "import_path" in request.question.lower():
answer = "Use the standard import path based on the project structure. Check existing imports in similar files for patterns."
elif "format" in request.question.lower():
answer = "Match the existing code style in the file. Use the same indentation and formatting patterns."
else:
answer = "Analyze the surrounding code and project structure to infer the missing information."
elif request.request_type == ClarificationType.MULTIPLE_OPTIONS:
# Choose the best option based on context
if request.options:
# Simple heuristic: choose the first option that seems most standard
for option in request.options:
if "common" in option or "standard" in option:
answer = f"Choose option: {option}"
break
else:
answer = f"Choose option: {request.options[0]}"
else:
answer = "Choose the most conventional approach based on the codebase patterns."
elif request.request_type == ClarificationType.CONFIRMATION_NEEDED:
# Auto-confirm safe operations
if "add import" in request.question.lower():
answer = "Yes, proceed with adding the import."
elif "multi_edit" in request.question.lower():
answer = "Yes, use multi_edit for efficiency."
else:
answer = "Proceed if the operation is safe and reversible."
else: # ADDITIONAL_INFO
answer = "Continue with available information and make reasonable assumptions based on context."
return ClarificationResponse(
request_id=request_id,
answer=answer,
additional_context={"auto_resolved": True},
)
class AgentClarificationMixin:
"""Mixin for agents to request clarification."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.clarification_handler = ClarificationHandler()
self.clarification_count = 0
self.max_clarifications = 1 # Allow up to 1 clarification per task
async def request_clarification(
self,
request_type: ClarificationType,
question: str,
context: Dict[str, Any],
options: Optional[List[str]] = None,
) -> str:
"""Request clarification from the main loop.
Args:
request_type: Type of clarification needed
question: The question to ask
context: Relevant context for the question
options: Optional list of choices
Returns:
The clarification response
Raises:
RuntimeError: If clarification limit exceeded
"""
if self.clarification_count >= self.max_clarifications:
raise RuntimeError("Clarification limit exceeded")
self.clarification_count += 1
# Create request
request = ClarificationRequest(
agent_id=getattr(self, "agent_id", "unknown"),
request_type=request_type,
question=question,
context=context,
options=options,
)
# In real implementation, this would communicate with main loop
# For now, use the automatic handler
response = self.clarification_handler.handle_request(request)
return response.answer
def format_clarification_in_output(self, question: str, answer: str) -> str:
"""Format clarification exchange for output."""
return f"\n🤔 Clarification needed: {question}\n✅ Resolved: {answer}\n"
@@ -0,0 +1,75 @@
"""Clarification tool for agents to request information from main loop."""
from typing import Any, Dict, List, Optional, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import auto_timeout
class ClarificationTool(BaseTool):
"""Tool for agents to request clarification from the main loop."""
name = "request_clarification"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Request clarification from the main loop (not the human user).
Use this when you encounter:
- Ambiguous instructions that could be interpreted multiple ways
- Missing context needed to complete the task
- Multiple valid options where you need guidance
- Operations that need confirmation before proceeding
- Need for additional information not provided
Parameters:
- type: Type of clarification (AMBIGUOUS_INSTRUCTION, MISSING_CONTEXT, MULTIPLE_OPTIONS, CONFIRMATION_NEEDED, ADDITIONAL_INFO)
- question: Clear, specific question to ask
- context: Relevant context (e.g., file_path, current_operation, etc.)
- options: Optional list of possible choices (for MULTIPLE_OPTIONS type)
You can only use this ONCE per task, so make it count!
Example:
request_clarification(
type="MISSING_CONTEXT",
question="What is the correct import path for the common package?",
context={"file_path": "/path/to/file.go", "undefined_symbol": "common"},
options=["github.com/luxfi/node/common", "github.com/project/common"]
)"""
@auto_timeout("clarification")
async def call(
self,
ctx: MCPContext,
type: str,
question: str,
context: Dict[str, Any],
options: Optional[List[str]] = None,
) -> str:
"""Delegate to AgentTool for actual implementation.
This method provides the interface, but the actual clarification logic
is handled by the AgentTool's execution framework.
"""
# This tool is handled specially in the agent execution
return f"Clarification request: {question}"
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 request_clarification(
ctx: MCPContext,
type: str,
question: str,
context: Dict[str, Any],
options: Optional[List[str]] = None,
) -> str:
return await tool_self.call(ctx, type, question, context, options)
@@ -0,0 +1,126 @@
"""Claude Code CLI agent tool.
This tool provides integration with the Claude Code CLI (claude command),
allowing programmatic execution of Claude for code tasks.
"""
from typing import List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from .code_auth import get_latest_claude_model
from hanzo_tools.core import PermissionManager
from .cli_agent_base import CLIAgentBase
@final
class ClaudeCLITool(CLIAgentBase):
"""Tool for executing Claude Code CLI."""
def __init__(
self,
permission_manager: PermissionManager,
model: Optional[str] = None,
**kwargs,
):
"""Initialize Claude CLI tool.
Args:
permission_manager: Permission manager for access control
model: Optional model override (defaults to latest Sonnet)
**kwargs: Additional arguments
"""
super().__init__(
permission_manager=permission_manager,
command_name="claude",
provider_name="Claude Code",
default_model=model or get_latest_claude_model(),
env_vars=["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
**kwargs,
)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "claude_cli"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute Claude Code CLI for advanced code tasks.
This tool runs the Claude Code CLI (claude command) for code generation,
editing, analysis, and other programming tasks. It uses the latest
Claude 3.5 Sonnet model by default.
Features:
- Direct access to Claude's coding capabilities
- File-aware context and editing
- Interactive code generation
- Supports all Claude Code CLI features
Usage:
claude_cli(prompts="Fix the bug in main.py and add tests")
claude_cli(prompts="Refactor this class to use dependency injection", model="claude-3-opus-20240229")
Requirements:
- Claude Code CLI must be installed
- ANTHROPIC_API_KEY or CLAUDE_API_KEY environment variable
"""
@override
def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
"""Get CLI arguments for Claude.
Args:
prompt: The prompt to send
**kwargs: Additional arguments (model, temperature, etc.)
Returns:
List of command arguments
"""
args = []
# Add model if specified
model = kwargs.get("model", self.default_model)
if model:
args.extend(["--model", model])
# Add temperature if specified
if "temperature" in kwargs:
args.extend(["--temperature", str(kwargs["temperature"])])
# Add max tokens if specified
if "max_tokens" in kwargs:
args.extend(["--max-tokens", str(kwargs["max_tokens"])])
# Add the prompt
args.append(prompt)
return args
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def claude_cli(
ctx: MCPContext,
prompts: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
working_dir: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
prompts=prompts,
model=model,
temperature=temperature,
max_tokens=max_tokens,
working_dir=working_dir,
)
@@ -0,0 +1,497 @@
from hanzo_tools.core import auto_timeout
"""Claude Desktop authentication management.
This module provides tools to automate Claude Desktop login/logout,
manage separate accounts for swarm agents, and handle authentication flows.
"""
import os
import json
import time
import asyncio
import subprocess
import webbrowser
from typing import Any, Dict, Tuple, Optional
from pathlib import Path
from urllib.parse import parse_qs
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
class ClaudeDesktopAuth:
"""Manages Claude Desktop authentication."""
# Claude Desktop paths
CLAUDE_APP_MAC = "/Applications/Claude.app"
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
CLAUDE_SESSION_FILE = CLAUDE_CONFIG_DIR / "session.json"
CLAUDE_ACCOUNTS_FILE = CLAUDE_CONFIG_DIR / "accounts.json"
# Authentication endpoints
CLAUDE_LOGIN_URL = "https://claude.ai/login"
CLAUDE_API_URL = "https://api.claude.ai"
def __init__(self):
"""Initialize Claude Desktop auth manager."""
self.ensure_config_dir()
def ensure_config_dir(self):
"""Ensure Claude config directory exists."""
self.CLAUDE_CONFIG_DIR.mkdir(exist_ok=True)
def is_claude_installed(self) -> bool:
"""Check if Claude Desktop is installed."""
if os.path.exists(self.CLAUDE_APP_MAC):
return True
# Check if claude command is available
try:
result = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5)
return result.returncode == 0
except Exception:
return False
def is_logged_in(self, account: Optional[str] = None) -> bool:
"""Check if Claude Desktop is logged in.
Args:
account: Optional account identifier to check
Returns:
True if logged in
"""
if not self.CLAUDE_SESSION_FILE.exists():
return False
try:
with open(self.CLAUDE_SESSION_FILE, "r") as f:
session = json.load(f)
# Check if session is valid
if not session.get("access_token"):
return False
# Check expiry if available
if "expires_at" in session:
if time.time() > session["expires_at"]:
return False
# Check specific account if requested
if account and session.get("account") != account:
return False
return True
except Exception:
return False
def get_current_account(self) -> Optional[str]:
"""Get the currently logged in account."""
if not self.is_logged_in():
return None
try:
with open(self.CLAUDE_SESSION_FILE, "r") as f:
session = json.load(f)
return session.get("account", session.get("email"))
except Exception:
return None
async def login_interactive(self, account: Optional[str] = None, headless: bool = False) -> Tuple[bool, str]:
"""Login to Claude Desktop interactively.
Args:
account: Optional account email/identifier
headless: Whether to run in headless mode
Returns:
Tuple of (success, message)
"""
# Check if already logged in
if self.is_logged_in(account):
current = self.get_current_account()
return True, f"Already logged in as {current}"
# Start login flow
if headless:
return await self._login_headless(account)
else:
return await self._login_browser(account)
async def _login_browser(self, account: Optional[str]) -> Tuple[bool, str]:
"""Login using browser flow."""
# Generate state for OAuth-like flow
state = os.urandom(16).hex()
# Create callback server
callback_port = 9876
auth_code = None
async def handle_callback(reader, writer):
"""Handle OAuth callback."""
nonlocal auth_code
# Read request
request = await reader.read(1024)
request_str = request.decode()
# Extract code from query params
if "GET /" in request_str:
path = request_str.split(" ")[1]
if "?code=" in path:
query = path.split("?")[1]
params = parse_qs(query)
if "code" in params:
auth_code = params["code"][0]
# Send response
response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
response += b"<html><body><h1>Authentication successful!</h1>"
response += b"<p>You can close this window.</p></body></html>"
writer.write(response)
await writer.drain()
writer.close()
# Start callback server
server = await asyncio.start_server(handle_callback, "localhost", callback_port)
# Build login URL
login_url = f"{self.CLAUDE_LOGIN_URL}?callback=http://localhost:{callback_port}&state={state}"
if account:
login_url += f"&login_hint={account}"
# Open browser
print(f"Opening browser for Claude login...")
print(f"URL: {login_url}")
webbrowser.open(login_url)
# Wait for callback (timeout after 2 minutes)
try:
start_time = time.time()
while not auth_code and (time.time() - start_time) < 120:
await asyncio.sleep(0.5)
if auth_code:
# Exchange code for session
success = await self._exchange_code_for_session(auth_code, account)
if success:
return True, f"Successfully logged in as {account or 'default'}"
else:
return False, "Failed to exchange auth code for session"
else:
return False, "Login timeout - no auth code received"
finally:
server.close()
await server.wait_closed()
async def _login_headless(self, account: Optional[str]) -> Tuple[bool, str]:
"""Login in headless mode using TTY automation."""
# Headless login requires browser automation or OAuth flow
# This is not supported in CLI mode for security reasons
return False, "Headless login requires browser. Use 'claude login' with --browser flag"
async def _exchange_code_for_session(self, code: str, account: Optional[str]) -> bool:
"""Exchange auth code for session token."""
# Create a session from the OAuth code
import hashlib
# Generate a secure session token from the auth code
session_token = hashlib.sha256(f"{code}:{time.time()}".encode()).hexdigest()
session = {
"access_token": session_token,
"account": account or "default",
"email": account,
"expires_at": time.time() + 3600 * 24, # 24 hours
"created_at": time.time(),
"auth_type": "oauth",
}
try:
with open(self.CLAUDE_SESSION_FILE, "w") as f:
json.dump(session, f, indent=2)
return True
except Exception:
return False
async def logout(self, account: Optional[str] = None) -> Tuple[bool, str]:
"""Logout from Claude Desktop.
Args:
account: Optional account to logout (if multiple accounts)
Returns:
Tuple of (success, message)
"""
current = self.get_current_account()
if not current:
return True, "No active session to logout"
if account and current != account:
return False, f"Not logged in as {account} (current: {current})"
try:
# Remove session file
if self.CLAUDE_SESSION_FILE.exists():
self.CLAUDE_SESSION_FILE.unlink()
# Clear any cached credentials
self._clear_credentials_cache()
return True, f"Successfully logged out {current}"
except Exception as e:
return False, f"Logout failed: {str(e)}"
def _clear_credentials_cache(self):
"""Clear any cached credentials."""
# Clear keychain on macOS
if os.path.exists("/usr/bin/security"):
try:
subprocess.run(
[
"/usr/bin/security",
"delete-generic-password",
"-s",
"claude.ai",
"-a",
"claude-desktop",
],
capture_output=True,
timeout=10,
)
except Exception:
pass
def switch_account(self, account: str) -> Tuple[bool, str]:
"""Switch to a different Claude account.
Args:
account: Account identifier to switch to
Returns:
Tuple of (success, message)
"""
# Load accounts configuration
accounts = self._load_accounts()
if account not in accounts:
return False, f"Unknown account: {account}"
# Save current session if any
current = self.get_current_account()
if current and current != account:
self._save_session_for_account(current)
# Load session for new account
if self._load_session_for_account(account):
return True, f"Switched to account: {account}"
else:
return False, f"No saved session for account: {account}"
def _load_accounts(self) -> Dict[str, Any]:
"""Load accounts configuration."""
if not self.CLAUDE_ACCOUNTS_FILE.exists():
return {}
try:
with open(self.CLAUDE_ACCOUNTS_FILE, "r") as f:
return json.load(f)
except Exception:
return {}
def _save_accounts(self, accounts: Dict[str, Any]):
"""Save accounts configuration."""
with open(self.CLAUDE_ACCOUNTS_FILE, "w") as f:
json.dump(accounts, f, indent=2)
def _save_session_for_account(self, account: str):
"""Save current session for an account."""
if not self.CLAUDE_SESSION_FILE.exists():
return
accounts = self._load_accounts()
try:
with open(self.CLAUDE_SESSION_FILE, "r") as f:
session = json.load(f)
accounts[account] = {"session": session, "saved_at": time.time()}
self._save_accounts(accounts)
except Exception:
pass
def _load_session_for_account(self, account: str) -> bool:
"""Load saved session for an account."""
accounts = self._load_accounts()
if account not in accounts:
return False
account_data = accounts[account]
if "session" not in account_data:
return False
try:
# Restore session
session = account_data["session"]
# Update account info
session["account"] = account
with open(self.CLAUDE_SESSION_FILE, "w") as f:
json.dump(session, f, indent=2)
return True
except Exception:
return False
def create_agent_account(self, agent_id: str) -> str:
"""Create a unique account identifier for an agent.
Args:
agent_id: Unique agent identifier
Returns:
Account identifier for the agent
"""
# Generate agent-specific account
return f"agent_{agent_id}@claude.local"
async def ensure_agent_auth(self, agent_id: str, force_new: bool = False) -> Tuple[bool, str]:
"""Ensure an agent is authenticated with its own account.
Args:
agent_id: Unique agent identifier
force_new: Force new login even if cached
Returns:
Tuple of (success, message/account)
"""
agent_account = self.create_agent_account(agent_id)
# Check if agent already has a session
if not force_new and self._has_saved_session(agent_account):
# Try to switch to agent account
success, msg = self.switch_account(agent_account)
if success:
return True, agent_account
# Need to create new session for agent
# For now, we'll use the main account
# In production, this would create separate auth
current = self.get_current_account()
if current:
# Clone current session for agent
self._clone_session_for_agent(current, agent_account)
return True, agent_account
else:
return False, "No active session to clone for agent"
def _has_saved_session(self, account: str) -> bool:
"""Check if account has a saved session."""
accounts = self._load_accounts()
return account in accounts and "session" in accounts[account]
def _clone_session_for_agent(self, source: str, agent_account: str):
"""Clone a session for an agent account."""
# In a real implementation, this would create a sub-session
# or use delegation tokens
if self.CLAUDE_SESSION_FILE.exists():
try:
with open(self.CLAUDE_SESSION_FILE, "r") as f:
session = json.load(f)
# Modify for agent
session["account"] = agent_account
session["parent_account"] = source
session["is_agent"] = True
# Save as agent session
accounts = self._load_accounts()
accounts[agent_account] = {
"session": session,
"saved_at": time.time(),
"parent": source,
}
self._save_accounts(accounts)
except Exception:
pass
class ClaudeDesktopAuthTool(BaseTool):
"""Tool for managing Claude Desktop authentication."""
@property
def name(self) -> str:
return "claude_auth"
@property
def description(self) -> str:
return """Manage Claude Desktop authentication.
Actions:
- status: Check login status
- login: Login to Claude Desktop
- logout: Logout from Claude Desktop
- switch: Switch between accounts
- ensure_agent: Ensure agent has auth
Usage:
claude_auth status
claude_auth login --account [email protected]
claude_auth logout
claude_auth switch agent_1
claude_auth ensure_agent swarm_agent_1"""
def __init__(self):
"""Initialize the auth tool."""
self.auth = ClaudeDesktopAuth()
@auto_timeout("claude_desktop_auth")
async def call(self, ctx, action: str = "status", **kwargs) -> str:
"""Execute auth action."""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
if action == "status":
if self.auth.is_logged_in():
account = self.auth.get_current_account()
return f"Logged in as: {account}"
else:
return "Not logged in"
elif action == "login":
account = kwargs.get("account")
headless = kwargs.get("headless", False)
success, msg = await self.auth.login_interactive(account, headless)
return msg
elif action == "logout":
account = kwargs.get("account")
success, msg = await self.auth.logout(account)
return msg
elif action == "switch":
account = kwargs.get("account")
if not account:
return "Error: account required for switch"
success, msg = self.auth.switch_account(account)
return msg
elif action == "ensure_agent":
agent_id = kwargs.get("agent_id")
if not agent_id:
return "Error: agent_id required"
force_new = kwargs.get("force_new", False)
success, result = await self.auth.ensure_agent_auth(agent_id, force_new)
if success:
return f"Agent authenticated as: {result}"
else:
return f"Failed: {result}"
else:
return f"Unknown action: {action}"
@@ -0,0 +1,182 @@
"""Base class for CLI-based AI agent tools.
This provides common functionality for spawning CLI-based AI coding assistants
like Claude Code, OpenAI Codex, Google Gemini, and Grok.
"""
import os
import shutil
import asyncio
import tempfile
from abc import abstractmethod
from typing import List, Optional
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
class CLIAgentBase(BaseTool):
"""Base class for CLI-based AI agent tools."""
def __init__(
self,
permission_manager: PermissionManager,
command_name: str,
provider_name: str,
default_model: Optional[str] = None,
env_vars: Optional[List[str]] = None,
**kwargs,
):
"""Initialize CLI agent base.
Args:
permission_manager: Permission manager for access control
command_name: The CLI command name (e.g., 'claude', 'openai')
provider_name: The provider name (e.g., 'Claude', 'OpenAI')
default_model: Default model to use
env_vars: List of environment variables to check for API keys
**kwargs: Additional arguments
"""
self.permission_manager = permission_manager
self.command_name = command_name
self.provider_name = provider_name
self.default_model = default_model
self.env_vars = env_vars or []
def is_installed(self) -> bool:
"""Check if the CLI tool is installed."""
return shutil.which(self.command_name) is not None
def has_api_key(self) -> bool:
"""Check if API key is available in environment."""
if not self.env_vars:
return True # No API key needed
for var in self.env_vars:
if os.environ.get(var):
return True
return False
@abstractmethod
def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
"""Get CLI arguments for the specific tool.
Args:
prompt: The prompt to send
**kwargs: Additional arguments
Returns:
List of command arguments
"""
pass
async def execute_cli(
self,
ctx: MCPContext,
prompt: str,
working_dir: Optional[str] = None,
timeout: int = 300,
**kwargs,
) -> str:
"""Execute the CLI command.
Args:
ctx: MCP context
prompt: The prompt to send
working_dir: Working directory for the command
timeout: Command timeout in seconds
**kwargs: Additional arguments
Returns:
Command output
"""
tool_ctx = create_tool_context(ctx)
# Check if installed
if not self.is_installed():
error_msg = f"{self.provider_name} CLI ({self.command_name}) is not installed. "
error_msg += f"Please install it first: https://github.com/anthropics/{self.command_name}"
await tool_ctx.error(error_msg)
return f"Error: {error_msg}"
# Check API key if needed
if not self.has_api_key():
error_msg = f"No API key found for {self.provider_name}. "
error_msg += f"Set one of: {', '.join(self.env_vars)}"
await tool_ctx.error(error_msg)
return f"Error: {error_msg}"
# Get command arguments
cli_args = self.get_cli_args(prompt, **kwargs)
# Log command
await tool_ctx.info(f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}...")
try:
# Create temp file for prompt if needed
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write(prompt)
prompt_file = f.name
# Some CLIs might need the prompt via stdin or file
if "--prompt-file" in cli_args:
# Replace placeholder with actual file
cli_args = [
(arg.replace("--prompt-file", prompt_file) if arg == "--prompt-file" else arg) for arg in cli_args
]
# Execute command
process = await asyncio.create_subprocess_exec(
self.command_name,
*cli_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
cwd=working_dir or os.getcwd(),
)
# Send prompt via stdin if not using file
if "--prompt-file" not in cli_args:
stdout, stderr = await asyncio.wait_for(process.communicate(input=prompt.encode()), timeout=timeout)
else:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
# Clean up temp file
try:
os.unlink(prompt_file)
except Exception:
pass
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
await tool_ctx.error(f"{self.provider_name} failed: {error_msg}")
return f"Error: {error_msg}"
result = stdout.decode()
await tool_ctx.info(f"{self.provider_name} completed successfully")
return result
except asyncio.TimeoutError:
await tool_ctx.error(f"{self.provider_name} timed out after {timeout} seconds")
return f"Error: Command timed out after {timeout} seconds"
except Exception as e:
await tool_ctx.error(f"{self.provider_name} error: {str(e)}")
return f"Error: {str(e)}"
@auto_timeout("cli_agent_base")
async def call(self, ctx: MCPContext, prompts: str, **kwargs) -> str:
"""Execute the CLI agent.
Args:
ctx: MCP context
prompts: The prompt(s) to send
**kwargs: Additional arguments
Returns:
Agent response
"""
return await self.execute_cli(ctx, prompts, **kwargs)
@@ -0,0 +1,553 @@
"""CLI tool implementations for direct batch execution.
This module provides CLI tool wrappers that can be used directly in batch operations,
including claude (cc), codex, gemini, grok, openhands (oh), hanzo-dev, cline, and aider.
"""
from __future__ import annotations
import os
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 import FastMCP
from mcp.server.fastmcp import Context
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
# Parameter types for CLI tools
Prompt = Annotated[
str,
Field(
description="The prompt or command to send to the CLI tool",
min_length=1,
),
]
Model = Annotated[
Optional[str],
Field(
description="Optional model override for the CLI tool",
default=None,
),
]
WorkingDir = Annotated[
Optional[str],
Field(
description="Working directory for the command",
default=None,
),
]
Timeout = Annotated[
Optional[int],
Field(
description="Timeout in seconds for the command",
default=300, # 5 minutes default
),
]
class CLIToolParams(TypedDict, total=False):
"""Common parameters for CLI tools."""
prompt: str
model: Optional[str]
working_dir: Optional[str]
timeout: Optional[int]
class BaseCLITool(BaseTool):
"""Base class for CLI tool implementations."""
def __init__(
self,
permission_manager: Optional[PermissionManager] = None,
default_model: Optional[str] = None,
api_key_env: Optional[str] = None,
):
"""Initialize CLI tool.
Args:
permission_manager: Permission manager for access control
default_model: Default model to use
api_key_env: Environment variable name for API key
"""
self.permission_manager = permission_manager
self.default_model = default_model
self.api_key_env = api_key_env
def get_auth_env(self) -> dict[str, str]:
"""Get authentication environment variables."""
env = os.environ.copy()
# Add API key if configured
if self.api_key_env and self.api_key_env in os.environ:
env[self.api_key_env] = os.environ[self.api_key_env]
# Add Hanzo API key for unified auth
if "HANZO_API_KEY" in os.environ:
env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
return env
async def execute_cli(
self,
command: list[str],
input_text: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
"""Execute CLI command with proper error handling.
Args:
command: Command and arguments
input_text: Optional stdin input
working_dir: Working directory
timeout: Timeout in seconds
Returns:
Command output
"""
try:
# Set up environment with auth
env = self.get_auth_env()
# Execute command
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE if input_text else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
# Send input and get output
stdout, stderr = await asyncio.wait_for(
process.communicate(input_text.encode() if input_text else None),
timeout=timeout,
)
# Check for errors
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
return f"Error: {error_msg}"
return stdout.decode()
except asyncio.TimeoutError:
return f"Error: Command timed out after {timeout} seconds"
except Exception as e:
return f"Error executing command: {str(e)}"
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def tool_wrapper(
prompt: str,
ctx: Context[Any, Any, Any],
model: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
result: str = await tool_self.call(
ctx, prompt=prompt, model=model, working_dir=working_dir, timeout=timeout
)
return result
class ClaudeCLITool(BaseCLITool):
"""Claude CLI tool (also available as 'cc' alias)."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="ANTHROPIC_API_KEY",
)
@property
def name(self) -> str:
return "claude"
@property
def description(self) -> str:
return "Execute Claude CLI for AI assistance using Anthropic's models"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["claude"]
if model:
command.extend(["--model", model])
# Execute
return await self.execute_cli(
command,
input_text=prompt,
working_dir=working_dir,
timeout=timeout,
)
class ClaudeCodeCLITool(ClaudeCLITool):
"""Claude Code CLI tool (cc alias)."""
@property
def name(self) -> str:
return "cc"
@property
def description(self) -> str:
return "Claude Code CLI (alias for claude)"
class CodexCLITool(BaseCLITool):
"""OpenAI Codex/GPT-4 CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gpt-4-turbo",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "codex"
@property
def description(self) -> str:
return "Execute OpenAI Codex/GPT-4 CLI for code generation and AI assistance"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command (using openai CLI or custom wrapper)
command: list[str] = ["openai", "api", "chat.completions.create"]
if model:
command.extend(["-m", model])
command.extend(["-g", "user", prompt])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class GeminiCLITool(BaseCLITool):
"""Google Gemini CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gemini-1.5-pro",
api_key_env="GEMINI_API_KEY",
)
@property
def name(self) -> str:
return "gemini"
@property
def description(self) -> str:
return "Execute Google Gemini CLI for multimodal AI assistance"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["gemini"]
if model:
command.extend(["--model", model])
command.append(prompt)
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class GrokCLITool(BaseCLITool):
"""xAI Grok CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="grok-4",
api_key_env="XAI_API_KEY",
)
@property
def name(self) -> str:
return "grok"
@property
def description(self) -> str:
return "Execute xAI Grok CLI for real-time AI assistance"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["grok"]
if model:
command.extend(["--model", model])
command.append(prompt)
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class OpenHandsCLITool(BaseCLITool):
"""OpenHands (OpenDevin) CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "openhands"
@property
def description(self) -> str:
return "Execute OpenHands (OpenDevin) for autonomous coding assistance"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600) # 10 minutes for OpenHands
# Build command
command: list[str] = ["openhands", "run", prompt]
if model:
command.extend(["--model", model])
command.extend(["--workspace", working_dir])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class OpenHandsShortCLITool(OpenHandsCLITool):
"""OpenHands CLI tool (oh alias)."""
@property
def name(self) -> str:
return "oh"
@property
def description(self) -> str:
return "OpenHands CLI (alias for openhands)"
class HanzoDevCLITool(BaseCLITool):
"""Hanzo Dev AI coding assistant."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="HANZO_API_KEY",
)
@property
def name(self) -> str:
return "hanzo_dev"
@property
def description(self) -> str:
return "Execute Hanzo Dev for AI-powered code editing and development"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["dev"]
if model:
command.extend(["--model", model])
command.extend(["--prompt", prompt])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class ClineCLITool(BaseCLITool):
"""Cline (formerly Claude Engineer) CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="ANTHROPIC_API_KEY",
)
@property
def name(self) -> str:
return "cline"
@property
def description(self) -> str:
return "Execute Cline for autonomous coding with Claude"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["cline", prompt]
command.extend(["--no-interactive"]) # Non-interactive mode for batch
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class AiderCLITool(BaseCLITool):
"""Aider AI pair programming tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gpt-4-turbo",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "aider"
@property
def description(self) -> str:
return "Execute Aider for AI pair programming"
@auto_timeout("cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["aider"]
if model:
command.extend(["--model", model])
command.extend(["--message", prompt])
command.extend(["--yes"]) # Auto-approve changes
command.extend(["--no-stream"]) # No streaming for batch
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
def register_cli_tools(
mcp_server: FastMCP,
permission_manager: Optional[PermissionManager] = None,
) -> list[BaseTool]:
"""Register all CLI tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
Returns:
List of registered CLI tools
"""
tools: list[BaseTool] = [
ClaudeCLITool(permission_manager),
ClaudeCodeCLITool(permission_manager), # cc alias
CodexCLITool(permission_manager),
GeminiCLITool(permission_manager),
GrokCLITool(permission_manager),
OpenHandsCLITool(permission_manager),
OpenHandsShortCLITool(permission_manager), # oh alias
HanzoDevCLITool(permission_manager),
ClineCLITool(permission_manager),
AiderCLITool(permission_manager),
]
# Register each tool
for tool in tools:
tool.register(mcp_server)
return tools
# Export all CLI tool classes
__all__ = [
"ClaudeCLITool",
"ClaudeCodeCLITool",
"CodexCLITool",
"GeminiCLITool",
"GrokCLITool",
"OpenHandsCLITool",
"OpenHandsShortCLITool",
"HanzoDevCLITool",
"ClineCLITool",
"AiderCLITool",
"register_cli_tools",
]
@@ -0,0 +1,429 @@
"""Claude Code and OpenAI Codex authentication management.
This module provides tools to manage API keys and authentication for
Claude Code CLI and OpenAI Codex, allowing separate accounts for swarm agents.
"""
import os
import json
import getpass
import subprocess
from typing import Any, Dict, List, Tuple, Optional
from pathlib import Path
from dataclasses import dataclass
import keyring
@dataclass
class APICredential:
"""API credential information."""
provider: str
api_key: str
model: Optional[str] = None
base_url: Optional[str] = None
org_id: Optional[str] = None
description: Optional[str] = None
class CodeAuthManager:
"""Manages authentication for Claude Code and other AI coding tools."""
# Configuration paths
CONFIG_DIR = Path.home() / ".hanzo" / "auth"
ACCOUNTS_FILE = CONFIG_DIR / "accounts.json"
ACTIVE_ACCOUNT_FILE = CONFIG_DIR / "active_account"
# Environment variable mappings
ENV_VARS = {
"claude": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
"openai": ["OPENAI_API_KEY"],
"azure": ["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
"deepseek": ["DEEPSEEK_API_KEY"],
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"groq": ["GROQ_API_KEY"],
}
# Default models
DEFAULT_MODELS = {
"claude": "claude-3-5-sonnet-20241022", # Latest Sonnet
"openai": "gpt-4o",
"azure": "gpt-4",
"deepseek": "deepseek-coder",
"google": "gemini-1.5-pro",
"groq": "llama3-70b-8192",
}
def __init__(self):
"""Initialize auth manager."""
self.ensure_config_dir()
self._env_backup = {}
def ensure_config_dir(self):
"""Ensure config directory exists."""
self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def get_active_account(self) -> Optional[str]:
"""Get the currently active account."""
if self.ACTIVE_ACCOUNT_FILE.exists():
return self.ACTIVE_ACCOUNT_FILE.read_text().strip()
return "default"
def set_active_account(self, account: str):
"""Set the active account."""
self.ACTIVE_ACCOUNT_FILE.write_text(account)
def _load_accounts(self) -> Dict[str, Dict[str, Any]]:
"""Load all accounts."""
if not self.ACCOUNTS_FILE.exists():
return {}
try:
with open(self.ACCOUNTS_FILE, "r") as f:
return json.load(f)
except Exception:
return {}
def _save_accounts(self, accounts: Dict[str, Dict[str, Any]]):
"""Save accounts."""
with open(self.ACCOUNTS_FILE, "w") as f:
json.dump(accounts, f, indent=2)
def list_accounts(self) -> List[str]:
"""List all available accounts."""
accounts = self._load_accounts()
return list(accounts.keys())
def get_account_info(self, account: str) -> Optional[Dict[str, Any]]:
"""Get information about an account."""
accounts = self._load_accounts()
return accounts.get(account)
def create_account(
self,
account: str,
provider: str = "claude",
api_key: Optional[str] = None,
model: Optional[str] = None,
description: Optional[str] = None,
) -> Tuple[bool, str]:
"""Create a new account.
Args:
account: Account name
provider: Provider (claude, openai, etc.)
api_key: API key (will prompt if not provided)
model: Model to use (defaults to provider default)
description: Account description
Returns:
Tuple of (success, message)
"""
accounts = self._load_accounts()
if account in accounts:
return False, f"Account '{account}' already exists"
# Get API key if not provided
if not api_key:
api_key = self._prompt_for_api_key(provider)
if not api_key:
return False, "No API key provided"
# Use default model if not specified
if not model:
model = self.DEFAULT_MODELS.get(provider)
# Store in keyring for security
try:
keyring.set_password(f"hanzo-{provider}", account, api_key)
except Exception:
# Fallback to file storage (less secure)
pass
# Save account info
accounts[account] = {
"provider": provider,
"model": model,
"description": description or f"{provider} account",
"created_at": os.path.getmtime(__file__),
"has_keyring": self._has_keyring_support(),
}
self._save_accounts(accounts)
return True, f"Created account '{account}' for {provider}"
def _prompt_for_api_key(self, provider: str) -> Optional[str]:
"""Prompt user for API key."""
prompt = f"Enter {provider.upper()} API key: "
try:
return getpass.getpass(prompt)
except KeyboardInterrupt:
return None
def _has_keyring_support(self) -> bool:
"""Check if keyring is available."""
try:
keyring.get_keyring()
return True
except Exception:
return False
def login(self, account: str = "default") -> Tuple[bool, str]:
"""Login to an account by setting environment variables.
Args:
account: Account name to login to
Returns:
Tuple of (success, message)
"""
accounts = self._load_accounts()
if account not in accounts:
return False, f"Account '{account}' not found"
account_info = accounts[account]
provider = account_info["provider"]
# Get API key from keyring or prompt
api_key = None
if account_info.get("has_keyring"):
try:
api_key = keyring.get_password(f"hanzo-{provider}", account)
except Exception:
pass
if not api_key:
# Try environment variable
for env_var in self.ENV_VARS.get(provider, []):
if env_var in os.environ:
api_key = os.environ[env_var]
break
if not api_key:
api_key = self._prompt_for_api_key(provider)
if not api_key:
return False, "No API key available"
# Backup current environment
self._backup_environment(provider)
# Set environment variables
for env_var in self.ENV_VARS.get(provider, []):
os.environ[env_var] = api_key
# Set active account
self.set_active_account(account)
# Update shell if using claude command
self._update_claude_command(account_info)
return True, f"Logged in as '{account}' ({provider})"
def logout(self) -> Tuple[bool, str]:
"""Logout by clearing environment variables."""
current = self.get_active_account()
if not current or current == "default":
return False, "No active session"
accounts = self._load_accounts()
if current not in accounts:
return False, f"Unknown account: {current}"
provider = accounts[current]["provider"]
# Clear environment variables
for env_var in self.ENV_VARS.get(provider, []):
if env_var in os.environ:
del os.environ[env_var]
# Restore backed up environment if any
self._restore_environment(provider)
# Clear active account
if self.ACTIVE_ACCOUNT_FILE.exists():
self.ACTIVE_ACCOUNT_FILE.unlink()
return True, f"Logged out from '{current}'"
def _backup_environment(self, provider: str):
"""Backup current environment variables."""
for env_var in self.ENV_VARS.get(provider, []):
if env_var in os.environ:
self._env_backup[env_var] = os.environ[env_var]
def _restore_environment(self, provider: str):
"""Restore backed up environment variables."""
for env_var in self.ENV_VARS.get(provider, []):
if env_var in self._env_backup:
os.environ[env_var] = self._env_backup[env_var]
del self._env_backup[env_var]
def _update_claude_command(self, account_info: Dict[str, Any]):
"""Update claude command configuration if needed."""
# Check if claude command exists
try:
result = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
# Claude command exists, update its config
claude_config = Path.home() / ".claude" / "config.json"
if claude_config.exists():
try:
with open(claude_config, "r") as f:
config = json.load(f)
# Update model if specified
if account_info.get("model"):
config["default_model"] = account_info["model"]
with open(claude_config, "w") as f:
json.dump(config, f, indent=2)
except Exception:
pass
except Exception:
pass
def switch_account(self, account: str) -> Tuple[bool, str]:
"""Switch to a different account."""
# Logout current
self.logout()
# Login to new account
return self.login(account)
def create_agent_account(
self,
agent_id: str,
provider: str = "claude",
parent_account: Optional[str] = None,
) -> Tuple[bool, str]:
"""Create an account for a swarm agent.
Args:
agent_id: Unique agent identifier
provider: AI provider
parent_account: Parent account to clone from
Returns:
Tuple of (success, account_name)
"""
agent_account = f"agent_{agent_id}"
# If parent account specified, clone its credentials
if parent_account:
parent_info = self.get_account_info(parent_account)
if not parent_info:
return False, f"Parent account '{parent_account}' not found"
# Get parent API key
api_key = None
if parent_info.get("has_keyring"):
try:
api_key = keyring.get_password(f"hanzo-{parent_info['provider']}", parent_account)
except Exception:
pass
if api_key:
success, msg = self.create_account(
agent_account,
provider=parent_info["provider"],
api_key=api_key,
model=parent_info.get("model"),
description=f"Agent account (parent: {parent_account})",
)
if success:
return True, agent_account
# Create with current environment
for env_var in self.ENV_VARS.get(provider, []):
if env_var in os.environ:
success, msg = self.create_account(
agent_account,
provider=provider,
api_key=os.environ[env_var],
model=self.DEFAULT_MODELS.get(provider),
description=f"Agent account for {agent_id}",
)
if success:
return True, agent_account
return False, "No credentials available for agent"
def get_agent_credentials(self, agent_id: str) -> Optional[APICredential]:
"""Get credentials for an agent.
Args:
agent_id: Agent identifier
Returns:
APICredential if found
"""
agent_account = f"agent_{agent_id}"
account_info = self.get_account_info(agent_account)
if not account_info:
return None
# Get API key
api_key = None
provider = account_info["provider"]
if account_info.get("has_keyring"):
try:
api_key = keyring.get_password(f"hanzo-{provider}", agent_account)
except Exception:
pass
if not api_key:
# Try current environment
for env_var in self.ENV_VARS.get(provider, []):
if env_var in os.environ:
api_key = os.environ[env_var]
break
if not api_key:
return None
return APICredential(
provider=provider,
api_key=api_key,
model=account_info.get("model"),
description=account_info.get("description"),
)
# Update swarm tool to use latest Sonnet
def get_latest_claude_model() -> str:
"""Get the latest Claude model identifier."""
# As of the knowledge cutoff, this is the latest Sonnet
# In production, this could query an API for the latest model
return "claude-3-5-sonnet-20241022"
# Token counting using tiktoken (same as current implementation)
def count_tokens_streaming(text_stream) -> int:
"""Count tokens in a streaming fashion.
This uses the same tiktoken approach as the truncate module,
but processes text as it streams.
"""
import tiktoken
try:
# Use cl100k_base encoding (Claude/GPT-4 compatible)
encoding = tiktoken.get_encoding("cl100k_base")
except Exception:
# Fallback to simple estimation
return len(text_stream) // 4
total_tokens = 0
for chunk in text_stream:
if isinstance(chunk, str):
total_tokens += len(encoding.encode(chunk))
elif isinstance(chunk, bytes):
total_tokens += len(encoding.encode(chunk.decode("utf-8", errors="ignore")))
return total_tokens
@@ -0,0 +1,193 @@
"""Claude Code authentication tool.
This tool manages API keys and accounts for Claude Code and other AI coding tools.
"""
from typing import Unpack, Optional, TypedDict, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from .code_auth import CodeAuthManager
from hanzo_tools.core import auto_timeout
class CodeAuthParams(TypedDict, total=False):
"""Parameters for code auth tool."""
action: str
account: Optional[str]
provider: Optional[str]
api_key: Optional[str]
model: Optional[str]
description: Optional[str]
agent_id: Optional[str]
parent_account: Optional[str]
@final
class CodeAuthTool(BaseTool):
"""Tool for managing Claude Code authentication and API keys."""
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "code_auth"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Manage Claude Code and AI provider authentication.
Actions:
- status: Show current login status
- list: List all accounts
- create: Create a new account
- login: Login to an account
- logout: Logout current account
- switch: Switch between accounts
- agent: Create/get agent account
Examples:
code_auth status
code_auth list
code_auth create --account work --provider claude
code_auth login --account work
code_auth logout
code_auth switch --account personal
code_auth agent --agent_id swarm_1 --parent_account work
Providers: claude, openai, azure, deepseek, google, groq"""
def __init__(self):
"""Initialize the code auth tool."""
self.auth_manager = CodeAuthManager()
@override
@auto_timeout("code_auth")
async def call(
self,
ctx: MCPContext,
**params: Unpack[CodeAuthParams],
) -> str:
"""Execute the code auth tool.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
action = params.get("action", "status")
if action == "status":
current = self.auth_manager.get_active_account()
if current:
info = self.auth_manager.get_account_info(current)
if info:
return f"Logged in as: {current} ({info['provider']})"
return "Not logged in"
elif action == "list":
accounts = self.auth_manager.list_accounts()
if not accounts:
return "No accounts configured"
current = self.auth_manager.get_active_account()
lines = ["Configured accounts:"]
for account in accounts:
info = self.auth_manager.get_account_info(account)
marker = " (active)" if account == current else ""
lines.append(f" - {account}: {info['provider']}{marker}")
return "\n".join(lines)
elif action == "create":
account = params.get("account")
if not account:
return "Error: account name required"
provider = params.get("provider", "claude")
api_key = params.get("api_key")
model = params.get("model")
description = params.get("description")
success, msg = self.auth_manager.create_account(account, provider, api_key, model, description)
return msg
elif action == "login":
account = params.get("account", "default")
success, msg = self.auth_manager.login(account)
return msg
elif action == "logout":
success, msg = self.auth_manager.logout()
return msg
elif action == "switch":
account = params.get("account")
if not account:
return "Error: account name required"
success, msg = self.auth_manager.switch_account(account)
return msg
elif action == "agent":
agent_id = params.get("agent_id")
if not agent_id:
return "Error: agent_id required"
provider = params.get("provider", "claude")
parent_account = params.get("parent_account")
# Try to create agent account
success, result = self.auth_manager.create_agent_account(agent_id, provider, parent_account)
if success:
# Get credentials
creds = self.auth_manager.get_agent_credentials(agent_id)
if creds:
return f"Agent account ready: {result} ({creds.provider})"
else:
return f"Agent account created but no credentials: {result}"
else:
return f"Failed to create agent account: {result}"
else:
return f"Unknown action: {action}. Use: status, list, create, login, logout, switch, agent"
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def code_auth(
ctx: MCPContext,
action: str = "status",
account: Optional[str] = None,
provider: Optional[str] = None,
api_key: Optional[str] = None,
model: Optional[str] = None,
description: Optional[str] = None,
agent_id: Optional[str] = None,
parent_account: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
action=action,
account=account,
provider=provider,
api_key=api_key,
model=model,
description=description,
agent_id=agent_id,
parent_account=parent_account,
)
@@ -0,0 +1,124 @@
"""OpenAI Codex CLI agent tool.
This tool provides integration with OpenAI's CLI (openai command),
allowing programmatic execution of GPT-4 and other models for code tasks.
"""
from typing import List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import PermissionManager
from .cli_agent_base import CLIAgentBase
@final
class CodexCLITool(CLIAgentBase):
"""Tool for executing OpenAI CLI (formerly Codex)."""
def __init__(
self,
permission_manager: PermissionManager,
model: Optional[str] = None,
**kwargs,
):
"""Initialize Codex CLI tool.
Args:
permission_manager: Permission manager for access control
model: Optional model override (defaults to gpt-4o)
**kwargs: Additional arguments
"""
super().__init__(
permission_manager=permission_manager,
command_name="openai",
provider_name="OpenAI",
default_model=model or "gpt-4o",
env_vars=["OPENAI_API_KEY"],
**kwargs,
)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "codex_cli"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute OpenAI CLI for code generation and analysis.
This tool runs the OpenAI CLI (openai command) for code generation,
completion, and analysis tasks. It uses GPT-4o by default but supports
all OpenAI models.
Features:
- GPT-4 and GPT-4o for advanced reasoning
- Code generation and completion
- Multi-modal support (with gpt-4-vision)
- Function calling capabilities
Usage:
codex_cli(prompts="Generate a Python function to sort a binary tree")
codex_cli(prompts="Explain this code and suggest improvements", model="gpt-4-turbo")
Requirements:
- OpenAI CLI must be installed: pip install openai
- OPENAI_API_KEY environment variable
"""
@override
def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
"""Get CLI arguments for OpenAI.
Args:
prompt: The prompt to send
**kwargs: Additional arguments (model, temperature, etc.)
Returns:
List of command arguments
"""
args = ["api", "chat.completions.create"]
# Add model
model = kwargs.get("model", self.default_model)
args.extend(["-m", model])
# Add temperature if specified
if "temperature" in kwargs:
args.extend(["--temperature", str(kwargs["temperature"])])
# Add max tokens if specified
if "max_tokens" in kwargs:
args.extend(["--max-tokens", str(kwargs["max_tokens"])])
# Add the prompt as a message
args.extend(["-g", prompt])
return args
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def codex_cli(
ctx: MCPContext,
prompts: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
working_dir: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
prompts=prompts,
model=model,
temperature=temperature,
max_tokens=max_tokens,
working_dir=working_dir,
)
@@ -0,0 +1,379 @@
"""Critic tool for agents to request critical review from main loop."""
from enum import Enum
from typing import List, Optional, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import auto_timeout
class ReviewType(Enum):
"""Types of review requests."""
CODE_QUALITY = "code_quality"
CORRECTNESS = "correctness"
PERFORMANCE = "performance"
SECURITY = "security"
COMPLETENESS = "completeness"
BEST_PRACTICES = "best_practices"
GENERAL = "general"
class CriticTool(BaseTool):
"""Tool for agents to request critical review from the main loop."""
name = "critic"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Request critical review and feedback from the main loop.
Use this tool to get automated critical analysis of your work. The main loop will:
- Review your implementation for bugs, edge cases, and improvements
- Check for security issues and best practices
- Suggest performance optimizations
- Ensure completeness and correctness
- Provide actionable feedback for improvements
Parameters:
- review_type: Type of review (CODE_QUALITY, CORRECTNESS, PERFORMANCE, SECURITY, COMPLETENESS, BEST_PRACTICES, GENERAL)
- work_description: Clear description of what you've done
- code_snippets: Optional code snippets to review (as a list of strings)
- file_paths: Optional list of file paths you've modified
- specific_concerns: Optional specific areas you want reviewed
The critic will provide harsh but constructive feedback to ensure high quality.
Example:
critic(
review_type="CODE_QUALITY",
work_description="Added import statements to fix undefined symbols in Go files",
code_snippets=["import (\n \"fmt\"\n \"github.com/luxfi/node/common\"\n)"],
file_paths=["/path/to/atomic.go", "/path/to/network.go"],
specific_concerns="Are the imports in the correct format and location?"
)"""
@auto_timeout("critic")
async def call(
self,
ctx: MCPContext,
review_type: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
specific_concerns: Optional[str] = None,
) -> str:
"""Delegate to AgentTool for actual implementation.
This method provides the interface, but the actual critic logic
is handled by the AgentTool's execution framework.
"""
# This tool is handled specially in the agent execution
return f"Critic review requested for: {work_description}"
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 critic(
ctx: MCPContext,
review_type: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
specific_concerns: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
review_type,
work_description,
code_snippets,
file_paths,
specific_concerns,
)
class AutoCritic:
"""Automated critic that provides harsh but constructive feedback."""
def __init__(self):
self.review_patterns = {
ReviewType.CODE_QUALITY: self._review_code_quality,
ReviewType.CORRECTNESS: self._review_correctness,
ReviewType.PERFORMANCE: self._review_performance,
ReviewType.SECURITY: self._review_security,
ReviewType.COMPLETENESS: self._review_completeness,
ReviewType.BEST_PRACTICES: self._review_best_practices,
ReviewType.GENERAL: self._review_general,
}
def review(
self,
review_type: ReviewType,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
specific_concerns: Optional[str] = None,
) -> str:
"""Perform automated critical review."""
review_func = self.review_patterns.get(review_type, self._review_general)
return review_func(work_description, code_snippets, file_paths, specific_concerns)
def _review_code_quality(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review code quality aspects."""
issues = []
suggestions = []
# Check for common code quality issues
if code_snippets:
for snippet in code_snippets:
# Check for proper error handling
if "error" in snippet.lower() and "if err" not in snippet:
issues.append("❌ Missing error handling - always check errors in Go")
# Check for magic numbers
if any(char.isdigit() for char in snippet) and "const" not in snippet:
suggestions.append("💡 Consider extracting magic numbers to named constants")
# Check for proper imports
if "import" in snippet:
if '"fmt"' in snippet and snippet.count("fmt.") == 0:
issues.append("❌ Unused import 'fmt' - remove unused imports")
if not snippet.strip().endswith(")") and "import (" in snippet:
issues.append("❌ Import block not properly closed")
# General quality checks
if "fix" in work_description.lower():
suggestions.append("💡 Ensure you've tested the fix thoroughly")
suggestions.append("💡 Consider edge cases and error scenarios")
if file_paths and len(file_paths) > 5:
suggestions.append("💡 Large number of files modified - consider breaking into smaller PRs")
# Build response
response = "🔍 CODE QUALITY REVIEW:\n\n"
if issues:
response += "Issues Found:\n" + "\n".join(issues) + "\n\n"
else:
response += "✅ No major code quality issues detected.\n\n"
if suggestions:
response += "Suggestions for Improvement:\n" + "\n".join(suggestions) + "\n\n"
if specific_concerns:
response += f"Regarding your concern: '{specific_concerns}'\n"
if "import" in specific_concerns.lower():
response += "→ Imports look properly formatted. Ensure they're in the standard order: stdlib, external, internal.\n"
response += "\nOverall: " + (
"⚠️ Address the issues before proceeding." if issues else "✅ Good work, but always room for improvement!"
)
return response
def _review_correctness(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review correctness aspects."""
return """🔍 CORRECTNESS REVIEW:
Critical Questions:
❓ Have you verified the changes compile without errors?
❓ Do the changes actually fix the reported issue?
❓ Have you introduced any new bugs or regressions?
❓ Are all edge cases handled properly?
Specific Checks:
- If fixing imports: Verify the import paths are correct for the project
- If modifying logic: Ensure the logic is sound and handles all cases
- If refactoring: Confirm behavior is preserved
⚠️ Remember: Working code > elegant code. Make sure it works first!"""
def _review_performance(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review performance aspects."""
return """🔍 PERFORMANCE REVIEW:
Performance Considerations:
- Are you doing any operations in loops that could be moved outside?
- Are there any unnecessary allocations or copies?
- Could any synchronous operations be made concurrent?
- Are you caching results that might be reused?
For file operations:
- Consider batch operations over individual ones
- Use buffered I/O for large files
- Avoid reading entire files into memory if possible
💡 Remember: Premature optimization is evil, but obvious inefficiencies should be fixed."""
def _review_security(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review security aspects."""
return """🔍 SECURITY REVIEW:
Security Checklist:
🔐 No hardcoded secrets or credentials
🔐 All user inputs are validated/sanitized
🔐 File paths are properly validated
🔐 No SQL injection vulnerabilities
🔐 Proper access control checks
🔐 Sensitive data is not logged
⚠️ If in doubt, err on the side of caution!"""
def _review_completeness(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review completeness aspects."""
tasks_mentioned = work_description.lower()
response = "🔍 COMPLETENESS REVIEW:\n\n"
if "fix" in tasks_mentioned and "test" not in tasks_mentioned:
response += "❌ No mention of tests - have you verified the fix with tests?\n"
if "import" in tasks_mentioned:
response += "✓ Import fixes mentioned\n"
response += "❓ Have you checked for other files with similar issues?\n"
response += "❓ Are all undefined symbols now resolved?\n"
if file_paths:
response += f"\n✓ Modified {len(file_paths)} files\n"
response += "❓ Are there any related files that also need updates?\n"
response += "\n💡 Completeness means not just fixing the immediate issue, but considering the broader impact."
return response
def _review_best_practices(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""Review best practices."""
return """🔍 BEST PRACTICES REVIEW:
Go Best Practices (if applicable):
✓ Imports are grouped: stdlib, external, internal
✓ Error handling follows Go idioms
✓ Variable names are clear and idiomatic
✓ Comments explain why, not what
✓ Functions do one thing well
General Best Practices:
✓ Code is self-documenting
✓ DRY principle is followed
✓ SOLID principles are respected
✓ Changes are minimal and focused
💡 Good code is code that others (including future you) can understand and modify."""
def _review_general(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
specific_concerns: Optional[str],
) -> str:
"""General review covering multiple aspects."""
response = "🔍 GENERAL CRITICAL REVIEW:\n\n"
response += f"Work Description: {work_description}\n\n"
# Quick assessment
response += "Quick Assessment:\n"
if "fix" in work_description.lower():
response += "- Type: Bug fix / Error resolution\n"
response += "- Critical: Ensure the fix is complete and tested\n"
elif "add" in work_description.lower():
response += "- Type: Feature addition\n"
response += "- Critical: Ensure no regressions introduced\n"
elif "refactor" in work_description.lower():
response += "- Type: Code refactoring\n"
response += "- Critical: Ensure behavior is preserved\n"
if file_paths:
response += f"- Scope: {len(file_paths)} files affected\n"
if len(file_paths) > 10:
response += "- ⚠️ Large scope - consider breaking down\n"
response += "\nCritical Questions:\n"
response += "1. Is this the minimal change needed?\n"
response += "2. Have you considered all edge cases?\n"
response += "3. Will this work in production?\n"
response += "4. Is there a simpler solution?\n"
if specific_concerns:
response += f"\nYour Concern: {specific_concerns}\n"
response += "→ Valid concern. Double-check this area carefully.\n"
response += "\n🎯 Bottom Line: Good work needs critical thinking. Question everything, verify everything."
return response
class CriticProtocol:
"""Protocol for critic interactions."""
def __init__(self):
self.auto_critic = AutoCritic()
self.review_count = 0
self.max_reviews = 2 # Allow up to 2 reviews per task
def request_review(
self,
review_type: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
specific_concerns: Optional[str] = None,
) -> str:
"""Request a critical review."""
if self.review_count >= self.max_reviews:
return "❌ Review limit exceeded. Time to move forward with what you have."
self.review_count += 1
try:
review_enum = ReviewType[review_type.upper()]
except KeyError:
review_enum = ReviewType.GENERAL
review = self.auto_critic.review(review_enum, work_description, code_snippets, file_paths, specific_concerns)
return f"Review {self.review_count}/{self.max_reviews}:\n\n{review}"
@@ -0,0 +1,129 @@
"""Google Gemini CLI agent tool.
This tool provides integration with Google's Gemini CLI,
allowing programmatic execution of Gemini models for code tasks.
"""
from typing import List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import PermissionManager
from .cli_agent_base import CLIAgentBase
@final
class GeminiCLITool(CLIAgentBase):
"""Tool for executing Google Gemini CLI."""
def __init__(
self,
permission_manager: PermissionManager,
model: Optional[str] = None,
**kwargs,
):
"""Initialize Gemini CLI tool.
Args:
permission_manager: Permission manager for access control
model: Optional model override (defaults to gemini-1.5-pro)
**kwargs: Additional arguments
"""
super().__init__(
permission_manager=permission_manager,
command_name="gemini",
provider_name="Google Gemini",
default_model=model or "gemini-1.5-pro",
env_vars=["GOOGLE_API_KEY", "GEMINI_API_KEY"],
**kwargs,
)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "gemini_cli"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute Google Gemini CLI for code tasks.
This tool runs the Google Gemini CLI for code generation, analysis,
and multi-modal tasks. It uses Gemini 1.5 Pro by default.
Features:
- Gemini 1.5 Pro with 2M token context window
- Gemini 1.5 Flash for faster responses
- Multi-modal capabilities (code + images)
- Advanced reasoning and analysis
Usage:
gemini_cli(prompts="Create a React component for a data table")
gemini_cli(prompts="Analyze this code for security vulnerabilities", model="gemini-1.5-flash")
Requirements:
- Gemini CLI must be installed
- GOOGLE_API_KEY or GEMINI_API_KEY environment variable
"""
@override
def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
"""Get CLI arguments for Gemini.
Args:
prompt: The prompt to send
**kwargs: Additional arguments (model, temperature, etc.)
Returns:
List of command arguments
"""
args = ["generate"]
# Add model
model = kwargs.get("model", self.default_model)
args.extend(["--model", model])
# Add temperature if specified
if "temperature" in kwargs:
args.extend(["--temperature", str(kwargs["temperature"])])
# Add max tokens if specified
if "max_tokens" in kwargs:
args.extend(["--max-output-tokens", str(kwargs["max_tokens"])])
# Add safety settings if needed
if kwargs.get("safety_settings"):
args.extend(["--safety-settings", kwargs["safety_settings"]])
# Add the prompt
args.extend(["--prompt", prompt])
return args
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def gemini_cli(
ctx: MCPContext,
prompts: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
working_dir: Optional[str] = None,
safety_settings: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
prompts=prompts,
model=model,
temperature=temperature,
max_tokens=max_tokens,
working_dir=working_dir,
safety_settings=safety_settings,
)
@@ -0,0 +1,129 @@
"""Grok CLI agent tool.
This tool provides integration with xAI's Grok CLI,
allowing programmatic execution of Grok models for code tasks.
"""
from typing import List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import PermissionManager
from .cli_agent_base import CLIAgentBase
@final
class GrokCLITool(CLIAgentBase):
"""Tool for executing Grok CLI."""
def __init__(
self,
permission_manager: PermissionManager,
model: Optional[str] = None,
**kwargs,
):
"""Initialize Grok CLI tool.
Args:
permission_manager: Permission manager for access control
model: Optional model override (defaults to grok-2)
**kwargs: Additional arguments
"""
super().__init__(
permission_manager=permission_manager,
command_name="grok",
provider_name="xAI Grok",
default_model=model or "grok-2",
env_vars=["XAI_API_KEY", "GROK_API_KEY"],
**kwargs,
)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "grok_cli"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute xAI Grok CLI for code tasks.
This tool runs the Grok CLI for code generation, analysis,
and reasoning tasks. It uses Grok-2 by default.
Features:
- Grok-2 with advanced reasoning capabilities
- Real-time information access
- Code generation and analysis
- Humor and personality in responses
Usage:
grok_cli(prompts="Write a Python web scraper with async support")
grok_cli(prompts="Explain quantum computing like I'm a programmer", model="grok-1")
Requirements:
- Grok CLI must be installed
- XAI_API_KEY or GROK_API_KEY environment variable
"""
@override
def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
"""Get CLI arguments for Grok.
Args:
prompt: The prompt to send
**kwargs: Additional arguments (model, temperature, etc.)
Returns:
List of command arguments
"""
args = ["chat"]
# Add model
model = kwargs.get("model", self.default_model)
args.extend(["--model", model])
# Add temperature if specified
if "temperature" in kwargs:
args.extend(["--temperature", str(kwargs["temperature"])])
# Add max tokens if specified
if "max_tokens" in kwargs:
args.extend(["--max-tokens", str(kwargs["max_tokens"])])
# Add system prompt if specified
if "system_prompt" in kwargs:
args.extend(["--system", kwargs["system_prompt"]])
# Add the prompt
args.append(prompt)
return args
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def grok_cli(
ctx: MCPContext,
prompts: str,
model: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
working_dir: Optional[str] = None,
system_prompt: Optional[str] = None,
) -> str:
return await tool_self.call(
ctx,
prompts=prompts,
model=model,
temperature=temperature,
max_tokens=max_tokens,
working_dir=working_dir,
system_prompt=system_prompt,
)
@@ -0,0 +1,623 @@
"""I Ching tool for creative problem solving using Hanzo principles."""
import random
from enum import Enum
from typing import List, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import auto_timeout
class HanzoPrinciple(Enum):
"""Hanzo principles organized by category."""
# Empathy
AUTONOMY = ("Autonomy", "Trust fully; freedom fuels genius", "🦅")
BALANCE = ("Balance", "Steady wins; burnout loses every time", "⚖️")
CUSTOMER_OBSESSION = (
"Customer Obsession",
"Coach relentlessly; their victories yours",
"🎓",
)
HUMILITY = ("Humility", "Quiet confidence; greatness emerges naturally", "🧘")
INTEGRITY = ("Integrity", "Principles never break; reputation never fades", "🛡️")
SELFLESSNESS = ("Selflessness", "Elevate others; personal success follows", "🤝")
# Science
CURIOSITY = ("Curiosity", "Question always; truth never ends", "🌱")
EMPIRICISM = ("Empiricism", "Hypothesize, measure; reality defines truth", "🔬")
PRECISION = (
"Precision",
"Discipline in data; eliminate guesswork completely",
"🎯",
)
VALIDATION = ("Validation", "Test assumptions hard; illusions crumble fast", "✅")
OBJECTIVITY = ("Objectivity", "Ego out; results speak plainly", "🧊")
REPEATABILITY = (
"Repeatability",
"Do it again; success repeats systematically",
"🔄",
)
# Design
ACCESSIBILITY = (
"Accessibility",
"Open doors wide; adoption thrives naturally",
"🌐",
)
BEAUTY = ("Beauty", "Form speaks louder; aesthetics lift utility", "🎨")
CLARITY = ("Clarity", "Obvious is perfect; complexity hidden cleanly", "🔍")
CONSISTENCY = ("Consistency", "Uniform patterns; predictable results always", "🎯")
SIMPLICITY = ("Simplicity", "Cut ruthlessly; essential alone remains", "🪶")
FLOW = ("Flow", "Remove friction; natural motion prevails", "🌊")
# Engineering
BATTERIES_INCLUDED = (
"Batteries Included",
"Ready instantly; everything you need to start",
"🔋",
)
CONCURRENCY = ("Concurrency", "Parallel flows; frictionless scale", "⚡")
COMPOSABLE = ("Composable", "Modular magic; pieces multiply power", "🧩")
INTEROPERABLE = (
"Interoperable",
"Integrate effortlessly; value compounds infinitely",
"🔗",
)
ORTHOGONAL = ("Orthogonal", "Each tool exact; no overlap, no waste", "⚙️")
SCALABLE = ("Scalable", "Growth limitless; obstacles removed at inception", "📈")
# Scale
DISRUPTION = ("Disruption", "Reinvent boldly; transcend competition entirely", "💥")
EXPERIMENTATION = ("Experimentation", "Test quickly; iterate endlessly", "🧪")
EXPONENTIALITY = ("Exponentiality", "Compound constantly; incremental fades", "📈")
VELOCITY = ("Velocity", "Ship fast; refine faster", "🚀")
URGENCY = ("Urgency", "Act now; delays destroy opportunity", "⏱️")
# Wisdom
ADAPTABILITY = (
"Adaptability",
"Pivot sharply; fluid response accelerates evolution",
"🌊",
)
DECENTRALIZATION = (
"Decentralization",
"Distribute power; resilience born from autonomy",
"🕸️",
)
FREEDOM = (
"Freedom",
"Democratize creativity; tools liberated, gatekeepers removed",
"🗽",
)
LONGEVITY = (
"Longevity",
"Build timelessly; greatness endures beyond lifetimes",
"⏳",
)
SECURITY = ("Security", "Encryption first; privacy non-negotiable", "🔐")
ZEN = ("Zen", "Calm mastery; effortless excellence every moment", "☯️")
class Hexagram:
"""I Ching hexagram with interpretation."""
HEXAGRAMS = {
"111111": (
"乾 (Qián)",
"Creative",
"Initiating force, pure yang energy. Time for bold action.",
),
"000000": (
"坤 (Kūn)",
"Receptive",
"Pure receptivity, yielding. Time to listen and adapt.",
),
"100010": (
"屯 (Zhūn)",
"Initial Difficulty",
"Growing pains. Persevere through early challenges.",
),
"010001": (
"蒙 (Méng)",
"Youthful Folly",
"Beginner's mind. Learn humbly, question assumptions.",
),
"111010": (
"需 (Xū)",
"Waiting",
"Strategic patience. Prepare while waiting for the right moment.",
),
"010111": (
"訟 (Sòng)",
"Conflict",
"Address conflicts directly but seek resolution, not victory.",
),
"010000": (
"師 (Shī)",
"Army",
"Organize resources, build strong teams, lead by example.",
),
"000010": (
"比 (Bǐ)",
"Holding Together",
"Unity and collaboration. Strengthen bonds.",
),
"111011": (
"小畜 (Xiǎo Chù)",
"Small Accumulation",
"Small consistent improvements compound over time.",
),
"110111": (
"履 (Lǚ)",
"Treading",
"Careful progress. Mind the details while moving forward.",
),
"111000": (
"泰 (Tài)",
"Peace",
"Harmony achieved. Maintain balance while building.",
),
"000111": (
"否 (Pǐ)",
"Standstill",
"Blockage present. Pause, reassess, find new paths.",
),
"101111": (
"同人 (Tóng Rén)",
"Fellowship",
"Community strength. Build alliances and share knowledge.",
),
"111101": (
"大有 (Dà Yǒu)",
"Great Possession",
"Abundance available. Share generously to multiply value.",
),
"001000": (
"謙 (Qiān)",
"Modesty",
"Humble confidence. Let work speak for itself.",
),
"000100": (
"豫 (Yù)",
"Enthusiasm",
"Infectious energy. Channel excitement into action.",
),
"100110": (
"隨 (Suí)",
"Following",
"Adaptive leadership. Know when to lead and when to follow.",
),
"011001": (
"蠱 (Gǔ)",
"Work on Decay",
"Fix technical debt. Address root causes.",
),
"110000": (
"臨 (Lín)",
"Approach",
"Opportunity approaching. Prepare to receive it.",
),
"000011": (
"觀 (Guān)",
"Contemplation",
"Step back for perspective. See the whole system.",
),
"100101": (
"噬嗑 (Shì Kè)",
"Biting Through",
"Remove obstacles decisively. Clear blockages.",
),
"101001": ("賁 (Bì)", "Grace", "Polish and refine. Beauty enhances function."),
"000001": (
"剝 (Bō)",
"Splitting Apart",
"Decay phase. Let go of what's not working.",
),
"100000": (
"復 (Fù)",
"Return",
"New cycle begins. Start fresh with lessons learned.",
),
"100111": (
"無妄 (Wú Wàng)",
"Innocence",
"Act with pure intention. Avoid overthinking.",
),
"111001": (
"大畜 (Dà Chù)",
"Great Accumulation",
"Build reserves. Invest in infrastructure.",
),
"100001": (
"頤 (Yí)",
"Nourishment",
"Feed growth. Provide resources teams need.",
),
"011110": (
"大過 (Dà Guò)",
"Great Excess",
"Extraordinary measures needed. Bold action required.",
),
"010010": (
"坎 (Kǎn)",
"Abysmal",
"Navigate danger carefully. Trust your training.",
),
"101101": (
"離 (Lí)",
"Clinging Fire",
"Clarity and vision. Illuminate the path forward.",
),
"001110": (
"咸 (Xián)",
"Influence",
"Mutual attraction. Build on natural affinities.",
),
"011100": (
"恆 (Héng)",
"Duration",
"Persistence pays. Maintain steady effort.",
),
"001111": ("遯 (Dùn)", "Retreat", "Strategic withdrawal. Regroup and refocus."),
"111100": (
"大壯 (Dà Zhuàng)",
"Great Power",
"Strength available. Use power responsibly.",
),
"000101": (
"晉 (Jìn)",
"Progress",
"Advance steadily. Each step builds momentum.",
),
"101000": (
"明夷 (Míng Yí)",
"Darkening Light",
"Work quietly. Keep brilliance hidden for now.",
),
"101011": (
"家人 (Jiā Rén)",
"Family",
"Team harmony. Strengthen internal culture.",
),
"110101": (
"睽 (Kuí)",
"Opposition",
"Creative tension. Find synthesis in differences.",
),
"001010": (
"蹇 (Jiǎn)",
"Obstruction",
"Difficulty ahead. Find alternative routes.",
),
"010100": (
"解 (Xiè)",
"Deliverance",
"Breakthrough achieved. Consolidate gains.",
),
"110001": ("損 (Sǔn)", "Decrease", "Simplify ruthlessly. Less is more."),
"100011": ("益 (Yì)", "Increase", "Multiply value. Invest in growth."),
"111110": (
"夬 (Guài)",
"Breakthrough",
"Decisive moment. Act with conviction.",
),
"011111": (
"姤 (Gòu)",
"Coming to Meet",
"Unexpected encounter. Stay alert to opportunity.",
),
"000110": (
"萃 (Cuì)",
"Gathering",
"Convergence point. Bring elements together.",
),
"011000": (
"升 (Shēng)",
"Pushing Upward",
"Gradual ascent. Build systematically.",
),
"010110": ("困 (Kùn)", "Exhaustion", "Resources depleted. Rest and recharge."),
"011010": ("井 (Jǐng)", "The Well", "Deep resources. Draw from fundamentals."),
"101110": (
"革 (Gé)",
"Revolution",
"Transform completely. Embrace radical change.",
),
"011101": (
"鼎 (Dǐng)",
"The Cauldron",
"Transformation vessel. Cook new solutions.",
),
"100100": (
"震 (Zhèn)",
"Thunder",
"Shocking awakening. Respond to wake-up calls.",
),
"001001": (
"艮 (Gèn)",
"Mountain",
"Stillness and stability. Find solid ground.",
),
"001011": (
"漸 (Jiàn)",
"Gradual Progress",
"Step by step. Patient development.",
),
"110100": (
"歸妹 (Guī Mèi)",
"Marrying Maiden",
"New partnerships. Align expectations.",
),
"101100": ("豐 (Fēng)", "Abundance", "Peak achievement. Prepare for cycles."),
"001101": ("旅 (Lǚ)", "The Wanderer", "Explorer mindset. Learn from journey."),
"011011": (
"巽 (Xùn)",
"Gentle Wind",
"Subtle influence. Persistent gentle pressure.",
),
"110110": ("兌 (Duì)", "Joy", "Infectious happiness. Celebrate progress."),
"010011": ("渙 (Huàn)", "Dispersion", "Break up rigidity. Dissolve barriers."),
"110010": (
"節 (Jié)",
"Limitation",
"Healthy constraints. Focus through limits.",
),
"110011": (
"中孚 (Zhōng Fú)",
"Inner Truth",
"Authentic core. Build from truth.",
),
"001100": (
"小過 (Xiǎo Guò)",
"Small Excess",
"Minor adjustments. Fine-tune carefully.",
),
"101010": (
"既濟 (Jì Jì)",
"After Completion",
"Success achieved. Maintain vigilance.",
),
"010101": (
"未濟 (Wèi Jì)",
"Before Completion",
"Almost there. Final push needed.",
),
}
def __init__(self, lines: str):
self.lines = lines
self.name, self.title, self.meaning = self.HEXAGRAMS.get(
lines,
("Unknown", "Mystery", "The pattern is unclear. Trust your intuition."),
)
def get_changing_lines(self) -> List[int]:
"""Identify which lines are changing (would be 6 or 9 in traditional I Ching)."""
# For simplicity, randomly select 0-2 changing lines
num_changes = random.choice([0, 1, 1, 2])
if num_changes == 0:
return []
positions = list(range(6))
return sorted(random.sample(positions, num_changes))
class IChing:
"""I Ching oracle for engineering guidance."""
def __init__(self):
self.principles = list(HanzoPrinciple)
def cast_hexagram(self) -> Hexagram:
"""Cast a hexagram using virtual coins."""
lines = ""
for _ in range(6):
# Three coin tosses: heads=3, tails=2
coins = sum(random.choice([2, 3]) for _ in range(3))
# 6=old yin(changing 0), 7=young yang(1), 8=young yin(0), 9=old yang(changing 1)
if coins in [6, 8]:
lines += "0"
else:
lines += "1"
return Hexagram(lines)
def select_principles(self, hexagram: Hexagram, challenge: str) -> List[HanzoPrinciple]:
"""Select relevant Hanzo principles based on hexagram and challenge."""
# Use hexagram pattern to deterministically but creatively select principles
selected = []
# Primary principle based on hexagram pattern
primary_index = sum(int(bit) * (2**i) for i, bit in enumerate(hexagram.lines)) % len(self.principles)
selected.append(self.principles[primary_index])
# Supporting principles based on challenge keywords
keywords = challenge.lower().split()
keyword_matches = {
"scale": [HanzoPrinciple.SCALABLE, HanzoPrinciple.EXPONENTIALITY],
"speed": [HanzoPrinciple.VELOCITY, HanzoPrinciple.URGENCY],
"quality": [HanzoPrinciple.PRECISION, HanzoPrinciple.VALIDATION],
"team": [HanzoPrinciple.AUTONOMY, HanzoPrinciple.BALANCE],
"design": [HanzoPrinciple.SIMPLICITY, HanzoPrinciple.BEAUTY],
"bug": [HanzoPrinciple.EMPIRICISM, HanzoPrinciple.OBJECTIVITY],
"refactor": [HanzoPrinciple.CLARITY, HanzoPrinciple.COMPOSABLE],
"security": [HanzoPrinciple.SECURITY, HanzoPrinciple.INTEGRITY],
"performance": [HanzoPrinciple.CONCURRENCY, HanzoPrinciple.ORTHOGONAL],
"user": [HanzoPrinciple.CUSTOMER_OBSESSION, HanzoPrinciple.ACCESSIBILITY],
}
for keyword, principles in keyword_matches.items():
if keyword in keywords:
selected.extend(principles)
# Add complementary principle based on changing lines
changing_lines = hexagram.get_changing_lines()
if changing_lines:
complement_index = (primary_index + sum(changing_lines)) % len(self.principles)
selected.append(self.principles[complement_index])
# Ensure uniqueness and limit to 3-5 principles
seen = set()
unique_selected = []
for principle in selected:
if principle not in seen:
seen.add(principle)
unique_selected.append(principle)
return unique_selected[:5]
def generate_guidance(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
"""Generate creative guidance combining I Ching wisdom and Hanzo principles."""
guidance = f"☯️ I CHING GUIDANCE FOR ENGINEERING CHALLENGE ☯️\n\n"
guidance += f"**Your Challenge:** {challenge}\n\n"
guidance += f"**Hexagram Cast:** {hexagram.name} - {hexagram.title}\n"
guidance += f"**Pattern:** {''.join('━━━' if l == '1' else '━ ━' for l in hexagram.lines[::-1])}\n"
guidance += f"**Ancient Wisdom:** {hexagram.meaning}\n\n"
guidance += "**Hanzo Principles to Apply:**\n\n"
for principle in principles:
name, wisdom, emoji = principle.value
guidance += f"{emoji} **{name}**\n"
guidance += f" *{wisdom}*\n\n"
# Generate specific actionable advice
guidance += "**Synthesized Approach:**\n\n"
# Hexagram-specific guidance
if "Creative" in hexagram.title:
guidance += "• This is a time for bold innovation. Don't hold back on ambitious ideas.\n"
elif "Receptive" in hexagram.title:
guidance += "• Listen deeply to user needs and system constraints before acting.\n"
elif "Difficulty" in hexagram.title:
guidance += "• Challenges are teachers. Each obstacle reveals the path forward.\n"
elif "Waiting" in hexagram.title:
guidance += "• Strategic patience required. Prepare thoroughly before implementation.\n"
elif "Conflict" in hexagram.title:
guidance += "• Technical disagreements? Seek data-driven resolution.\n"
elif "Peace" in hexagram.title:
guidance += "• Harmony achieved. Now build sustainably on this foundation.\n"
# Principle-specific actionable advice
principle_actions = {
HanzoPrinciple.SCALABLE: "• Design for 10x growth from day one. Remove scaling bottlenecks now.",
HanzoPrinciple.VELOCITY: "• Ship an MVP today. Perfect is the enemy of shipped.",
HanzoPrinciple.SIMPLICITY: "• Delete half your code. The best code is no code.",
HanzoPrinciple.EMPIRICISM: "• Measure everything. Let data guide your decisions.",
HanzoPrinciple.CUSTOMER_OBSESSION: "• Talk to users now. Their pain is your roadmap.",
HanzoPrinciple.CONCURRENCY: "• Parallelize everything possible. Sequential is slow.",
HanzoPrinciple.SECURITY: "• Security is not optional. Encrypt by default.",
HanzoPrinciple.ZEN: "• Find calm in the chaos. Clear mind writes better code.",
}
for principle in principles:
if principle in principle_actions:
guidance += principle_actions[principle] + "\n"
# Changing lines wisdom
changing_lines = hexagram.get_changing_lines()
if changing_lines:
guidance += f"\n**Lines in Transition:** {', '.join(str(i + 1) for i in changing_lines)}\n"
guidance += "• Change is imminent in these areas. Prepare for transformation.\n"
# Final synthesis
guidance += "\n**The Way Forward:**\n"
guidance += self._synthesize_action_plan(hexagram, principles, challenge)
guidance += "\n\n*Remember: The I Ching reveals patterns, not prescriptions. "
guidance += "Let this wisdom guide your intuition as you craft your solution.*"
return guidance
def _synthesize_action_plan(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
"""Create a specific action plan based on the reading."""
plan = ""
# Determine the nature of the challenge
if any(word in challenge.lower() for word in ["bug", "error", "fix", "broken"]):
plan += "1. **Diagnose systematically** - Use empirical debugging, not guesswork\n"
plan += "2. **Fix root cause** - Address the source, not just symptoms\n"
plan += "3. **Prevent recurrence** - Add tests and monitoring\n"
elif any(word in challenge.lower() for word in ["scale", "performance", "slow"]):
plan += "1. **Measure first** - Profile to find actual bottlenecks\n"
plan += "2. **Parallelize** - Use concurrency where possible\n"
plan += "3. **Simplify** - Remove complexity before optimizing\n"
elif any(word in challenge.lower() for word in ["design", "architect", "structure"]):
plan += "1. **Start simple** - MVP first, elaborate later\n"
plan += "2. **Stay flexible** - Design for change\n"
plan += "3. **Think holistically** - Consider entire system\n"
elif any(word in challenge.lower() for word in ["team", "collaborate", "people"]):
plan += "1. **Enable autonomy** - Trust your team\n"
plan += "2. **Maintain balance** - Sustainable pace wins\n"
plan += "3. **Share knowledge** - Elevate everyone\n"
else:
plan += "1. **Clarify intent** - What problem are you really solving?\n"
plan += "2. **Start small** - Build incrementally\n"
plan += "3. **Iterate rapidly** - Fast feedback loops\n"
return plan
class IChingTool(BaseTool):
"""Tool for applying I Ching wisdom to engineering challenges."""
name = "iching"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Apply I Ching wisdom and Hanzo principles to engineering challenges.
This tool casts an I Ching hexagram and selects relevant Hanzo principles
to provide creative guidance for your engineering challenge.
Parameters:
- challenge: Description of the engineering challenge or question
The oracle will:
1. Cast a hexagram representing the current situation
2. Select relevant Hanzo principles
3. Synthesize actionable guidance
4. Provide specific recommendations
Example:
iching(
challenge="How should I approach refactoring this legacy codebase?"
)
Use this when you need:
- Fresh perspective on a problem
- Creative approach to challenges
- Wisdom for difficult decisions
- Alignment with Hanzo principles"""
def __init__(self):
"""Initialize the I Ching tool."""
super().__init__()
self.oracle = IChing()
@auto_timeout("iching")
async def call(self, ctx: MCPContext, challenge: str) -> str:
"""Cast I Ching and provide guidance."""
# Cast hexagram
hexagram = self.oracle.cast_hexagram()
# Select relevant principles
principles = self.oracle.select_principles(hexagram, challenge)
# Generate guidance
guidance = self.oracle.generate_guidance(hexagram, principles, challenge)
return guidance
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 iching(ctx: MCPContext, challenge: str) -> str:
return await tool_self.call(ctx, challenge)
@@ -0,0 +1,234 @@
"""Network tool for dispatching work to agent networks.
This tool enables distributed AI workloads across local and remote agent networks,
with support for both local-only execution (via hanzo-miner) and cloud fallback.
"""
import os
import json
from typing import (
List,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from pydantic import Field
from mcp.server import FastMCP
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
# Import hanzo cluster if available
try:
from hanzoai import cluster
CLUSTER_AVAILABLE = True
except ImportError:
CLUSTER_AVAILABLE = False
class NetworkToolParams(TypedDict, total=False):
"""Parameters for the network tool."""
task: str
agents: Optional[List[str]]
mode: Optional[str] # "local", "distributed", "hybrid"
model: Optional[str]
routing: Optional[str] # "sequential", "parallel", "consensus"
require_local: Optional[bool]
@final
class NetworkTool(BaseTool):
"""Dispatch work to agent networks for distributed AI processing.
Modes:
- local: Use only local compute (via hanzo-cluster/miner)
- distributed: Use available network resources
- hybrid: Prefer local, fallback to cloud
This tool is the evolution of the swarm tool, providing:
- True distributed execution across devices
- Local-first privacy-preserving AI
- Automatic routing and load balancing
- Integration with hanzo-miner for compute contribution
"""
name = "network"
description = "Dispatch tasks to agent networks for distributed AI processing"
def __init__(
self,
permission_manager: PermissionManager,
default_mode: str = "hybrid",
cluster_endpoint: str = None,
):
"""Initialize the network tool.
Args:
permission_manager: Permission manager
default_mode: Default execution mode
cluster_endpoint: Optional cluster endpoint
"""
self.permission_manager = permission_manager
self.default_mode = default_mode
self.cluster_endpoint = cluster_endpoint or os.environ.get("HANZO_CLUSTER_ENDPOINT", "http://localhost:8000")
self._cluster = None
async def _ensure_cluster(self):
"""Ensure we have a cluster connection."""
if not CLUSTER_AVAILABLE:
return None
if not self._cluster:
try:
# Try to connect to existing cluster
self._cluster = cluster.HanzoCluster()
# Check if cluster is running
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.cluster_endpoint}/health")
if response.status_code != 200:
# Start local cluster if not running
await self._cluster.start()
except Exception:
# Cluster not available
self._cluster = None
return self._cluster
@override
@auto_timeout("network")
async def call(self, ctx: MCPContext, **params: Unpack[NetworkToolParams]) -> str:
"""Execute a task on the agent network.
Args:
ctx: MCP context
task: Task description to execute
agents: Optional list of specific agents to use
mode: Execution mode (local/distributed/hybrid)
model: Optional model preference
routing: Routing strategy
require_local: Require local-only execution
Returns:
JSON string with results
"""
task = params.get("task", "")
if not task:
return json.dumps({"error": "Task description required", "success": False})
mode = params.get("mode", self.default_mode)
agents_list = params.get("agents", [])
model_pref = params.get("model")
routing = params.get("routing", "sequential")
require_local = params.get("require_local", False)
# Check if we should use local cluster
use_local = mode in ["local", "hybrid"] or require_local
results = {
"task": task,
"mode": mode,
"routing": routing,
"agents_used": [],
"results": [],
"success": False,
}
try:
# Try local execution first if requested
if use_local:
cluster = await self._ensure_cluster()
if cluster:
try:
# Execute on local cluster
local_result = await cluster.inference(
prompt=task,
model=model_pref or "llama-3.2-3b",
max_tokens=4000,
)
results["agents_used"].append("local-cluster")
results["results"].append(
{
"agent": "local-cluster",
"response": local_result.get("choices", [{}])[0].get("text", ""),
"local": True,
}
)
results["success"] = True
# If local succeeded and not hybrid, return
if mode == "local" or (mode == "hybrid" and results["results"]):
return json.dumps(results, indent=2)
except Exception as e:
if require_local:
results["error"] = f"Local execution failed: {str(e)}"
return json.dumps(results, indent=2)
# Agent-based execution with concurrency
if not results["success"] or mode in ["distributed", "hybrid"]:
from .agent_tool import AgentTool
agent = AgentTool(permission_manager=self.permission_manager, model=model_pref)
concurrency = max(1, len(agents_list)) if agents_list else 5 if routing == "parallel" else 1
agent_params = {"prompts": task, "concurrency": concurrency}
agent_result = await agent.call(ctx, **agent_params)
# Wrap agent_result as a simple result list
results["agents_used"].append("agent")
results["results"].append({"agent": "agent", "response": agent_result})
results["success"] = True
except Exception as e:
results["error"] = str(e)
return json.dumps(results, indent=2)
def register(self, server: FastMCP):
"""Register the network tool with the server.
Args:
server: FastMCP server instance
"""
tool = self
@server.tool(name=tool.name, description=tool.description)
async def network_handler(
ctx: MCPContext,
task: Annotated[str, Field(description="Task to execute on the network")],
agents: Annotated[Optional[List[str]], Field(description="Specific agents to use")] = None,
mode: Annotated[
Optional[str],
Field(description="Execution mode: local, distributed, or hybrid"),
] = None,
model: Annotated[Optional[str], Field(description="Model preference")] = None,
routing: Annotated[
Optional[str],
Field(description="Routing strategy: sequential, parallel, or consensus"),
] = None,
require_local: Annotated[Optional[bool], Field(description="Require local-only execution")] = None,
) -> str:
"""Dispatch work to agent networks."""
params = NetworkToolParams(
task=task,
agents=agents,
mode=mode,
model=model,
routing=routing,
require_local=require_local,
)
return await tool.call(ctx, **params)
return tool
# Remove swarm compatibility tool; swarm is an alias of agent with concurrency
@@ -0,0 +1,179 @@
"""Prompt generation utilities for agent tool.
This module provides functions for generating effective prompts for sub-agents,
including filtering tools based on permissions and formatting system instructions.
"""
import os
from typing import Any
from hanzo_tools.core import BaseTool
from hanzo_tools.core import PermissionManager
def get_allowed_agent_tools(
tools: list[BaseTool],
permission_manager: PermissionManager,
) -> list[BaseTool]:
"""Filter tools available to the agent based on permissions.
Args:
tools: List of available tools
permission_manager: Permission manager for checking tool access
Returns:
Filtered list of tools available to the agent
"""
# Get all tools except for the agent tool itself (avoid recursion)
filtered_tools = [tool for tool in tools if tool.name != "agent"]
return filtered_tools
def get_system_prompt(
tools: list[BaseTool],
permission_manager: PermissionManager,
) -> str:
"""Generate system prompt for the sub-agent.
Args:
tools: List of available tools
permission_manager: Permission manager for checking tool access
Returns:
System prompt for the sub-agent
"""
# Get filtered tools
filtered_tools = get_allowed_agent_tools(tools, permission_manager)
# Extract tool names for display
tool_names = ", ".join(f"`{tool.name}`" for tool in filtered_tools)
# Base system prompt - agents always have edit tools
system_prompt = f"""You are a Claude sub-agent with access to these tools: {tool_names}.
CAPABILITIES:
1. You have FULL read and write access - you can create, edit, and modify files
2. You can ask clarifying questions if needed - your response goes to the coordinating agent
3. You work as part of a team of specialized agents
4. Other agents may be available via MCP tools (look for tools named after agents)
5. When relevant, share file names and code snippets
6. Any file paths you return MUST be absolute. DO NOT use relative paths.
7. You can only work with the absolute paths provided in your task prompt.
CLARIFICATION:
- You can request clarification ONCE per task using the request_clarification tool
- Use this when instructions are ambiguous or you need additional context
- Types: AMBIGUOUS_INSTRUCTION, MISSING_CONTEXT, MULTIPLE_OPTIONS, CONFIRMATION_NEEDED, ADDITIONAL_INFO
- The main loop will provide automated guidance based on context
CRITICAL REVIEW (Devil's Advocate):
- Use the critic tool to get harsh, challenging feedback that attacks assumptions
- The critic will find flaws and push for improvements aggressively
- You can request up to 2 critic reviews per task
- Review types: CODE_QUALITY, CORRECTNESS, PERFORMANCE, SECURITY, COMPLETENESS, BEST_PRACTICES, GENERAL
- Use when you need someone to find what's wrong with your approach
BALANCED REVIEW:
- Use the review tool for constructive, balanced code review
- Provides objective assessment without predetermined bias
- You can request up to 3 reviews per task
- Focus areas: GENERAL, FUNCTIONALITY, READABILITY, MAINTAINABILITY, TESTING, DOCUMENTATION, ARCHITECTURE
- Use for regular code review and feedback
CREATIVE GUIDANCE:
- Use the iching tool when you need creative problem-solving approaches
- Combines ancient I Ching wisdom with Hanzo engineering principles
- Provides unique perspectives and actionable guidance
- Use when stuck, need fresh ideas, or want philosophical alignment
EDITING GUIDELINES:
- ALWAYS read the file first before attempting any edits
- For edit tool: The old_string must match EXACTLY including all whitespace, tabs, and newlines
- When copying text from read output, be careful with line numbers and indentation
- If an edit fails due to whitespace mismatch, try reading the specific lines again
- Prefer multi_edit when making multiple changes to the same file
- Test your edits by verifying the exact string exists in the file first
COLLABORATION:
- If you see MCP tools named after other agents, you can communicate with them
- Use agent MCP tools to delegate specialized tasks or get expert opinions
- Share context when communicating with other agents
RESPONSE FORMAT:
- Begin with a summary of what you did or found
- If you have questions, ask them clearly
- Include details of any edits performed
- Report any errors encountered
- End with clear conclusions or next steps
"""
return system_prompt
def get_default_model(model_override: str | None = None) -> str:
"""Get the default model for agent execution.
Args:
model_override: Optional model override string in LiteLLM format (e.g., "openai/gpt-4o")
Returns:
Model identifier string with provider prefix
"""
# Use model override if provided
if model_override:
# If in testing mode and using a test model, return as-is
if model_override.startswith("test-model") or "TEST_MODE" in os.environ:
return model_override
# If the model already has a provider prefix, return as-is
if "/" in model_override:
return model_override
# Otherwise, add the default provider prefix
provider = os.environ.get("AGENT_PROVIDER", "openai")
return f"{provider}/{model_override}"
# Fall back to environment variables
# Default to Sonnet for cost efficiency
model = os.environ.get("AGENT_MODEL", "claude-3-5-sonnet-20241022")
# Special cases for tests
if model.startswith("test-model") or "TEST_MODE" in os.environ and model == "claude-3-5-sonnet-20241022":
return model
provider = os.environ.get("AGENT_PROVIDER", "anthropic")
# Only add provider prefix if it's not already in the model name
if "/" not in model and provider != "anthropic":
return f"{provider}/{model}"
elif "/" not in model and provider == "anthropic":
return f"anthropic/{model}"
elif "/" not in model:
return f"openai/{model}"
else:
# Model already has a provider prefix
return model
def get_model_parameters(max_tokens: int | None = None) -> dict[str, Any]:
"""Get model parameters from environment variables.
Args:
max_tokens: Optional maximum tokens parameter override
Returns:
Dictionary of model parameters
"""
params = {
"temperature": float(os.environ.get("AGENT_TEMPERATURE", "0.7")),
"timeout": int(os.environ.get("AGENT_API_TIMEOUT", "60")),
}
# Add max_tokens if provided or if set in environment variable
if max_tokens is not None:
params["max_tokens"] = max_tokens
elif os.environ.get("AGENT_MAX_TOKENS"):
params["max_tokens"] = int(os.environ.get("AGENT_MAX_TOKENS", "1000"))
return params
@@ -0,0 +1,427 @@
"""Review tool for agents to request balanced code review from main loop."""
from enum import Enum
from typing import List, Optional, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import auto_timeout
class ReviewFocus(Enum):
"""Types of review focus areas."""
GENERAL = "general"
FUNCTIONALITY = "functionality"
READABILITY = "readability"
MAINTAINABILITY = "maintainability"
TESTING = "testing"
DOCUMENTATION = "documentation"
ARCHITECTURE = "architecture"
class ReviewTool(BaseTool):
"""Tool for agents to request balanced code review from the main loop."""
name = "review"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Request a balanced, constructive code review from the main loop.
Unlike the critic tool (which plays devil's advocate), this provides:
- Objective assessment of code quality
- Recognition of what's done well
- Constructive suggestions for improvement
- Focus on practical concerns
- No predetermined bias or harsh judgment
Parameters:
- focus: Review focus area (GENERAL, FUNCTIONALITY, READABILITY, MAINTAINABILITY, TESTING, DOCUMENTATION, ARCHITECTURE)
- work_description: Clear description of what you've implemented
- code_snippets: Optional code snippets to review (as a list of strings)
- file_paths: Optional list of file paths you've modified
- context: Optional additional context about the implementation
The review will be balanced, highlighting both strengths and areas for improvement.
Example:
review(
focus="FUNCTIONALITY",
work_description="Implemented auto-import feature for Go files",
code_snippets=["func AddImport(file string, importPath string) error { ... }"],
file_paths=["/path/to/import_handler.go"],
context="This will be used to automatically fix missing imports in Go files"
)"""
@auto_timeout("review")
async def call(
self,
ctx: MCPContext,
focus: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
context: Optional[str] = None,
) -> str:
"""Delegate to AgentTool for actual implementation.
This method provides the interface, but the actual review logic
is handled by the AgentTool's execution framework.
"""
# This tool is handled specially in the agent execution
return f"Review requested for: {work_description}"
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 review(
ctx: MCPContext,
focus: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
context: Optional[str] = None,
) -> str:
return await tool_self.call(ctx, focus, work_description, code_snippets, file_paths, context)
class BalancedReviewer:
"""Provides balanced, constructive code reviews."""
def __init__(self):
self.review_handlers = {
ReviewFocus.GENERAL: self._review_general,
ReviewFocus.FUNCTIONALITY: self._review_functionality,
ReviewFocus.READABILITY: self._review_readability,
ReviewFocus.MAINTAINABILITY: self._review_maintainability,
ReviewFocus.TESTING: self._review_testing,
ReviewFocus.DOCUMENTATION: self._review_documentation,
ReviewFocus.ARCHITECTURE: self._review_architecture,
}
def review(
self,
focus: ReviewFocus,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
context: Optional[str] = None,
) -> str:
"""Perform a balanced code review."""
review_func = self.review_handlers.get(focus, self._review_general)
return review_func(work_description, code_snippets, file_paths, context)
def _review_general(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Provide a general balanced review."""
response = "📋 GENERAL CODE REVIEW:\n\n"
response += f"**Work Reviewed:** {work_description}\n\n"
# Positive observations
response += "**Positive Aspects:**\n"
if "fix" in work_description.lower():
response += "✓ Addressing identified issues proactively\n"
if "implement" in work_description.lower():
response += "✓ Adding new functionality to enhance the system\n"
if code_snippets:
response += "✓ Code structure appears organized\n"
if file_paths and len(file_paths) == 1:
response += "✓ Focused changes in a single file (good for reviewability)\n"
elif file_paths and len(file_paths) > 1:
response += "✓ Comprehensive approach across multiple files\n"
# Constructive suggestions
response += "\n**Suggestions for Consideration:**\n"
response += "• Ensure all edge cases are handled appropriately\n"
response += "• Consider adding unit tests if not already present\n"
response += "• Verify the changes integrate well with existing code\n"
if context:
response += f"\n**Context Consideration:**\n{context}\n"
response += "→ This context helps understand the implementation choices.\n"
# Summary
response += "\n**Summary:**\n"
response += "The implementation appears sound. Consider the suggestions above to further strengthen the code."
return response
def _review_functionality(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review functionality aspects."""
response = "📋 FUNCTIONALITY REVIEW:\n\n"
response += f"**Implementation:** {work_description}\n\n"
response += "**Functional Assessment:**\n"
# Analyze code snippets if provided
if code_snippets:
for i, snippet in enumerate(code_snippets, 1):
response += f"\nCode Snippet {i}:\n"
# Check for function definitions
if "func " in snippet or "def " in snippet or "function " in snippet:
response += "✓ Function definition looks properly structured\n"
# Check for error handling
if "error" in snippet or "err" in snippet or "try" in snippet:
response += "✓ Error handling is present\n"
elif "return" in snippet:
response += "• Consider adding error handling if applicable\n"
# Check for input validation
if "if " in snippet or "check" in snippet.lower():
response += "✓ Input validation appears to be present\n"
response += "\n**Functional Considerations:**\n"
response += "• Does the implementation handle all expected inputs?\n"
response += "• Are return values meaningful and consistent?\n"
response += "• Is the functionality easily testable?\n"
response += "• Does it integrate well with existing features?\n"
response += "\n**Overall:** The functionality appears to meet the described requirements."
return response
def _review_readability(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review code readability."""
response = "📋 READABILITY REVIEW:\n\n"
response += "**Readability Factors:**\n"
if code_snippets:
total_lines = sum(snippet.count("\n") + 1 for snippet in code_snippets)
avg_line_length = sum(len(line) for snippet in code_snippets for line in snippet.split("\n")) / max(
total_lines, 1
)
if avg_line_length < 80:
response += "✓ Line lengths are reasonable\n"
else:
response += "• Some lines might be too long, consider breaking them up\n"
# Check naming
has_good_names = any(
any(word in snippet for word in ["Add", "Get", "Set", "Create", "Update", "Delete"])
for snippet in code_snippets
)
if has_good_names:
response += "✓ Function/method names appear descriptive\n"
response += "\n**Readability Suggestions:**\n"
response += "• Use meaningful variable and function names\n"
response += "• Keep functions focused on a single responsibility\n"
response += "• Add comments for complex logic sections\n"
response += "• Maintain consistent indentation and formatting\n"
response += "\n**Overall:** Code readability appears acceptable with room for minor improvements."
return response
def _review_maintainability(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review maintainability aspects."""
response = "📋 MAINTAINABILITY REVIEW:\n\n"
response += "**Maintainability Factors:**\n"
# Check file organization
if file_paths:
if len(file_paths) == 1:
response += "✓ Changes are localized to a single file\n"
else:
response += "✓ Changes are logically distributed across files\n"
# Check for modularity in code
if code_snippets:
function_count = sum(
snippet.count("func ") + snippet.count("def ") + snippet.count("function ") for snippet in code_snippets
)
if function_count > 0:
response += "✓ Code is broken into functions/methods\n"
response += "\n**Maintainability Considerations:**\n"
response += "• Is the code modular and reusable?\n"
response += "• Are dependencies clearly defined?\n"
response += "• Will future developers understand the intent?\n"
response += "• Is the code structured to allow easy updates?\n"
response += "\n**Recommendations:**\n"
response += "• Consider extracting common patterns into utilities\n"
response += "• Ensure consistent patterns across the codebase\n"
response += "• Document any non-obvious design decisions\n"
return response
def _review_testing(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review testing aspects."""
response = "📋 TESTING REVIEW:\n\n"
has_test_files = any("test" in str(path).lower() for path in (file_paths or []))
if has_test_files:
response += "✓ Test files are included with the changes\n\n"
else:
response += "⚠️ No test files detected in the changes\n\n"
response += "**Testing Checklist:**\n"
response += "□ Unit tests for new functions\n"
response += "□ Integration tests for feature interactions\n"
response += "□ Edge case coverage\n"
response += "□ Error condition testing\n"
response += "□ Performance tests (if applicable)\n"
response += "\n**Testing Recommendations:**\n"
response += "• Write tests that document expected behavior\n"
response += "• Include both positive and negative test cases\n"
response += "• Ensure tests are maintainable and clear\n"
response += "• Aim for good coverage of critical paths\n"
if not has_test_files:
response += "\n💡 Consider adding tests to ensure reliability and prevent regressions."
return response
def _review_documentation(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review documentation aspects."""
response = "📋 DOCUMENTATION REVIEW:\n\n"
# Check for documentation in code
has_comments = False
if code_snippets:
has_comments = any(
"//" in snippet or "/*" in snippet or "#" in snippet or '"""' in snippet for snippet in code_snippets
)
if has_comments:
response += "✓ Code includes some documentation\n"
else:
response += "• Consider adding documentation comments\n"
response += "\n**Documentation Guidelines:**\n"
response += "• Document the 'why' not just the 'what'\n"
response += "• Include examples for complex functions\n"
response += "• Document any assumptions or limitations\n"
response += "• Keep documentation up-to-date with code changes\n"
response += "\n**Recommended Documentation:**\n"
response += "• Function/method purpose and parameters\n"
response += "• Complex algorithm explanations\n"
response += "• API usage examples\n"
response += "• Configuration requirements\n"
return response
def _review_architecture(
self,
work_description: str,
code_snippets: Optional[List[str]],
file_paths: Optional[List[str]],
context: Optional[str],
) -> str:
"""Review architectural aspects."""
response = "📋 ARCHITECTURE REVIEW:\n\n"
response += "**Architectural Considerations:**\n"
# Analyze file structure
if file_paths:
# Check for separation of concerns
has_separation = len(set(str(p).split("/")[-2] for p in file_paths if "/" in str(p))) > 1
if has_separation:
response += "✓ Changes span multiple modules (good separation)\n"
else:
response += "✓ Changes are cohesive within a module\n"
response += "\n**Architectural Principles:**\n"
response += "• Single Responsibility - Each component has one clear purpose\n"
response += "• Open/Closed - Open for extension, closed for modification\n"
response += "• Dependency Inversion - Depend on abstractions, not concretions\n"
response += "• Interface Segregation - Keep interfaces focused and minimal\n"
response += "\n**Questions to Consider:**\n"
response += "• Does this fit well with the existing architecture?\n"
response += "• Are the right abstractions in place?\n"
response += "• Is the coupling between components appropriate?\n"
response += "• Will this scale as requirements grow?\n"
if context:
response += f"\n**Context Impact:**\n{context}\n"
response += "→ Ensure the architectural choices align with this context.\n"
return response
class ReviewProtocol:
"""Protocol for review interactions."""
def __init__(self):
self.reviewer = BalancedReviewer()
self.review_count = 0
self.max_reviews = 3 # Allow up to 3 reviews per task
def request_review(
self,
focus: str,
work_description: str,
code_snippets: Optional[List[str]] = None,
file_paths: Optional[List[str]] = None,
context: Optional[str] = None,
) -> str:
"""Request a balanced review."""
if self.review_count >= self.max_reviews:
return "📋 Review limit reached. You've received comprehensive feedback - time to finalize your implementation."
self.review_count += 1
try:
focus_enum = ReviewFocus[focus.upper()]
except KeyError:
focus_enum = ReviewFocus.GENERAL
review = self.reviewer.review(focus_enum, work_description, code_snippets, file_paths, context)
header = f"Review {self.review_count}/{self.max_reviews} (Focus: {focus_enum.value}):\n\n"
footer = "\n\n💡 This is a balanced review - consider both strengths and suggestions."
return header + review + footer
@@ -0,0 +1,88 @@
from hanzo_tools.core import auto_timeout
"""Swarm tool as an alias to Network tool for backward compatibility.
This module makes swarm an alias to the network tool, as network is the
evolution of swarm with better distributed execution capabilities.
"""
from .network_tool import NetworkTool
from hanzo_tools.core import PermissionManager
class SwarmTool(NetworkTool):
"""Swarm tool - alias to Network tool for backward compatibility.
The swarm tool is now an alias to the network tool, which provides
all the same functionality plus additional distributed execution modes.
Use 'network' for new code, 'swarm' is maintained for compatibility.
"""
@property
def name(self) -> str:
"""Get the tool name."""
return "swarm"
@property
def description(self) -> str:
"""Get the tool description."""
return """Execute a network of AI agents (alias to 'network' tool).
The 'swarm' tool is now an alias to the 'network' tool for backward compatibility.
All swarm functionality is available through network, which additionally provides:
- Local-first execution with privacy preservation
- Distributed compute across devices
- Hybrid mode with cloud fallback
- Integration with hanzo-network for MCP-connected agents
Examples:
```python
# These are equivalent:
swarm(task="Analyze code", agents=["analyzer", "reviewer"])
network(task="Analyze code", agents=["analyzer", "reviewer"])
# Network adds new modes:
network(task="Process data", mode="local") # Privacy-first
network(task="Large analysis", mode="distributed") # Scale out
```
For new code, prefer using 'network' directly."""
def __init__(
self,
permission_manager: PermissionManager,
default_mode: str = "hybrid",
**kwargs,
):
"""Initialize swarm as an alias to network.
Args:
permission_manager: Permission manager
default_mode: Default execution mode (hybrid/local/distributed)
**kwargs: Additional arguments passed to NetworkTool
"""
# Just pass through to NetworkTool
super().__init__(permission_manager=permission_manager, default_mode=default_mode, **kwargs)
@auto_timeout("swarm_alias")
async def call(self, **kwargs) -> str:
"""Execute swarm via network tool.
All parameters are passed through to the network tool.
"""
# For backward compatibility, rename some parameters if needed
if "config" in kwargs and "agents" not in kwargs:
# Old swarm used 'config' for agent definitions
config = kwargs.pop("config")
if isinstance(config, dict) and "agents" in config:
kwargs["agents"] = config["agents"]
if "topology" in config:
kwargs["routing"] = config["topology"]
# Pass through to network tool
return await super().call(**kwargs)
# For backward compatibility exports
__all__ = ["SwarmTool"]
@@ -0,0 +1,701 @@
"""Swarm tool implementation using hanzo-agents SDK.
This module implements the SwarmTool that leverages the hanzo-agents SDK
for sophisticated multi-agent orchestration with flexible network topologies.
"""
import os
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
TypedDict,
final,
override,
)
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import auto_timeout
# Import hanzo-agents SDK with fallback
try:
from hanzo_agents import (
Tool,
Agent,
State,
Router,
History,
Network,
ToolCall,
ModelRegistry,
InferenceResult,
)
HANZO_AGENTS_AVAILABLE = True
except ImportError:
# Define minimal stubs if hanzo-agents is not available
HANZO_AGENTS_AVAILABLE = False
class Agent:
pass
class State:
pass
class Network:
pass
class Tool:
pass
class History:
pass
class ModelRegistry:
pass
class InferenceResult:
pass
class ToolCall:
pass
class Router:
pass
# Import optional components with fallbacks
try:
from hanzo_agents import LLMRouter, HybridRouter, DeterministicRouter
except ImportError:
try:
# Try core module import
from hanzo_agents.core.router import (
LLMRouter,
HybridRouter,
DeterministicRouter,
)
except ImportError:
# Define stubs if not available
class DeterministicRouter:
pass
class LLMRouter:
pass
class HybridRouter:
pass
try:
from hanzo_agents import create_memory_kv, create_memory_vector
except ImportError:
try:
# Try core module import
from hanzo_agents.core.memory import create_memory_kv, create_memory_vector
except ImportError:
# Define stubs if not available
def create_memory_kv(*args, **kwargs):
pass
def create_memory_vector(*args, **kwargs):
pass
try:
from hanzo_agents import sequential_router, conditional_router, state_based_router
except ImportError:
try:
# Try core module import
from hanzo_agents.core.router import (
sequential_router,
conditional_router,
state_based_router,
)
except ImportError:
# Define stubs if not available
def sequential_router(*args, **kwargs):
pass
def conditional_router(*args, **kwargs):
pass
def state_based_router(*args, **kwargs):
pass
try:
from hanzo_agents.core.cli_agent import (
GrokAgent,
GeminiAgent,
ClaudeCodeAgent,
OpenAICodexAgent,
)
except ImportError:
# Define stub classes if not available
class ClaudeCodeAgent(Agent):
pass
class OpenAICodexAgent(Agent):
pass
class GeminiAgent(Agent):
pass
class GrokAgent(Agent):
pass
from hanzo_mcp.tools.jupyter import get_read_only_jupyter_tools
from hanzo_mcp.tools.filesystem import Edit, MultiEdit, get_read_only_filesystem_tools
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from .agent_tool import MCPAgent
from hanzo_mcp.tools.common.batch_tool import BatchTool
from hanzo_tools.core import PermissionManager
class AgentNode(TypedDict):
"""Node in the agent network."""
id: str
query: str
model: Optional[str]
role: Optional[str]
connections: Optional[List[str]]
receives_from: Optional[List[str]]
file_path: Optional[str]
class SwarmConfig(TypedDict):
"""Configuration for an agent network."""
agents: Dict[str, AgentNode]
entry_point: Optional[str]
topology: Optional[str]
class SwarmToolParams(TypedDict):
"""Parameters for the SwarmTool."""
config: SwarmConfig
query: str
context: Optional[str]
max_concurrent: Optional[int]
use_memory: Optional[bool]
memory_backend: Optional[str]
class SwarmState(State):
"""State for swarm execution."""
def __init__(self, config: SwarmConfig, initial_query: str, context: Optional[str] = None):
"""Initialize swarm state."""
super().__init__()
self.config = config
self.initial_query = initial_query
self.context = context
self.agent_results = {}
self.completed_agents = set()
self.current_agent = None
self.execution_order = []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
base_dict = super().to_dict()
base_dict.update(
{
"config": self.config,
"initial_query": self.initial_query,
"context": self.context,
"agent_results": self.agent_results,
"completed_agents": list(self.completed_agents),
"current_agent": self.current_agent,
"execution_order": self.execution_order,
}
)
return base_dict
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SwarmState":
"""Create from dictionary."""
state = cls(
config=data.get("config", {}),
initial_query=data.get("initial_query", ""),
context=data.get("context"),
)
state.agent_results = data.get("agent_results", {})
state.completed_agents = set(data.get("completed_agents", []))
state.current_agent = data.get("current_agent")
state.execution_order = data.get("execution_order", [])
return state
class SwarmAgent(MCPAgent):
"""Agent that executes within a swarm network."""
def __init__(
self,
agent_id: str,
agent_config: AgentNode,
available_tools: List[BaseTool],
permission_manager: PermissionManager,
ctx: MCPContext,
**kwargs,
):
"""Initialize swarm agent."""
# Set name and description from config
self.name = agent_id
self.description = agent_config.get("role", f"Agent {agent_id}")
self.agent_config = agent_config
# Initialize with specified model
model = agent_config.get("model")
if model:
model = self._normalize_model(model)
else:
model = "model://anthropic/claude-3-5-sonnet-20241022"
super().__init__(
available_tools=available_tools,
permission_manager=permission_manager,
ctx=ctx,
model=model,
**kwargs,
)
def _normalize_model(self, model: str) -> str:
"""Normalize model names to full format."""
model_map = {
"claude-3-5-sonnet": "model://anthropic/claude-3-5-sonnet-20241022",
"claude-3-opus": "model://anthropic/claude-3-opus-20240229",
"gpt-4o": "model://openai/gpt-4o",
"gpt-4": "model://openai/gpt-4",
"gemini-1.5-pro": "model://google/gemini-1.5-pro",
"gemini-1.5-flash": "model://google/gemini-1.5-flash",
}
# Check if it's already a model:// URI
if model.startswith("model://"):
return model
# Check mapping
if model in model_map:
return model_map[model]
# Assume it's a provider/model format
if "/" in model:
return f"model://{model}"
# Default to anthropic
return f"model://anthropic/{model}"
async def run(self, state: SwarmState, history: History, network: Network) -> InferenceResult:
"""Execute the swarm agent."""
# Build prompt with context
prompt_parts = []
# Add role context
if self.agent_config.get("role"):
prompt_parts.append(f"Your role: {self.agent_config['role']}")
# Add shared context
if state.context:
prompt_parts.append(f"Context:\n{state.context}")
# Add inputs from connected agents
receives_from = self.agent_config.get("receives_from", [])
if receives_from:
inputs = {}
for agent_id in receives_from:
if agent_id in state.agent_results:
inputs[agent_id] = state.agent_results[agent_id]
if inputs:
prompt_parts.append("Input from previous agents:")
for input_agent, input_result in inputs.items():
prompt_parts.append(f"\n--- From {input_agent} ---\n{input_result}")
# Add file context if specified
if self.agent_config.get("file_path"):
prompt_parts.append(f"\nFile to work on: {self.agent_config['file_path']}")
# Add the main query
prompt_parts.append(f"\nTask: {self.agent_config['query']}")
# Add initial query if this is entry point
if state.current_agent == state.config.get("entry_point"):
prompt_parts.append(f"\nMain objective: {state.initial_query}")
full_prompt = "\n\n".join(prompt_parts)
# Execute using base class
messages = [
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": full_prompt},
]
# Call model
from hanzo_agents import ModelRegistry
adapter = ModelRegistry.get_adapter(self.model)
response = await adapter.chat(messages)
# Store result in state
state.agent_results[self.name] = response
state.completed_agents.add(self.name)
state.execution_order.append(self.name)
# Return result
return InferenceResult(
agent=self.name,
content=response,
metadata={
"agent_id": self.name,
"role": self.agent_config.get("role"),
"connections": self.agent_config.get("connections", []),
},
)
class SwarmRouter(DeterministicRouter):
"""Router for swarm agent orchestration."""
def __init__(self, swarm_config: SwarmConfig):
"""Initialize swarm router."""
self.swarm_config = swarm_config
self.agents_config = swarm_config["agents"]
self.entry_point = swarm_config.get("entry_point")
# Build dependency graph
self.dependencies = {}
self.dependents = {}
for agent_id, config in self.agents_config.items():
# Dependencies (agents this one waits for)
self.dependencies[agent_id] = set(config.get("receives_from", []))
# Dependents (agents that wait for this one)
connections = config.get("connections", [])
for conn in connections:
if conn not in self.dependents:
self.dependents[conn] = set()
self.dependents[conn].add(agent_id)
def route(self, network, call_count, last_result, agent_stack):
"""Determine next agent to execute."""
state = network.state
# First call - start with entry point or roots
if call_count == 0:
if self.entry_point:
state.current_agent = self.entry_point
return self._get_agent_class(self.entry_point, agent_stack)
else:
# Find roots (no dependencies)
roots = [aid for aid, deps in self.dependencies.items() if not deps]
if roots:
state.current_agent = roots[0]
return self._get_agent_class(roots[0], agent_stack)
# Find next agent to execute
for agent_id in self.agents_config:
if agent_id in state.completed_agents:
continue
# Check if all dependencies are met
deps = self.dependencies.get(agent_id, set())
if deps.issubset(state.completed_agents):
state.current_agent = agent_id
return self._get_agent_class(agent_id, agent_stack)
# No more agents to execute
return None
def _get_agent_class(self, agent_id: str, agent_stack: List[type[Agent]]) -> type[Agent]:
"""Get agent class for given agent ID."""
# Find matching agent by name
for agent_class in agent_stack:
if hasattr(agent_class, "name") and agent_class.name == agent_id:
return agent_class
# Not found - this shouldn't happen
return None
@final
class SwarmTool(BaseTool):
"""Tool for executing agent networks using hanzo-agents SDK."""
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "swarm"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute a network of AI agents with flexible connection topologies.
This tool enables sophisticated agent orchestration where agents can be connected
in various network patterns. Each agent can pass results to connected agents,
enabling complex workflows.
Features:
- Flexible agent networks (tree, DAG, pipeline, star, mesh)
- Each agent can use different models (Claude, GPT-4, Gemini, etc.)
- Agents automatically pass results to connected agents
- Parallel execution with dependency management
- Full editing capabilities for each agent
- Memory and state management via hanzo-agents SDK
Common Topologies:
1. Tree (Architect pattern):
architect → [frontend, backend, database] → reviewer
2. Pipeline (Sequential processing):
analyzer → planner → implementer → tester → reviewer
3. Star (Central coordinator):
coordinator ← → [agent1, agent2, agent3, agent4]
4. DAG (Complex dependencies):
Multiple agents with custom connections
Models can be specified as:
- Full: 'anthropic/claude-3-5-sonnet-20241022'
- Short: 'claude-3-5-sonnet', 'gpt-4o', 'gemini-1.5-pro'
- CLI tools: 'claude_cli', 'codex_cli', 'gemini_cli', 'grok_cli'
- Model URIs: 'model://anthropic/claude-3-opus'
"""
def __init__(
self,
permission_manager: PermissionManager,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_tokens: int | None = None,
agent_max_iterations: int = 10,
agent_max_tool_uses: int = 30,
):
"""Initialize the swarm tool."""
self.permission_manager = permission_manager
# Default to latest Claude Sonnet if no model specified
from .code_auth import get_latest_claude_model
self.model = model or f"anthropic/{get_latest_claude_model()}"
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
self.base_url = base_url
self.max_tokens = max_tokens
self.agent_max_iterations = agent_max_iterations
self.agent_max_tool_uses = agent_max_tool_uses
# Set up available tools for agents
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
# Add edit tools
self.available_tools.append(Edit(self.permission_manager))
self.available_tools.append(MultiEdit(self.permission_manager))
# Add batch tool
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
@override
@auto_timeout("swarm")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SwarmToolParams],
) -> str:
"""Execute the swarm tool."""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
config = params.get("config", {})
initial_query = params.get("query", "")
context = params.get("context", "")
max_concurrent = params.get("max_concurrent", 10)
use_memory = params.get("use_memory", False)
memory_backend = params.get("memory_backend", "sqlite")
agents_config = config.get("agents", {})
if not agents_config:
await tool_ctx.error("No agents provided")
return "Error: At least one agent must be provided."
# hanzo-agents SDK is required (already imported above)
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents using hanzo-agents SDK")
# Create state
state = SwarmState(config=config, initial_query=initial_query, context=context)
# Create agent classes dynamically
agent_classes = []
for agent_id, agent_config in agents_config.items():
# Check for CLI agents
model = agent_config.get("model", self.model)
cli_agents = {
"claude_cli": ClaudeCodeAgent,
"codex_cli": OpenAICodexAgent,
"gemini_cli": GeminiAgent,
"grok_cli": GrokAgent,
}
if model in cli_agents:
# Use CLI agent
agent_class = type(
f"Swarm{agent_id}",
(cli_agents[model],),
{
"name": agent_id,
"description": agent_config.get("role", f"Agent {agent_id}"),
"agent_config": agent_config,
},
)
else:
# Create dynamic SwarmAgent class
agent_class = type(
f"Swarm{agent_id}",
(SwarmAgent,),
{
"name": agent_id,
"__init__": lambda self, aid=agent_id, acfg=agent_config: SwarmAgent.__init__(
self,
agent_id=aid,
agent_config=acfg,
available_tools=self.available_tools,
permission_manager=self.permission_manager,
ctx=ctx,
),
},
)
agent_classes.append(agent_class)
# Create memory if requested
memory_kv = None
memory_vector = None
if use_memory:
memory_kv = create_memory_kv(memory_backend)
memory_vector = create_memory_vector("simple")
# Create router
router = SwarmRouter(config)
# Create network
network = Network(
state=state,
agents=agent_classes,
router=router,
memory_kv=memory_kv,
memory_vector=memory_vector,
max_steps=self.agent_max_iterations * len(agents_config),
)
# Execute
try:
final_state = await network.run()
# Format results
return self._format_network_results(
agents_config,
final_state.agent_results,
final_state.execution_order,
config.get("entry_point"),
)
except Exception as e:
await tool_ctx.error(f"Swarm execution failed: {str(e)}")
return f"Error: {str(e)}"
def _format_network_results(
self,
agents_config: Dict[str, Any],
results: Dict[str, str],
execution_order: List[str],
entry_point: Optional[str],
) -> str:
"""Format results from agent network execution."""
output = ["Agent Network Execution Results (hanzo-agents SDK)"]
output.append("=" * 80)
output.append(f"Total agents: {len(agents_config)}")
output.append(f"Completed: {len(results)}")
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
if entry_point:
output.append(f"Entry point: {entry_point}")
output.append(f"\nExecution Order: {' → '.join(execution_order)}")
output.append("-" * 40)
# Detailed results
output.append("\n\nDetailed Results:")
output.append("=" * 80)
for agent_id in execution_order:
if agent_id in results:
config = agents_config.get(agent_id, {})
role = config.get("role", "Agent")
model = config.get("model", "default")
output.append(f"\n### {agent_id} ({role}) [{model}]")
output.append("-" * 40)
result = results[agent_id]
if result.startswith("Error:"):
output.append(result)
else:
# Show first part of result
lines = result.split("\n")
preview_lines = lines[:10]
output.extend(preview_lines)
if len(lines) > 10:
output.append(f"... ({len(lines) - 10} more lines)")
return "\n".join(output)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this swarm tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def swarm(
ctx: MCPContext,
config: dict[str, Any],
query: str,
context: Optional[str] = None,
max_concurrent: int = 10,
use_memory: bool = False,
memory_backend: str = "sqlite",
) -> str:
# Convert to typed format
typed_config = SwarmConfig(
agents=config.get("agents", {}),
entry_point=config.get("entry_point"),
topology=config.get("topology"),
)
return await tool_self.call(
ctx,
config=typed_config,
query=query,
context=context,
max_concurrent=max_concurrent,
use_memory=use_memory,
memory_backend=memory_backend,
)
@@ -0,0 +1,551 @@
"""Swarm tool implementation for parallel and hierarchical agent execution.
This module implements the SwarmTool that enables both parallel execution of multiple
agent instances and hierarchical workflows with specialized roles.
"""
import os
import asyncio
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
TypedDict,
final,
override,
)
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from .agent_tool import AgentTool
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
class AgentNode(TypedDict):
"""Node in the agent network.
Attributes:
id: Unique identifier for this agent
query: The specific query/task for this agent
model: Optional model override (e.g., 'claude-3-5-sonnet', 'gpt-4o')
role: Optional role description (e.g., 'architect', 'frontend', 'reviewer')
connections: List of agent IDs this agent connects to (sends results to)
receives_from: Optional list of agent IDs this agent receives input from
file_path: Optional specific file for the agent to work on
"""
id: str
query: str
model: Optional[str]
role: Optional[str]
connections: Optional[List[str]]
receives_from: Optional[List[str]]
file_path: Optional[str]
class SwarmConfig(TypedDict):
"""Configuration for an agent network.
Attributes:
agents: Dictionary of agent configurations keyed by ID
entry_point: ID of the first agent to execute (optional, defaults to finding roots)
topology: Optional topology type (tree, dag, pipeline, star, mesh)
"""
agents: Dict[str, AgentNode]
entry_point: Optional[str]
topology: Optional[str]
class SwarmToolParams(TypedDict):
"""Parameters for the SwarmTool.
Attributes:
config: Agent network configuration
query: Initial query to send to entry point agent(s)
context: Optional context shared by all agents
max_concurrent: Maximum number of concurrent agents (default: 10)
"""
config: SwarmConfig
query: str
context: Optional[str]
max_concurrent: Optional[int]
@final
class SwarmTool(BaseTool):
"""Tool for executing multiple agent tasks in parallel.
The SwarmTool enables efficient parallel processing of multiple files or tasks
by spawning independent agent instances for each task.
"""
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "swarm"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute a network of AI agents with flexible connection topologies.
This tool enables sophisticated agent orchestration where agents can be connected
in various network patterns. Each agent can pass results to connected agents,
enabling complex workflows.
Features:
- Flexible agent networks (tree, DAG, pipeline, star, mesh)
- Each agent can use different models (Claude, GPT-4, Gemini, etc.)
- Agents automatically pass results to connected agents
- Parallel execution with dependency management
- Full editing capabilities for each agent
Common Topologies:
1. Tree (Architect pattern):
architect → [frontend, backend, database] → reviewer
2. Pipeline (Sequential processing):
analyzer → planner → implementer → tester → reviewer
3. Star (Central coordinator):
coordinator ← → [agent1, agent2, agent3, agent4]
4. DAG (Complex dependencies):
Multiple agents with custom connections
Usage Example:
swarm(
config={
"agents": {
"architect": {
"id": "architect",
"query": "Analyze codebase and create refactoring plan",
"model": "claude-3-5-sonnet",
"connections": ["frontend", "backend", "database"]
},
"frontend": {
"id": "frontend",
"query": "Refactor UI components based on architect's plan",
"role": "Frontend Developer",
"connections": ["reviewer"]
},
"backend": {
"id": "backend",
"query": "Refactor API endpoints based on architect's plan",
"role": "Backend Developer",
"connections": ["reviewer"]
},
"database": {
"id": "database",
"query": "Optimize database schema based on architect's plan",
"role": "Database Expert",
"connections": ["reviewer"]
},
"reviewer": {
"id": "reviewer",
"query": "Review all changes and ensure consistency",
"model": "gpt-4o",
"receives_from": ["frontend", "backend", "database"]
}
},
"entry_point": "architect"
},
query="Refactor the authentication system for better security and performance"
)
Models can be specified as:
- Full: 'anthropic/claude-3-5-sonnet-20241022'
- Short: 'claude-3-5-sonnet', 'gpt-4o', 'gemini-1.5-pro'
- CLI tools: 'claude_cli', 'codex_cli', 'gemini_cli', 'grok_cli'
"""
def __init__(
self,
permission_manager: PermissionManager,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_tokens: int | None = None,
agent_max_iterations: int = 10,
agent_max_tool_uses: int = 30,
):
"""Initialize the swarm tool.
Args:
permission_manager: Permission manager for access control
model: Optional model name override (defaults to Claude Sonnet)
api_key: Optional API key for the model provider
base_url: Optional base URL for the model provider
max_tokens: Optional maximum tokens for model responses
agent_max_iterations: Max iterations per agent (default: 10)
agent_max_tool_uses: Max tool uses per agent (default: 30)
"""
self.permission_manager = permission_manager
# Default to latest Claude Sonnet if no model specified
from .code_auth import get_latest_claude_model
self.model = model or f"anthropic/{get_latest_claude_model()}"
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
self.base_url = base_url
self.max_tokens = max_tokens
self.agent_max_iterations = agent_max_iterations
self.agent_max_tool_uses = agent_max_tool_uses
@override
@auto_timeout("swarm_tool_v1_deprecated")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SwarmToolParams],
) -> str:
"""Execute the swarm tool.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Combined results from all agents
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
agents = params.get("agents", [])
manager_query = params.get("manager_query")
reviewer_query = params.get("reviewer_query")
common_context = params.get("common_context", "")
max_concurrent = params.get("max_concurrent", 10)
if not agents:
await tool_ctx.error("No agents provided")
return "Error: At least one agent must be provided."
# Extract parameters
config = params.get("config", {})
initial_query = params.get("query", "")
context = params.get("context", "")
agents_config = config.get("agents", {})
entry_point = config.get("entry_point")
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents")
# Build agent network
agent_instances = {}
agent_results = {}
execution_queue = asyncio.Queue()
completed_agents = set()
# Create agent instances
for agent_id, agent_config in agents_config.items():
model = agent_config.get("model", self.model)
# Support CLI tools
cli_tools = {
"claude_cli": self._get_cli_tool("claude_cli"),
"codex_cli": self._get_cli_tool("codex_cli"),
"gemini_cli": self._get_cli_tool("gemini_cli"),
"grok_cli": self._get_cli_tool("grok_cli"),
}
if model in cli_tools:
agent = cli_tools[model]
else:
# Regular agent with model
agent = AgentTool(
permission_manager=self.permission_manager,
model=self._normalize_model(model),
api_key=self.api_key,
base_url=self.base_url,
max_tokens=self.max_tokens,
max_iterations=self.agent_max_iterations,
max_tool_uses=self.agent_max_tool_uses,
)
agent_instances[agent_id] = agent
# Find entry points (agents with no incoming connections)
if entry_point:
await execution_queue.put((entry_point, initial_query, {}))
else:
# Find root agents (no receives_from)
roots = []
for agent_id, agent_config in agents_config.items():
if not agent_config.get("receives_from"):
# Check if any other agent connects to this one
has_incoming = False
for other_config in agents_config.values():
if other_config.get("connections") and agent_id in other_config["connections"]:
has_incoming = True
break
if not has_incoming:
roots.append(agent_id)
if not roots:
await tool_ctx.error("No entry point found in agent network")
return "Error: Could not determine entry point for agent network"
for root in roots:
await execution_queue.put((root, initial_query, {}))
# Execute agents in network order
async def execute_agent(agent_id: str, query: str, inputs: Dict[str, str]) -> str:
"""Execute a single agent in the network."""
async with semaphore:
try:
agent_config = agents_config[agent_id]
agent = agent_instances[agent_id]
await tool_ctx.info(f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})")
# Build prompt with context and inputs
prompt_parts = []
# Add role context
if agent_config.get("role"):
prompt_parts.append(f"Your role: {agent_config['role']}")
# Add shared context
if context:
prompt_parts.append(f"Context:\n{context}")
# Add inputs from connected agents
if inputs:
prompt_parts.append("Input from previous agents:")
for input_agent, input_result in inputs.items():
prompt_parts.append(f"\n--- From {input_agent} ---\n{input_result}")
# Add file context if specified
if agent_config.get("file_path"):
prompt_parts.append(f"\nFile to work on: {agent_config['file_path']}")
# Add the main query
prompt_parts.append(f"\nTask: {agent_config['query']}")
# Combine query with initial query if this is entry point
if query and query != agent_config["query"]:
prompt_parts.append(f"\nMain objective: {query}")
full_prompt = "\n\n".join(prompt_parts)
# Execute the agent
result = await agent.call(ctx, prompts=full_prompt)
await tool_ctx.info(f"Agent {agent_id} completed")
return result
except Exception as e:
error_msg = f"Agent {agent_id} failed: {str(e)}"
await tool_ctx.error(error_msg)
return f"Error: {error_msg}"
# Process agent network
running_tasks = set()
while not execution_queue.empty() or running_tasks:
# Start new tasks up to concurrency limit
while not execution_queue.empty() and len(running_tasks) < max_concurrent:
agent_id, query, inputs = await execution_queue.get()
if agent_id not in completed_agents:
# Check if all dependencies are met
agent_config = agents_config[agent_id]
receives_from = agent_config.get("receives_from", [])
# Collect inputs from dependencies
ready = True
for dep in receives_from:
if dep not in agent_results:
ready = False
# Re-queue for later
await execution_queue.put((agent_id, query, inputs))
break
else:
inputs[dep] = agent_results[dep]
if ready:
# Execute agent
task = asyncio.create_task(execute_agent(agent_id, query, inputs))
running_tasks.add(task)
async def handle_completion(task, agent_id=agent_id):
result = await task
agent_results[agent_id] = result
completed_agents.add(agent_id)
running_tasks.discard(task)
# Queue connected agents
agent_config = agents_config[agent_id]
connections = agent_config.get("connections", [])
for next_agent in connections:
if next_agent in agents_config:
await execution_queue.put((next_agent, "", {agent_id: result}))
asyncio.create_task(handle_completion(task))
# Wait a bit if we're at capacity
if running_tasks:
await asyncio.sleep(0.1)
# Wait for all tasks to complete
if running_tasks:
await asyncio.gather(*running_tasks, return_exceptions=True)
# Format results
return self._format_network_results(agents_config, agent_results, entry_point)
def _normalize_model(self, model: str) -> str:
"""Normalize model names to full format."""
model_map = {
"claude-3-5-sonnet": "anthropic/claude-3-5-sonnet-20241022",
"claude-3-opus": "anthropic/claude-3-opus-20240229",
"gpt-4o": "openai/gpt-4o",
"gpt-4": "openai/gpt-4",
"gemini-1.5-pro": "google/gemini-1.5-pro",
"gemini-1.5-flash": "google/gemini-1.5-flash",
}
return model_map.get(model, model)
def _get_cli_tool(self, tool_name: str):
"""Get CLI tool instance."""
# Import here to avoid circular imports
if tool_name == "claude_cli":
from .claude_cli_tool import ClaudeCLITool
return ClaudeCLITool(self.permission_manager)
elif tool_name == "codex_cli":
from .codex_cli_tool import CodexCLITool
return CodexCLITool(self.permission_manager)
elif tool_name == "gemini_cli":
from .gemini_cli_tool import GeminiCLITool
return GeminiCLITool(self.permission_manager)
elif tool_name == "grok_cli":
from .grok_cli_tool import GrokCLITool
return GrokCLITool(self.permission_manager)
return None
def _format_network_results(
self,
agents_config: Dict[str, Any],
results: Dict[str, str],
entry_point: Optional[str],
) -> str:
"""Format results from agent network execution."""
output = ["Agent Network Execution Results"]
output.append("=" * 80)
output.append(f"Total agents: {len(agents_config)}")
output.append(f"Completed: {len(results)}")
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
if entry_point:
output.append(f"Entry point: {entry_point}")
output.append("\nExecution Flow:")
output.append("-" * 40)
# Show results in execution order
def format_agent_tree(agent_id: str, level: int = 0) -> List[str]:
lines = []
indent = " " * level
if agent_id in agents_config:
config = agents_config[agent_id]
role = config.get("role", "Agent")
model = config.get("model", "default")
status = "✅" if agent_id in results and not results[agent_id].startswith("Error:") else "❌"
lines.append(f"{indent}{status} {agent_id} ({role}) [{model}]")
# Show connections
connections = config.get("connections", [])
for conn in connections:
if conn in agents_config:
lines.extend(format_agent_tree(conn, level + 1))
return lines
# Start from entry point or roots
if entry_point:
output.extend(format_agent_tree(entry_point))
else:
# Find roots
roots = []
for agent_id in agents_config:
has_incoming = False
for config in agents_config.values():
if config.get("connections") and agent_id in config["connections"]:
has_incoming = True
break
if not has_incoming:
roots.append(agent_id)
for root in roots:
output.extend(format_agent_tree(root))
# Detailed results
output.append("\n\nDetailed Results:")
output.append("=" * 80)
for agent_id, result in results.items():
config = agents_config.get(agent_id, {})
role = config.get("role", "Agent")
output.append(f"\n### {agent_id} ({role})")
output.append("-" * 40)
if result.startswith("Error:"):
output.append(result)
else:
# Show first part of result
lines = result.split("\n")
preview_lines = lines[:10]
output.extend(preview_lines)
if len(lines) > 10:
output.append(f"... ({len(lines) - 10} more lines)")
return "\n".join(output)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this swarm tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def swarm(
ctx: MCPContext,
config: dict[str, Any],
query: str,
context: Optional[str] = None,
max_concurrent: int = 10,
) -> str:
# Convert to typed format
typed_config = SwarmConfig(
agents=config.get("agents", {}),
entry_point=config.get("entry_point"),
topology=config.get("topology"),
)
return await tool_self.call(
ctx,
config=typed_config,
query=query,
context=context,
max_concurrent=max_concurrent,
)
@@ -0,0 +1,109 @@
"""Tool adapters for converting between MCP tools and OpenAI tools.
This module handles conversion between MCP tool formats and OpenAI function
formats, making MCP tools available to the OpenAI API, and processing tool inputs
and outputs for agent execution.
"""
# Import litellm with warnings suppressed
import warnings
from openai.types import FunctionParameters
from openai.types.chat import ChatCompletionToolParam
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from hanzo_tools.core import BaseTool
def convert_tools_to_openai_functions(
tools: list[BaseTool],
) -> list[ChatCompletionToolParam]:
"""Convert MCP tools to OpenAI function format.
Args:
tools: List of MCP tools
Returns:
List of tools formatted for OpenAI API
"""
openai_tools: list[ChatCompletionToolParam] = []
for tool in tools:
openai_tool: ChatCompletionToolParam = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": convert_tool_parameters(tool),
},
}
openai_tools.append(openai_tool)
return openai_tools
def convert_tool_parameters(tool: BaseTool) -> FunctionParameters:
"""Convert tool parameters to OpenAI format.
Args:
tool: MCP tool
Returns:
Parameter schema in OpenAI format
"""
# Start with a copy of the parameters
params = tool.parameters.copy()
# Ensure the schema has the right format for OpenAI
if "properties" not in params:
params["properties"] = {}
if "type" not in params:
params["type"] = "object"
if "required" not in params:
params["required"] = tool.required
return params
def supports_parallel_function_calling(model: str) -> bool:
"""Check if a model supports parallel function calling.
Args:
model: Model identifier in LiteLLM format (e.g., "openai/gpt-4-turbo-preview")
Returns:
True if the model supports parallel function calling, False otherwise
"""
# Since litellm doesn't have this function, we'll implement a simple check
# based on known models that support parallel function calling
parallel_capable_models = {
# OpenAI models that support parallel function calling
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4-turbo-2024-04-09",
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-3.5-turbo",
"gpt-3.5-turbo-0125",
"gpt-3.5-turbo-1106",
# Anthropic models with tool support
"claude-3-opus",
"claude-3-sonnet",
"claude-3-haiku",
"claude-3-5-sonnet",
"claude-3-5-sonnet-20241022",
}
# Extract model name without provider prefix
model_name = model.split("/")[-1] if "/" in model else model
# Check if the base model name matches any known parallel-capable models
for capable_model in parallel_capable_models:
if model_name.startswith(capable_model):
return True
return False
@@ -0,0 +1,262 @@
"""Unified CLI Tools - DRY implementation using base agent classes.
This module provides the single, clean implementation of all CLI tools
following Python best practices and eliminating all duplication.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from pathlib import Path
from mcp.server import FastMCP
from mcp.server.fastmcp import Context
from hanzo_tools.core import auto_timeout
from ..common.base import BaseTool
from ...core.base_agent import CLIAgent, AgentConfig
from ..common.permissions import PermissionManager
from ...core.model_registry import registry
class UnifiedCLITool(BaseTool, CLIAgent):
"""Unified CLI tool that combines BaseTool and CLIAgent functionality.
MRO: BaseTool first for proper method resolution order.
"""
def __init__(
self,
name: str,
description: str,
cli_command: str,
default_model: str,
permission_manager: Optional[PermissionManager] = None,
):
"""Initialize unified CLI tool.
Args:
name: Tool name
description: Tool description
cli_command: CLI command to execute
default_model: Default model to use
permission_manager: Permission manager for access control
"""
# Initialize CLIAgent with config
config = AgentConfig(model=default_model)
CLIAgent.__init__(self, config)
# Store tool metadata
self._name = name
self._description = description
self._cli_command = cli_command
self.permission_manager = permission_manager
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def cli_command(self) -> str:
return self._cli_command
def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
"""Build the CLI command with model-specific formatting.
Args:
prompt: The prompt
**kwargs: Additional parameters
Returns:
Command arguments list
"""
command = [self.cli_command]
# Get model config from registry
model_config = registry.get(self.config.model)
# Handle different CLI tool formats
if self.cli_command == "claude":
if model_config:
command.extend(["--model", model_config.full_name])
# Claude takes prompt via stdin
return command
elif self.cli_command == "openai":
# OpenAI CLI format
command.extend(["api", "chat.completions.create"])
if model_config:
command.extend(["-m", model_config.full_name])
command.extend(["-g", "user", prompt])
return command
elif self.cli_command in ["gemini", "grok"]:
# Simple format: command --model MODEL prompt
if model_config:
command.extend(["--model", model_config.full_name])
command.append(prompt)
return command
elif self.cli_command == "openhands":
# OpenHands format
command.extend(["run", prompt])
if model_config:
command.extend(["--model", model_config.full_name])
if self.config.working_dir:
command.extend(["--workspace", str(self.config.working_dir)])
return command
elif self.cli_command == "hanzo":
# Hanzo dev format
command.append("dev")
if model_config:
command.extend(["--model", model_config.full_name])
command.extend(["--prompt", prompt])
return command
elif self.cli_command == "cline":
# Cline format
command.append(prompt)
command.append("--no-interactive")
return command
elif self.cli_command == "aider":
# Aider format
if model_config:
command.extend(["--model", model_config.full_name])
command.extend(["--message", prompt])
command.extend(["--yes", "--no-stream"])
return command
elif self.cli_command == "ollama":
# Ollama format for local models
command.extend(["run", self.config.model.replace("ollama/", "")])
command.append(prompt)
return command
# Default format
command.append(prompt)
return command
@auto_timeout("unified_cli_tools")
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
"""Execute the CLI tool via MCP interface.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Execution result
"""
# Update config from params
if params.get("model"):
self.config.model = registry.resolve(params["model"])
if params.get("working_dir"):
self.config.working_dir = Path(params["working_dir"])
if params.get("timeout"):
self.config.timeout = params["timeout"]
# Execute using base agent
result = await self.execute(
params.get("prompt", ""),
context=ctx,
)
return result.content
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def tool_wrapper(
prompt: str,
ctx: Context[Any, Any, Any],
model: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
return await tool_self.call(
ctx,
prompt=prompt,
model=model,
working_dir=working_dir,
timeout=timeout,
)
def create_cli_tools(permission_manager: Optional[PermissionManager] = None) -> Dict[str, UnifiedCLITool]:
"""Create all CLI tools with unified implementation.
Args:
permission_manager: Permission manager for access control
Returns:
Dictionary of tool name to tool instance
"""
tools = {}
# Define all tools with their configurations
tool_configs = [
("claude", "Execute Claude CLI for AI assistance", "claude", "claude"),
("cc", "Claude Code CLI (alias for claude)", "claude", "claude"),
("codex", "Execute OpenAI Codex/GPT-4 CLI", "openai", "gpt-4-turbo"),
("gemini", "Execute Google Gemini CLI", "gemini", "gemini"),
("grok", "Execute xAI Grok CLI", "grok", "grok"),
("openhands", "Execute OpenHands for autonomous coding", "openhands", "claude"),
("oh", "OpenHands CLI (alias)", "openhands", "claude"),
("hanzo_dev", "Execute Hanzo Dev AI assistant", "hanzo", "claude"),
("cline", "Execute Cline for autonomous coding", "cline", "claude"),
("aider", "Execute Aider for AI pair programming", "aider", "gpt-4-turbo"),
]
for name, description, cli_command, default_model in tool_configs:
tools[name] = UnifiedCLITool(
name=name,
description=description,
cli_command=cli_command,
default_model=default_model,
permission_manager=permission_manager,
)
return tools
def register_cli_tools(
mcp_server: FastMCP,
permission_manager: Optional[PermissionManager] = None,
) -> List[BaseTool]:
"""Register all CLI tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
Returns:
List of registered CLI tools
"""
tools = create_cli_tools(permission_manager)
# Register each tool
for tool in tools.values():
tool.register(mcp_server)
return list(tools.values())
__all__ = [
"UnifiedCLITool",
"create_cli_tools",
"register_cli_tools",
]
+44
View File
@@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-agent"
version = "0.1.0"
description = "Agent tools for Hanzo AI - multi-agent orchestration, CLI agents, swarms"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "agent", "swarm", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
full = [
"openai>=1.0.0",
"anthropic>=0.40.0",
]
dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
[project.entry-points."hanzo.tools"]
agent = "hanzo_tools.agent:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
@@ -0,0 +1,2 @@
"""Hanzo Tools namespace package."""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,63 @@
"""Browser automation tools for Hanzo AI.
High-performance async browser automation using Playwright.
Supports shared browser instance for low latency and cross-MCP sharing via CDP.
"""
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolRegistry
from hanzo_tools.browser.browser_tool import (
BrowserTool,
BrowserPool,
create_browser_tool,
launch_browser_server,
browser_tool,
PLAYWRIGHT_AVAILABLE,
)
# Tools list for entry point discovery
TOOLS = [BrowserTool]
__all__ = [
# Main tool
"BrowserTool",
"browser_tool",
"create_browser_tool",
# Browser pool
"BrowserPool",
"launch_browser_server",
# Availability check
"PLAYWRIGHT_AVAILABLE",
# Registration
"TOOLS",
"register_browser_tools",
"register_tools",
]
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register browser tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
**kwargs: Additional arguments (headless, cdp_endpoint)
Returns:
List of registered tools
"""
headless = kwargs.get("headless", True)
cdp_endpoint = kwargs.get("cdp_endpoint")
tool = create_browser_tool(headless=headless, cdp_endpoint=cdp_endpoint)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register all browser tools with the MCP server.
This is the standard entry point called by the tool discovery system.
"""
return register_browser_tools(mcp_server, **kwargs)
@@ -0,0 +1,494 @@
"""High-performance async browser automation tool using Playwright.
Design goals:
- Async-first: All operations are non-blocking
- Shared browser instance: Reuse browser across calls (low latency)
- Connection pooling: Multiple pages/contexts for parallel work
- Cross-MCP sharing: Connect to existing browser via CDP endpoint
"""
import asyncio
import base64
import os
from typing import Annotated, Any, Literal, Optional, ClassVar
from pathlib import Path
import logging
from pydantic import Field
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool
# Playwright import with graceful fallback
try:
from playwright.async_api import async_playwright, Browser, Page, BrowserContext, Playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
Browser = Page = BrowserContext = Playwright = None
logger = logging.getLogger(__name__)
Action = Annotated[
Literal[
"navigate", # Go to URL
"click", # Click element
"type", # Type text into element
"fill", # Fill form field (clears first)
"press", # Press key
"screenshot", # Take screenshot
"snapshot", # Get accessibility tree
"evaluate", # Run JavaScript
"wait", # Wait for selector or time
"close", # Close browser
"tabs", # List/switch tabs
"connect", # Connect to existing browser (CDP)
],
Field(description="Browser action to perform"),
]
class BrowserPool:
"""Shared browser instance pool for high-performance automation.
Features:
- Singleton browser instance across all MCP calls
- Connection to remote browser via CDP (cross-MCP sharing)
- Context pooling for parallel operations
- Automatic cleanup on shutdown
"""
_instance: ClassVar[Optional["BrowserPool"]] = None
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
def __init__(self):
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
self._pages: list[Page] = []
self._headless: bool = True
self._cdp_endpoint: Optional[str] = None
self._initialized: bool = False
@classmethod
async def get_instance(cls) -> "BrowserPool":
"""Get or create the singleton browser pool."""
async with cls._lock:
if cls._instance is None:
cls._instance = BrowserPool()
return cls._instance
@classmethod
async def shutdown(cls) -> None:
"""Shutdown the browser pool."""
async with cls._lock:
if cls._instance is not None:
await cls._instance.close()
cls._instance = None
async def ensure_browser(
self,
headless: bool = True,
cdp_endpoint: Optional[str] = None,
) -> Page:
"""Ensure browser is running, return current page."""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError(
"Playwright not installed. Run: pip install playwright && playwright install chromium"
)
needs_init = (
not self._initialized or
self._page is None or
self._browser is None or
self._cdp_endpoint != cdp_endpoint
)
if needs_init:
if self._initialized:
await self.close()
self._playwright = await async_playwright().start()
self._headless = headless
self._cdp_endpoint = cdp_endpoint
if cdp_endpoint:
logger.info(f"Connecting to browser at {cdp_endpoint}")
self._browser = await self._playwright.chromium.connect_over_cdp(cdp_endpoint)
contexts = self._browser.contexts
if contexts:
self._context = contexts[0]
pages = self._context.pages
if pages:
self._page = pages[0]
self._pages = list(pages)
else:
self._page = await self._context.new_page()
self._pages = [self._page]
else:
self._context = await self._browser.new_context(
viewport={"width": 1280, "height": 720},
)
self._page = await self._context.new_page()
self._pages = [self._page]
else:
self._browser = await self._playwright.chromium.launch(
headless=headless,
args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
self._context = await self._browser.new_context(
viewport={"width": 1280, "height": 720},
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
)
self._page = await self._context.new_page()
self._pages = [self._page]
self._initialized = True
logger.info("Browser initialized")
return self._page
async def new_tab(self) -> Page:
"""Open a new tab and switch to it."""
if not self._context:
raise RuntimeError("Browser not initialized")
page = await self._context.new_page()
self._pages.append(page)
self._page = page
return page
async def switch_tab(self, index: int) -> Page:
"""Switch to tab by index."""
if 0 <= index < len(self._pages):
self._page = self._pages[index]
return self._page
raise ValueError(f"Invalid tab index: {index}")
async def close(self) -> None:
"""Close browser and cleanup."""
if self._browser:
try:
await self._browser.close()
except Exception as e:
logger.warning(f"Error closing browser: {e}")
if self._playwright:
try:
await self._playwright.stop()
except Exception as e:
logger.warning(f"Error stopping playwright: {e}")
self._browser = None
self._context = None
self._page = None
self._pages = []
self._playwright = None
self._initialized = False
logger.info("Browser closed")
@property
def page(self) -> Optional[Page]:
return self._page
@property
def pages(self) -> list[Page]:
return self._pages
class BrowserTool(BaseTool):
"""Unified browser automation tool.
High-performance, async-first browser automation.
Uses shared browser pool for low latency.
"""
name = "browser"
def __init__(self, headless: bool = True, cdp_endpoint: Optional[str] = None):
self.headless = headless
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
self.timeout = 30000
@property
def description(self) -> str:
return """Unified browser automation with Playwright.
High-performance, async-first browser automation.
Uses shared browser instance for low latency.
Supports CDP connection for cross-MCP browser sharing.
Actions:
- navigate: Go to URL (requires url)
- click: Click element (requires selector)
- type: Type text (requires selector, text)
- fill: Fill form field (requires selector, text)
- press: Press key (requires key, optional selector)
- screenshot: Take screenshot (optional full_page)
- snapshot: Get accessibility tree
- evaluate: Run JavaScript (requires code)
- wait: Wait for selector or time
- close: Close browser
- tabs: List or switch tabs
- connect: Connect to existing browser via CDP
Examples:
browser(action="navigate", url="https://example.com")
browser(action="click", selector="button#submit")
browser(action="screenshot", full_page=True)
browser(action="connect", cdp_endpoint="http://localhost:9222")
"""
async def _get_page(self) -> Page:
"""Get page from shared pool."""
pool = await BrowserPool.get_instance()
return await pool.ensure_browser(
headless=self.headless,
cdp_endpoint=self.cdp_endpoint,
)
async def call(
self,
ctx,
action: str,
url: Optional[str] = None,
selector: Optional[str] = None,
ref: Optional[str] = None,
text: Optional[str] = None,
key: Optional[str] = None,
code: Optional[str] = None,
timeout: Optional[int] = None,
full_page: bool = False,
tab_index: Optional[int] = None,
cdp_endpoint: Optional[str] = None,
**kwargs,
) -> dict[str, Any]:
"""Execute browser action."""
return await self.execute(
action=action,
url=url,
selector=selector,
ref=ref,
text=text,
key=key,
code=code,
timeout=timeout,
full_page=full_page,
tab_index=tab_index,
cdp_endpoint=cdp_endpoint,
)
async def execute(
self,
action: str,
url: Optional[str] = None,
selector: Optional[str] = None,
ref: Optional[str] = None,
text: Optional[str] = None,
key: Optional[str] = None,
code: Optional[str] = None,
timeout: Optional[int] = None,
full_page: bool = False,
tab_index: Optional[int] = None,
cdp_endpoint: Optional[str] = None,
) -> dict[str, Any]:
"""Execute browser action."""
pool = await BrowserPool.get_instance()
timeout = timeout or self.timeout
sel = selector or ref
try:
if action == "connect":
endpoint = cdp_endpoint or self.cdp_endpoint
if not endpoint:
return {"error": "cdp_endpoint required for connect"}
page = await pool.ensure_browser(
headless=self.headless,
cdp_endpoint=endpoint,
)
return {
"success": True,
"connected": True,
"endpoint": endpoint,
"url": page.url if page else None,
}
page = await self._get_page()
if action == "navigate":
if not url:
return {"error": "url required for navigate"}
response = await page.goto(url, timeout=timeout, wait_until="domcontentloaded")
return {
"success": True,
"url": page.url,
"title": await page.title(),
"status": response.status if response else None,
}
elif action == "click":
if not sel:
return {"error": "selector required for click"}
await page.click(sel, timeout=timeout)
return {"success": True, "clicked": sel}
elif action == "type":
if not sel or text is None:
return {"error": "selector and text required for type"}
await page.type(sel, text, timeout=timeout)
return {"success": True, "typed": len(text), "selector": sel}
elif action == "fill":
if not sel or text is None:
return {"error": "selector and text required for fill"}
await page.fill(sel, text, timeout=timeout)
return {"success": True, "filled": sel}
elif action == "press":
if not key:
return {"error": "key required for press"}
if sel:
await page.press(sel, key, timeout=timeout)
else:
await page.keyboard.press(key)
return {"success": True, "pressed": key}
elif action == "screenshot":
screenshot_bytes = await page.screenshot(
full_page=full_page,
type="png",
)
return {
"success": True,
"format": "png",
"base64": base64.b64encode(screenshot_bytes).decode(),
"url": page.url,
}
elif action == "snapshot":
tree = await page.accessibility.snapshot()
return {
"success": True,
"url": page.url,
"title": await page.title(),
"snapshot": tree,
}
elif action == "evaluate":
if not code:
return {"error": "code required for evaluate"}
result = await page.evaluate(code)
return {"success": True, "result": result}
elif action == "wait":
if sel:
await page.wait_for_selector(sel, timeout=timeout)
return {"success": True, "found": sel}
elif timeout:
await asyncio.sleep(timeout / 1000)
return {"success": True, "waited_ms": timeout}
else:
return {"error": "selector or timeout required for wait"}
elif action == "close":
await pool.close()
return {"success": True, "closed": True}
elif action == "tabs":
if tab_index is not None:
try:
page = await pool.switch_tab(tab_index)
return {
"success": True,
"switched_to": tab_index,
"url": page.url,
}
except ValueError as e:
return {"error": str(e)}
else:
return {
"success": True,
"count": len(pool.pages),
"tabs": [
{"index": i, "url": p.url}
for i, p in enumerate(pool.pages)
],
}
else:
return {"error": f"Unknown action: {action}"}
except Exception as e:
logger.exception(f"Browser action failed: {action}")
return {"error": str(e), "action": action}
def register(self, mcp_server: FastMCP) -> None:
"""Register the browser tool with an MCP server."""
tool_instance = self
@mcp_server.tool(name=self.name, description=self.description)
async def browser(
action: Action,
url: Annotated[Optional[str], Field(description="URL for navigate action")] = None,
selector: Annotated[Optional[str], Field(description="CSS selector for element")] = None,
ref: Annotated[Optional[str], Field(description="Element reference (alias for selector)")] = None,
text: Annotated[Optional[str], Field(description="Text for type/fill actions")] = None,
key: Annotated[Optional[str], Field(description="Key for press action")] = None,
code: Annotated[Optional[str], Field(description="JavaScript for evaluate")] = None,
timeout: Annotated[Optional[int], Field(description="Timeout in ms")] = None,
full_page: Annotated[bool, Field(description="Full page screenshot")] = False,
tab_index: Annotated[Optional[int], Field(description="Tab index for tabs action")] = None,
cdp_endpoint: Annotated[Optional[str], Field(description="CDP endpoint for connect action")] = None,
) -> dict[str, Any]:
"""Unified browser automation tool (async, high-performance)."""
return await tool_instance.execute(
action=action,
url=url,
selector=selector,
ref=ref,
text=text,
key=key,
code=code,
timeout=timeout,
full_page=full_page,
tab_index=tab_index,
cdp_endpoint=cdp_endpoint,
)
def create_browser_tool(headless: bool = True, cdp_endpoint: Optional[str] = None) -> BrowserTool:
"""Create a browser tool instance."""
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint)
async def launch_browser_server(port: int = 9222, headless: bool = False) -> str:
"""Launch a persistent browser server for cross-MCP sharing.
This launches Chrome with remote debugging enabled.
Multiple MCP instances can connect to this browser.
"""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError("Playwright not installed")
pw = await async_playwright().start()
await pw.chromium.launch(
headless=headless,
args=[
f"--remote-debugging-port={port}",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
endpoint = f"http://localhost:{port}"
logger.info(f"Browser server launched at {endpoint}")
return endpoint
# Default tool instance
browser_tool = BrowserTool()
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.1.0"
description = "Browser automation tools using Playwright - async-first, shared browser pool"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "browser", "playwright", "mcp", "ai", "automation"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
playwright = [
"playwright>=1.49.0",
]
[project.entry-points."hanzo.tools"]
browser = "hanzo_tools.browser:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
@@ -0,0 +1,25 @@
"""Hanzo Tools - Modular tool packages for AI agents.
This is the namespace package for all hanzo-tools-* packages.
Each tool category is a separate installable package:
pip install hanzo-tools-core # Base infrastructure
pip install hanzo-tools-filesystem # File operations
pip install hanzo-tools-shell # Shell/command execution
pip install hanzo-tools-browser # Browser automation
pip install hanzo-tools-agent # Agent orchestration
pip install hanzo-tools-llm # LLM integrations
pip install hanzo-tools-database # Database tools
pip install hanzo-tools-memory # Memory/knowledge base
pip install hanzo-tools-editor # Editor integrations
pip install hanzo-tools-jupyter # Jupyter notebook tools
pip install hanzo-tools-lsp # Language server protocol
pip install hanzo-tools-refactor # Code refactoring
pip install hanzo-tools-vector # Vector search
pip install hanzo-tools-todo # Task management
Or install all at once:
pip install hanzo-tools[all]
"""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,42 @@
"""Core infrastructure for Hanzo tool packages.
Provides:
- BaseTool: Abstract base class for all tools
- ToolRegistry: Tool registration and discovery
- ToolContext: Execution context utilities
- Decorators for timeouts, error handling, etc.
"""
from hanzo_tools.core.base import (
BaseTool,
FileSystemTool,
ToolRegistry,
with_error_logging,
handle_connection_errors,
)
from hanzo_tools.core.context import ToolContext, create_tool_context
from hanzo_tools.core.permissions import PermissionManager
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.validation import ValidationResult, validate_path_parameter
from hanzo_tools.core.types import MCPResourceDocument
__all__ = [
# Base classes
"BaseTool",
"FileSystemTool",
"ToolRegistry",
# Context
"ToolContext",
"create_tool_context",
# Permissions
"PermissionManager",
# Decorators
"auto_timeout",
"with_error_logging",
"handle_connection_errors",
# Validation
"ValidationResult",
"validate_path_parameter",
# Types
"MCPResourceDocument",
]
@@ -0,0 +1,244 @@
"""Base classes for Hanzo tool packages.
Provides the foundation for all tool implementations:
- BaseTool: Abstract base class defining the tool interface
- FileSystemTool: Base class for filesystem operations
- ToolRegistry: Central registry for tool management
"""
import functools
import inspect
import logging
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, final
from collections.abc import Awaitable
from pathlib import Path
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
logger = logging.getLogger(__name__)
def with_error_logging(tool_name: str) -> Callable:
"""Decorator to add comprehensive error logging to tool functions.
Args:
tool_name: Name of the tool for logging purposes
Returns:
Decorator function
"""
log_dir = Path.home() / ".hanzo" / "mcp" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
def decorator(func: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> str:
try:
return await func(*args, **kwargs)
except TypeError as e:
error_msg = str(e)
if "takes" in error_msg and "positional argument" in error_msg:
sig = inspect.signature(func)
logger.error(
f"Tool {tool_name} call signature mismatch: "
f"expected {func.__name__}{sig}, got args={args}, kwargs={kwargs}"
)
logger.exception(f"Tool {tool_name} TypeError: {e}")
return (
f"Error executing tool '{tool_name}': {error_msg}\n\n"
f"Check logs at ~/.hanzo/mcp/logs/ for details."
)
except Exception as e:
logger.exception(f"Tool {tool_name} error: {e}")
return (
f"Error executing tool '{tool_name}': {str(e)}\n\n"
f"Check logs at ~/.hanzo/mcp/logs/ for details."
)
return wrapper
return decorator
def handle_connection_errors(
func: Callable[..., Awaitable[str]],
) -> Callable[..., Awaitable[str]]:
"""Decorator to handle connection errors gracefully."""
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> str:
try:
return await func(*args, **kwargs)
except Exception as e:
error_name = type(e).__name__
if any(
name in error_name
for name in ["ClosedResourceError", "ConnectionError", "BrokenPipeError"]
):
return f"Client disconnected during operation: {error_name}"
raise
return wrapper
class BaseTool(ABC):
"""Abstract base class for all Hanzo tools.
All tool packages must implement this interface to be compatible
with the hanzo-mcp server and tool registry.
Example:
class MyTool(BaseTool):
@property
def name(self) -> str:
return "my_tool"
@property
def description(self) -> str:
return "Does something useful"
async def call(self, ctx, **params) -> str:
return "Result"
def register(self, mcp_server):
@mcp_server.tool()
async def my_tool(...):
return await self.call(...)
"""
@property
@abstractmethod
def name(self) -> str:
"""Get the tool name as it appears in the MCP server."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Get detailed description of the tool's purpose and usage."""
pass
@abstractmethod
async def call(self, ctx: MCPContext, **params: Any) -> Any:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context for the tool call
**params: Tool parameters provided by the caller
Returns:
Tool execution result
"""
pass
@abstractmethod
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Must create a wrapper function with explicit parameters
that calls this tool's call method.
Args:
mcp_server: The FastMCP server instance
"""
pass
class FileSystemTool(BaseTool, ABC):
"""Base class for filesystem-related tools.
Provides common functionality for working with files and directories,
including permission checking and path validation.
"""
def __init__(self, permission_manager: "PermissionManager") -> None:
"""Initialize filesystem tool.
Args:
permission_manager: Permission manager for access control
"""
self.permission_manager = permission_manager
def validate_path(self, path: str, param_name: str = "path") -> "ValidationResult":
"""Validate a path parameter."""
from hanzo_tools.core.validation import validate_path_parameter
return validate_path_parameter(path, param_name)
def is_path_allowed(self, path: str) -> bool:
"""Check if a path is allowed according to permission settings."""
return self.permission_manager.is_path_allowed(path)
@final
class ToolRegistry:
"""Registry for Hanzo tools.
Provides functionality for registering tool implementations
with an MCP server, with support for enable/disable states.
"""
# Class-level storage for tool states
_enabled_tools: ClassVar[dict[str, bool]] = {}
_config_loaded: ClassVar[bool] = False
@classmethod
def _load_config(cls) -> None:
"""Load tool enable/disable states from config."""
if cls._config_loaded:
return
import json
config_file = Path.home() / ".hanzo" / "mcp" / "tool_states.json"
if config_file.exists():
try:
with open(config_file) as f:
cls._enabled_tools = json.load(f)
except Exception:
pass
cls._config_loaded = True
@classmethod
def is_tool_enabled(cls, tool_name: str) -> bool:
"""Check if a tool is enabled."""
cls._load_config()
return cls._enabled_tools.get(tool_name, True) # Enabled by default
@classmethod
def set_tool_enabled(cls, tool_name: str, enabled: bool, persist: bool = True) -> None:
"""Enable or disable a tool."""
import json
cls._load_config()
cls._enabled_tools[tool_name] = enabled
if persist:
config_file = Path.home() / ".hanzo" / "mcp" / "tool_states.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w") as f:
json.dump(cls._enabled_tools, f, indent=2)
@staticmethod
def register_tool(mcp_server: FastMCP, tool: BaseTool) -> None:
"""Register a tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
tool: The tool to register
"""
if ToolRegistry.is_tool_enabled(tool.name):
tool.register(mcp_server)
logger.debug(f"Registered tool: {tool.name}")
else:
logger.debug(f"Skipped disabled tool: {tool.name}")
@staticmethod
def register_tools(mcp_server: FastMCP, tools: list[BaseTool]) -> None:
"""Register multiple tools with the MCP server."""
for tool in tools:
ToolRegistry.register_tool(mcp_server, tool)
# Import PermissionManager type for type hints
from hanzo_tools.core.permissions import PermissionManager
from hanzo_tools.core.validation import ValidationResult
@@ -0,0 +1,67 @@
"""Tool execution context utilities."""
import logging
from typing import Any, Optional
from dataclasses import dataclass, field
from mcp.server.fastmcp import Context as MCPContext
logger = logging.getLogger(__name__)
@dataclass
class ToolContext:
"""Extended context for tool execution.
Provides utilities for logging, progress reporting,
and accessing the MCP context.
"""
mcp_ctx: MCPContext
tool_name: Optional[str] = None
metadata: dict[str, Any] = field(default_factory=dict)
async def set_tool_info(self, tool_name: str) -> None:
"""Set the current tool name for logging."""
self.tool_name = tool_name
async def info(self, message: str) -> None:
"""Log an info message."""
logger.info(f"[{self.tool_name or 'tool'}] {message}")
async def warning(self, message: str) -> None:
"""Log a warning message."""
logger.warning(f"[{self.tool_name or 'tool'}] {message}")
async def error(self, message: str) -> None:
"""Log an error message."""
logger.error(f"[{self.tool_name or 'tool'}] {message}")
async def debug(self, message: str) -> None:
"""Log a debug message."""
logger.debug(f"[{self.tool_name or 'tool'}] {message}")
async def progress(self, current: int, total: int, message: str = "") -> None:
"""Report progress."""
pct = (current / total * 100) if total > 0 else 0
await self.info(f"Progress: {current}/{total} ({pct:.1f}%) {message}")
def get(self, key: str, default: Any = None) -> Any:
"""Get a metadata value."""
return self.metadata.get(key, default)
def set(self, key: str, value: Any) -> None:
"""Set a metadata value."""
self.metadata[key] = value
def create_tool_context(mcp_ctx: MCPContext) -> ToolContext:
"""Create a ToolContext from an MCP context.
Args:
mcp_ctx: The MCP context from the tool call
Returns:
Extended ToolContext for tool execution
"""
return ToolContext(mcp_ctx=mcp_ctx)
@@ -0,0 +1,102 @@
"""Decorators for tool functions."""
import asyncio
import functools
import os
from typing import Any, Callable, Optional
from collections.abc import Awaitable
# Default timeouts per tool (can be overridden via env vars)
DEFAULT_TIMEOUTS: dict[str, int] = {
"read": 30,
"write": 60,
"edit": 60,
"search": 120,
"dag": 600,
"browser": 300,
"default": 120,
}
def get_timeout(tool_name: str) -> int:
"""Get timeout for a tool.
Checks environment variable HANZO_TIMEOUT_{TOOL_NAME} first,
then falls back to defaults.
"""
env_key = f"HANZO_TIMEOUT_{tool_name.upper()}"
if env_val := os.environ.get(env_key):
try:
return int(env_val)
except ValueError:
pass
return DEFAULT_TIMEOUTS.get(tool_name, DEFAULT_TIMEOUTS["default"])
def auto_timeout(
tool_name: str,
timeout: Optional[int] = None,
) -> Callable:
"""Decorator to add automatic timeout to async tool functions.
Args:
tool_name: Name of the tool (for logging and config)
timeout: Override timeout in seconds (optional)
Returns:
Decorator that wraps the function with timeout handling
"""
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
effective_timeout = timeout or get_timeout(tool_name)
try:
return await asyncio.wait_for(
func(*args, **kwargs),
timeout=effective_timeout,
)
except asyncio.TimeoutError:
return f"Tool '{tool_name}' timed out after {effective_timeout}s"
return wrapper
return decorator
def retry(
max_attempts: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
exceptions: tuple = (Exception,),
) -> Callable:
"""Decorator to add retry logic to async functions.
Args:
max_attempts: Maximum number of attempts
delay: Initial delay between retries (seconds)
backoff: Multiplier for delay after each attempt
exceptions: Tuple of exceptions to catch and retry
"""
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
current_delay = delay
last_exception = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
await asyncio.sleep(current_delay)
current_delay *= backoff
raise last_exception
return wrapper
return decorator
@@ -0,0 +1,87 @@
"""Permission management for filesystem tools."""
import os
from pathlib import Path
from typing import Optional
class PermissionManager:
"""Manages filesystem permissions for tools.
Controls which paths tools are allowed to access.
"""
def __init__(
self,
allowed_paths: Optional[list[str | Path]] = None,
deny_patterns: Optional[list[str]] = None,
):
"""Initialize permission manager.
Args:
allowed_paths: List of allowed base paths (defaults to cwd)
deny_patterns: Patterns to deny (e.g., '.git', 'node_modules')
"""
self.allowed_paths: list[Path] = []
if allowed_paths:
for p in allowed_paths:
self.allowed_paths.append(Path(p).resolve())
else:
self.allowed_paths.append(Path.cwd())
self.deny_patterns = deny_patterns or [
'.git',
'__pycache__',
'.pyc',
'node_modules',
'.env',
'.secrets',
]
def is_path_allowed(self, path: str | Path) -> bool:
"""Check if a path is allowed.
Args:
path: Path to check
Returns:
True if path is within allowed paths and not denied
"""
try:
resolved = Path(path).resolve()
# Check if path is under any allowed path
is_under_allowed = any(
self._is_subpath(resolved, allowed)
for allowed in self.allowed_paths
)
if not is_under_allowed:
return False
# Check deny patterns
path_str = str(resolved)
for pattern in self.deny_patterns:
if pattern in path_str:
return False
return True
except Exception:
return False
def _is_subpath(self, path: Path, parent: Path) -> bool:
"""Check if path is under parent."""
try:
path.relative_to(parent)
return True
except ValueError:
return False
def add_allowed_path(self, path: str | Path) -> None:
"""Add a path to the allowed list."""
self.allowed_paths.append(Path(path).resolve())
def add_deny_pattern(self, pattern: str) -> None:
"""Add a deny pattern."""
self.deny_patterns.append(pattern)
@@ -0,0 +1,24 @@
"""Type definitions for Hanzo tool packages."""
import json
from typing import Any, Dict, Optional
from dataclasses import dataclass
@dataclass
class MCPResourceDocument:
"""Resource document returned by MCP tools."""
data: Dict[str, Any]
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary format."""
result = {"data": self.data}
if self.metadata:
result["metadata"] = self.metadata
return result
def to_json_string(self) -> str:
"""Convert to JSON string."""
return json.dumps(self.to_dict(), indent=2)
@@ -0,0 +1,113 @@
"""Validation utilities for tool parameters."""
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class ValidationResult:
"""Result of a validation check."""
is_valid: bool
error_message: Optional[str] = None
def __bool__(self) -> bool:
return self.is_valid
def validate_path_parameter(
path: str,
param_name: str = "path",
must_exist: bool = False,
must_be_file: bool = False,
must_be_dir: bool = False,
) -> ValidationResult:
"""Validate a path parameter.
Args:
path: Path string to validate
param_name: Name of the parameter for error messages
must_exist: If True, path must exist
must_be_file: If True, path must be a file
must_be_dir: If True, path must be a directory
Returns:
ValidationResult with status and error if any
"""
if not path:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is required",
)
if not path.strip():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} cannot be empty",
)
try:
p = Path(path)
if must_exist and not p.exists():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} does not exist: {path}",
)
if must_be_file and p.exists() and not p.is_file():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is not a file: {path}",
)
if must_be_dir and p.exists() and not p.is_dir():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is not a directory: {path}",
)
return ValidationResult(is_valid=True)
except Exception as e:
return ValidationResult(
is_valid=False,
error_message=f"Invalid {param_name}: {e}",
)
def validate_string_parameter(
value: str,
param_name: str,
min_length: int = 0,
max_length: Optional[int] = None,
pattern: Optional[str] = None,
) -> ValidationResult:
"""Validate a string parameter."""
if not value:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is required",
)
if len(value) < min_length:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} must be at least {min_length} characters",
)
if max_length and len(value) > max_length:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} must be at most {max_length} characters",
)
if pattern:
import re
if not re.match(pattern, value):
return ValidationResult(
is_valid=False,
error_message=f"{param_name} does not match required pattern",
)
return ValidationResult(is_valid=True)
+41
View File
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-core"
version = "0.1.0"
description = "Core infrastructure for Hanzo tool packages"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "mcp", "ai"]
dependencies = [
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
"typing-extensions>=4.13.0",
]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"ruff>=0.14.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,107 @@
"""Database tools for Hanzo AI.
This package provides tools for working with embedded SQLite databases
and graph databases in projects.
Tools:
- sql_query: Execute SQL queries
- sql_search: Search database content
- sql_stats: Get database statistics
- graph_add: Add nodes/edges to graph
- graph_remove: Remove from graph
- graph_query: Query graph database
- graph_search: Search graph
- graph_stats: Graph statistics
Install:
pip install hanzo-tools-database
Usage:
from hanzo_tools.database import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server, permission_manager)
"""
from hanzo_tools.core import BaseTool, PermissionManager, ToolRegistry
from .database_manager import DatabaseManager
from .sql_query import SqlQueryTool
from .sql_stats import SqlStatsTool
from .sql_search import SqlSearchTool
from .graph_add import GraphAddTool
from .graph_query import GraphQueryTool
from .graph_stats import GraphStatsTool
from .graph_remove import GraphRemoveTool
from .graph_search import GraphSearchTool
# Export list for tool discovery
TOOLS = [
SqlQueryTool,
SqlSearchTool,
SqlStatsTool,
GraphAddTool,
GraphRemoveTool,
GraphQueryTool,
GraphSearchTool,
GraphStatsTool,
]
__all__ = [
"register_tools",
"TOOLS",
"DatabaseManager",
"SqlQueryTool",
"SqlSearchTool",
"SqlStatsTool",
"GraphAddTool",
"GraphRemoveTool",
"GraphQueryTool",
"GraphSearchTool",
"GraphStatsTool",
]
def register_tools(
mcp_server,
permission_manager: PermissionManager,
db_manager: DatabaseManager | None = None,
enabled_tools: dict[str, bool] | None = None,
) -> list[BaseTool]:
"""Register database tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
db_manager: Optional database manager instance
enabled_tools: Dict of tool_name -> enabled state
Returns:
List of registered tools
"""
# Create database manager if not provided
if db_manager is None:
db_manager = DatabaseManager(permission_manager)
enabled = enabled_tools or {}
registered = []
# Create and register tool instances
tool_instances = [
SqlQueryTool(permission_manager, db_manager),
SqlSearchTool(permission_manager, db_manager),
SqlStatsTool(permission_manager, db_manager),
GraphAddTool(permission_manager, db_manager),
GraphRemoveTool(permission_manager, db_manager),
GraphQueryTool(permission_manager, db_manager),
GraphSearchTool(permission_manager, db_manager),
GraphStatsTool(permission_manager, db_manager),
]
for tool in tool_instances:
tool_name = tool.name if hasattr(tool, 'name') else tool.__class__.__name__.lower()
if enabled.get(tool_name, True): # Enabled by default
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
return registered
@@ -0,0 +1,242 @@
"""Database manager for project-specific SQLite and graph databases."""
import os
import sqlite3
from typing import Dict, List, Optional
from pathlib import Path
from hanzo_tools.core import PermissionManager
class ProjectDatabase:
"""Manages SQLite and graph databases for a project."""
def __init__(self, project_path: str):
self.project_path = Path(project_path)
self.db_dir = self.project_path / ".hanzo" / "db"
self.db_dir.mkdir(parents=True, exist_ok=True)
# SQLite database path
self.sqlite_path = self.db_dir / "project.db"
self.graph_path = self.db_dir / "graph.db"
# Initialize databases
self._init_sqlite()
self._init_graph()
# Keep graph in memory for performance
self.graph_conn = sqlite3.connect(":memory:")
self._init_graph_schema(self.graph_conn)
self._load_graph_from_disk()
def _init_sqlite(self):
"""Initialize SQLite database with common tables."""
conn = sqlite3.connect(self.sqlite_path)
try:
# Create metadata table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# Create files table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
content TEXT,
size INTEGER,
modified_at TIMESTAMP,
hash TEXT,
metadata TEXT
)
"""
)
# Create symbols table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT,
name TEXT,
type TEXT,
line_start INTEGER,
line_end INTEGER,
scope TEXT,
signature TEXT,
FOREIGN KEY (file_path) REFERENCES files(path)
)
"""
)
# Create index for fast searches
conn.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_symbols_type ON symbols(type)")
conn.commit()
finally:
conn.close()
def _init_graph(self):
"""Initialize graph database on disk."""
conn = sqlite3.connect(self.graph_path)
try:
self._init_graph_schema(conn)
conn.commit()
finally:
conn.close()
def _init_graph_schema(self, conn: sqlite3.Connection):
"""Initialize graph database schema."""
# Nodes table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
properties TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# Edges table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS edges (
source TEXT NOT NULL,
target TEXT NOT NULL,
relationship TEXT NOT NULL,
weight REAL DEFAULT 1.0,
properties TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source, target, relationship),
FOREIGN KEY (source) REFERENCES nodes(id),
FOREIGN KEY (target) REFERENCES nodes(id)
)
"""
)
# Indexes for graph traversal
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_relationship ON edges(relationship)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)")
def _load_graph_from_disk(self):
"""Load graph from disk into memory."""
disk_conn = sqlite3.connect(self.graph_path)
try:
# Copy nodes
nodes = disk_conn.execute("SELECT * FROM nodes").fetchall()
self.graph_conn.executemany("INSERT OR REPLACE INTO nodes VALUES (?, ?, ?, ?)", nodes)
# Copy edges
edges = disk_conn.execute("SELECT * FROM edges").fetchall()
self.graph_conn.executemany("INSERT OR REPLACE INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges)
self.graph_conn.commit()
finally:
disk_conn.close()
def _save_graph_to_disk(self):
"""Save in-memory graph to disk."""
disk_conn = sqlite3.connect(self.graph_path)
try:
# Clear existing data
disk_conn.execute("DELETE FROM edges")
disk_conn.execute("DELETE FROM nodes")
# Copy nodes
nodes = self.graph_conn.execute("SELECT * FROM nodes").fetchall()
disk_conn.executemany("INSERT INTO nodes VALUES (?, ?, ?, ?)", nodes)
# Copy edges
edges = self.graph_conn.execute("SELECT * FROM edges").fetchall()
disk_conn.executemany("INSERT INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges)
disk_conn.commit()
finally:
disk_conn.close()
def get_sqlite_connection(self) -> sqlite3.Connection:
"""Get SQLite connection."""
conn = sqlite3.connect(self.sqlite_path)
conn.row_factory = sqlite3.Row
return conn
def get_graph_connection(self) -> sqlite3.Connection:
"""Get in-memory graph connection."""
return self.graph_conn
def close(self):
"""Close connections and save graph to disk."""
self._save_graph_to_disk()
self.graph_conn.close()
class DatabaseManager:
"""Manages databases for multiple projects."""
def __init__(self, permission_manager: PermissionManager):
self.permission_manager = permission_manager
self.projects: Dict[str, ProjectDatabase] = {}
self.search_paths: List[str] = []
def add_search_path(self, path: str):
"""Add a path to search for projects."""
if path not in self.search_paths:
self.search_paths.append(path)
def get_project_db(self, project_path: str) -> ProjectDatabase:
"""Get or create database for a project."""
project_path = os.path.abspath(project_path)
# Check permissions
if not self.permission_manager.is_path_allowed(project_path):
raise PermissionError(f"No permission to access: {project_path}")
# Create database if not exists
if project_path not in self.projects:
self.projects[project_path] = ProjectDatabase(project_path)
return self.projects[project_path]
def get_project_for_path(self, file_path: str) -> Optional[ProjectDatabase]:
"""Find the project database for a given file path."""
file_path = os.path.abspath(file_path)
# Check if file is in a known project
for project_path in self.projects:
if file_path.startswith(project_path):
return self.projects[project_path]
# Search up the directory tree for a project
current = Path(file_path)
if current.is_file():
current = current.parent
while current != current.parent:
# Check for project markers
if (current / ".git").exists() or (current / "LLM.md").exists():
return self.get_project_db(str(current))
current = current.parent
# No project found, use the directory of the file
if Path(file_path).is_file():
return self.get_project_db(str(Path(file_path).parent))
else:
return self.get_project_db(file_path)
def close_all(self):
"""Close all project databases."""
for db in self.projects.values():
db.close()
self.projects.clear()
@@ -0,0 +1,528 @@
"""Unified graph database tool."""
import json
from typing import (
Any,
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 hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
# Parameter types
Action = Annotated[
str,
Field(
description="Action: query (default), add, remove, search, stats",
default="query",
),
]
NodeId = Annotated[
Optional[str],
Field(
description="Node ID",
default=None,
),
]
NodeType = Annotated[
Optional[str],
Field(
description="Node type/label",
default=None,
),
]
EdgeType = Annotated[
Optional[str],
Field(
description="Edge type/relationship",
default=None,
),
]
FromNode = Annotated[
Optional[str],
Field(
description="Source node ID for edges",
default=None,
),
]
ToNode = Annotated[
Optional[str],
Field(
description="Target node ID for edges",
default=None,
),
]
Properties = Annotated[
Optional[Dict[str, Any]],
Field(
description="Node/edge properties as JSON",
default=None,
),
]
Pattern = Annotated[
Optional[str],
Field(
description="Search pattern for properties",
default=None,
),
]
Depth = Annotated[
int,
Field(
description="Max traversal depth for queries",
default=2,
),
]
Limit = Annotated[
int,
Field(
description="Maximum results to return",
default=50,
),
]
class GraphParams(TypedDict, total=False):
"""Parameters for graph tool."""
action: str
node_id: Optional[str]
node_type: Optional[str]
edge_type: Optional[str]
from_node: Optional[str]
to_node: Optional[str]
properties: Optional[Dict[str, Any]]
pattern: Optional[str]
depth: int
limit: int
@final
class GraphTool(BaseTool):
"""Unified graph database tool."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph tool."""
super().__init__(permission_manager)
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Graph database. Actions: query (default), add, remove, search, stats.
Usage:
graph --node-id user123
graph --action add --node-id user123 --node-type User --properties '{"name": "John"}'
graph --action add --from-node user123 --to-node post456 --edge-type CREATED
graph --action query --node-id user123 --depth 3
graph --action search --pattern "John" --node-type User
"""
@override
@auto_timeout("graph")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphParams],
) -> str:
"""Execute graph operation."""
tool_ctx = self.create_tool_context(ctx)
# Get current project database
project_db = self.db_manager.get_current_project_db()
if not project_db:
return "Error: No project database found. Are you in a project directory?"
# Extract action
action = params.get("action", "query")
# Route to appropriate handler
if action == "query":
return await self._handle_query(project_db, params, tool_ctx)
elif action == "add":
return await self._handle_add(project_db, params, tool_ctx)
elif action == "remove":
return await self._handle_remove(project_db, params, tool_ctx)
elif action == "search":
return await self._handle_search(project_db, params, tool_ctx)
elif action == "stats":
return await self._handle_stats(project_db, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: query, add, remove, search, stats"
async def _handle_query(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Query graph relationships."""
node_id = params.get("node_id")
node_type = params.get("node_type")
depth = params.get("depth", 2)
limit = params.get("limit", 50)
if not node_id and not node_type:
return "Error: node_id or node_type required for query"
try:
with project_db.get_graph_connection() as conn:
results = []
if node_id:
# Query specific node and its relationships
cursor = conn.execute(
"""
WITH RECURSIVE
node_tree(id, type, properties, depth, path) AS (
SELECT id, type, properties, 0, id
FROM nodes
WHERE id = ?
UNION ALL
SELECT n.id, n.type, n.properties, nt.depth + 1,
nt.path || ' -> ' || n.id
FROM nodes n
JOIN edges e ON (e.to_node = n.id OR e.from_node = n.id)
JOIN node_tree nt ON (
(e.from_node = nt.id AND e.to_node = n.id) OR
(e.to_node = nt.id AND e.from_node = n.id)
)
WHERE nt.depth < ?
)
SELECT DISTINCT * FROM node_tree
ORDER BY depth, id
LIMIT ?
""",
(node_id, depth, limit),
)
nodes = cursor.fetchall()
# Get edges
cursor = conn.execute(
"""
SELECT from_node, to_node, type, properties
FROM edges
WHERE from_node IN (SELECT id FROM node_tree)
OR to_node IN (SELECT id FROM node_tree)
"""
)
edges = cursor.fetchall()
else:
# Query by type
cursor = conn.execute(
"""
SELECT id, type, properties
FROM nodes
WHERE type = ?
LIMIT ?
""",
(node_type, limit),
)
nodes = cursor.fetchall()
edges = []
# Format results
output = ["=== Graph Query Results ==="]
if nodes:
output.append(f"\nNodes ({len(nodes)}):")
for node in nodes:
props = json.loads(node[2]) if node[2] else {}
output.append(f" {node[0]} [{node[1]}] {props}")
if edges:
output.append(f"\nEdges ({len(edges)}):")
for edge in edges:
props = json.loads(edge[3]) if edge[3] else {}
output.append(f" {edge[0]} --[{edge[2]}]--> {edge[1]} {props}")
if not nodes and not edges:
output.append("No results found")
return "\n".join(output)
except Exception as e:
await tool_ctx.error(f"Query failed: {str(e)}")
return f"Error during query: {str(e)}"
async def _handle_add(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Add nodes or edges."""
node_id = params.get("node_id")
from_node = params.get("from_node")
to_node = params.get("to_node")
if node_id:
# Add node
node_type = params.get("node_type")
if not node_type:
return "Error: node_type required when adding node"
properties = params.get("properties", {})
try:
with project_db.get_graph_connection() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO nodes (id, type, properties)
VALUES (?, ?, ?)
""",
(node_id, node_type, json.dumps(properties)),
)
conn.commit()
await tool_ctx.info(f"Added node: {node_id}")
return f"Added node {node_id} [{node_type}]"
except Exception as e:
await tool_ctx.error(f"Failed to add node: {str(e)}")
return f"Error adding node: {str(e)}"
elif from_node and to_node:
# Add edge
edge_type = params.get("edge_type", "RELATED")
properties = params.get("properties", {})
try:
with project_db.get_graph_connection() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO edges (from_node, to_node, type, properties)
VALUES (?, ?, ?, ?)
""",
(from_node, to_node, edge_type, json.dumps(properties)),
)
conn.commit()
await tool_ctx.info(f"Added edge: {from_node} -> {to_node}")
return f"Added edge {from_node} --[{edge_type}]--> {to_node}"
except Exception as e:
await tool_ctx.error(f"Failed to add edge: {str(e)}")
return f"Error adding edge: {str(e)}"
else:
return "Error: Either node_id (for node) or from_node + to_node (for edge) required"
async def _handle_remove(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Remove nodes or edges."""
node_id = params.get("node_id")
from_node = params.get("from_node")
to_node = params.get("to_node")
if node_id:
# Remove node and its edges
try:
with project_db.get_graph_connection() as conn:
# Delete edges first
cursor = conn.execute(
"""
DELETE FROM edges
WHERE from_node = ? OR to_node = ?
""",
(node_id, node_id),
)
edges_deleted = cursor.rowcount
# Delete node
cursor = conn.execute(
"""
DELETE FROM nodes WHERE id = ?
""",
(node_id,),
)
if cursor.rowcount == 0:
return f"Node {node_id} not found"
conn.commit()
msg = f"Removed node {node_id}"
if edges_deleted > 0:
msg += f" and {edges_deleted} connected edges"
await tool_ctx.info(msg)
return msg
except Exception as e:
await tool_ctx.error(f"Failed to remove node: {str(e)}")
return f"Error removing node: {str(e)}"
elif from_node and to_node:
# Remove specific edge
edge_type = params.get("edge_type")
try:
with project_db.get_graph_connection() as conn:
if edge_type:
cursor = conn.execute(
"""
DELETE FROM edges
WHERE from_node = ? AND to_node = ? AND type = ?
""",
(from_node, to_node, edge_type),
)
else:
cursor = conn.execute(
"""
DELETE FROM edges
WHERE from_node = ? AND to_node = ?
""",
(from_node, to_node),
)
if cursor.rowcount == 0:
return f"Edge not found"
conn.commit()
return f"Removed edge {from_node} --> {to_node}"
except Exception as e:
await tool_ctx.error(f"Failed to remove edge: {str(e)}")
return f"Error removing edge: {str(e)}"
else:
return "Error: Either node_id or from_node + to_node required for remove"
async def _handle_search(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Search graph by pattern."""
pattern = params.get("pattern")
if not pattern:
return "Error: pattern required for search"
node_type = params.get("node_type")
limit = params.get("limit", 50)
try:
with project_db.get_graph_connection() as conn:
# Search in properties
if node_type:
cursor = conn.execute(
"""
SELECT id, type, properties
FROM nodes
WHERE type = ? AND properties LIKE ?
LIMIT ?
""",
(node_type, f"%{pattern}%", limit),
)
else:
cursor = conn.execute(
"""
SELECT id, type, properties
FROM nodes
WHERE properties LIKE ?
LIMIT ?
""",
(f"%{pattern}%", limit),
)
results = cursor.fetchall()
if not results:
return f"No nodes found matching '{pattern}'"
# Format results
output = [f"=== Graph Search Results for '{pattern}' ==="]
output.append(f"Found {len(results)} nodes\n")
for node in results:
props = json.loads(node[2]) if node[2] else {}
output.append(f"{node[0]} [{node[1]}] {props}")
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_stats(self, project_db, tool_ctx) -> str:
"""Get graph statistics."""
try:
with project_db.get_graph_connection() as conn:
# Node stats
cursor = conn.execute(
"""
SELECT type, COUNT(*) as count
FROM nodes
GROUP BY type
ORDER BY count DESC
"""
)
node_stats = cursor.fetchall()
# Edge stats
cursor = conn.execute(
"""
SELECT type, COUNT(*) as count
FROM edges
GROUP BY type
ORDER BY count DESC
"""
)
edge_stats = cursor.fetchall()
# Total counts
cursor = conn.execute("SELECT COUNT(*) FROM nodes")
total_nodes = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM edges")
total_edges = cursor.fetchone()[0]
# Format output
output = [f"=== Graph Database Statistics ==="]
output.append(f"Project: {project_db.project_path}")
output.append(f"\nTotal nodes: {total_nodes}")
output.append(f"Total edges: {total_edges}")
if node_stats:
output.append("\nNodes by type:")
for node_type, count in node_stats:
output.append(f" {node_type}: {count}")
if edge_stats:
output.append("\nEdges by type:")
for edge_type, count in edge_stats:
output.append(f" {edge_type}: {count}")
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)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,265 @@
"""Graph add tool for adding nodes and edges to the graph database."""
import json
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
NodeId = Annotated[
Optional[str],
Field(
description="Node ID to add (required for nodes)",
default=None,
),
]
NodeType = Annotated[
Optional[str],
Field(
description="Node type (e.g., 'file', 'function', 'class')",
default=None,
),
]
Properties = Annotated[
Optional[dict],
Field(
description="Properties as JSON object",
default=None,
),
]
Source = Annotated[
Optional[str],
Field(
description="Source node ID (required for edges)",
default=None,
),
]
Target = Annotated[
Optional[str],
Field(
description="Target node ID (required for edges)",
default=None,
),
]
Relationship = Annotated[
Optional[str],
Field(
description="Edge relationship type (e.g., 'imports', 'calls', 'inherits')",
default=None,
),
]
Weight = Annotated[
float,
Field(
description="Edge weight (default 1.0)",
default=1.0,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
class GraphAddParams(TypedDict, total=False):
"""Parameters for graph add tool."""
node_id: Optional[str]
node_type: Optional[str]
properties: Optional[dict]
source: Optional[str]
target: Optional[str]
relationship: Optional[str]
weight: float
project_path: Optional[str]
@final
class GraphAddTool(BaseTool):
"""Tool for adding nodes and edges to graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph add tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph_add"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Add nodes and edges to the project's graph database.
To add a node:
- Provide node_id and node_type
- Optionally add properties as JSON
To add an edge:
- Provide source, target, and relationship
- Optionally add weight and properties
Common node types:
- file, function, class, module, variable
Common relationships:
- imports, calls, inherits, contains, references, depends_on
Examples:
- graph_add --node-id "main.py" --node-type "file" --properties '{"size": 1024}'
- graph_add --node-id "MyClass" --node-type "class" --properties '{"file": "main.py"}'
- graph_add --source "main.py" --target "utils.py" --relationship "imports"
- graph_add --source "func1" --target "func2" --relationship "calls" --weight 5.0
"""
@override
@auto_timeout("graph_add")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphAddParams],
) -> str:
"""Add nodes or edges to graph.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of add operation
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
node_id = params.get("node_id")
node_type = params.get("node_type")
properties = params.get("properties", {})
source = params.get("source")
target = params.get("target")
relationship = params.get("relationship")
weight = params.get("weight", 1.0)
project_path = params.get("project_path")
# Determine if adding node or edge
is_node = node_id is not None
is_edge = source is not None and target is not None
if not is_node and not is_edge:
return "Error: Must provide either (node_id and node_type) for a node, or (source, target, relationship) for an edge"
if is_node and is_edge:
return "Error: Cannot add both node and edge in one operation"
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
# Get graph connection
graph_conn = project_db.get_graph_connection()
try:
if is_node:
# Add node
if not node_type:
return "Error: node_type is required when adding a node"
await tool_ctx.info(f"Adding node: {node_id} (type: {node_type})")
# Serialize properties
properties_json = json.dumps(properties) if properties else None
# Insert or update node
graph_conn.execute(
"""
INSERT OR REPLACE INTO nodes (id, type, properties)
VALUES (?, ?, ?)
""",
(node_id, node_type, properties_json),
)
graph_conn.commit()
return f"Successfully added node '{node_id}' of type '{node_type}'"
else:
# Add edge
if not relationship:
return "Error: relationship is required when adding an edge"
await tool_ctx.info(f"Adding edge: {source} --[{relationship}]--> {target}")
# Check if nodes exist
cursor = graph_conn.cursor()
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (source, target))
existing = [row[0] for row in cursor.fetchall()]
if source not in existing:
return f"Error: Source node '{source}' does not exist"
if target not in existing:
return f"Error: Target node '{target}' does not exist"
# Serialize properties
properties_json = json.dumps(properties) if properties else None
# Insert or update edge
graph_conn.execute(
"""
INSERT OR REPLACE INTO edges (source, target, relationship, weight, properties)
VALUES (?, ?, ?, ?, ?)
""",
(source, target, relationship, weight, properties_json),
)
graph_conn.commit()
# Save to disk
project_db._save_graph_to_disk()
return f"Successfully added edge: {source} --[{relationship}]--> {target} (weight: {weight})"
except Exception as e:
await tool_ctx.error(f"Failed to add to graph: {str(e)}")
return f"Error adding to graph: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,591 @@
"""Graph query tool for querying the graph database."""
import json
import sqlite3
from typing import (
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from collections import deque
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
Query = Annotated[
str,
Field(
description="Query type: neighbors, path, subgraph, connected, ancestors, descendants",
min_length=1,
),
]
NodeId = Annotated[
Optional[str],
Field(
description="Starting node ID",
default=None,
),
]
TargetId = Annotated[
Optional[str],
Field(
description="Target node ID (for path queries)",
default=None,
),
]
Depth = Annotated[
int,
Field(
description="Maximum depth for traversal",
default=2,
),
]
Relationship = Annotated[
Optional[str],
Field(
description="Filter by relationship type",
default=None,
),
]
NodeType = Annotated[
Optional[str],
Field(
description="Filter by node type",
default=None,
),
]
Direction = Annotated[
str,
Field(
description="Direction: both, incoming, outgoing",
default="both",
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
class GraphQueryParams(TypedDict, total=False):
"""Parameters for graph query tool."""
query: str
node_id: Optional[str]
target_id: Optional[str]
depth: int
relationship: Optional[str]
node_type: Optional[str]
direction: str
project_path: Optional[str]
@final
class GraphQueryTool(BaseTool):
"""Tool for querying the graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph query tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph_query"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Query the project's graph database for relationships and patterns.
Query types:
- neighbors: Find direct neighbors of a node
- path: Find shortest path between two nodes
- subgraph: Get subgraph around a node up to depth
- connected: Find all nodes connected to a node
- ancestors: Find nodes that point TO this node
- descendants: Find nodes that this node points TO
Options:
- --depth: Max traversal depth (default 2)
- --relationship: Filter by edge type
- --node-type: Filter by node type
- --direction: both, incoming, outgoing
Examples:
- graph_query --query neighbors --node-id "main.py"
- graph_query --query path --node-id "main.py" --target-id "utils.py"
- graph_query --query subgraph --node-id "MyClass" --depth 3
- graph_query --query ancestors --node-id "error_handler" --relationship "calls"
- graph_query --query descendants --node-id "BaseClass" --relationship "inherits"
"""
@override
@auto_timeout("graph_query")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphQueryParams],
) -> str:
"""Execute graph query.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Query results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
query = params.get("query")
if not query:
return "Error: query is required"
node_id = params.get("node_id")
target_id = params.get("target_id")
depth = params.get("depth", 2)
relationship = params.get("relationship")
node_type = params.get("node_type")
direction = params.get("direction", "both")
project_path = params.get("project_path")
# Validate query type
valid_queries = [
"neighbors",
"path",
"subgraph",
"connected",
"ancestors",
"descendants",
]
if query not in valid_queries:
return f"Error: Invalid query '{query}'. Must be one of: {', '.join(valid_queries)}"
# Validate required parameters
if query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"] and not node_id:
return f"Error: node_id is required for '{query}' query"
if query == "path" and (not node_id or not target_id):
return "Error: Both node_id and target_id are required for 'path' query"
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
# Get graph connection
graph_conn = project_db.get_graph_connection()
await tool_ctx.info(f"Executing {query} query")
try:
if query == "neighbors":
return self._query_neighbors(graph_conn, node_id, relationship, node_type, direction)
elif query == "path":
return self._query_path(graph_conn, node_id, target_id, relationship)
elif query == "subgraph":
return self._query_subgraph(graph_conn, node_id, depth, relationship, node_type, direction)
elif query == "connected":
return self._query_connected(graph_conn, node_id, relationship, node_type, direction)
elif query == "ancestors":
return self._query_ancestors(graph_conn, node_id, depth, relationship, node_type)
elif query == "descendants":
return self._query_descendants(graph_conn, node_id, depth, relationship, node_type)
except Exception as e:
await tool_ctx.error(f"Failed to execute query: {str(e)}")
return f"Error executing query: {str(e)}"
def _query_neighbors(
self,
conn: sqlite3.Connection,
node_id: str,
relationship: Optional[str],
node_type: Optional[str],
direction: str,
) -> str:
"""Get direct neighbors of a node."""
cursor = conn.cursor()
# Check if node exists
cursor.execute("SELECT type, properties FROM nodes WHERE id = ?", (node_id,))
node_info = cursor.fetchone()
if not node_info:
return f"Error: Node '{node_id}' not found"
neighbors = []
# Get outgoing edges
if direction in ["both", "outgoing"]:
query = """SELECT e.target, e.relationship, e.weight, n.type, n.properties
FROM edges e JOIN nodes n ON e.target = n.id
WHERE e.source = ?"""
params = [node_id]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
for row in cursor.fetchall():
neighbors.append(
{
"direction": "outgoing",
"node_id": row[0],
"relationship": row[1],
"weight": row[2],
"node_type": row[3],
"properties": json.loads(row[4]) if row[4] else {},
}
)
# Get incoming edges
if direction in ["both", "incoming"]:
query = """SELECT e.source, e.relationship, e.weight, n.type, n.properties
FROM edges e JOIN nodes n ON e.source = n.id
WHERE e.target = ?"""
params = [node_id]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
for row in cursor.fetchall():
neighbors.append(
{
"direction": "incoming",
"node_id": row[0],
"relationship": row[1],
"weight": row[2],
"node_type": row[3],
"properties": json.loads(row[4]) if row[4] else {},
}
)
if not neighbors:
return f"No neighbors found for node '{node_id}'"
# Format output
output = [f"Neighbors of '{node_id}' ({node_info[0]}):\n"]
for n in neighbors:
arrow = "<--" if n["direction"] == "incoming" else "-->"
output.append(f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})")
if n["properties"]:
output.append(f" Properties: {json.dumps(n['properties'], indent=6)[:100]}")
output.append(f"\nTotal neighbors: {len(neighbors)}")
return "\n".join(output)
def _query_path(
self,
conn: sqlite3.Connection,
start: str,
end: str,
relationship: Optional[str],
) -> str:
"""Find shortest path between two nodes using BFS."""
cursor = conn.cursor()
# Check if nodes exist
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (start, end))
existing = [row[0] for row in cursor.fetchall()]
if start not in existing:
return f"Error: Start node '{start}' not found"
if end not in existing:
return f"Error: End node '{end}' not found"
# BFS to find shortest path
queue = deque([(start, [start])])
visited = {start}
while queue:
current, path = queue.popleft()
if current == end:
# Found path, get edge details
output = [f"Shortest path from '{start}' to '{end}':\n"]
for i in range(len(path) - 1):
src, tgt = path[i], path[i + 1]
# Get edge details
query = "SELECT relationship, weight FROM edges WHERE source = ? AND target = ?"
cursor.execute(query, (src, tgt))
edge = cursor.fetchone()
if edge:
output.append(f" {src} --[{edge[0]}]--> {tgt}")
else:
output.append(f" {src} --> {tgt}")
output.append(f"\nPath length: {len(path) - 1} edge(s)")
return "\n".join(output)
# Get neighbors
query = "SELECT target FROM edges WHERE source = ?"
params = [current]
if relationship:
query += " AND relationship = ?"
params.append(relationship)
cursor.execute(query, params)
for (neighbor,) in cursor.fetchall():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return f"No path found from '{start}' to '{end}'" + (
f" with relationship '{relationship}'" if relationship else ""
)
def _query_subgraph(
self,
conn: sqlite3.Connection,
node_id: str,
depth: int,
relationship: Optional[str],
node_type: Optional[str],
direction: str,
) -> str:
"""Get subgraph around a node up to specified depth."""
cursor = conn.cursor()
# Check if node exists
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
if not cursor.fetchone():
return f"Error: Node '{node_id}' not found"
# BFS to collect nodes and edges
nodes = {node_id: 0} # node_id -> depth
edges = set() # (source, target, relationship)
queue = deque([(node_id, 0)])
while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
continue
# Get edges based on direction
if direction in ["both", "outgoing"]:
query = """SELECT e.target, e.relationship, n.type
FROM edges e JOIN nodes n ON e.target = n.id
WHERE e.source = ?"""
params = [current]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
for target, rel, _ in cursor.fetchall():
edges.add((current, target, rel))
if target not in nodes or nodes[target] > current_depth + 1:
nodes[target] = current_depth + 1
queue.append((target, current_depth + 1))
if direction in ["both", "incoming"]:
query = """SELECT e.source, e.relationship, n.type
FROM edges e JOIN nodes n ON e.source = n.id
WHERE e.target = ?"""
params = [current]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
for source, rel, _ in cursor.fetchall():
edges.add((source, current, rel))
if source not in nodes or nodes[source] > current_depth + 1:
nodes[source] = current_depth + 1
queue.append((source, current_depth + 1))
# Format output
output = [f"Subgraph around '{node_id}' (depth={depth}):\n"]
output.append(f"Nodes ({len(nodes)}):")
# Get node details
for node, d in sorted(nodes.items(), key=lambda x: (x[1], x[0])):
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node,))
node_type = cursor.fetchone()[0]
output.append(f" [{d}] {node} ({node_type})")
output.append(f"\nEdges ({len(edges)}):")
for src, tgt, rel in sorted(edges):
output.append(f" {src} --[{rel}]--> {tgt}")
return "\n".join(output)
def _query_connected(
self,
conn: sqlite3.Connection,
node_id: str,
relationship: Optional[str],
node_type: Optional[str],
direction: str,
) -> str:
"""Find all nodes connected to a node (transitive closure)."""
cursor = conn.cursor()
# Check if node exists
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
if not cursor.fetchone():
return f"Error: Node '{node_id}' not found"
# BFS to find all connected nodes
visited = {node_id}
queue = deque([node_id])
connections = [] # (node_id, node_type, distance)
distance = {node_id: 0}
while queue:
current = queue.popleft()
current_dist = distance[current]
# Get edges based on direction
neighbors = []
if direction in ["both", "outgoing"]:
query = """SELECT e.target, n.type FROM edges e
JOIN nodes n ON e.target = n.id
WHERE e.source = ?"""
params = [current]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
neighbors.extend(cursor.fetchall())
if direction in ["both", "incoming"]:
query = """SELECT e.source, n.type FROM edges e
JOIN nodes n ON e.source = n.id
WHERE e.target = ?"""
params = [current]
if relationship:
query += " AND e.relationship = ?"
params.append(relationship)
if node_type:
query += " AND n.type = ?"
params.append(node_type)
cursor.execute(query, params)
neighbors.extend(cursor.fetchall())
for neighbor, n_type in neighbors:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
distance[neighbor] = current_dist + 1
connections.append((neighbor, n_type, current_dist + 1))
if not connections:
return f"No connected nodes found for '{node_id}'"
# Format output
output = [f"Nodes connected to '{node_id}' ({direction}):"]
output.append(f"\nTotal connected: {len(connections)}\n")
# Group by distance
by_distance = {}
for node, n_type, dist in connections:
if dist not in by_distance:
by_distance[dist] = []
by_distance[dist].append((node, n_type))
for dist in sorted(by_distance.keys()):
output.append(f"Distance {dist}:")
for node, n_type in sorted(by_distance[dist]):
output.append(f" {node} ({n_type})")
return "\n".join(output)
def _query_ancestors(
self,
conn: sqlite3.Connection,
node_id: str,
depth: int,
relationship: Optional[str],
node_type: Optional[str],
) -> str:
"""Find nodes that point TO this node (incoming edges only)."""
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "incoming")
def _query_descendants(
self,
conn: sqlite3.Connection,
node_id: str,
depth: int,
relationship: Optional[str],
node_type: Optional[str],
) -> str:
"""Find nodes that this node points TO (outgoing edges only)."""
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "outgoing")
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,268 @@
"""Graph remove tool for removing nodes and edges from the graph database."""
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
NodeId = Annotated[
Optional[str],
Field(
description="Node ID to remove",
default=None,
),
]
Source = Annotated[
Optional[str],
Field(
description="Source node ID (for edge removal)",
default=None,
),
]
Target = Annotated[
Optional[str],
Field(
description="Target node ID (for edge removal)",
default=None,
),
]
Relationship = Annotated[
Optional[str],
Field(
description="Edge relationship type (for edge removal)",
default=None,
),
]
Cascade = Annotated[
bool,
Field(
description="Cascade delete - remove all connected edges when removing a node",
default=True,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
class GraphRemoveParams(TypedDict, total=False):
"""Parameters for graph remove tool."""
node_id: Optional[str]
source: Optional[str]
target: Optional[str]
relationship: Optional[str]
cascade: bool
project_path: Optional[str]
@final
class GraphRemoveTool(BaseTool):
"""Tool for removing nodes and edges from graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph remove tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph_remove"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Remove nodes and edges from the project's graph database.
To remove a node:
- Provide node_id
- Use --cascade (default true) to remove connected edges
- Use --no-cascade to keep edges (may leave orphaned edges)
To remove an edge:
- Provide source, target, and relationship
- Removes only the specific edge
To remove all edges between two nodes:
- Provide source and target (no relationship)
Examples:
- graph_remove --node-id "main.py" # Remove node and its edges
- graph_remove --node-id "MyClass" --no-cascade # Remove node only
- graph_remove --source "main.py" --target "utils.py" --relationship "imports"
- graph_remove --source "func1" --target "func2" # Remove all edges
"""
@override
@auto_timeout("graph_remove")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphRemoveParams],
) -> str:
"""Remove nodes or edges from graph.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of remove operation
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
node_id = params.get("node_id")
source = params.get("source")
target = params.get("target")
relationship = params.get("relationship")
cascade = params.get("cascade", True)
project_path = params.get("project_path")
# Determine if removing node or edge
is_node = node_id is not None
is_edge = source is not None and target is not None
if not is_node and not is_edge:
return "Error: Must provide either node_id for a node, or (source, target) for edges"
if is_node and is_edge:
return "Error: Cannot remove both node and edge in one operation"
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
# Get graph connection
graph_conn = project_db.get_graph_connection()
try:
if is_node:
# Remove node
await tool_ctx.info(f"Removing node: {node_id}")
# Check if node exists
cursor = graph_conn.cursor()
cursor.execute("SELECT id FROM nodes WHERE id = ?", (node_id,))
if not cursor.fetchone():
return f"Error: Node '{node_id}' does not exist"
if cascade:
# Count edges that will be removed
cursor.execute(
"SELECT COUNT(*) FROM edges WHERE source = ? OR target = ?",
(node_id, node_id),
)
edge_count = cursor.fetchone()[0]
# Remove connected edges
graph_conn.execute(
"DELETE FROM edges WHERE source = ? OR target = ?",
(node_id, node_id),
)
# Remove node
graph_conn.execute("DELETE FROM nodes WHERE id = ?", (node_id,))
graph_conn.commit()
# Save to disk
project_db._save_graph_to_disk()
return f"Successfully removed node '{node_id}' and {edge_count} connected edge(s)"
else:
# Remove node only
graph_conn.execute("DELETE FROM nodes WHERE id = ?", (node_id,))
graph_conn.commit()
# Save to disk
project_db._save_graph_to_disk()
return f"Successfully removed node '{node_id}' (edges preserved)"
else:
# Remove edge(s)
if relationship:
# Remove specific edge
await tool_ctx.info(f"Removing edge: {source} --[{relationship}]--> {target}")
cursor = graph_conn.cursor()
cursor.execute(
"DELETE FROM edges WHERE source = ? AND target = ? AND relationship = ?",
(source, target, relationship),
)
removed = cursor.rowcount
graph_conn.commit()
if removed == 0:
return f"No edge found: {source} --[{relationship}]--> {target}"
# Save to disk
project_db._save_graph_to_disk()
return f"Successfully removed edge: {source} --[{relationship}]--> {target}"
else:
# Remove all edges between nodes
await tool_ctx.info(f"Removing all edges between {source} and {target}")
cursor = graph_conn.cursor()
cursor.execute(
"DELETE FROM edges WHERE source = ? AND target = ?",
(source, target),
)
removed = cursor.rowcount
graph_conn.commit()
if removed == 0:
return f"No edges found between '{source}' and '{target}'"
# Save to disk
project_db._save_graph_to_disk()
return f"Successfully removed {removed} edge(s) between '{source}' and '{target}'"
except Exception as e:
await tool_ctx.error(f"Failed to remove from graph: {str(e)}")
return f"Error removing from graph: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,358 @@
"""Graph search tool for searching nodes and edges in the graph database."""
import json
import sqlite3
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
Pattern = Annotated[
str,
Field(
description="Search pattern (SQL LIKE syntax, % for wildcard)",
min_length=1,
),
]
SearchType = Annotated[
str,
Field(
description="What to search: nodes, edges, properties, all",
default="all",
),
]
NodeType = Annotated[
Optional[str],
Field(
description="Filter by node type",
default=None,
),
]
Relationship = Annotated[
Optional[str],
Field(
description="Filter by relationship type",
default=None,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
MaxResults = Annotated[
int,
Field(
description="Maximum number of results",
default=50,
),
]
class GraphSearchParams(TypedDict, total=False):
"""Parameters for graph search tool."""
pattern: str
search_type: str
node_type: Optional[str]
relationship: Optional[str]
project_path: Optional[str]
max_results: int
@final
class GraphSearchTool(BaseTool):
"""Tool for searching nodes and edges in graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph search tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph_search"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Search for nodes and edges in the project's graph database.
Search types:
- nodes: Search in node IDs
- edges: Search in edge relationships
- properties: Search in node/edge properties
- all: Search everywhere (default)
Supports SQL LIKE pattern matching:
- % matches any sequence of characters
- _ matches any single character
Examples:
- graph_search --pattern "%test%" # Find anything with 'test'
- graph_search --pattern "%.py" --search-type nodes # Find Python files
- graph_search --pattern "%import%" --search-type edges
- graph_search --pattern "%TODO%" --search-type properties
- graph_search --pattern "MyClass%" --node-type "class"
"""
@override
@auto_timeout("graph_search")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphSearchParams],
) -> str:
"""Execute graph search.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Search results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
pattern = params.get("pattern")
if not pattern:
return "Error: pattern is required"
search_type = params.get("search_type", "all")
node_type = params.get("node_type")
relationship = params.get("relationship")
project_path = params.get("project_path")
max_results = params.get("max_results", 50)
# Validate search type
valid_types = ["nodes", "edges", "properties", "all"]
if search_type not in valid_types:
return f"Error: Invalid search_type '{search_type}'. Must be one of: {', '.join(valid_types)}"
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Searching graph for pattern: {pattern}")
# Get graph connection
graph_conn = project_db.get_graph_connection()
results = []
try:
cursor = graph_conn.cursor()
# Search nodes
if search_type in ["nodes", "all"]:
query = "SELECT id, type, properties FROM nodes WHERE id LIKE ?"
params_list = [pattern]
if node_type:
query += " AND type = ?"
params_list.append(node_type)
if search_type == "nodes":
query += f" LIMIT {max_results}"
cursor.execute(query, params_list)
for row in cursor.fetchall():
results.append(
{
"type": "node",
"id": row[0],
"node_type": row[1],
"properties": json.loads(row[2]) if row[2] else {},
"match_field": "id",
}
)
# Search edges
if search_type in ["edges", "all"]:
query = """SELECT source, target, relationship, weight, properties
FROM edges WHERE relationship LIKE ?"""
params_list = [pattern]
if relationship:
query += " AND relationship = ?"
params_list.append(relationship)
if search_type == "edges":
query += f" LIMIT {max_results}"
cursor.execute(query, params_list)
for row in cursor.fetchall():
results.append(
{
"type": "edge",
"source": row[0],
"target": row[1],
"relationship": row[2],
"weight": row[3],
"properties": json.loads(row[4]) if row[4] else {},
"match_field": "relationship",
}
)
# Search in properties
if search_type in ["properties", "all"]:
# Search node properties
query = """SELECT id, type, properties FROM nodes
WHERE properties IS NOT NULL AND properties LIKE ?"""
params_list = [f"%{pattern}%"]
if node_type:
query += " AND type = ?"
params_list.append(node_type)
cursor.execute(query, params_list)
for row in cursor.fetchall():
props = json.loads(row[2]) if row[2] else {}
# Check which property matches
matching_props = {}
for key, value in props.items():
if pattern.replace("%", "").lower() in str(value).lower():
matching_props[key] = value
if matching_props:
results.append(
{
"type": "node",
"id": row[0],
"node_type": row[1],
"properties": props,
"match_field": "properties",
"matching_properties": matching_props,
}
)
# Search edge properties
query = """SELECT source, target, relationship, weight, properties
FROM edges WHERE properties IS NOT NULL AND properties LIKE ?"""
params_list = [f"%{pattern}%"]
if relationship:
query += " AND relationship = ?"
params_list.append(relationship)
cursor.execute(query, params_list)
for row in cursor.fetchall():
props = json.loads(row[4]) if row[4] else {}
# Check which property matches
matching_props = {}
for key, value in props.items():
if pattern.replace("%", "").lower() in str(value).lower():
matching_props[key] = value
if matching_props:
results.append(
{
"type": "edge",
"source": row[0],
"target": row[1],
"relationship": row[2],
"weight": row[3],
"properties": props,
"match_field": "properties",
"matching_properties": matching_props,
}
)
# Limit total results if searching all
if search_type == "all" and len(results) > max_results:
results = results[:max_results]
if not results:
return f"No results found for pattern '{pattern}'"
# Format results
output = [f"Found {len(results)} result(s) for pattern '{pattern}':\n"]
# Group by type
nodes = [r for r in results if r["type"] == "node"]
edges = [r for r in results if r["type"] == "edge"]
if nodes:
output.append(f"Nodes ({len(nodes)}):")
for node in nodes[:20]: # Show first 20
output.append(f" {node['id']} ({node['node_type']})")
if node["match_field"] == "properties" and "matching_properties" in node:
output.append(f" Matched in: {list(node['matching_properties'].keys())}")
if node["properties"] and node["match_field"] != "properties":
props_str = json.dumps(node["properties"], indent=6)[:100]
if len(props_str) == 100:
props_str += "..."
output.append(f" Properties: {props_str}")
if len(nodes) > 20:
output.append(f" ... and {len(nodes) - 20} more nodes")
output.append("")
if edges:
output.append(f"Edges ({len(edges)}):")
for edge in edges[:20]: # Show first 20
output.append(f" {edge['source']} --[{edge['relationship']}]--> {edge['target']}")
if edge["match_field"] == "properties" and "matching_properties" in edge:
output.append(f" Matched in: {list(edge['matching_properties'].keys())}")
if edge["weight"] != 1.0:
output.append(f" Weight: {edge['weight']}")
if edge["properties"]:
props_str = json.dumps(edge["properties"], indent=6)[:100]
if len(props_str) == 100:
props_str += "..."
output.append(f" Properties: {props_str}")
if len(edges) > 20:
output.append(f" ... and {len(edges) - 20} more edges")
return "\n".join(output)
except sqlite3.Error as e:
await tool_ctx.error(f"SQL error: {str(e)}")
return f"SQL error: {str(e)}"
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error executing search: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,357 @@
"""Graph statistics tool for analyzing the graph database."""
import sqlite3
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from collections import defaultdict
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
Detailed = Annotated[
bool,
Field(
description="Show detailed statistics",
default=False,
),
]
NodeType = Annotated[
Optional[str],
Field(
description="Filter stats by node type",
default=None,
),
]
Relationship = Annotated[
Optional[str],
Field(
description="Filter stats by relationship type",
default=None,
),
]
class GraphStatsParams(TypedDict, total=False):
"""Parameters for graph stats tool."""
project_path: Optional[str]
detailed: bool
node_type: Optional[str]
relationship: Optional[str]
@final
class GraphStatsTool(BaseTool):
"""Tool for getting graph database statistics."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the graph stats tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "graph_stats"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Get statistics about the project's graph database.
Shows:
- Node and edge counts
- Node type distribution
- Relationship type distribution
- Degree statistics (connections per node)
- Connected components
- Most connected nodes (hubs)
- Orphaned nodes
Examples:
- graph_stats # Basic stats
- graph_stats --detailed # Detailed analysis
- graph_stats --node-type "class" # Stats for specific node type
- graph_stats --relationship "calls" # Stats for specific relationship
"""
@override
@auto_timeout("graph_stats")
async def call(
self,
ctx: MCPContext,
**params: Unpack[GraphStatsParams],
) -> str:
"""Get graph statistics.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Graph statistics
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
project_path = params.get("project_path")
detailed = params.get("detailed", False)
node_type_filter = params.get("node_type")
relationship_filter = params.get("relationship")
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Getting graph statistics for project: {project_db.project_path}")
# Get graph connection
graph_conn = project_db.get_graph_connection()
try:
cursor = graph_conn.cursor()
output = []
output.append(f"=== Graph Database Statistics ===")
output.append(f"Project: {project_db.project_path}")
output.append(f"Database: {project_db.graph_path}")
output.append("")
# Basic counts
if node_type_filter:
cursor.execute("SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,))
node_count = cursor.fetchone()[0]
output.append(f"Nodes (type='{node_type_filter}'): {node_count:,}")
else:
cursor.execute("SELECT COUNT(*) FROM nodes")
node_count = cursor.fetchone()[0]
output.append(f"Total Nodes: {node_count:,}")
if relationship_filter:
cursor.execute(
"SELECT COUNT(*) FROM edges WHERE relationship = ?",
(relationship_filter,),
)
edge_count = cursor.fetchone()[0]
output.append(f"Edges (relationship='{relationship_filter}'): {edge_count:,}")
else:
cursor.execute("SELECT COUNT(*) FROM edges")
edge_count = cursor.fetchone()[0]
output.append(f"Total Edges: {edge_count:,}")
if node_count == 0:
output.append("\nGraph is empty.")
return "\n".join(output)
output.append("")
# Node type distribution
output.append("=== Node Types ===")
cursor.execute("SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC")
node_types = cursor.fetchall()
for n_type, count in node_types[:10]:
pct = (count / node_count) * 100
output.append(f"{n_type}: {count:,} ({pct:.1f}%)")
if len(node_types) > 10:
output.append(f"... and {len(node_types) - 10} more types")
output.append("")
# Relationship distribution
output.append("=== Relationship Types ===")
cursor.execute(
"SELECT relationship, COUNT(*) as count FROM edges GROUP BY relationship ORDER BY count DESC"
)
rel_types = cursor.fetchall()
if rel_types:
for rel, count in rel_types[:10]:
pct = (count / edge_count) * 100 if edge_count > 0 else 0
output.append(f"{rel}: {count:,} ({pct:.1f}%)")
if len(rel_types) > 10:
output.append(f"... and {len(rel_types) - 10} more types")
else:
output.append("No edges in graph")
output.append("")
# Degree statistics
output.append("=== Connectivity ===")
# Calculate degrees
degrees = defaultdict(int)
# Out-degree
query = "SELECT source, COUNT(*) FROM edges"
if relationship_filter:
query += " WHERE relationship = ?"
cursor.execute(query + " GROUP BY source", (relationship_filter,))
else:
cursor.execute(query + " GROUP BY source")
for node, out_degree in cursor.fetchall():
degrees[node] += out_degree
# In-degree
query = "SELECT target, COUNT(*) FROM edges"
if relationship_filter:
query += " WHERE relationship = ?"
cursor.execute(query + " GROUP BY target", (relationship_filter,))
else:
cursor.execute(query + " GROUP BY target")
for node, in_degree in cursor.fetchall():
degrees[node] += in_degree
if degrees:
degree_values = list(degrees.values())
avg_degree = sum(degree_values) / len(degree_values)
max_degree = max(degree_values)
min_degree = min(degree_values)
output.append(f"Average degree: {avg_degree:.2f}")
output.append(f"Max degree: {max_degree}")
output.append(f"Min degree: {min_degree}")
# Most connected nodes
output.append("\nMost connected nodes:")
sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)
for node, degree in sorted_nodes[:5]:
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node,))
node_type = cursor.fetchone()
type_str = f" ({node_type[0]})" if node_type else ""
output.append(f" {node}{type_str}: {degree} connections")
# Orphaned nodes
cursor.execute(
"""
SELECT COUNT(*) FROM nodes n
WHERE NOT EXISTS (SELECT 1 FROM edges WHERE source = n.id OR target = n.id)
"""
)
orphan_count = cursor.fetchone()[0]
if orphan_count > 0:
orphan_pct = (orphan_count / node_count) * 100
output.append(f"\nOrphaned nodes: {orphan_count} ({orphan_pct:.1f}%)")
if detailed:
output.append("\n=== Detailed Analysis ===")
# Node properties usage
cursor.execute("SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL")
nodes_with_props = cursor.fetchone()[0]
if nodes_with_props > 0:
props_pct = (nodes_with_props / node_count) * 100
output.append(f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)")
# Edge properties usage
cursor.execute("SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL")
edges_with_props = cursor.fetchone()[0]
if edges_with_props > 0 and edge_count > 0:
props_pct = (edges_with_props / edge_count) * 100
output.append(f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)")
# Weight distribution
cursor.execute("SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges")
weight_stats = cursor.fetchone()
if weight_stats[0] is not None:
output.append(f"\nEdge weights:")
output.append(f" Min: {weight_stats[0]}")
output.append(f" Max: {weight_stats[1]}")
output.append(f" Avg: {weight_stats[2]:.2f}")
# Most common patterns
if not relationship_filter:
output.append("\n=== Common Patterns ===")
# Most common node type connections
cursor.execute(
"""
SELECT n1.type, e.relationship, n2.type, COUNT(*) as count
FROM edges e
JOIN nodes n1 ON e.source = n1.id
JOIN nodes n2 ON e.target = n2.id
GROUP BY n1.type, e.relationship, n2.type
ORDER BY count DESC
LIMIT 10
"""
)
patterns = cursor.fetchall()
if patterns:
output.append("Most common connections:")
for src_type, rel, tgt_type, count in patterns:
output.append(f" {src_type} --[{rel}]--> {tgt_type}: {count} times")
# Component analysis (simplified)
output.append("\n=== Graph Structure ===")
# Check if graph is fully connected (simplified)
cursor.execute(
"""
SELECT COUNT(DISTINCT node_id) FROM (
SELECT source as node_id FROM edges
UNION
SELECT target as node_id FROM edges
)
"""
)
connected_nodes = cursor.fetchone()[0]
if connected_nodes < node_count:
output.append(f"Connected nodes: {connected_nodes} / {node_count}")
output.append("Graph has disconnected components")
else:
output.append("All nodes are connected")
return "\n".join(output)
except sqlite3.Error as e:
await tool_ctx.error(f"SQL error: {str(e)}")
return f"SQL error: {str(e)}"
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error getting statistics: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,433 @@
"""Unified SQL database tool."""
import sqlite3
from typing import (
Any,
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 hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
# Parameter types
Query = Annotated[
Optional[str],
Field(
description="SQL query to execute",
default=None,
),
]
Pattern = Annotated[
Optional[str],
Field(
description="Search pattern for table/column names or data",
default=None,
),
]
Table = Annotated[
Optional[str],
Field(
description="Table name for operations",
default=None,
),
]
Action = Annotated[
str,
Field(
description="Action: query (default), search, schema, stats",
default="query",
),
]
Limit = Annotated[
int,
Field(
description="Maximum rows to return",
default=100,
),
]
class SQLParams(TypedDict, total=False):
"""Parameters for SQL tool."""
query: Optional[str]
pattern: Optional[str]
table: Optional[str]
action: str
limit: int
@final
class SQLTool(BaseTool):
"""Unified SQL database tool."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the SQL tool."""
super().__init__(permission_manager)
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "sql"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """SQLite database. Actions: query (default), search, schema, stats.
Usage:
sql "SELECT * FROM users WHERE active = 1"
sql --action schema
sql --action search --pattern "john"
sql --action stats --table users
"""
@override
@auto_timeout("sql")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SQLParams],
) -> str:
"""Execute SQL operation."""
tool_ctx = self.create_tool_context(ctx)
# Get current project database
project_db = self.db_manager.get_current_project_db()
if not project_db:
return "Error: No project database found. Are you in a project directory?"
# Extract action
action = params.get("action", "query")
# Route to appropriate handler
if action == "query":
return await self._handle_query(project_db, params, tool_ctx)
elif action == "search":
return await self._handle_search(project_db, params, tool_ctx)
elif action == "schema":
return await self._handle_schema(project_db, params, tool_ctx)
elif action == "stats":
return await self._handle_stats(project_db, params, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: query, search, schema, stats"
async def _handle_query(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Execute SQL query."""
query = params.get("query")
if not query:
return "Error: query required for query action"
limit = params.get("limit", 100)
try:
with project_db.get_sqlite_connection() as conn:
# Enable row factory for dict-like access
conn.row_factory = sqlite3.Row
# Add LIMIT if not present in SELECT queries
query_upper = query.upper().strip()
if query_upper.startswith("SELECT") and "LIMIT" not in query_upper:
query = f"{query} LIMIT {limit}"
cursor = conn.execute(query)
# Handle different query types
if query_upper.startswith("SELECT"):
rows = cursor.fetchall()
if not rows:
return "No results found"
# Get column names
columns = [description[0] for description in cursor.description]
# Format as table
output = ["=== Query Results ==="]
output.append(f"Columns: {', '.join(columns)}")
output.append("-" * 60)
for row in rows:
row_data = []
for col in columns:
value = row[col]
if value is None:
value = "NULL"
elif isinstance(value, str) and len(value) > 50:
value = value[:50] + "..."
row_data.append(str(value))
output.append(" | ".join(row_data))
output.append(f"\nRows returned: {len(rows)}")
if len(rows) == limit:
output.append(f"(Limited to {limit} rows)")
return "\n".join(output)
else:
# For INSERT, UPDATE, DELETE
conn.commit()
rows_affected = cursor.rowcount
if query_upper.startswith("INSERT"):
return f"Inserted {rows_affected} row(s)"
elif query_upper.startswith("UPDATE"):
return f"Updated {rows_affected} row(s)"
elif query_upper.startswith("DELETE"):
return f"Deleted {rows_affected} row(s)"
else:
return f"Query executed successfully. Rows affected: {rows_affected}"
except Exception as e:
await tool_ctx.error(f"Query failed: {str(e)}")
return f"Error executing query: {str(e)}"
async def _handle_search(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Search for data in tables."""
pattern = params.get("pattern")
if not pattern:
return "Error: pattern required for search action"
table = params.get("table")
limit = params.get("limit", 100)
try:
with project_db.get_sqlite_connection() as conn:
conn.row_factory = sqlite3.Row
# Get all tables if not specified
if not table:
cursor = conn.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
"""
)
tables = [row[0] for row in cursor.fetchall()]
else:
tables = [table]
all_results = []
for tbl in tables:
# Get columns
cursor = conn.execute(f"PRAGMA table_info({tbl})")
columns = [row[1] for row in cursor.fetchall()]
# Build search query
where_clauses = [f"{col} LIKE ?" for col in columns]
query = f"SELECT * FROM {tbl} WHERE {' OR '.join(where_clauses)} LIMIT {limit}"
# Search
cursor = conn.execute(query, [f"%{pattern}%"] * len(columns))
rows = cursor.fetchall()
if rows:
all_results.append((tbl, columns, rows))
if not all_results:
return f"No results found for pattern '{pattern}'"
# Format results
output = [f"=== Search Results for '{pattern}' ==="]
for tbl, columns, rows in all_results:
output.append(f"\nTable: {tbl}")
output.append(f"Columns: {', '.join(columns)}")
output.append("-" * 60)
for row in rows:
row_data = []
for col in columns:
value = row[col]
if value is None:
value = "NULL"
elif isinstance(value, str):
# Highlight matches
if pattern.lower() in str(value).lower():
value = f"**{value}**"
if len(value) > 50:
value = value[:50] + "..."
row_data.append(str(value))
output.append(" | ".join(row_data))
output.append(f"Found {len(rows)} row(s) in {tbl}")
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_schema(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Show database schema."""
table = params.get("table")
try:
with project_db.get_sqlite_connection() as conn:
if table:
# Show specific table schema
cursor = conn.execute(f"PRAGMA table_info({table})")
columns = cursor.fetchall()
if not columns:
return f"Table '{table}' not found"
output = [f"=== Schema for table '{table}' ==="]
output.append("Column | Type | Not Null | Default | Primary Key")
output.append("-" * 60)
for col in columns:
output.append(f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}")
# Get indexes
cursor = conn.execute(f"PRAGMA index_list({table})")
indexes = cursor.fetchall()
if indexes:
output.append("\nIndexes:")
for idx in indexes:
output.append(f" {idx[1]} (unique: {idx[2]})")
else:
# Show all tables
cursor = conn.execute(
"""
SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
"""
)
tables = cursor.fetchall()
if not tables:
return "No tables found in database"
output = ["=== Database Schema ==="]
for table_name, _create_sql in tables:
output.append(f"\nTable: {table_name}")
# Get row count
cursor = conn.execute(f"SELECT COUNT(*) FROM {table_name}")
count = cursor.fetchone()[0]
output.append(f"Rows: {count}")
# Get columns
cursor = conn.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
output.append(f"Columns: {', '.join([col[1] for col in columns])}")
return "\n".join(output)
except Exception as e:
await tool_ctx.error(f"Failed to get schema: {str(e)}")
return f"Error getting schema: {str(e)}"
async def _handle_stats(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
"""Get database statistics."""
table = params.get("table")
try:
with project_db.get_sqlite_connection() as conn:
output = ["=== Database Statistics ==="]
output.append(f"Database: {project_db.sqlite_path}")
# Get file size
db_size = project_db.sqlite_path.stat().st_size
output.append(f"Size: {db_size / 1024 / 1024:.2f} MB")
if table:
# Stats for specific table
cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
count = cursor.fetchone()[0]
output.append(f"\nTable: {table}")
output.append(f"Total rows: {count}")
# Get column stats
cursor = conn.execute(f"PRAGMA table_info({table})")
columns = cursor.fetchall()
output.append("\nColumn statistics:")
for col in columns:
col_name = col[1]
col_type = col[2]
# Get basic stats based on type
if "INT" in col_type.upper() or "REAL" in col_type.upper():
cursor = conn.execute(
f"""
SELECT
MIN({col_name}) as min_val,
MAX({col_name}) as max_val,
AVG({col_name}) as avg_val,
COUNT(DISTINCT {col_name}) as distinct_count
FROM {table}
"""
)
stats = cursor.fetchone()
output.append(
f" {col_name}: min={stats[0]}, max={stats[1]}, avg={stats[2]:.2f}, distinct={stats[3]}"
)
else:
cursor = conn.execute(
f"""
SELECT
COUNT(DISTINCT {col_name}) as distinct_count,
COUNT(*) - COUNT({col_name}) as null_count
FROM {table}
"""
)
stats = cursor.fetchone()
output.append(f" {col_name}: distinct={stats[0]}, nulls={stats[1]}")
else:
# Overall database stats
cursor = conn.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
"""
)
tables = cursor.fetchall()
output.append(f"\nTotal tables: {len(tables)}")
output.append("\nTable row counts:")
total_rows = 0
for (table_name,) in tables:
cursor = conn.execute(f"SELECT COUNT(*) FROM {table_name}")
count = cursor.fetchone()[0]
total_rows += count
output.append(f" {table_name}: {count} rows")
output.append(f"\nTotal rows across all tables: {total_rows}")
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)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,233 @@
"""SQL query tool for direct database queries."""
import sqlite3
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
Query = Annotated[
str,
Field(
description="SQL query to execute",
min_length=1,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
ReadOnly = Annotated[
bool,
Field(
description="Execute in read-only mode (no INSERT/UPDATE/DELETE)",
default=True,
),
]
class SqlQueryParams(TypedDict, total=False):
"""Parameters for SQL query tool."""
query: str
project_path: Optional[str]
read_only: bool
@final
class SqlQueryTool(BaseTool):
"""Tool for executing SQL queries on project databases."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the SQL query tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "sql_query"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute SQL queries on the project's embedded SQLite database.
Each project has its own SQLite database with tables:
- metadata: Key-value store for project metadata
- files: File information and content
- symbols: Code symbols (functions, classes, etc.)
Features:
- Direct SQL query execution
- Read-only mode by default (safety)
- Returns results in tabular format
- Automatic project detection
Examples:
- sql_query --query "SELECT * FROM files LIMIT 10"
- sql_query --query "SELECT name, type FROM symbols WHERE type='function'"
- sql_query --query "INSERT INTO metadata (key, value) VALUES ('version', '1.0')" --read-only false
Note: Use sql_search for text search operations."""
@override
@auto_timeout("sql_query")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SqlQueryParams],
) -> str:
"""Execute SQL query.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Query results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
query = params.get("query")
if not query:
return "Error: query is required"
project_path = params.get("project_path")
read_only = params.get("read_only", True)
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
# Check if query is read-only
if read_only:
# Simple check for write operations
write_keywords = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER"]
query_upper = query.upper()
for keyword in write_keywords:
if keyword in query_upper:
return (
f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
)
await tool_ctx.info(f"Executing SQL query on project: {project_db.project_path}")
# Execute query
conn = None
try:
conn = project_db.get_sqlite_connection()
cursor = conn.cursor()
# Execute the query
cursor.execute(query)
# Handle different query types
if query.strip().upper().startswith("SELECT"):
# Fetch results
results = cursor.fetchall()
if not results:
return "No results found."
# Get column names
columns = [desc[0] for desc in cursor.description]
# Format as table
output = self._format_results_table(columns, results)
return f"Query executed successfully. Found {len(results)} row(s).\n\n{output}"
else:
# For non-SELECT queries, commit and return affected rows
conn.commit()
affected = cursor.rowcount
return f"Query executed successfully. Affected {affected} row(s)."
except sqlite3.Error as e:
await tool_ctx.error(f"SQL error: {str(e)}")
return f"SQL error: {str(e)}"
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error executing query: {str(e)}"
finally:
if conn:
conn.close()
def _format_results_table(self, columns: list[str], rows: list[tuple]) -> str:
"""Format query results as a table."""
if not rows:
return "No results"
# Calculate column widths
col_widths = []
for i, col in enumerate(columns):
max_width = len(col)
for row in rows[:100]: # Check first 100 rows
val_str = str(row[i]) if row[i] is not None else "NULL"
max_width = max(max_width, len(val_str))
col_widths.append(min(max_width, 50)) # Cap at 50 chars
# Build header
header = " | ".join(col.ljust(width) for col, width in zip(columns, col_widths))
separator = "-+-".join("-" * width for width in col_widths)
# Build rows
output_rows = []
for row in rows[:1000]: # Limit to 1000 rows
row_str = " | ".join(
self._truncate(str(val) if val is not None else "NULL", width).ljust(width)
for val, width in zip(row, col_widths)
)
output_rows.append(row_str)
# Combine
output = [header, separator] + output_rows
if len(rows) > 1000:
output.append(f"\n... and {len(rows) - 1000} more rows")
return "\n".join(output)
def _truncate(self, text: str, max_width: int) -> str:
"""Truncate text to max width."""
if len(text) <= max_width:
return text
return text[: max_width - 3] + "..."
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,298 @@
"""SQL search tool for text search in database."""
import sqlite3
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
SearchPattern = Annotated[
str,
Field(
description="Search pattern (SQL LIKE syntax, % for wildcard)",
min_length=1,
),
]
Table = Annotated[
str,
Field(
description="Table to search in (files, symbols, metadata)",
default="files",
),
]
Column = Annotated[
Optional[str],
Field(
description="Specific column to search (searches all text columns if not specified)",
default=None,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
MaxResults = Annotated[
int,
Field(
description="Maximum number of results",
default=50,
),
]
class SqlSearchParams(TypedDict, total=False):
"""Parameters for SQL search tool."""
pattern: str
table: str
column: Optional[str]
project_path: Optional[str]
max_results: int
@final
class SqlSearchTool(BaseTool):
"""Tool for searching text in SQLite database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the SQL search tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "sql_search"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Search for text patterns in the project's SQLite database.
Supports SQL LIKE pattern matching:
- % matches any sequence of characters
- _ matches any single character
- Use %pattern% to search for pattern anywhere
Tables available:
- files: Search in file paths and content
- symbols: Search in symbol names, types, and signatures
- metadata: Search in key-value metadata
Examples:
- sql_search --pattern "%TODO%" --table files
- sql_search --pattern "test_%" --table symbols --column name
- sql_search --pattern "%config%" --table metadata
- sql_search --pattern "%.py" --table files --column path
Use sql_query for complex queries with joins, conditions, etc."""
@override
@auto_timeout("sql_search")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SqlSearchParams],
) -> str:
"""Execute SQL search.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Search results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
pattern = params.get("pattern")
if not pattern:
return "Error: pattern is required"
table = params.get("table", "files")
column = params.get("column")
project_path = params.get("project_path")
max_results = params.get("max_results", 50)
# Validate table
valid_tables = ["files", "symbols", "metadata"]
if table not in valid_tables:
return f"Error: Invalid table '{table}'. Must be one of: {', '.join(valid_tables)}"
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Searching in {table} table for pattern: {pattern}")
# Build search query
conn = None
try:
conn = project_db.get_sqlite_connection()
cursor = conn.cursor()
# Get searchable columns for the table
if column:
# Validate column exists
cursor.execute(f"PRAGMA table_info({table})")
columns_info = cursor.fetchall()
column_names = [col[1] for col in columns_info]
if column not in column_names:
return f"Error: Column '{column}' not found in table '{table}'. Available columns: {', '.join(column_names)}"
search_columns = [column]
else:
# Get all text columns
search_columns = self._get_text_columns(cursor, table)
if not search_columns:
return f"Error: No searchable text columns in table '{table}'"
# Build WHERE clause
where_conditions = [f"{col} LIKE ?" for col in search_columns]
where_clause = " OR ".join(where_conditions)
# Build query
if table == "files":
query = f"""
SELECT path, SUBSTR(content, 1, 200) as snippet, size, modified_at
FROM {table}
WHERE {where_clause}
LIMIT ?
"""
params_list = [pattern] * len(search_columns) + [max_results]
elif table == "symbols":
query = f"""
SELECT name, type, file_path, line_start, signature
FROM {table}
WHERE {where_clause}
ORDER BY type, name
LIMIT ?
"""
params_list = [pattern] * len(search_columns) + [max_results]
else: # metadata
query = f"""
SELECT key, value, updated_at
FROM {table}
WHERE {where_clause}
LIMIT ?
"""
params_list = [pattern] * len(search_columns) + [max_results]
# Execute search
cursor.execute(query, params_list)
results = cursor.fetchall()
if not results:
return f"No results found for pattern '{pattern}' in {table}"
# Format results
output = self._format_results(table, results, pattern, search_columns)
return f"Found {len(results)} result(s) in {table}:\n\n{output}"
except sqlite3.Error as e:
await tool_ctx.error(f"SQL error: {str(e)}")
return f"SQL error: {str(e)}"
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error executing search: {str(e)}"
finally:
if conn:
conn.close()
def _get_text_columns(self, cursor: sqlite3.Cursor, table: str) -> list[str]:
"""Get text columns for a table."""
cursor.execute(f"PRAGMA table_info({table})")
columns_info = cursor.fetchall()
# Get TEXT columns
text_columns = []
for col in columns_info:
col_name = col[1]
col_type = col[2].upper()
if "TEXT" in col_type or "CHAR" in col_type or col_type == "":
text_columns.append(col_name)
return text_columns
def _format_results(self, table: str, results: list, pattern: str, search_columns: list[str]) -> str:
"""Format search results based on table type."""
output = []
if table == "files":
output.append(f"Searched columns: {', '.join(search_columns)}\n")
for row in results:
path, snippet, size, modified = row
output.append(f"File: {path}")
output.append(f"Size: {size} bytes")
output.append(f"Modified: {modified}")
if snippet:
# Highlight pattern in snippet
snippet = snippet.replace("\n", " ")
if len(snippet) > 150:
snippet = snippet[:150] + "..."
output.append(f"Content: {snippet}")
output.append("-" * 60)
elif table == "symbols":
output.append(f"Searched columns: {', '.join(search_columns)}\n")
for row in results:
name, type_, file_path, line_start, signature = row
output.append(f"{type_}: {name}")
output.append(f"File: {file_path}:{line_start}")
if signature:
output.append(f"Signature: {signature}")
output.append("-" * 60)
else: # metadata
output.append(f"Searched columns: {', '.join(search_columns)}\n")
for row in results:
key, value, updated = row
output.append(f"Key: {key}")
output.append(f"Value: {value}")
output.append(f"Updated: {updated}")
output.append("-" * 60)
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,258 @@
"""SQL statistics tool for database insights."""
import sqlite3
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
from .database_manager import DatabaseManager
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
Detailed = Annotated[
bool,
Field(
description="Show detailed statistics",
default=False,
),
]
class SqlStatsParams(TypedDict, total=False):
"""Parameters for SQL stats tool."""
project_path: Optional[str]
detailed: bool
@final
class SqlStatsTool(BaseTool):
"""Tool for getting SQLite database statistics."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
"""Initialize the SQL stats tool.
Args:
permission_manager: Permission manager for access control
db_manager: Database manager instance
"""
self.permission_manager = permission_manager
self.db_manager = db_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "sql_stats"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Get statistics about the project's SQLite database.
Shows:
- Database size and location
- Table information (row counts, sizes)
- Index information
- Column statistics
- Most common values (with --detailed)
Examples:
- sql_stats # Basic stats for current project
- sql_stats --detailed # Detailed statistics
- sql_stats --project-path /path/to/project
"""
@override
@auto_timeout("sql_stats")
async def call(
self,
ctx: MCPContext,
**params: Unpack[SqlStatsParams],
) -> str:
"""Get database statistics.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Database statistics
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
project_path = params.get("project_path")
detailed = params.get("detailed", False)
# Get project database
try:
if project_path:
project_db = self.db_manager.get_project_db(project_path)
else:
import os
project_db = self.db_manager.get_project_for_path(os.getcwd())
if not project_db:
return "Error: Could not find project database"
except PermissionError as e:
return str(e)
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Getting statistics for project: {project_db.project_path}")
# Collect statistics
conn = None
try:
conn = project_db.get_sqlite_connection()
cursor = conn.cursor()
output = []
output.append(f"=== SQLite Database Statistics ===")
output.append(f"Project: {project_db.project_path}")
output.append(f"Database: {project_db.sqlite_path}")
# Get database size
db_size = project_db.sqlite_path.stat().st_size
output.append(f"Database Size: {self._format_size(db_size)}")
output.append("")
# Get table statistics
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = cursor.fetchall()
output.append("=== Tables ===")
total_rows = 0
for (table_name,) in tables:
if table_name.startswith("sqlite_"):
continue
# Get row count
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
row_count = cursor.fetchone()[0]
total_rows += row_count
# Get table info
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
col_count = len(columns)
output.append(f"\n{table_name}:")
output.append(f" Rows: {row_count:,}")
output.append(f" Columns: {col_count}")
if detailed and row_count > 0:
# Show column details
output.append(" Columns:")
for col in columns:
col_name = col[1]
col_type = col[2]
is_pk = col[5]
not_null = col[3]
flags = []
if is_pk:
flags.append("PRIMARY KEY")
if not_null:
flags.append("NOT NULL")
flag_str = f" ({', '.join(flags)})" if flags else ""
output.append(f" - {col_name}: {col_type}{flag_str}")
# Show sample data for specific tables
if table_name == "files" and row_count > 0:
cursor.execute(f"SELECT COUNT(DISTINCT SUBSTR(path, -3)) as ext_count FROM {table_name}")
ext_count = cursor.fetchone()[0]
output.append(f" File types: ~{ext_count}")
elif table_name == "symbols" and row_count > 0:
cursor.execute(
f"SELECT type, COUNT(*) as count FROM {table_name} GROUP BY type ORDER BY count DESC LIMIT 5"
)
symbol_types = cursor.fetchall()
output.append(" Symbol types:")
for sym_type, count in symbol_types:
output.append(f" - {sym_type}: {count}")
# Get indexes
cursor.execute(f"PRAGMA index_list({table_name})")
indexes = cursor.fetchall()
if indexes:
output.append(f" Indexes: {len(indexes)}")
output.append(f"\nTotal Rows: {total_rows:,}")
# Get index statistics
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name")
indexes = cursor.fetchall()
if indexes:
output.append(f"\n=== Indexes ===")
output.append(f"Total Indexes: {len(indexes)}")
if detailed:
for (idx_name,) in indexes:
cursor.execute(f"PRAGMA index_info({idx_name})")
idx_info = cursor.fetchall()
if idx_info:
cols = [info[2] for info in idx_info]
output.append(f" {idx_name}: ({', '.join(cols)})")
# Database properties
if detailed:
output.append("\n=== Database Properties ===")
# Page size
cursor.execute("PRAGMA page_size")
page_size = cursor.fetchone()[0]
output.append(f"Page Size: {page_size:,} bytes")
# Page count
cursor.execute("PRAGMA page_count")
page_count = cursor.fetchone()[0]
output.append(f"Page Count: {page_count:,}")
# Cache size
cursor.execute("PRAGMA cache_size")
cache_size = cursor.fetchone()[0]
output.append(f"Cache Size: {abs(cache_size):,} pages")
return "\n".join(output)
except sqlite3.Error as e:
await tool_ctx.error(f"SQL error: {str(e)}")
return f"SQL error: {str(e)}"
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error getting statistics: {str(e)}"
finally:
if conn:
conn.close()
def _format_size(self, size: int) -> str:
"""Format file size in human-readable format."""
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
+44
View File
@@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-database"
version = "0.1.0"
description = "Database tools for Hanzo AI - SQL, Graph, Vector storage"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "database", "sql", "graph", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
full = [
"networkx>=3.0",
"aiosqlite>=0.21.0",
]
dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
[project.entry-points."hanzo.tools"]
database = "hanzo_tools.database:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,63 @@
"""Editor integration tools for Hanzo AI.
Tools:
- neovim_edit: Edit files in Neovim
- neovim_command: Execute Neovim commands
- neovim_session: Manage Neovim sessions
Install:
pip install hanzo-tools-editor
pip install hanzo-tools-editor[neovim] # For Neovim support
Usage:
from hanzo_tools.editor import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server)
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .neovim_edit import NeovimEditTool
from .neovim_command import NeovimCommandTool
from .neovim_session import NeovimSessionTool
# Export list for tool discovery
TOOLS = [
NeovimEditTool,
NeovimCommandTool,
NeovimSessionTool,
]
__all__ = [
"NeovimEditTool",
"NeovimCommandTool",
"NeovimSessionTool",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
"""Register all editor tools with the MCP server.
Args:
mcp_server: FastMCP server instance
enabled_tools: Dict of tool_name -> enabled state
Returns:
List of registered tool instances
"""
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
if enabled.get(tool_name, True): # Enabled by default
try:
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except Exception:
pass # Tool may require optional deps
return registered
@@ -0,0 +1,269 @@
"""Execute Neovim commands and macros."""
import os
import shutil
import tempfile
import subprocess
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
Command = Annotated[
Optional[str],
Field(
description="Neovim command to execute (Ex commands like :w, :q, etc.)",
default=None,
),
]
Commands = Annotated[
Optional[List[str]],
Field(
description="List of Neovim commands to execute in sequence",
default=None,
),
]
Macro = Annotated[
Optional[str],
Field(
description="Vim macro to execute (e.g., 'dd' to delete line)",
default=None,
),
]
FilePath = Annotated[
Optional[str],
Field(
description="File to operate on (optional, uses current buffer if not specified)",
default=None,
),
]
SaveAfter = Annotated[
bool,
Field(
description="Save file after executing commands",
default=True,
),
]
ReturnOutput = Annotated[
bool,
Field(
description="Return output/messages from Neovim",
default=True,
),
]
class NeovimCommandParams(TypedDict, total=False):
"""Parameters for Neovim command tool."""
command: Optional[str]
commands: Optional[List[str]]
macro: Optional[str]
file_path: Optional[str]
save_after: bool
return_output: bool
@final
class NeovimCommandTool(BaseTool):
"""Tool for executing Neovim commands and macros."""
def __init__(self, permission_manager: PermissionManager):
"""Initialize the Neovim command tool.
Args:
permission_manager: Permission manager for access control
"""
self.permission_manager = permission_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "neovim_command"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Execute Neovim commands and macros programmatically.
Run Ex commands, normal mode commands, or complex macros in Neovim.
Can operate on files without opening the editor interface.
Examples:
- neovim_command --command ":set number" --file-path main.py
- neovim_command --command ":%s/old/new/g" --file-path config.json
- neovim_command --commands ":set expandtab" ":retab" --file-path script.sh
- neovim_command --macro "ggVG=" --file-path messy.py # Format entire file
- neovim_command --macro "dd10j" --file-path list.txt # Delete line and go down 10
Common commands:
- :w - Save file
- :q - Quit
- :%s/old/new/g - Replace all occurrences
- :set number - Show line numbers
- :set expandtab - Use spaces instead of tabs
- :retab - Convert tabs to spaces
Common macros:
- gg - Go to beginning of file
- G - Go to end of file
- dd - Delete line
- yy - Yank (copy) line
- p - Paste
- V - Visual line mode
- = - Format/indent
Note: Requires Neovim to be installed.
"""
@override
@auto_timeout("neovim_command")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NeovimCommandParams],
) -> str:
"""Execute Neovim command.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of the command execution
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
command = params.get("command")
commands = params.get("commands")
macro = params.get("macro")
file_path = params.get("file_path")
save_after = params.get("save_after", True)
return_output = params.get("return_output", True)
# Validate inputs
if not any([command, commands, macro]):
return "Error: Must provide either 'command', 'commands', or 'macro'"
if sum(bool(x) for x in [command, commands, macro]) > 1:
return "Error: Can only use one of 'command', 'commands', or 'macro' at a time"
# Check if Neovim is available
nvim_cmd = shutil.which("nvim")
if not nvim_cmd:
return "Error: Neovim (nvim) not found. Install it first."
# Prepare commands list
nvim_commands = []
if command:
nvim_commands.append(command)
elif commands:
nvim_commands.extend(commands)
elif macro:
# Convert macro to normal mode command
# Escape special characters
escaped_macro = macro.replace('"', '\\"')
nvim_commands.append(f':normal "{escaped_macro}"')
# Add save command if requested
if save_after:
nvim_commands.append(":w")
# Always quit at the end
nvim_commands.append(":q")
# Build Neovim command line
cmd = [nvim_cmd, "-n", "-i", "NONE"] # No swap file, no shada file
# Add commands
for vim_cmd in nvim_commands:
cmd.extend(["-c", vim_cmd])
# Add file if specified
if file_path:
file_path = os.path.abspath(file_path)
# Check permissions
if not self.permission_manager.is_path_allowed(file_path):
return f"Error: No permission to access {file_path}"
if not os.path.exists(file_path):
return f"Error: File not found: {file_path}"
cmd.append(file_path)
else:
# Create empty buffer
cmd.append("-")
await tool_ctx.info(f"Executing Neovim commands: {nvim_commands}")
try:
# Execute Neovim
if return_output:
# Capture output by redirecting messages
output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)
output_file.close()
# Add command to redirect messages
cmd.insert(3, "-c")
cmd.insert(4, f":redir! > {output_file.name}")
# Execute
result = subprocess.run(cmd, capture_output=True, text=True)
# Read output
output_content = ""
try:
with open(output_file.name, "r") as f:
output_content = f.read().strip()
finally:
os.unlink(output_file.name)
if result.returncode == 0:
response = "Commands executed successfully"
if file_path:
response += f" on {os.path.basename(file_path)}"
if output_content:
response += f"\n\nOutput:\n{output_content}"
return response
else:
error_msg = "Error executing Neovim commands"
if result.stderr:
error_msg += f"\n\nError:\n{result.stderr}"
if output_content:
error_msg += f"\n\nOutput:\n{output_content}"
return error_msg
else:
# Just execute without capturing output
result = subprocess.run(cmd)
if result.returncode == 0:
response = "Commands executed successfully"
if file_path:
response += f" on {os.path.basename(file_path)}"
return response
else:
return f"Neovim exited with code {result.returncode}"
except Exception as e:
await tool_ctx.error(f"Failed to execute Neovim commands: {str(e)}")
return f"Error executing Neovim commands: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,290 @@
"""Open files in Neovim editor."""
import os
import shutil
import subprocess
from typing import 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 hanzo_tools.core import create_tool_context
from hanzo_tools.core import PermissionManager
from hanzo_tools.core import auto_timeout
FilePath = Annotated[
str,
Field(
description="Path to the file to open",
min_length=1,
),
]
LineNumber = Annotated[
Optional[int],
Field(
description="Line number to jump to",
default=None,
),
]
ColumnNumber = Annotated[
Optional[int],
Field(
description="Column number to jump to",
default=None,
),
]
ReadOnly = Annotated[
bool,
Field(
description="Open file in read-only mode",
default=False,
),
]
Split = Annotated[
Optional[str],
Field(
description="Split mode: vsplit, split, tab",
default=None,
),
]
Wait = Annotated[
bool,
Field(
description="Wait for Neovim to exit before returning",
default=True,
),
]
InTerminal = Annotated[
bool,
Field(
description="Open in terminal (requires terminal that supports it)",
default=True,
),
]
class NeovimEditParams(TypedDict, total=False):
"""Parameters for Neovim edit tool."""
file_path: str
line_number: Optional[int]
column_number: Optional[int]
read_only: bool
split: Optional[str]
wait: bool
in_terminal: bool
@final
class NeovimEditTool(BaseTool):
"""Tool for opening files in Neovim."""
def __init__(self, permission_manager: PermissionManager):
"""Initialize the Neovim edit tool.
Args:
permission_manager: Permission manager for access control
"""
self.permission_manager = permission_manager
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "neovim_edit"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Open files in Neovim editor with advanced options.
Open files at specific lines/columns, in different split modes, or read-only.
Integrates with your existing Neovim configuration.
Examples:
- neovim_edit --file-path main.py
- neovim_edit --file-path main.py --line-number 42
- neovim_edit --file-path main.py --line-number 42 --column-number 10
- neovim_edit --file-path config.json --read-only
- neovim_edit --file-path test.py --split vsplit
- neovim_edit --file-path README.md --split tab
Split modes:
- vsplit: Open in vertical split
- split: Open in horizontal split
- tab: Open in new tab
Note: Requires Neovim to be installed and available in PATH.
"""
@override
@auto_timeout("neovim_edit")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NeovimEditParams],
) -> str:
"""Open file in Neovim.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of the operation
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Extract parameters
file_path = params.get("file_path")
if not file_path:
return "Error: file_path is required"
line_number = params.get("line_number")
column_number = params.get("column_number")
read_only = params.get("read_only", False)
split = params.get("split")
wait = params.get("wait", True)
in_terminal = params.get("in_terminal", True)
# Check if Neovim is available
nvim_cmd = shutil.which("nvim")
if not nvim_cmd:
# Try common locations
common_paths = [
"/usr/local/bin/nvim",
"/usr/bin/nvim",
"/opt/homebrew/bin/nvim",
os.path.expanduser("~/.local/bin/nvim"),
]
for path in common_paths:
if os.path.exists(path):
nvim_cmd = path
break
if not nvim_cmd:
return """Error: Neovim (nvim) not found. Install it with:
On macOS:
brew install neovim
On Ubuntu/Debian:
sudo apt install neovim
On Arch:
sudo pacman -S neovim
Or visit: https://neovim.io/"""
# Convert to absolute path
file_path = os.path.abspath(file_path)
# Check permissions
if not self.permission_manager.is_path_allowed(file_path):
return f"Error: No permission to access {file_path}"
# Build Neovim command
cmd = [nvim_cmd]
# Add read-only flag
if read_only:
cmd.append("-R")
# Add split mode
if split:
if split == "vsplit":
cmd.extend(["-c", "vsplit"])
elif split == "split":
cmd.extend(["-c", "split"])
elif split == "tab":
cmd.extend(["-c", "tabnew"])
else:
return f"Error: Invalid split mode '{split}'. Use 'vsplit', 'split', or 'tab'"
# Add file path
cmd.append(file_path)
# Add line/column positioning
if line_number:
if column_number:
# Go to specific line and column
cmd.extend(["+call cursor({}, {})".format(line_number, column_number)])
else:
# Go to specific line
cmd.append(f"+{line_number}")
await tool_ctx.info(f"Opening {file_path} in Neovim")
try:
# Determine how to run Neovim
if in_terminal and not wait:
# Open in a new terminal window (platform-specific)
if os.uname().sysname == "Darwin": # macOS
# Try to use iTerm2 if available, otherwise Terminal
if shutil.which("osascript"):
# Build AppleScript to open in iTerm2 or Terminal
nvim_cmd_str = " ".join(f'"{arg}"' for arg in cmd)
# Try iTerm2 first
applescript = f"""tell application "System Events"
if exists application process "iTerm2" then
tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "{nvim_cmd_str}"
end tell
end tell
end tell
else
tell application "Terminal"
activate
do script "{nvim_cmd_str}"
end tell
end if
end tell"""
subprocess.run(["osascript", "-e", applescript], timeout=10)
return f"Opened {file_path} in Neovim (new terminal window)"
elif shutil.which("gnome-terminal"):
# Linux with GNOME
subprocess.Popen(["gnome-terminal", "--"] + cmd)
return f"Opened {file_path} in Neovim (new terminal window)"
elif shutil.which("xterm"):
# Fallback to xterm
subprocess.Popen(["xterm", "-e"] + cmd)
return f"Opened {file_path} in Neovim (new terminal window)"
else:
# Can't open in terminal, fall back to subprocess
subprocess.Popen(cmd)
return f"Opened {file_path} in Neovim (background process)"
else:
# Run and wait for completion
result = subprocess.run(cmd, timeout=120)
if result.returncode == 0:
return f"Successfully edited {file_path} in Neovim"
else:
return f"Neovim exited with code {result.returncode}"
except Exception as e:
await tool_ctx.error(f"Failed to open Neovim: {str(e)}")
return f"Error opening Neovim: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,358 @@
"""Manage Neovim sessions."""
import os
import json
import shutil
import subprocess
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from pathlib import Path
from datetime import datetime
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
from hanzo_tools.core import create_tool_context
from hanzo_tools.core import auto_timeout
Action = Annotated[
str,
Field(
description="Action to perform: save, restore, list, delete",
min_length=1,
),
]
SessionName = Annotated[
Optional[str],
Field(
description="Name of the session",
default=None,
),
]
ProjectPath = Annotated[
Optional[str],
Field(
description="Project path (defaults to current directory)",
default=None,
),
]
AutoName = Annotated[
bool,
Field(
description="Auto-generate session name based on project and timestamp",
default=False,
),
]
Overwrite = Annotated[
bool,
Field(
description="Overwrite existing session",
default=False,
),
]
class NeovimSessionParams(TypedDict, total=False):
"""Parameters for Neovim session tool."""
action: str
session_name: Optional[str]
project_path: Optional[str]
auto_name: bool
overwrite: bool
@final
class NeovimSessionTool(BaseTool):
"""Tool for managing Neovim sessions."""
def __init__(self):
"""Initialize the Neovim session tool."""
self.session_dir = Path.home() / ".hanzo" / "neovim" / "sessions"
self.session_dir.mkdir(parents=True, exist_ok=True)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "neovim_session"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Save and restore Neovim editing sessions.
Manage Neovim sessions to save your workspace state including:
- Open files and buffers
- Window layouts and splits
- Cursor positions
- Marks and registers
- Local options and mappings
Actions:
- save: Save current Neovim session
- restore: Restore a saved session
- list: List all saved sessions
- delete: Delete a saved session
Examples:
- neovim_session --action save --session-name "feature-work"
- neovim_session --action save --auto-name # Auto-generate name
- neovim_session --action restore --session-name "feature-work"
- neovim_session --action list
- neovim_session --action list --project-path /path/to/project
- neovim_session --action delete --session-name "old-session"
Sessions are stored in ~/.hanzo/neovim/sessions/
Project-specific sessions are automatically organized by project path.
Note: Requires Neovim to be installed.
"""
@override
@auto_timeout("neovim_session")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NeovimSessionParams],
) -> str:
"""Manage Neovim session.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Result of the session operation
"""
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 (save, restore, list, delete)"
session_name = params.get("session_name")
project_path = params.get("project_path") or os.getcwd()
auto_name = params.get("auto_name", False)
overwrite = params.get("overwrite", False)
# Validate action
valid_actions = ["save", "restore", "list", "delete"]
if action not in valid_actions:
return f"Error: Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
# Check if Neovim is available
nvim_cmd = shutil.which("nvim")
if not nvim_cmd and action in ["save", "restore"]:
return "Error: Neovim (nvim) not found. Install it first."
# Get project-specific session directory
project_hash = str(hash(os.path.abspath(project_path)) % 10**8)
project_name = os.path.basename(project_path) or "root"
project_session_dir = self.session_dir / f"{project_name}_{project_hash}"
project_session_dir.mkdir(exist_ok=True)
# Handle different actions
if action == "save":
return await self._save_session(
tool_ctx,
session_name,
project_session_dir,
auto_name,
overwrite,
project_path,
)
elif action == "restore":
return await self._restore_session(tool_ctx, session_name, project_session_dir)
elif action == "list":
return self._list_sessions(project_session_dir, project_path)
elif action == "delete":
return self._delete_session(session_name, project_session_dir)
async def _save_session(
self,
tool_ctx,
session_name: Optional[str],
project_dir: Path,
auto_name: bool,
overwrite: bool,
project_path: str,
) -> str:
"""Save Neovim session."""
# Generate session name if needed
if auto_name or not session_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_name = f"session_{timestamp}"
# Sanitize session name
session_name = session_name.replace("/", "_").replace(" ", "_")
session_file = project_dir / f"{session_name}.vim"
metadata_file = project_dir / f"{session_name}.json"
# Check if exists
if session_file.exists() and not overwrite:
return f"Error: Session '{session_name}' already exists. Use --overwrite to replace."
await tool_ctx.info(f"Saving Neovim session: {session_name}")
# Create temporary vim script to save session
vim_script = f"""
:mksession! {session_file}
:echo "Session saved to {session_file}"
:qa
"""
try:
# Run Neovim to save session
# First, check if Neovim is already running
# For now, we'll create a new instance
result = subprocess.run(["nvim", "-c", vim_script.strip()], capture_output=True, text=True)
if result.returncode != 0 and result.stderr:
return f"Error saving session: {result.stderr}"
# Save metadata
metadata = {
"name": session_name,
"created_at": datetime.now().isoformat(),
"project_path": project_path,
"description": f"Neovim session for {project_path}",
}
with open(metadata_file, "w") as f:
json.dump(metadata, f, indent=2)
return f"""Successfully saved Neovim session '{session_name}'
Session file: {session_file}
Project: {project_path}
To restore this session:
neovim_session --action restore --session-name "{session_name}"
Or manually in Neovim:
:source {session_file}"""
except Exception as e:
return f"Error saving session: {str(e)}"
async def _restore_session(self, tool_ctx, session_name: Optional[str], project_dir: Path) -> str:
"""Restore Neovim session."""
if not session_name:
# List available sessions
sessions = list(project_dir.glob("*.vim"))
if not sessions:
return (
"Error: No sessions found for this project. Use 'neovim_session --action list' to see all sessions."
)
# Use most recent
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
session_file = sessions[0]
session_name = session_file.stem
else:
session_file = project_dir / f"{session_name}.vim"
if not session_file.exists():
return f"Error: Session '{session_name}' not found. Use 'neovim_session --action list' to see available sessions."
await tool_ctx.info(f"Restoring Neovim session: {session_name}")
try:
# Open Neovim with the session
subprocess.run(["nvim", "-S", str(session_file)])
return f"Restored Neovim session '{session_name}'"
except Exception as e:
return f"Error restoring session: {str(e)}"
def _list_sessions(self, project_dir: Path, project_path: str) -> str:
"""List available sessions."""
output = ["=== Neovim Sessions ==="]
output.append(f"Project: {project_path}\n")
# List project-specific sessions
sessions = list(project_dir.glob("*.vim"))
if sessions:
output.append("Project Sessions:")
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
for session_file in sessions:
session_name = session_file.stem
metadata_file = project_dir / f"{session_name}.json"
# Get metadata if available
created_at = "Unknown"
if metadata_file.exists():
try:
with open(metadata_file, "r") as f:
metadata = json.load(f)
created_at = metadata.get("created_at", "Unknown")
if created_at != "Unknown":
# Format date
dt = datetime.fromisoformat(created_at)
created_at = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
# Get file size
size = session_file.stat().st_size
size_kb = size / 1024
output.append(f" - {session_name}")
output.append(f" Created: {created_at}")
output.append(f" Size: {size_kb:.1f} KB")
else:
output.append("No sessions found for this project.")
# Also list all sessions
all_sessions = list(self.session_dir.rglob("*.vim"))
other_sessions = [s for s in all_sessions if s.parent != project_dir]
if other_sessions:
output.append("\nOther Projects' Sessions:")
for session_file in other_sessions[:10]: # Show max 10
project_name = session_file.parent.name
session_name = session_file.stem
output.append(f" - {project_name}/{session_name}")
if len(other_sessions) > 10:
output.append(f" ... and {len(other_sessions) - 10} more")
output.append("\nUse 'neovim_session --action restore --session-name <name>' to restore a session.")
return "\n".join(output)
def _delete_session(self, session_name: Optional[str], project_dir: Path) -> str:
"""Delete a session."""
if not session_name:
return "Error: session_name is required for delete action"
session_file = project_dir / f"{session_name}.vim"
metadata_file = project_dir / f"{session_name}.json"
if not session_file.exists():
return f"Error: Session '{session_name}' not found"
try:
session_file.unlink()
if metadata_file.exists():
metadata_file.unlink()
return f"Successfully deleted session '{session_name}'"
except Exception as e:
return f"Error deleting session: {str(e)}"
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
+41
View File
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-editor"
version = "0.1.0"
description = "Editor integration tools for Hanzo AI - Neovim, VSCode, etc."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "editor", "neovim", "vscode", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
neovim = ["pynvim>=0.5.0"]
dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
[project.entry-points."hanzo.tools"]
editor = "hanzo_tools.editor:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
@@ -0,0 +1,2 @@
"""Hanzo Tools namespace package."""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,91 @@
"""Filesystem tools for Hanzo AI.
Tools:
- read: Read file contents
- write: Write/create files
- edit: Edit files with find/replace
- multi_edit: Multiple edits in one operation
- tree: Directory tree view
- find: Find files by pattern
- search: Search file contents
- ast: AST-based code search
- rules: Read project rules/config
Install:
pip install hanzo-tools-filesystem
Usage:
from hanzo_tools.filesystem import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server, permission_manager)
# Or access individual tools
from hanzo_tools.filesystem import ReadTool, WriteTool
"""
from hanzo_tools.filesystem.read import ReadTool
from hanzo_tools.filesystem.write import WriteTool
from hanzo_tools.filesystem.edit import EditTool
from hanzo_tools.filesystem.tree import TreeTool
from hanzo_tools.filesystem.find import FindTool
from hanzo_tools.filesystem.search import SearchTool
from hanzo_tools.filesystem.ast import ASTTool
# Export list for tool discovery
TOOLS = [
ReadTool,
WriteTool,
EditTool,
TreeTool,
FindTool,
SearchTool,
ASTTool,
]
__all__ = [
# Tool classes
"ReadTool",
"WriteTool",
"EditTool",
"TreeTool",
"FindTool",
"SearchTool",
"ASTTool",
# Registration
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, permission_manager, enabled_tools: dict[str, bool] | None = None):
"""Register all filesystem tools with the MCP server.
Args:
mcp_server: FastMCP server instance
permission_manager: PermissionManager for access control
enabled_tools: Dict of tool_name -> enabled state
Returns:
List of registered tool instances
"""
from hanzo_tools.core import ToolRegistry
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
if enabled.get(tool_name, True): # Enabled by default
try:
tool = tool_class(permission_manager)
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
except TypeError:
# Tool doesn't need permission_manager
tool = tool_class()
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
return registered
@@ -0,0 +1,270 @@
"""AST-based code structure search using tree-sitter.
This module provides the ASTTool for searching, indexing, and querying code symbols
using tree-sitter AST parsing. It can find function definitions, class declarations,
and other code structures with full context.
"""
import os
from typing import Annotated, TypedDict, Unpack, final, override
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool
# Lazy import for grep_ast
_tree_context_cls = None
def _get_tree_context():
"""Lazy load TreeContext to avoid import-time overhead."""
global _tree_context_cls
if _tree_context_cls is None:
from grep_ast.grep_ast import TreeContext
_tree_context_cls = TreeContext
return _tree_context_cls
# Type annotations for parameters
Pattern = Annotated[
str,
Field(
description="The regex pattern to search for in source code files",
min_length=1,
),
]
SearchPath = Annotated[
str,
Field(
description="The path to search in (file or directory)",
min_length=1,
),
]
IgnoreCase = Annotated[
bool,
Field(
description="Whether to ignore case when matching",
default=False,
),
]
LineNumber = Annotated[
bool,
Field(
description="Whether to display line numbers",
default=False,
),
]
class ASTToolParams(TypedDict, total=False):
"""Parameters for the ASTTool.
Attributes:
pattern: The regex pattern to search for in source code files
path: The path to search in (file or directory)
ignore_case: Whether to ignore case when matching
line_number: Whether to display line numbers
"""
pattern: Pattern
path: SearchPath
ignore_case: IgnoreCase
line_number: LineNumber
# Extensions supported by tree-sitter (common programming languages)
SUPPORTED_EXTENSIONS = {
'.py', '.pyw', # Python
'.js', '.jsx', '.mjs', '.cjs', # JavaScript
'.ts', '.tsx', '.mts', '.cts', # TypeScript
'.go', # Go
'.rs', # Rust
'.c', '.h', # C
'.cpp', '.cc', '.cxx', '.hpp', '.hh', '.hxx', # C++
'.java', # Java
'.rb', # Ruby
'.php', # PHP
'.cs', # C#
'.swift', # Swift
'.kt', '.kts', # Kotlin
'.scala', # Scala
'.lua', # Lua
'.r', '.R', # R
'.jl', # Julia
'.ex', '.exs', # Elixir
'.erl', '.hrl', # Erlang
'.ml', '.mli', # OCaml
'.hs', # Haskell
'.elm', # Elm
'.vue', # Vue
'.svelte', # Svelte
}
@final
class ASTTool(BaseTool):
"""Tool for searching and querying code structures using tree-sitter AST parsing."""
name = "ast"
@property
@override
def description(self) -> str:
"""Get the tool description.
Returns:
Tool description
"""
return """AST-based code structure search using tree-sitter. Find functions, classes, methods with full context.
Usage:
ast "function_name" ./src
ast "class.*Service" ./src
ast "def test_" ./tests
Searches code structure intelligently, understanding syntax and providing semantic context."""
def _is_supported_file(self, path: str) -> bool:
"""Check if file has a supported extension for tree-sitter parsing."""
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
@override
async def call(
self,
ctx: MCPContext,
**params: Unpack[ASTToolParams],
) -> str:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Tool result
"""
# Extract parameters
pattern: str = params["pattern"]
path: str = params["path"]
ignore_case = params.get("ignore_case", False)
line_number = params.get("line_number", False)
# Expand ~ in path
path = os.path.expanduser(path)
# Check if path exists
path_obj = Path(path)
if not path_obj.exists():
return f"Error: Path does not exist: {path}"
# Get the files to process
files_to_process = []
if path_obj.is_file():
if self._is_supported_file(str(path_obj)):
files_to_process.append(str(path_obj))
else:
return f"Error: File type not supported for AST parsing: {path_obj.suffix}"
elif path_obj.is_dir():
for root, _, files in os.walk(path_obj):
# Skip hidden directories and common non-code directories
root_path = Path(root)
if any(part.startswith('.') for part in root_path.parts):
continue
if any(part in ('node_modules', '__pycache__', 'venv', '.venv', 'dist', 'build')
for part in root_path.parts):
continue
for file in files:
file_path = Path(root) / file
if self._is_supported_file(str(file_path)):
files_to_process.append(str(file_path))
if not files_to_process:
return f"No source code files found in {path}"
# Get TreeContext class
TreeContext = _get_tree_context()
# Process each file
results = []
errors = []
for file_path in files_to_process:
try:
# Read the file
with open(file_path, "r", encoding="utf-8") as f:
code = f.read()
# Process the file with grep-ast
try:
tc = TreeContext(
file_path,
code,
color=False,
verbose=False,
line_number=line_number,
)
# Find matches
loi = tc.grep(pattern, ignore_case)
if loi:
tc.add_lines_of_interest(loi)
tc.add_context()
output = tc.format()
# Add the result to our list
results.append(f"\n{file_path}:\n{output}\n")
except Exception as e:
# Skip files that can't be parsed by tree-sitter
errors.append(f"Could not parse {file_path}: {str(e)}")
except UnicodeDecodeError:
errors.append(f"Could not read {file_path} as text")
except Exception as e:
errors.append(f"Error processing {file_path}: {str(e)}")
if not results:
error_info = ""
if errors:
error_info = f"\n\nErrors encountered:\n" + "\n".join(errors[:5])
if len(errors) > 5:
error_info += f"\n... and {len(errors) - 5} more errors"
return f"No matches found for '{pattern}' in {path}{error_info}"
summary = f"Found matches in {len(results)} file(s) (searched {len(files_to_process)} files)"
return summary + "\n" + "".join(results)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Creates a wrapper function with explicitly defined parameters that match
the tool's parameter schema and registers it with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def ast(
ctx: MCPContext,
pattern: Pattern,
path: SearchPath,
ignore_case: IgnoreCase = False,
line_number: LineNumber = False,
) -> str:
return await tool_self.call(
ctx,
pattern=pattern,
path=path,
ignore_case=ignore_case,
line_number=line_number,
)
@@ -0,0 +1,111 @@
"""Edit tool - find and replace in files."""
from typing import Annotated
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
class EditTool(FileSystemTool):
"""Edit files with find and replace."""
name = "edit"
@property
def description(self) -> str:
return """Edit a file by replacing text.
Args:
file_path: Absolute path to the file
old_string: Text to find (must be unique)
new_string: Text to replace with
expected_replacements: Expected number of replacements (default 1)
Returns:
Success message with diff or error
"""
def __init__(self, permission_manager: PermissionManager):
super().__init__(permission_manager)
@auto_timeout("edit")
async def call(
self,
ctx: MCPContext,
file_path: str,
old_string: str,
new_string: str,
expected_replacements: int = 1,
**kwargs,
) -> str:
"""Edit file with find/replace."""
validation = self.validate_path(file_path)
if not validation:
return validation.error_message
if not self.is_path_allowed(file_path):
return f"Error: Access denied to path: {file_path}"
path = Path(file_path)
if not path.exists():
return f"Error: File does not exist: {file_path}"
if old_string == new_string:
return "Error: old_string and new_string are identical"
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Count occurrences
count = content.count(old_string)
if count == 0:
return f"Error: old_string not found in file"
if count != expected_replacements:
return (
f"Error: Found {count} occurrences of old_string, "
f"expected {expected_replacements}. "
f"Use expected_replacements={count} or make old_string more specific."
)
# Perform replacement
new_content = content.replace(old_string, new_string, expected_replacements)
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
return (
f"Successfully edited {file_path}\n"
f"Replaced {count} occurrence(s)"
)
except Exception as e:
return f"Error editing file: {e}"
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def edit(
file_path: Annotated[str, Field(description="Absolute path to the file")],
old_string: Annotated[str, Field(description="Text to find")],
new_string: Annotated[str, Field(description="Text to replace with")],
expected_replacements: Annotated[int, Field(description="Expected replacements")] = 1,
ctx: MCPContext = None,
) -> str:
"""Edit a file by replacing text."""
return await tool_instance.call(
ctx,
file_path=file_path,
old_string=old_string,
new_string=new_string,
expected_replacements=expected_replacements,
)
@@ -0,0 +1,107 @@
"""Find tool - find files by pattern."""
from typing import Annotated, Optional
from pathlib import Path
import fnmatch
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout
class FindTool(BaseTool):
"""Find files by name pattern."""
name = "find"
@property
def description(self) -> str:
return """Find files and directories by name pattern.
Args:
pattern: Glob pattern (e.g., "*.py", "test_*")
path: Directory to search in (default: current dir)
type: "file", "dir", or None for both
max_results: Maximum results to return (default 100)
Returns:
List of matching paths
"""
IGNORED_DIRS = {
'.git', '__pycache__', 'node_modules', '.venv', 'venv',
'.idea', '.vscode', '.mypy_cache', '.pytest_cache',
}
@auto_timeout("find")
async def call(
self,
ctx: MCPContext,
pattern: str,
path: str = ".",
type: Optional[str] = None,
max_results: int = 100,
**kwargs,
) -> str:
"""Find files matching pattern."""
root = Path(path).resolve()
if not root.exists():
return f"Error: Path does not exist: {path}"
matches = []
def should_skip(p: Path) -> bool:
return any(part in self.IGNORED_DIRS for part in p.parts)
for item in root.rglob("*"):
if len(matches) >= max_results:
break
if should_skip(item):
continue
# Check type filter
if type == "file" and not item.is_file():
continue
if type == "dir" and not item.is_dir():
continue
# Check pattern match
if fnmatch.fnmatch(item.name, pattern):
try:
rel_path = item.relative_to(root)
suffix = "/" if item.is_dir() else ""
matches.append(f"{rel_path}{suffix}")
except ValueError:
matches.append(str(item))
if not matches:
return f"No matches found for pattern: {pattern}"
result = f"Found {len(matches)} matches:\n\n"
result += "\n".join(matches)
if len(matches) >= max_results:
result += f"\n\n[Truncated at {max_results} results]"
return result
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def find(
pattern: Annotated[str, Field(description="Glob pattern")],
path: Annotated[str, Field(description="Directory to search")] = ".",
type: Annotated[Optional[str], Field(description="file, dir, or None")] = None,
max_results: Annotated[int, Field(description="Max results")] = 100,
ctx: MCPContext = None,
) -> str:
"""Find files and directories by pattern."""
return await tool_instance.call(
ctx, pattern=pattern, path=path, type=type, max_results=max_results
)
@@ -0,0 +1,100 @@
"""Read tool - read file contents."""
from typing import Annotated, Any
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
class ReadTool(FileSystemTool):
"""Read file contents with line numbers."""
name = "read"
@property
def description(self) -> str:
return """Read file contents with line numbers.
Args:
file_path: Absolute path to the file
offset: Starting line (0-based, optional)
limit: Max lines to read (optional, default 2000)
Returns:
File contents with line numbers
"""
def __init__(self, permission_manager: PermissionManager):
super().__init__(permission_manager)
@auto_timeout("read")
async def call(
self,
ctx: MCPContext,
file_path: str,
offset: int = 0,
limit: int = 2000,
**kwargs,
) -> str:
"""Read file contents."""
# Validate path
validation = self.validate_path(file_path)
if not validation:
return validation.error_message
if not self.is_path_allowed(file_path):
return f"Error: Access denied to path: {file_path}"
path = Path(file_path)
if not path.exists():
return f"Error: File does not exist: {file_path}"
if not path.is_file():
return f"Error: Not a file: {file_path}"
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
# Apply offset and limit
total_lines = len(lines)
selected = lines[offset:offset + limit]
# Format with line numbers
output_lines = []
for i, line in enumerate(selected, start=offset + 1):
line = line.rstrip("\n\r")
# Truncate long lines
if len(line) > 2000:
line = line[:2000] + "..."
output_lines.append(f"{i:6}→{line}")
result = "\n".join(output_lines)
# Add info if truncated
if offset > 0 or offset + limit < total_lines:
result += f"\n\n[Showing lines {offset + 1}-{min(offset + limit, total_lines)} of {total_lines}]"
return result
except Exception as e:
return f"Error reading file: {e}"
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def read(
file_path: Annotated[str, Field(description="Absolute path to the file")],
offset: Annotated[int, Field(description="Starting line (0-based)")] = 0,
limit: Annotated[int, Field(description="Max lines to read")] = 2000,
ctx: MCPContext = None,
) -> str:
"""Read file contents with line numbers."""
return await tool_instance.call(ctx, file_path=file_path, offset=offset, limit=limit)
@@ -0,0 +1,191 @@
"""Search tool - search file contents."""
import re
import subprocess
from typing import Annotated, Optional
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout
class SearchTool(BaseTool):
"""Search file contents using regex."""
name = "search"
@property
def description(self) -> str:
return """Search for patterns in file contents.
Uses ripgrep (rg) if available, falls back to Python regex.
Args:
pattern: Regex pattern to search for
path: Directory or file to search (default: current dir)
include: Glob pattern to filter files (e.g., "*.py")
context_lines: Lines of context around matches
max_results: Maximum results (default 50)
Returns:
Matching lines with file paths and line numbers
"""
@auto_timeout("search")
async def call(
self,
ctx: MCPContext,
pattern: str,
path: str = ".",
include: Optional[str] = None,
context_lines: int = 2,
max_results: int = 50,
**kwargs,
) -> str:
"""Search for pattern in files."""
root = Path(path).resolve()
if not root.exists():
return f"Error: Path does not exist: {path}"
# Try ripgrep first (much faster)
try:
result = await self._search_with_rg(
pattern, root, include, context_lines, max_results
)
if result is not None:
return result
except Exception:
pass
# Fallback to Python
return await self._search_with_python(
pattern, root, include, context_lines, max_results
)
async def _search_with_rg(
self,
pattern: str,
root: Path,
include: Optional[str],
context_lines: int,
max_results: int,
) -> Optional[str]:
"""Search using ripgrep."""
cmd = [
"rg",
"--line-number",
"--color=never",
f"--max-count={max_results}",
]
if context_lines > 0:
cmd.append(f"-C{context_lines}")
if include:
cmd.extend(["--glob", include])
cmd.extend([pattern, str(root)])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return result.stdout or "No matches found"
elif result.returncode == 1:
return "No matches found"
else:
return None # Fall back to Python
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
async def _search_with_python(
self,
pattern: str,
root: Path,
include: Optional[str],
context_lines: int,
max_results: int,
) -> str:
"""Search using Python regex."""
import fnmatch
try:
regex = re.compile(pattern)
except re.error as e:
return f"Invalid regex pattern: {e}"
matches = []
# Find files to search
if root.is_file():
files = [root]
else:
files = list(root.rglob("*"))
for file_path in files:
if not file_path.is_file():
continue
if include and not fnmatch.fnmatch(file_path.name, include):
continue
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
for i, line in enumerate(lines, 1):
if regex.search(line):
rel_path = file_path.relative_to(root)
matches.append(f"{rel_path}:{i}:{line.rstrip()}")
if len(matches) >= max_results:
break
if len(matches) >= max_results:
break
except Exception:
continue
if not matches:
return "No matches found"
result = f"Found {len(matches)} matches:\n\n"
result += "\n".join(matches)
if len(matches) >= max_results:
result += f"\n\n[Truncated at {max_results} results]"
return result
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def search(
pattern: Annotated[str, Field(description="Regex pattern")],
path: Annotated[str, Field(description="Path to search")] = ".",
include: Annotated[Optional[str], Field(description="File pattern")] = None,
context_lines: Annotated[int, Field(description="Context lines")] = 2,
max_results: Annotated[int, Field(description="Max results")] = 50,
ctx: MCPContext = None,
) -> str:
"""Search for patterns in file contents."""
return await tool_instance.call(
ctx,
pattern=pattern,
path=path,
include=include,
context_lines=context_lines,
max_results=max_results,
)
@@ -0,0 +1,115 @@
"""Tree tool - directory tree view."""
from typing import Annotated
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout
class TreeTool(BaseTool):
"""Display directory tree structure."""
name = "tree"
@property
def description(self) -> str:
return """Display directory tree structure.
Args:
path: Directory path to display
depth: Maximum depth to traverse (default 3)
include_filtered: Include normally filtered dirs like .git
Returns:
Tree structure as text
"""
# Directories to skip by default
FILTERED_DIRS = {
'.git', '__pycache__', 'node_modules', '.venv', 'venv',
'.idea', '.vscode', '.mypy_cache', '.pytest_cache',
'dist', 'build', 'egg-info', '.tox', '.nox',
}
@auto_timeout("tree")
async def call(
self,
ctx: MCPContext,
path: str,
depth: int = 3,
include_filtered: bool = False,
**kwargs,
) -> str:
"""Generate directory tree."""
root = Path(path)
if not root.exists():
return f"Error: Path does not exist: {path}"
if not root.is_dir():
return f"Error: Not a directory: {path}"
lines = []
self._build_tree(root, lines, "", depth, include_filtered)
return "\n".join(lines)
def _build_tree(
self,
path: Path,
lines: list[str],
prefix: str,
depth: int,
include_filtered: bool,
) -> None:
"""Recursively build tree lines."""
if depth < 0:
return
try:
entries = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
except PermissionError:
lines.append(f"{prefix}[permission denied]")
return
# Filter entries
if not include_filtered:
entries = [
e for e in entries
if e.name not in self.FILTERED_DIRS
]
for i, entry in enumerate(entries):
is_last = i == len(entries) - 1
connector = "└── " if is_last else "├── "
if entry.is_dir():
lines.append(f"{prefix}{connector}{entry.name}/")
if depth > 0:
extension = " " if is_last else "│ "
self._build_tree(
entry, lines, prefix + extension,
depth - 1, include_filtered
)
else:
lines.append(f"{prefix}{connector}{entry.name}")
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def tree(
path: Annotated[str, Field(description="Directory path")],
depth: Annotated[int, Field(description="Max depth")] = 3,
include_filtered: Annotated[bool, Field(description="Include filtered dirs")] = False,
ctx: MCPContext = None,
) -> str:
"""Display directory tree structure."""
return await tool_instance.call(
ctx, path=path, depth=depth, include_filtered=include_filtered
)
@@ -0,0 +1,75 @@
"""Write tool - write/create files."""
from typing import Annotated
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
class WriteTool(FileSystemTool):
"""Write content to a file."""
name = "write"
@property
def description(self) -> str:
return """Write content to a file (creates or overwrites).
Args:
file_path: Absolute path to the file
content: Content to write
Returns:
Success message or error
"""
def __init__(self, permission_manager: PermissionManager):
super().__init__(permission_manager)
@auto_timeout("write")
async def call(
self,
ctx: MCPContext,
file_path: str,
content: str,
**kwargs,
) -> str:
"""Write content to file."""
validation = self.validate_path(file_path)
if not validation:
return validation.error_message
if not self.is_path_allowed(file_path):
return f"Error: Access denied to path: {file_path}"
path = Path(file_path)
try:
# Create parent directories if needed
path.parent.mkdir(parents=True, exist_ok=True)
# Write content
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"Successfully wrote {len(content)} bytes to {file_path}"
except Exception as e:
return f"Error writing file: {e}"
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def write(
file_path: Annotated[str, Field(description="Absolute path to the file")],
content: Annotated[str, Field(description="Content to write")],
ctx: MCPContext = None,
) -> str:
"""Write content to a file."""
return await tool_instance.call(ctx, file_path=file_path, content=content)
+46
View File
@@ -0,0 +1,46 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-filesystem"
version = "0.1.0"
description = "Filesystem tools for Hanzo AI - read, write, edit, search, find"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "filesystem", "mcp", "ai"]
dependencies = [
"aiofiles>=24.1.0",
"grep-ast>=0.8.1",
"ffind>=1.3.0",
"watchdog>=6.0.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
]
# For development, install hanzo-tools-core from local:
# uv pip install -e ../hanzo-tools-core
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
[project.optional-dependencies]
dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
[project.entry-points."hanzo.tools"]
filesystem = "hanzo_tools.filesystem:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
+1705
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
import pkgutil
__path__ = pkgutil.extend_path(__path__, __name__)
@@ -0,0 +1,54 @@
"""Jupyter notebook tools for Hanzo AI.
Tools:
- jupyter: Read, edit, and execute Jupyter notebooks
Install:
pip install hanzo-tools-jupyter
Usage:
from hanzo_tools.jupyter import register_tools, TOOLS
# Register with MCP server
register_tools(mcp_server, permission_manager)
"""
from hanzo_tools.core import BaseTool, ToolRegistry, PermissionManager
from .jupyter import JupyterTool
# Export list for tool discovery
TOOLS = [JupyterTool]
__all__ = [
"JupyterTool",
"register_tools",
"TOOLS",
]
def register_tools(
mcp_server,
permission_manager: PermissionManager,
enabled_tools: dict[str, bool] | None = None,
) -> list[BaseTool]:
"""Register Jupyter notebook tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
enabled_tools: Dict of tool_name -> enabled state
Returns:
List of registered tools
"""
enabled = enabled_tools or {}
registered = []
for tool_class in TOOLS:
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
if enabled.get(tool_name, True): # Enabled by default
tool = tool_class(permission_manager)
ToolRegistry.register_tool(mcp_server, tool)
registered.append(tool)
return registered
@@ -0,0 +1,278 @@
"""Base functionality for Jupyter notebook tools.
This module provides common functionality for Jupyter notebook tools, including notebook parsing,
cell processing, and output formatting.
"""
import re
import json
from abc import ABC
from typing import Any, final
from pathlib import Path
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import ToolContext, create_tool_context
from hanzo_tools.core import FileSystemTool as FilesystemBaseTool
# Pattern to match ANSI escape sequences
ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-9;]*[a-zA-Z]")
# Function to clean ANSI escape codes from text
def clean_ansi_escapes(text: str) -> str:
"""Remove ANSI escape sequences from text.
Args:
text: Text containing ANSI escape sequences
Returns:
Text with ANSI escape sequences removed
"""
return ANSI_ESCAPE_PATTERN.sub("", text)
@final
class NotebookOutputImage:
"""Representation of an image output in a notebook cell."""
def __init__(self, image_data: str, media_type: str):
"""Initialize a notebook output image.
Args:
image_data: Base64-encoded image data
media_type: Media type of the image (e.g., "image/png")
"""
self.image_data = image_data
self.media_type = media_type
@final
class NotebookCellOutput:
"""Representation of an output from a notebook cell."""
def __init__(
self,
output_type: str,
text: str | None = None,
image: NotebookOutputImage | None = None,
):
"""Initialize a notebook cell output.
Args:
output_type: Type of output
text: Text output (if any)
image: Image output (if any)
"""
self.output_type = output_type
self.text = text
self.image = image
@final
class NotebookCellSource:
"""Representation of a source cell from a notebook."""
def __init__(
self,
cell_index: int,
cell_type: str,
source: str,
language: str,
execution_count: int | None = None,
outputs: list[NotebookCellOutput] | None = None,
):
"""Initialize a notebook cell source.
Args:
cell_index: Index of the cell in the notebook
cell_type: Type of cell (code or markdown)
source: Source code or text of the cell
language: Programming language of the cell
execution_count: Execution count of the cell (if any)
outputs: Outputs from the cell (if any)
"""
self.cell_index = cell_index
self.cell_type = cell_type
self.source = source
self.language = language
self.execution_count = execution_count
self.outputs = outputs or []
class JupyterBaseTool(FilesystemBaseTool, ABC):
"""Base class for Jupyter notebook tools.
Provides common functionality for working with Jupyter notebooks, including
parsing, cell extraction, and output formatting.
"""
def create_tool_context(self, ctx: MCPContext) -> ToolContext:
"""Create a tool context with the tool name.
Args:
ctx: MCP context
Returns:
Tool context
"""
tool_ctx = create_tool_context(ctx)
return tool_ctx
def set_tool_context_info(self, tool_ctx: ToolContext) -> None:
"""Set the tool info on the context.
Args:
tool_ctx: Tool context
"""
tool_ctx.set_tool_info(self.name)
async def parse_notebook(self, file_path: Path) -> tuple[dict[str, Any], list[NotebookCellSource]]:
"""Parse a Jupyter notebook file.
Args:
file_path: Path to the notebook file
Returns:
Tuple of (notebook_data, processed_cells)
"""
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
notebook = json.loads(content)
# Get notebook language
language = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
cells = notebook.get("cells", [])
processed_cells = []
for i, cell in enumerate(cells):
cell_type = cell.get("cell_type", "code")
# Skip if not code or markdown
if cell_type not in ["code", "markdown"]:
continue
# Get source
source = cell.get("source", "")
if isinstance(source, list):
source = "".join(source)
# Get execution count for code cells
execution_count = None
if cell_type == "code":
execution_count = cell.get("execution_count")
# Process outputs for code cells
outputs = []
if cell_type == "code" and "outputs" in cell:
for output in cell["outputs"]:
output_type = output.get("output_type", "")
# Process different output types
if output_type == "stream":
text = output.get("text", "")
if isinstance(text, list):
text = "".join(text)
outputs.append(NotebookCellOutput(output_type="stream", text=text))
elif output_type in ["execute_result", "display_data"]:
# Process text output
text = None
if "data" in output and "text/plain" in output["data"]:
text_data = output["data"]["text/plain"]
if isinstance(text_data, list):
text = "".join(text_data)
else:
text = text_data
# Process image output
image = None
if "data" in output:
if "image/png" in output["data"]:
image = NotebookOutputImage(
image_data=output["data"]["image/png"],
media_type="image/png",
)
elif "image/jpeg" in output["data"]:
image = NotebookOutputImage(
image_data=output["data"]["image/jpeg"],
media_type="image/jpeg",
)
outputs.append(NotebookCellOutput(output_type=output_type, text=text, image=image))
elif output_type == "error":
# Format error traceback
ename = output.get("ename", "")
evalue = output.get("evalue", "")
traceback = output.get("traceback", [])
# Handle raw text strings and lists of strings
if isinstance(traceback, list):
# Clean ANSI escape codes and join the list but preserve the formatting
clean_traceback = [clean_ansi_escapes(line) for line in traceback]
traceback_text = "\n".join(clean_traceback)
else:
traceback_text = clean_ansi_escapes(str(traceback))
error_text = f"{ename}: {evalue}\n{traceback_text}"
outputs.append(NotebookCellOutput(output_type="error", text=error_text))
# Create cell object
processed_cell = NotebookCellSource(
cell_index=i,
cell_type=cell_type,
source=source,
language=language,
execution_count=execution_count,
outputs=outputs,
)
processed_cells.append(processed_cell)
return notebook, processed_cells
def format_notebook_cells(self, cells: list[NotebookCellSource]) -> str:
"""Format notebook cells as a readable string.
Args:
cells: List of processed notebook cells
Returns:
Formatted string representation of the cells
"""
result = []
for cell in cells:
# Format the cell header
cell_header = f"Cell [{cell.cell_index}] {cell.cell_type}"
if cell.execution_count is not None:
cell_header += f" (execution_count: {cell.execution_count})"
if cell.cell_type == "code" and cell.language != "python":
cell_header += f" [{cell.language}]"
# Add cell to result
result.append(f"{cell_header}:")
result.append(f"```{cell.language if cell.cell_type == 'code' else ''}")
result.append(cell.source)
result.append("```")
# Add outputs if any
if cell.outputs:
result.append("Outputs:")
for output in cell.outputs:
if output.output_type == "error":
result.append("Error:")
result.append("```")
result.append(output.text)
result.append("```")
elif output.text:
result.append("Output:")
result.append("```")
result.append(output.text)
result.append("```")
if output.image:
result.append(f"[Image output: {output.image.media_type}]")
result.append("") # Empty line between cells
return "\n".join(result)
@@ -0,0 +1,432 @@
"""Unified Jupyter notebook tool."""
from typing import (
Any,
Dict,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from pathlib import Path
import nbformat
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from .base import JupyterBaseTool
from hanzo_tools.core import auto_timeout
# Parameter types
Action = Annotated[
str,
Field(
description="Action to perform: read (default), edit, create, delete, execute",
default="read",
),
]
NotebookPath = Annotated[
str,
Field(
description="Path to the Jupyter notebook file (.ipynb)",
),
]
CellId = Annotated[
Optional[str],
Field(
description="Cell ID for targeted operations",
default=None,
),
]
CellIndex = Annotated[
Optional[int],
Field(
description="Cell index (0-based) for operations",
default=None,
),
]
CellType = Annotated[
Optional[str],
Field(
description="Cell type: code or markdown",
default=None,
),
]
Source = Annotated[
Optional[str],
Field(
description="New source content for cell",
default=None,
),
]
EditMode = Annotated[
str,
Field(
description="Edit mode: replace (default), insert, delete",
default="replace",
),
]
class NotebookParams(TypedDict, total=False):
"""Parameters for notebook tool."""
action: str
notebook_path: str
cell_id: Optional[str]
cell_index: Optional[int]
cell_type: Optional[str]
source: Optional[str]
edit_mode: str
@final
class JupyterTool(JupyterBaseTool):
"""Tool for Jupyter notebook operations."""
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "jupyter"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Jupyter notebooks. Actions: read (default), edit, create, delete, execute.
Usage:
jupyter "path/to/notebook.ipynb"
jupyter "notebook.ipynb" --cell-index 2
jupyter --action edit "notebook.ipynb" --cell-index 0 --source "print('Hello')"
jupyter --action create "new.ipynb"
"""
@override
@auto_timeout("jupyter")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NotebookParams],
) -> str:
"""Execute notebook operation."""
tool_ctx = self.create_tool_context(ctx)
# Extract parameters
action = params.get("action", "read")
notebook_path = params.get("notebook_path")
if not notebook_path:
return "Error: notebook_path is required"
# Validate path
path_validation = self.validate_path(notebook_path)
if path_validation.is_error:
await tool_ctx.error(path_validation.error_message)
return f"Error: {path_validation.error_message}"
# Check permissions
allowed, error_msg = await self.check_path_allowed(notebook_path, tool_ctx)
if not allowed:
return error_msg
# Route to appropriate handler
if action == "read":
return await self._handle_read(notebook_path, params, tool_ctx)
elif action == "edit":
return await self._handle_edit(notebook_path, params, tool_ctx)
elif action == "create":
return await self._handle_create(notebook_path, tool_ctx)
elif action == "delete":
return await self._handle_delete(notebook_path, params, tool_ctx)
elif action == "execute":
return await self._handle_execute(notebook_path, params, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: read, edit, create, delete, execute"
async def _handle_read(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
"""Read notebook or specific cell."""
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
if not exists:
return error_msg
try:
nb = self.read_notebook(notebook_path)
# Check if specific cell requested
cell_id = params.get("cell_id")
cell_index = params.get("cell_index")
if cell_id:
# Find cell by ID
for i, cell in enumerate(nb.cells):
if cell.get("id") == cell_id:
return self._format_cell(cell, i)
return f"Error: Cell with ID '{cell_id}' not found"
elif cell_index is not None:
# Get cell by index
if 0 <= cell_index < len(nb.cells):
return self._format_cell(nb.cells[cell_index], cell_index)
else:
return f"Error: Cell index {cell_index} out of range (notebook has {len(nb.cells)} cells)"
else:
# Return all cells
return self.format_notebook(nb)
except Exception as e:
await tool_ctx.error(f"Failed to read notebook: {str(e)}")
return f"Error reading notebook: {str(e)}"
async def _handle_edit(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
"""Edit notebook cell."""
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
if not exists:
return error_msg
source = params.get("source")
edit_mode = params.get("edit_mode", "replace")
# Only require source for non-delete operations
if edit_mode != "delete" and not source:
return "Error: source is required for edit action"
cell_id = params.get("cell_id")
cell_index = params.get("cell_index")
cell_type = params.get("cell_type")
try:
nb = self.read_notebook(notebook_path)
if edit_mode == "insert":
# Insert new cell
new_cell = (
nbformat.v4.new_code_cell(source)
if cell_type != "markdown"
else nbformat.v4.new_markdown_cell(source)
)
if cell_index is not None:
nb.cells.insert(cell_index, new_cell)
else:
nb.cells.append(new_cell)
self.write_notebook(nb, notebook_path)
return f"Successfully inserted new cell at index {cell_index if cell_index is not None else len(nb.cells) - 1}"
elif edit_mode == "delete":
# Delete cell
if cell_id:
for i, cell in enumerate(nb.cells):
if cell.get("id") == cell_id:
nb.cells.pop(i)
self.write_notebook(nb, notebook_path)
return f"Successfully deleted cell with ID '{cell_id}'"
return f"Error: Cell with ID '{cell_id}' not found"
elif cell_index is not None:
if 0 <= cell_index < len(nb.cells):
nb.cells.pop(cell_index)
self.write_notebook(nb, notebook_path)
return f"Successfully deleted cell at index {cell_index}"
else:
return f"Error: Cell index {cell_index} out of range"
else:
return "Error: cell_id or cell_index required for delete"
else: # replace
# Replace cell content
if cell_id:
for cell in nb.cells:
if cell.get("id") == cell_id:
cell["source"] = source
if cell_type:
cell["cell_type"] = cell_type
self.write_notebook(nb, notebook_path)
return f"Successfully updated cell with ID '{cell_id}'"
return f"Error: Cell with ID '{cell_id}' not found"
elif cell_index is not None:
if 0 <= cell_index < len(nb.cells):
nb.cells[cell_index]["source"] = source
if cell_type:
nb.cells[cell_index]["cell_type"] = cell_type
self.write_notebook(nb, notebook_path)
return f"Successfully updated cell at index {cell_index}"
else:
return f"Error: Cell index {cell_index} out of range"
else:
return "Error: cell_id or cell_index required for replace"
except Exception as e:
await tool_ctx.error(f"Failed to edit notebook: {str(e)}")
return f"Error editing notebook: {str(e)}"
async def _handle_create(self, notebook_path: str, tool_ctx) -> str:
"""Create new notebook."""
# Check if already exists
path = Path(notebook_path)
if path.exists():
return f"Error: Notebook already exists at {notebook_path}"
try:
# Create new notebook
nb = nbformat.v4.new_notebook()
# Ensure parent directory exists
path.parent.mkdir(parents=True, exist_ok=True)
# Write notebook
self.write_notebook(nb, notebook_path)
return f"Successfully created notebook at {notebook_path}"
except Exception as e:
await tool_ctx.error(f"Failed to create notebook: {str(e)}")
return f"Error creating notebook: {str(e)}"
async def _handle_delete(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
"""Delete notebook or cell."""
# If cell specified, delegate to edit with delete mode
if params.get("cell_id") or params.get("cell_index") is not None:
params["edit_mode"] = "delete"
return await self._handle_edit(notebook_path, params, tool_ctx)
# Otherwise, delete entire notebook
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
if not exists:
return error_msg
try:
Path(notebook_path).unlink()
return f"Successfully deleted notebook {notebook_path}"
except Exception as e:
await tool_ctx.error(f"Failed to delete notebook: {str(e)}")
return f"Error deleting notebook: {str(e)}"
async def _handle_execute(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
"""Execute notebook cells using nbclient."""
try:
import nbclient
from nbclient import NotebookClient
nb = nbformat.read(notebook_path, as_version=4)
# Create a notebook client with default kernel
client = NotebookClient(
nb, timeout=params.get("timeout", 600), kernel_name=params.get("kernel_name", "python3")
)
# Execute the notebook
await client.async_execute()
# Save the executed notebook
nbformat.write(nb, notebook_path)
return f"Successfully executed all cells in {notebook_path}"
except ImportError:
return "Error: nbclient not installed. Install with: pip install nbclient"
except Exception as e:
return f"Error executing notebook: {str(e)}"
def _format_cell(self, cell: dict, index: int) -> str:
"""Format a single cell for display."""
output = [f"Cell {index} ({cell.get('cell_type', 'unknown')})"]
if cell.get("id"):
output.append(f"ID: {cell.get('id')}")
output.append("-" * 40)
# Get source content
source = cell.get("source", "")
if isinstance(source, list):
source = "".join(source)
output.append(source)
if cell.get("cell_type") == "code" and cell.get("outputs"):
output.append("\nOutputs:")
for out in cell.get("outputs", []):
out_type = out.get("output_type", "")
if out_type == "stream":
text = out.get("text", "")
if isinstance(text, list):
text = "".join(text)
name = out.get("name", "stdout")
output.append(f"[{name}]: {text}")
elif out_type == "execute_result":
exec_count = out.get("execution_count", "?")
data = out.get("data", {})
# Try to get plain text representation
if "text/plain" in data:
text_data = data["text/plain"]
if isinstance(text_data, list):
text_data = "".join(text_data)
output.append(f"[Out {exec_count}]: {text_data}")
else:
output.append(f"[Out {exec_count}]: {data}")
elif out_type == "error":
ename = out.get("ename", "Error")
evalue = out.get("evalue", "")
output.append(f"[Error]: {ename}: {evalue}")
# Include traceback if available
traceback = out.get("traceback", [])
if traceback:
output.append("Traceback:")
for line in traceback:
output.append(f" {line}")
return "\n".join(output)
def read_notebook(self, notebook_path: str) -> Any:
"""Read a notebook from disk using nbformat.
Args:
notebook_path: Path to the notebook file
Returns:
Notebook object
"""
with open(notebook_path, "r") as f:
return nbformat.read(f, as_version=4)
def write_notebook(self, nb: Any, notebook_path: str) -> None:
"""Write a notebook to disk using nbformat.
Args:
nb: Notebook object to write
notebook_path: Path to write the notebook to
"""
with open(notebook_path, "w") as f:
nbformat.write(nb, f)
def format_notebook(self, nb: Any) -> str:
"""Format an entire notebook for display.
Args:
nb: Notebook object
Returns:
Formatted string representation of the notebook
"""
output = []
output.append(f"Notebook with {len(nb.cells)} cells")
output.append("=" * 50)
for i, cell in enumerate(nb.cells):
output.append("")
output.append(self._format_cell(cell, i))
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
@@ -0,0 +1,295 @@
"""Edit notebook tool implementation.
This module provides the NoteBookEditTool for editing Jupyter notebook files.
"""
import json
from typing import Any, Unpack, Literal, Annotated, TypedDict, final, override
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from .base import JupyterBaseTool
from hanzo_tools.core import auto_timeout
NotebookPath = Annotated[
str,
Field(
description="The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)",
),
]
CellNumber = Annotated[
int,
Field(
description="The index of the cell to edit (0-based)",
ge=0,
),
]
NewSource = Annotated[
str,
Field(
description="The new source for the cell",
default="",
),
]
CellType = Annotated[
Literal["code", "markdown"],
Field(
description="The of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.",
default="code",
),
]
EditMode = Annotated[
Literal["replace", "insert", "delete"],
Field(
description="The of edit to make (replace, insert, delete). Defaults to replace.",
default="replace",
),
]
class NotebookEditToolParams(TypedDict):
"""Parameters for the NotebookEditTool.
Attributes:
notebook_path: The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)
cell_number: The index of the cell to edit (0-based)
new_source: The new source for the cell
cell_type: The of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.
edit_mode: The of edit to make (replace, insert, delete). Defaults to replace.
"""
notebook_path: NotebookPath
cell_number: CellNumber
new_source: NewSource
cell_type: CellType
edit_mode: EditMode
@final
class NoteBookEditTool(JupyterBaseTool):
"""Tool for editing Jupyter notebook files."""
@property
@override
def name(self) -> str:
"""Get the tool name.
Returns:
Tool name
"""
return "notebook_edit"
@property
@override
def description(self) -> str:
"""Get the tool description.
Returns:
Tool description
"""
return "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number."
@override
@auto_timeout("notebook_edit")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NotebookEditToolParams],
) -> str:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Tool result
"""
tool_ctx = self.create_tool_context(ctx)
self.set_tool_context_info(tool_ctx)
# Extract parameters
notebook_path = params.get("notebook_path")
cell_number = params.get("cell_number")
new_source = params.get("new_source")
cell_type = params.get("cell_type")
edit_mode = params.get("edit_mode", "replace")
path_validation = self.validate_path(notebook_path)
if path_validation.is_error:
await tool_ctx.error(path_validation.error_message)
return f"Error: {path_validation.error_message}"
# Validate edit_mode
if edit_mode not in ["replace", "insert", "delete"]:
await tool_ctx.error("Edit mode must be replace, insert, or delete")
return "Error: Edit mode must be replace, insert, or delete"
# In insert mode, cell_type is required
if edit_mode == "insert" and cell_type is None:
await tool_ctx.error("Cell type is required when using insert mode")
return "Error: Cell type is required when using insert mode"
# Don't validate new_source for delete mode
if edit_mode != "delete" and not new_source:
await tool_ctx.error("New source is required for replace or insert operations")
return "Error: New source is required for replace or insert operations"
await tool_ctx.info(f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})")
# Check if path is allowed
if not self.is_path_allowed(notebook_path):
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
try:
file_path = Path(notebook_path)
if not file_path.exists():
await tool_ctx.error(f"File does not exist: {notebook_path}")
return f"Error: File does not exist: {notebook_path}"
if not file_path.is_file():
await tool_ctx.error(f"Path is not a file: {notebook_path}")
return f"Error: Path is not a file: {notebook_path}"
# Check file extension
if file_path.suffix.lower() != ".ipynb":
await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}")
return f"Error: File is not a Jupyter notebook: {notebook_path}"
# Read and parse the notebook
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
notebook = json.loads(content)
except json.JSONDecodeError:
await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
return f"Error: Invalid notebook format: {notebook_path}"
except UnicodeDecodeError:
await tool_ctx.error(f"Cannot read notebook file: {notebook_path}")
return f"Error: Cannot read notebook file: {notebook_path}"
# Check cell_number is valid
cells = notebook.get("cells", [])
if edit_mode == "insert":
if cell_number > len(cells):
await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
else: # replace or delete
if cell_number >= len(cells):
await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
# Get notebook language (needed for context but not directly used in this block)
_ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
# Perform the requested operation
if edit_mode == "replace":
# Get the target cell
target_cell = cells[cell_number]
# Store previous contents for reporting
old_type = target_cell.get("cell_type", "code")
old_source = target_cell.get("source", "")
# Fix for old_source which might be a list of strings
if isinstance(old_source, list):
old_source = "".join([str(item) for item in old_source])
# Update source
target_cell["source"] = new_source
# Update type if specified
if cell_type is not None:
target_cell["cell_type"] = cell_type
# If changing to markdown, remove code-specific fields
if cell_type == "markdown":
if "outputs" in target_cell:
del target_cell["outputs"]
if "execution_count" in target_cell:
del target_cell["execution_count"]
# If code cell, reset execution
if target_cell["cell_type"] == "code":
target_cell["outputs"] = []
target_cell["execution_count"] = None
change_description = f"Replaced cell {cell_number}"
if cell_type is not None and cell_type != old_type:
change_description += f" (changed type from {old_type} to {cell_type})"
elif edit_mode == "insert":
# Create new cell
new_cell: dict[str, Any] = {
"cell_type": cell_type,
"source": new_source,
"metadata": {},
}
# Add code-specific fields
if cell_type == "code":
new_cell["outputs"] = []
new_cell["execution_count"] = None
# Insert the cell
cells.insert(cell_number, new_cell)
change_description = f"Inserted new {cell_type} cell at position {cell_number}"
else: # delete
# Store deleted cell info for reporting
deleted_cell = cells[cell_number]
deleted_type = deleted_cell.get("cell_type", "code")
# Remove the cell
del cells[cell_number]
change_description = f"Deleted {deleted_type} cell at position {cell_number}"
# Write the updated notebook back to file
with open(file_path, "w", encoding="utf-8") as f:
json.dump(notebook, f, indent=1)
await tool_ctx.info(f"Successfully edited notebook: {notebook_path} - {change_description}")
return f"Successfully edited notebook: {notebook_path} - {change_description}"
except Exception as e:
await tool_ctx.error(f"Error editing notebook: {str(e)}")
return f"Error editing notebook: {str(e)}"
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this edit notebook tool with the MCP server.
Creates a wrapper function with explicitly defined parameters that match
the tool's parameter schema and registers it with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def notebook_edit(
notebook_path: NotebookPath,
cell_number: CellNumber,
new_source: NewSource,
cell_type: CellType,
edit_mode: EditMode,
ctx: MCPContext,
) -> str:
return await tool_self.call(
ctx,
notebook_path=notebook_path,
cell_number=cell_number,
new_source=new_source,
cell_type=cell_type,
edit_mode=edit_mode,
)
@@ -0,0 +1,145 @@
"""Read notebook tool implementation.
This module provides the NotebookReadTool for reading Jupyter notebook files.
"""
import json
from typing import Unpack, Annotated, TypedDict, final, override
from pathlib import Path
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from .base import JupyterBaseTool
from hanzo_tools.core import auto_timeout
NotebookPath = Annotated[
str,
Field(
description="The absolute path to the Jupyter notebook file to read (must be absolute, not relative)",
),
]
class NotebookReadToolParams(TypedDict):
"""Parameters for the NotebookReadTool.
Attributes:
notebook_path: The absolute path to the Jupyter notebook file to read (must be absolute, not relative)
"""
notebook_path: NotebookPath
@final
class NotebookReadTool(JupyterBaseTool):
"""Tool for reading Jupyter notebook files."""
@property
@override
def name(self) -> str:
"""Get the tool name.
Returns:
Tool name
"""
return "notebook_read"
@property
@override
def description(self) -> str:
"""Get the tool description.
Returns:
Tool description
"""
return "Reads a Jupyter notebook (.ipynb file) and returns all of the cells with their outputs. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path."
@override
@auto_timeout("notebook_read")
async def call(
self,
ctx: MCPContext,
**params: Unpack[NotebookReadToolParams],
) -> str:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Tool result
"""
tool_ctx = self.create_tool_context(ctx)
self.set_tool_context_info(tool_ctx)
# Extract parameters
notebook_path: NotebookPath = params["notebook_path"]
# Validate path parameter
path_validation = self.validate_path(notebook_path)
if path_validation.is_error:
await tool_ctx.error(path_validation.error_message)
return f"Error: {path_validation.error_message}"
await tool_ctx.info(f"Reading notebook: {notebook_path}")
# Check if path is allowed
if not self.is_path_allowed(notebook_path):
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
try:
file_path = Path(notebook_path)
if not file_path.exists():
await tool_ctx.error(f"File does not exist: {notebook_path}")
return f"Error: File does not exist: {notebook_path}"
if not file_path.is_file():
await tool_ctx.error(f"Path is not a file: {notebook_path}")
return f"Error: Path is not a file: {notebook_path}"
# Check file extension
if file_path.suffix.lower() != ".ipynb":
await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}")
return f"Error: File is not a Jupyter notebook: {notebook_path}"
# Read and parse the notebook
try:
# This will read the file, so we don't need to read it separately
_, processed_cells = await self.parse_notebook(file_path)
# Format the notebook content as a readable string
result = self.format_notebook_cells(processed_cells)
await tool_ctx.info(f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)")
return result
except json.JSONDecodeError:
await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
return f"Error: Invalid notebook format: {notebook_path}"
except UnicodeDecodeError:
await tool_ctx.error(f"Cannot read notebook file: {notebook_path}")
return f"Error: Cannot read notebook file: {notebook_path}"
except Exception as e:
await tool_ctx.error(f"Error reading notebook: {str(e)}")
return f"Error reading notebook: {str(e)}"
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this read notebook tool with the MCP server.
Creates a wrapper function with explicitly defined parameters that match
the tool's parameter schema and registers it with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def notebook_read(notebook_path: NotebookPath, ctx: MCPContext) -> str:
return await tool_self.call(ctx, notebook_path=notebook_path)
+42
View File
@@ -0,0 +1,42 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-jupyter"
version = "0.1.0"
description = "Jupyter notebook tools for Hanzo AI - read, edit, execute notebooks"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "jupyter", "notebook", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
"nbformat>=5.0.0",
]
[project.optional-dependencies]
full = ["nbclient>=0.10.0", "jupyter-client>=8.0.0"]
dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
[project.entry-points."hanzo.tools"]
jupyter = "hanzo_tools.jupyter:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools.package-data]
hanzo_tools = ["py.typed"]
@@ -0,0 +1,2 @@
"""Hanzo Tools namespace package."""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,18 @@
"""LLM provider tools.
Tools:
- llm: Unified LLM interface supporting 100+ providers
- consensus: Multi-model consensus for improved accuracy
Install: pip install hanzo-tools-llm
"""
# Placeholder - full implementation to be migrated from hanzo-mcp
TOOLS = []
__all__ = ["TOOLS", "register_tools"]
def register_tools(mcp_server, enabled_tools=None):
"""Register LLM tools with the MCP server."""
# TODO: Migrate from hanzo_mcp.tools.llm
return []
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
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"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "llm", "openai", "anthropic", "mcp", "ai"]
dependencies = [
"mcp>=1.25.0",
"fastmcp>=2.14.1",
"pydantic>=2.12.5",
"litellm>=1.80.0",
"openai>=1.82.0",
]
[project.entry-points."hanzo.tools"]
llm = "hanzo_tools.llm:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,46 @@
"""Language Server Protocol tools for Hanzo MCP.
This package provides LSP-based code intelligence:
- LSPTool: Go-to-definition, find references, rename, hover, completion
"""
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolRegistry
from hanzo_tools.lsp.lsp_tool import LSPTool, create_lsp_tool
__all__ = [
"LSPTool",
"create_lsp_tool",
"get_lsp_tools",
"register_lsp_tools",
"TOOLS",
]
def get_lsp_tools() -> list[BaseTool]:
"""Create instances of all LSP tools.
Returns:
List of LSP tool instances
"""
return [LSPTool()]
def register_lsp_tools(mcp_server: FastMCP) -> list[BaseTool]:
"""Register all LSP tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
Returns:
List of registered tools
"""
tools = get_lsp_tools()
ToolRegistry.register_tools(mcp_server, tools)
return tools
# TOOLS list for entry point discovery
TOOLS = [LSPTool]
@@ -0,0 +1,528 @@
"""Language Server Protocol (LSP) tool for code intelligence.
This tool provides on-demand LSP configuration and installation for various
programming languages. It automatically installs language servers as needed
and provides code intelligence features like go-to-definition, find references,
rename symbol, and diagnostics.
"""
import os
import json
import shutil
import asyncio
import logging
import atexit
from typing import Any, Dict, List, Optional
from pathlib import Path
from dataclasses import dataclass, field
from hanzo_tools.core import BaseTool, MCPResourceDocument
# LSP server configurations
LSP_SERVERS = {
"go": {
"name": "gopls",
"install_cmd": ["go", "install", "golang.org/x/tools/gopls@latest"],
"check_cmd": ["gopls", "version"],
"start_cmd": ["gopls", "serve"],
"root_markers": ["go.mod", "go.sum"],
"file_extensions": [".go"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion"],
},
"python": {
"name": "pyright",
"install_cmd": ["npm", "install", "-g", "pyright"],
"check_cmd": ["pyright-langserver", "--version"],
"start_cmd": ["pyright-langserver", "--stdio"],
"root_markers": ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"],
"file_extensions": [".py", ".pyi"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion", "typeDefinition"],
},
"typescript": {
"name": "typescript-language-server",
"install_cmd": ["npm", "install", "-g", "typescript", "typescript-language-server"],
"check_cmd": ["typescript-language-server", "--version"],
"start_cmd": ["typescript-language-server", "--stdio"],
"root_markers": ["tsconfig.json", "package.json"],
"file_extensions": [".ts", ".tsx", ".js", ".jsx"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion"],
},
"rust": {
"name": "rust-analyzer",
"install_cmd": ["rustup", "component", "add", "rust-analyzer"],
"check_cmd": ["rust-analyzer", "--version"],
"start_cmd": ["rust-analyzer"],
"root_markers": ["Cargo.toml"],
"file_extensions": [".rs"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion", "inlay_hints"],
},
"java": {
"name": "jdtls",
"install_cmd": ["brew", "install", "jdtls"],
"check_cmd": ["jdtls", "--version"],
"start_cmd": ["jdtls"],
"root_markers": ["pom.xml", "build.gradle", "build.gradle.kts"],
"file_extensions": [".java"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion"],
},
"cpp": {
"name": "clangd",
"install_cmd": ["brew", "install", "llvm"],
"check_cmd": ["clangd", "--version"],
"start_cmd": ["clangd"],
"root_markers": ["compile_commands.json", "CMakeLists.txt"],
"file_extensions": [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion"],
},
"ruby": {
"name": "solargraph",
"install_cmd": ["gem", "install", "solargraph"],
"check_cmd": ["solargraph", "--version"],
"start_cmd": ["solargraph", "stdio"],
"root_markers": ["Gemfile", ".solargraph.yml"],
"file_extensions": [".rb"],
"capabilities": ["definition", "references", "diagnostics", "hover", "completion"],
},
"lua": {
"name": "lua-language-server",
"install_cmd": ["brew", "install", "lua-language-server"],
"check_cmd": ["lua-language-server", "--version"],
"start_cmd": ["lua-language-server"],
"root_markers": [".luarc.json"],
"file_extensions": [".lua"],
"capabilities": ["definition", "references", "rename", "diagnostics", "hover", "completion"],
},
}
# Global LSP server registry - singleton per language:root_uri
_GLOBAL_SERVERS: Dict[str, "LSPServer"] = {}
_GLOBAL_LOCK = asyncio.Lock()
_CLEANUP_REGISTERED = False
logger = logging.getLogger(__name__)
def _cleanup_all_servers():
"""Cleanup all LSP servers on process exit."""
for server in _GLOBAL_SERVERS.values():
if server.process and server.process.returncode is None:
try:
server.process.terminate()
except Exception:
pass
_GLOBAL_SERVERS.clear()
@dataclass
class LSPServer:
"""Represents an LSP server instance."""
language: str
process: Optional[asyncio.subprocess.Process]
config: Dict[str, Any]
root_uri: str
initialized: bool = False
request_id: int = field(default=0)
pending_responses: Dict[int, asyncio.Future] = field(default_factory=dict)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
def next_id(self) -> int:
"""Get next request ID."""
self.request_id += 1
return self.request_id
class LSPTool(BaseTool):
"""Language Server Protocol tool for code intelligence."""
name = "lsp"
@property
def description(self) -> str:
return """Language Server Protocol tool for code intelligence.
Actions:
- definition: Go to definition of symbol at position
- references: Find all references to symbol
- rename: Rename symbol across codebase
- diagnostics: Get errors and warnings for file
- hover: Get hover information at position
- completion: Get code completions at position
- status: Check LSP server status
The tool automatically installs language servers as needed.
Supported languages: Go, Python, TypeScript/JavaScript, Rust, Java, C/C++, Ruby, Lua
"""
def __init__(self):
super().__init__()
global _CLEANUP_REGISTERED
if not _CLEANUP_REGISTERED:
atexit.register(_cleanup_all_servers)
_CLEANUP_REGISTERED = True
def _get_language_from_file(self, file_path: str) -> Optional[str]:
"""Detect language from file extension."""
ext = Path(file_path).suffix.lower()
for lang, config in LSP_SERVERS.items():
if ext in config["file_extensions"]:
return lang
return None
def _find_project_root(self, file_path: str, language: str) -> str:
"""Find project root based on language markers."""
markers = LSP_SERVERS[language]["root_markers"]
path = Path(file_path).resolve()
for parent in path.parents:
for marker in markers:
if (parent / marker).exists():
return str(parent)
return str(path.parent)
async def _check_lsp_installed(self, language: str) -> bool:
"""Check if LSP server is installed."""
config = LSP_SERVERS.get(language)
if not config:
return False
try:
result = await asyncio.create_subprocess_exec(
*config["check_cmd"],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await result.communicate()
return result.returncode == 0
except (FileNotFoundError, OSError):
return False
async def _install_lsp(self, language: str) -> bool:
"""Install LSP server for language."""
config = LSP_SERVERS.get(language)
if not config:
return False
logger.info(f"Installing {config['name']} for {language}")
try:
installer = config["install_cmd"][0]
if not shutil.which(installer):
logger.error(f"Installer {installer} not found")
return False
result = await asyncio.create_subprocess_exec(
*config["install_cmd"],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await result.communicate()
if result.returncode != 0:
logger.error(f"Installation failed: {stderr.decode()}")
return False
logger.info(f"Successfully installed {config['name']}")
return True
except Exception as e:
logger.error(f"Installation error: {e}")
return False
async def _ensure_lsp_running(self, language: str, root_uri: str) -> Optional[LSPServer]:
"""Ensure LSP server is running for language."""
server_key = f"{language}:{root_uri}"
if server_key in _GLOBAL_SERVERS:
server = _GLOBAL_SERVERS[server_key]
if server.process and server.process.returncode is None:
return server
async with _GLOBAL_LOCK:
if server_key in _GLOBAL_SERVERS:
server = _GLOBAL_SERVERS[server_key]
if server.process and server.process.returncode is None:
return server
del _GLOBAL_SERVERS[server_key]
if not await self._check_lsp_installed(language):
if not await self._install_lsp(language):
return None
config = LSP_SERVERS[language]
try:
process = await asyncio.create_subprocess_exec(
*config["start_cmd"],
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=root_uri,
)
server = LSPServer(language=language, process=process, config=config, root_uri=root_uri)
if not await self._initialize_lsp(server):
if server.process:
server.process.terminate()
return None
_GLOBAL_SERVERS[server_key] = server
logger.info(f"Started global LSP server: {config['name']} for {root_uri}")
return server
except Exception as e:
logger.error(f"Failed to start LSP: {e}")
return None
async def _initialize_lsp(self, server: LSPServer) -> bool:
"""Send initialize request to LSP server."""
init_params = {
"processId": os.getpid(),
"rootUri": f"file://{server.root_uri}",
"rootPath": server.root_uri,
"capabilities": {
"workspace": {"workspaceFolders": True, "applyEdit": True},
"textDocument": {
"synchronization": {"dynamicRegistration": True, "didSave": True},
"completion": {"completionItem": {"snippetSupport": True}},
"hover": {"contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
},
"workspaceFolders": [{"uri": f"file://{server.root_uri}", "name": Path(server.root_uri).name}],
}
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "initialize", "params": init_params}
response = await self._send_request(server, request, timeout=60.0)
if not response or "error" in response:
logger.error(f"Failed to initialize LSP: {response}")
return False
await self._send_notification(server, "initialized", {})
server.initialized = True
return True
async def _read_lsp_message(self, server: LSPServer, timeout: float = 30.0) -> Optional[Dict[str, Any]]:
"""Read a single LSP message from server stdout."""
if not server.process or not server.process.stdout:
return None
try:
headers = {}
while True:
line = await asyncio.wait_for(server.process.stdout.readline(), timeout=timeout)
if not line:
return None
line_str = line.decode("utf-8").strip()
if not line_str:
break
if ":" in line_str:
key, value = line_str.split(":", 1)
headers[key.strip().lower()] = value.strip()
content_length = int(headers.get("content-length", 0))
if content_length == 0:
return None
content = await asyncio.wait_for(server.process.stdout.read(content_length), timeout=timeout)
return json.loads(content.decode("utf-8"))
except asyncio.TimeoutError:
return None
except Exception as e:
logger.error(f"Error reading LSP message: {e}")
return None
async def _send_request(self, server: LSPServer, request: Dict[str, Any], timeout: float = 30.0) -> Optional[Dict[str, Any]]:
"""Send JSON-RPC request to LSP server and wait for response."""
if not server.process or server.process.returncode is not None or not server.process.stdin:
return None
import time
async with server.lock:
try:
request_str = json.dumps(request)
content = request_str.encode("utf-8")
header = f"Content-Length: {len(content)}\r\n\r\n"
server.process.stdin.write(header.encode("utf-8") + content)
await server.process.stdin.drain()
request_id = request.get("id")
start_time = time.monotonic()
deadline = start_time + timeout
while time.monotonic() < deadline:
remaining = max(0.1, deadline - time.monotonic())
response = await self._read_lsp_message(server, timeout=remaining)
if response is None:
if server.process.returncode is not None:
return None
continue
if "id" in response and response["id"] == request_id:
return response
return None
except Exception as e:
logger.error(f"LSP communication error: {e}")
return None
async def _send_notification(self, server: LSPServer, method: str, params: Dict[str, Any]) -> bool:
"""Send JSON-RPC notification (no response expected)."""
if not server.process or server.process.returncode is not None or not server.process.stdin:
return False
try:
notification = {"jsonrpc": "2.0", "method": method, "params": params}
content = json.dumps(notification).encode("utf-8")
header = f"Content-Length: {len(content)}\r\n\r\n"
server.process.stdin.write(header.encode("utf-8") + content)
await server.process.stdin.drain()
return True
except Exception as e:
logger.error(f"Failed to send notification: {e}")
return False
async def run(self, action: str, file: str, line: Optional[int] = None, character: Optional[int] = None, new_name: Optional[str] = None, **kwargs) -> MCPResourceDocument:
"""Execute LSP action."""
valid_actions = ["definition", "references", "rename", "diagnostics", "hover", "completion", "status"]
if action not in valid_actions:
return MCPResourceDocument(data={"error": f"Invalid action. Must be one of: {', '.join(valid_actions)}"})
language = self._get_language_from_file(file)
if not language:
return MCPResourceDocument(data={"error": f"Unsupported file type: {file}", "supported_languages": list(LSP_SERVERS.keys())})
capabilities = LSP_SERVERS[language]["capabilities"]
if action not in capabilities and action != "status":
return MCPResourceDocument(data={"error": f"Action '{action}' not supported for {language}", "supported_actions": capabilities})
if action == "status":
installed = await self._check_lsp_installed(language)
return MCPResourceDocument(data={"language": language, "lsp_server": LSP_SERVERS[language]["name"], "installed": installed, "capabilities": capabilities})
root_uri = self._find_project_root(file, language)
server = await self._ensure_lsp_running(language, root_uri)
if not server:
return MCPResourceDocument(data={"error": f"Failed to start LSP server for {language}", "install_command": " ".join(LSP_SERVERS[language]["install_cmd"])})
result = await self._execute_lsp_action(server, action, file, line, character, new_name)
return MCPResourceDocument(data=result)
async def call(self, **kwargs) -> str:
"""Tool interface for MCP - converts result to JSON string."""
result = await self.run(**kwargs)
return result.to_json_string()
def register(self, mcp_server) -> None:
"""Register tool with MCP server."""
@mcp_server.tool(name=self.name, description=self.description)
async def lsp_handler(action: str, file: str, line: Optional[int] = None, character: Optional[int] = None, new_name: Optional[str] = None) -> str:
return await self.call(action=action, file=file, line=line, character=character, new_name=new_name)
async def _open_document(self, server: LSPServer, file_path: str) -> bool:
"""Notify LSP server that a document is opened."""
abs_path = str(Path(file_path).resolve())
try:
with open(abs_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
logger.error(f"Failed to read file {file_path}: {e}")
return False
language_id = server.language
if server.language == "typescript":
ext = Path(file_path).suffix.lower()
if ext in [".tsx", ".jsx"]:
language_id = "typescriptreact" if "ts" in ext else "javascriptreact"
elif ext in [".js", ".mjs", ".cjs"]:
language_id = "javascript"
params = {"textDocument": {"uri": f"file://{abs_path}", "languageId": language_id, "version": 1, "text": content}}
return await self._send_notification(server, "textDocument/didOpen", params)
def _parse_location(self, location: Dict[str, Any]) -> Dict[str, Any]:
"""Parse LSP Location into a readable format."""
uri = location.get("uri", "")
file_path = uri.replace("file://", "") if uri.startswith("file://") else uri
range_info = location.get("range", {})
start = range_info.get("start", {})
end = range_info.get("end", {})
return {
"file": file_path,
"start": {"line": start.get("line", 0) + 1, "character": start.get("character", 0)},
"end": {"line": end.get("line", 0) + 1, "character": end.get("character", 0)},
}
async def _execute_lsp_action(self, server: LSPServer, action: str, file: str, line: Optional[int], character: Optional[int], new_name: Optional[str]) -> Dict[str, Any]:
"""Execute specific LSP action."""
abs_path = str(Path(file).resolve())
uri = f"file://{abs_path}"
await self._open_document(server, file)
position = {"line": (line - 1) if line else 0, "character": character if character else 0}
if action == "definition":
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "textDocument/definition", "params": {"textDocument": {"uri": uri}, "position": position}}
response = await self._send_request(server, request)
if response and "result" in response:
result = response["result"]
if result is None:
return {"action": "definition", "file": file, "result": None, "message": "No definition found"}
if isinstance(result, list):
return {"action": "definition", "file": file, "definitions": [self._parse_location(loc) for loc in result]}
elif isinstance(result, dict):
return {"action": "definition", "file": file, "definition": self._parse_location(result)}
return {"action": "definition", "file": file, "error": response.get("error") if response else "No response"}
elif action == "references":
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "textDocument/references", "params": {"textDocument": {"uri": uri}, "position": position, "context": {"includeDeclaration": True}}}
response = await self._send_request(server, request)
if response and "result" in response:
result = response["result"]
refs = [self._parse_location(loc) for loc in result] if isinstance(result, list) else []
return {"action": "references", "file": file, "references": refs, "count": len(refs)}
return {"action": "references", "file": file, "error": response.get("error") if response else "No response"}
elif action == "rename":
if not new_name:
return {"action": "rename", "error": "new_name is required for rename action"}
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "textDocument/rename", "params": {"textDocument": {"uri": uri}, "position": position, "newName": new_name}}
response = await self._send_request(server, request)
if response and "result" in response:
result = response["result"]
if result is None:
return {"action": "rename", "file": file, "error": "Rename not possible at this location"}
changes = {}
if "changes" in result:
for file_uri, edits in result["changes"].items():
file_path = file_uri.replace("file://", "")
changes[file_path] = [{"range": self._parse_location({"uri": file_uri, "range": edit["range"]}), "newText": edit["newText"]} for edit in edits]
return {"action": "rename", "file": file, "new_name": new_name, "changes": changes, "files_affected": len(changes)}
return {"action": "rename", "file": file, "error": response.get("error") if response else "No response"}
elif action == "hover":
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "textDocument/hover", "params": {"textDocument": {"uri": uri}, "position": position}}
response = await self._send_request(server, request)
if response and "result" in response:
result = response["result"]
if result is None:
return {"action": "hover", "file": file, "result": None, "message": "No hover info"}
contents = result.get("contents", "")
if isinstance(contents, dict):
hover_text = contents.get("value", str(contents))
elif isinstance(contents, list):
hover_text = "\n".join(c.get("value", str(c)) if isinstance(c, dict) else str(c) for c in contents)
else:
hover_text = str(contents)
return {"action": "hover", "file": file, "position": {"line": line, "character": character}, "contents": hover_text}
return {"action": "hover", "file": file, "error": response.get("error") if response else "No response"}
elif action == "completion":
request = {"jsonrpc": "2.0", "id": server.next_id(), "method": "textDocument/completion", "params": {"textDocument": {"uri": uri}, "position": position}}
response = await self._send_request(server, request, timeout=10.0)
if response and "result" in response:
result = response["result"]
if result is None:
return {"action": "completion", "file": file, "completions": [], "count": 0}
items = result if isinstance(result, list) else result.get("items", [])
completions = [{"label": item.get("label", ""), "kind": item.get("kind", 0), "detail": item.get("detail", "")} for item in items[:50]]
return {"action": "completion", "file": file, "position": {"line": line, "character": character}, "completions": completions, "count": len(completions)}
return {"action": "completion", "file": file, "error": response.get("error") if response else "No response"}
elif action == "diagnostics":
return {"action": "diagnostics", "file": file, "note": "Diagnostics are push-based; use language-specific tools (go vet, pylint, etc.) for on-demand checking"}
return {"error": f"Unknown action: {action}"}
async def cleanup(self):
"""Clean up LSP servers."""
async with _GLOBAL_LOCK:
for server in list(_GLOBAL_SERVERS.values()):
if server.process and server.process.returncode is None:
try:
server.process.terminate()
await asyncio.wait_for(server.process.wait(), timeout=5.0)
except:
pass
_GLOBAL_SERVERS.clear()
def create_lsp_tool():
"""Factory function to create LSP tool."""
return LSPTool()
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-lsp"
version = "0.1.0"
description = "Language Server Protocol tools for code intelligence"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "lsp", "language-server", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"pydantic>=2.12.5",
]
[project.entry-points."hanzo.tools"]
lsp = "hanzo_tools.lsp:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
@@ -0,0 +1,4 @@
"""Hanzo tools namespace package."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,111 @@
"""Memory and knowledge tools for Hanzo MCP.
This package provides tools for managing memories and knowledge bases:
- RecallMemoriesTool: Search and retrieve memories
- CreateMemoriesTool: Store new memories
- UpdateMemoriesTool: Update existing memories
- DeleteMemoriesTool: Remove memories
- ManageMemoriesTool: Atomic memory operations
- RecallFactsTool: Search knowledge bases
- StoreFactsTool: Store facts in knowledge bases
- SummarizeToMemoryTool: Summarize and store information
- ManageKnowledgeBasesTool: Create and manage knowledge bases
"""
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolRegistry
from hanzo_tools.memory.memory_tools import (
RecallMemoriesTool,
CreateMemoriesTool,
UpdateMemoriesTool,
DeleteMemoriesTool,
ManageMemoriesTool,
MEMORY_TOOL_CLASSES,
)
from hanzo_tools.memory.knowledge_tools import (
RecallFactsTool,
StoreFactsTool,
SummarizeToMemoryTool,
ManageKnowledgeBasesTool,
KNOWLEDGE_TOOL_CLASSES,
)
__all__ = [
# Memory tools
"RecallMemoriesTool",
"CreateMemoriesTool",
"UpdateMemoriesTool",
"DeleteMemoriesTool",
"ManageMemoriesTool",
# Knowledge tools
"RecallFactsTool",
"StoreFactsTool",
"SummarizeToMemoryTool",
"ManageKnowledgeBasesTool",
# Registration helpers
"get_memory_tools",
"register_memory_tools",
"TOOLS",
]
# All tool classes for the TOOLS entry point
ALL_TOOL_CLASSES = MEMORY_TOOL_CLASSES + KNOWLEDGE_TOOL_CLASSES
def get_memory_tools(
user_id: str = "default",
project_id: str = "default",
**kwargs,
) -> list[BaseTool]:
"""Create instances of all memory and knowledge tools.
Args:
user_id: User ID for memory operations
project_id: Project ID for memory operations
**kwargs: Additional configuration
Returns:
List of tool instances
"""
return [
cls(user_id=user_id, project_id=project_id, **kwargs)
for cls in ALL_TOOL_CLASSES
]
def register_memory_tools(
mcp_server: FastMCP,
user_id: str = "default",
project_id: str = "default",
**kwargs,
) -> list[BaseTool]:
"""Register all memory and knowledge tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
user_id: User ID for memory operations
project_id: Project ID for memory operations
**kwargs: Additional configuration
Returns:
List of registered tools
"""
tools = get_memory_tools(user_id=user_id, project_id=project_id, **kwargs)
ToolRegistry.register_tools(mcp_server, tools)
return tools
# TOOLS list for entry point discovery
TOOLS = [
RecallMemoriesTool,
CreateMemoriesTool,
UpdateMemoriesTool,
DeleteMemoriesTool,
ManageMemoriesTool,
RecallFactsTool,
StoreFactsTool,
SummarizeToMemoryTool,
ManageKnowledgeBasesTool,
]
@@ -0,0 +1,523 @@
"""Knowledge base and fact management tools for MCP.
These tools use the hanzo-memory package to manage knowledge bases and facts,
supporting hierarchical organization (session, project, global).
IMPORTANT: All hanzo-memory imports are lazy to avoid slow startup.
The embedding service initialization takes 3+ seconds which would block MCP startup.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, create_tool_context, auto_timeout
# Type hints only - no runtime import
if TYPE_CHECKING:
from hanzo_memory.services.memory import MemoryService
# Use lazy loading from memory_tools
from hanzo_tools.memory.memory_tools import _check_memory_available, _get_lazy_memory_service
class KnowledgeToolBase(BaseTool):
"""Base class for knowledge tools using hanzo-memory package."""
def __init__(self, user_id: str = "default", project_id: str = "default", **kwargs):
"""Initialize knowledge tool.
Args:
user_id: User ID for knowledge operations
project_id: Project ID for knowledge operations
**kwargs: Additional configuration
"""
self.user_id = user_id
self.project_id = project_id
# Lazy service loading - don't initialize until first use
self._service: Optional["MemoryService"] = None
@property
def service(self) -> "MemoryService":
"""Get memory service lazily."""
if self._service is None:
self._service = _get_lazy_memory_service()
return self._service
@final
class RecallFactsTool(KnowledgeToolBase):
"""Tool for recalling facts from knowledge bases."""
name = "recall_facts"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Recall facts from knowledge bases relevant to queries.
Facts are structured pieces of information stored in knowledge bases.
Supports different scopes: session, project, or global.
Usage:
recall_facts(queries=["Python best practices"], kb_name="coding_standards")
recall_facts(queries=["API endpoints"], scope="project")
recall_facts(queries=["company policies"], scope="global", limit=5)
"""
@override
@auto_timeout("knowledge_tools")
async def call(
self,
ctx: MCPContext,
queries: List[str],
kb_name: Optional[str] = None,
scope: str = "project",
limit: int = 10,
) -> str:
"""Recall facts matching queries.
Args:
ctx: MCP context
queries: Search queries
kb_name: Optional knowledge base name to search in
scope: Scope level (session, project, global)
limit: Max results per query
Returns:
Formatted fact results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Searching for facts in scope: {scope}")
# Determine the appropriate IDs based on scope
if scope == "global":
user_id = "global"
project_id = "global"
elif scope == "session":
# Session scope uses a session-specific ID
user_id = f"session_{self.user_id}"
project_id = self.project_id
else:
user_id = self.user_id
project_id = self.project_id
all_facts = []
for query in queries:
# Search for facts using memory service with fact metadata
search_query = f"fact: {query}"
if kb_name:
search_query = f"kb:{kb_name} {search_query}"
memories = self.service.search_memories(
user_id=user_id, query=search_query, project_id=project_id, limit=limit
)
# Filter for fact-type memories
for memory in memories:
if memory.metadata and memory.metadata.get("type") == "fact":
all_facts.append(memory)
# Deduplicate by memory ID
seen = set()
unique_facts = []
for fact in all_facts:
if fact.memory_id not in seen:
seen.add(fact.memory_id)
unique_facts.append(fact)
if not unique_facts:
return "No relevant facts found."
# Format results
formatted = [f"Found {len(unique_facts)} relevant facts:\n"]
for i, fact in enumerate(unique_facts, 1):
kb_info = ""
if fact.metadata and fact.metadata.get("kb_name"):
kb_info = f" (KB: {fact.metadata['kb_name']})"
formatted.append(f"{i}. {fact.content}{kb_info}")
if fact.metadata and len(fact.metadata) > 2: # More than just type and kb_name
# Show other metadata
other_meta = {k: v for k, v in fact.metadata.items() if k not in ["type", "kb_name"]}
if other_meta:
formatted.append(f" Metadata: {other_meta}")
return "\n".join(formatted)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def recall_facts(
ctx: MCPContext,
queries: List[str],
kb_name: Optional[str] = None,
scope: str = "project",
limit: int = 10,
) -> str:
return await tool_self.call(ctx, queries=queries, kb_name=kb_name, scope=scope, limit=limit)
@final
class StoreFactsTool(KnowledgeToolBase):
"""Tool for storing facts in knowledge bases."""
name = "store_facts"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Store new facts in knowledge bases.
Facts can be stored at different scopes (session, project, global) and
organized into knowledge bases for better categorization.
Usage:
store_facts(facts=["Python uses indentation for blocks"], kb_name="python_basics")
store_facts(facts=["API rate limit: 100/hour"], scope="project", kb_name="api_docs")
store_facts(facts=["Company founded in 2020"], scope="global", kb_name="company_info")
"""
@override
@auto_timeout("knowledge_tools")
async def call(
self,
ctx: MCPContext,
facts: List[str],
kb_name: str = "general",
scope: str = "project",
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Store new facts.
Args:
ctx: MCP context
facts: Facts to store
kb_name: Knowledge base name
scope: Scope level (session, project, global)
metadata: Optional metadata for all facts
Returns:
Success message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Storing {len(facts)} facts in {kb_name} (scope: {scope})")
# Determine the appropriate IDs based on scope
if scope == "global":
user_id = "global"
project_id = "global"
elif scope == "session":
user_id = f"session_{self.user_id}"
project_id = self.project_id
else:
user_id = self.user_id
project_id = self.project_id
created_facts = []
for fact_content in facts:
# Create fact as a memory with special metadata
fact_metadata = {"type": "fact", "kb_name": kb_name}
if metadata:
fact_metadata.update(metadata)
memory = self.service.create_memory(
user_id=user_id,
project_id=project_id,
content=f"fact: {fact_content}",
metadata=fact_metadata,
importance=1.5, # Facts have higher importance
)
created_facts.append(memory)
return f"Successfully stored {len(created_facts)} facts in {kb_name}."
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def store_facts(
ctx: MCPContext,
facts: List[str],
kb_name: str = "general",
scope: str = "project",
metadata: Optional[Dict[str, Any]] = None,
) -> str:
return await tool_self.call(ctx, facts=facts, kb_name=kb_name, scope=scope, metadata=metadata)
@final
class SummarizeToMemoryTool(KnowledgeToolBase):
"""Tool for summarizing information and storing in memory."""
name = "summarize_to_memory"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Summarize information and store it in memory for future reference.
This tool helps agents remember important information by summarizing it
and storing it at the appropriate scope level.
Usage:
summarize_to_memory(content="Long discussion about API design...", topic="API Design Decisions")
summarize_to_memory(content="User preferences...", topic="User Preferences", scope="session")
summarize_to_memory(content="Company guidelines...", topic="Guidelines", scope="global")
"""
@override
@auto_timeout("knowledge_tools")
async def call(
self,
ctx: MCPContext,
content: str,
topic: str,
scope: str = "project",
auto_facts: bool = True,
) -> str:
"""Summarize content and store in memory.
Args:
ctx: MCP context
content: Content to summarize
topic: Topic or title for the summary
scope: Scope level (session, project, global)
auto_facts: Whether to extract facts automatically
Returns:
Success message with summary
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Summarizing content about {topic}")
# Use the memory service to create a summary
# This would typically use an LLM to summarize, but for now we'll store as-is
summary = f"Summary of {topic}:\n{content[:500]}..." if len(content) > 500 else content
# Store the summary as a memory (using lazy service)
memory_service = self.service
# Determine scope
if scope == "global":
user_id = "global"
project_id = "global"
elif scope == "session":
user_id = f"session_{self.user_id}"
project_id = self.project_id
else:
user_id = self.user_id
project_id = self.project_id
memory = memory_service.create_memory(
user_id=user_id,
project_id=project_id,
content=summary,
metadata={"topic": topic, "type": "summary", "scope": scope},
)
result = f"Stored summary of {topic} in {scope} memory."
# Optionally extract facts
if auto_facts:
# In a real implementation, this would use LLM to extract key facts
# For now, we'll just note it
result += "\n(Auto-fact extraction would extract key facts from the summary)"
return result
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def summarize_to_memory(
ctx: MCPContext,
content: str,
topic: str,
scope: str = "project",
auto_facts: bool = True,
) -> str:
return await tool_self.call(ctx, content=content, topic=topic, scope=scope, auto_facts=auto_facts)
@final
class ManageKnowledgeBasesTool(KnowledgeToolBase):
"""Tool for managing knowledge bases."""
name = "manage_knowledge_bases"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Create, list, and manage knowledge bases.
Knowledge bases help organize facts by topic or domain.
They can exist at different scopes for better organization.
Usage:
manage_knowledge_bases(action="create", kb_name="api_docs", description="API documentation")
manage_knowledge_bases(action="list", scope="project")
manage_knowledge_bases(action="delete", kb_name="old_docs")
"""
@override
@auto_timeout("knowledge_tools")
async def call(
self,
ctx: MCPContext,
action: str,
kb_name: Optional[str] = None,
description: Optional[str] = None,
scope: str = "project",
) -> str:
"""Manage knowledge bases.
Args:
ctx: MCP context
action: Action to perform (create, list, delete)
kb_name: Knowledge base name (for create/delete)
description: Description (for create)
scope: Scope level
Returns:
Result message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
# Determine scope
if scope == "global":
user_id = "global"
project_id = "global"
elif scope == "session":
user_id = f"session_{self.user_id}"
project_id = self.project_id
else:
user_id = self.user_id
project_id = self.project_id
if action == "create":
if not kb_name:
return "Error: kb_name required for create action"
# Create a knowledge base entry as a special memory
kb_metadata = {
"type": "knowledge_base",
"kb_name": kb_name,
"description": description or "",
"scope": scope,
}
memory = self.service.create_memory(
user_id=user_id,
project_id=project_id,
content=f"Knowledge Base: {kb_name}\nDescription: {description or 'No description'}",
metadata=kb_metadata,
importance=2.0, # KBs have high importance
)
return f"Created knowledge base '{kb_name}' in {scope} scope."
elif action == "list":
# Search for knowledge base entries
kbs = self.service.search_memories(
user_id=user_id,
query="type:knowledge_base",
project_id=project_id,
limit=100,
)
# Filter for KB-type memories
kb_list = []
for memory in kbs:
if memory.metadata and memory.metadata.get("type") == "knowledge_base":
kb_list.append(memory)
if not kb_list:
return f"No knowledge bases found in {scope} scope."
formatted = [f"Knowledge bases in {scope} scope:"]
for kb in kb_list:
name = kb.metadata.get("kb_name", "Unknown")
desc = kb.metadata.get("description", "")
desc_text = f" - {desc}" if desc else ""
formatted.append(f"- {name}{desc_text}")
return "\n".join(formatted)
elif action == "delete":
if not kb_name:
return "Error: kb_name required for delete action"
# Search for the KB entry
kbs = self.service.search_memories(
user_id=user_id,
query=f"type:knowledge_base kb_name:{kb_name}",
project_id=project_id,
limit=10,
)
deleted_count = 0
for memory in kbs:
if (
memory.metadata
and memory.metadata.get("type") == "knowledge_base"
and memory.metadata.get("kb_name") == kb_name
):
# Note: delete_memory is not fully implemented
# but we'll call it anyway
self.service.delete_memory(user_id, memory.memory_id)
deleted_count += 1
if deleted_count > 0:
return f"Deleted knowledge base '{kb_name}'."
else:
return f"Knowledge base '{kb_name}' not found."
else:
return f"Unknown action: {action}. Use create, list, or delete."
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def manage_knowledge_bases(
ctx: MCPContext,
action: str,
kb_name: Optional[str] = None,
description: Optional[str] = None,
scope: str = "project",
) -> str:
return await tool_self.call(
ctx,
action=action,
kb_name=kb_name,
description=description,
scope=scope,
)
# Export all knowledge tool classes
KNOWLEDGE_TOOL_CLASSES = [
RecallFactsTool,
StoreFactsTool,
SummarizeToMemoryTool,
ManageKnowledgeBasesTool,
]
@@ -0,0 +1,453 @@
"""Memory tools implementation for MCP.
This module provides MCP tools that use the hanzo-memory package as a library.
The hanzo-memory package provides embedded database and vector search capabilities.
IMPORTANT: All hanzo-memory imports are lazy to avoid slow startup.
The embedding service initialization takes 3+ seconds which would block MCP startup.
"""
from typing import TYPE_CHECKING, Dict, List, Optional, final, override
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, create_tool_context, auto_timeout
# Type hints only - no runtime import
if TYPE_CHECKING:
from hanzo_memory.models.memory import Memory, MemoryWithScore
from hanzo_memory.services.memory import MemoryService
# Lazy loading - don't import at module load time
MEMORY_AVAILABLE: Optional[bool] = None
_memory_service: Optional["MemoryService"] = None
def _check_memory_available() -> bool:
"""Check if hanzo-memory is available (lazy check)."""
global MEMORY_AVAILABLE
if MEMORY_AVAILABLE is None:
try:
import hanzo_memory # noqa: F401
MEMORY_AVAILABLE = True
except ImportError:
MEMORY_AVAILABLE = False
return MEMORY_AVAILABLE
def _get_lazy_memory_service() -> "MemoryService":
"""Get memory service lazily - only import when actually needed."""
global _memory_service
if _memory_service is None:
if not _check_memory_available():
raise ImportError(
"hanzo-memory package is required for memory tools. "
"Install with: pip install hanzo-memory"
)
from hanzo_memory.services.memory import get_memory_service
_memory_service = get_memory_service()
return _memory_service
class MemoryToolBase(BaseTool):
"""Base class for memory tools using hanzo-memory package."""
def __init__(self, user_id: str = "default", project_id: str = "default", **kwargs):
"""Initialize memory tool.
Args:
user_id: User ID for memory operations
project_id: Project ID for memory operations
**kwargs: Additional configuration
"""
self.user_id = user_id
self.project_id = project_id
# Lazy service loading - don't initialize until first use
self._service: Optional["MemoryService"] = None
@property
def service(self) -> "MemoryService":
"""Get memory service lazily."""
if self._service is None:
self._service = _get_lazy_memory_service()
return self._service
@final
class RecallMemoriesTool(MemoryToolBase):
"""Tool for recalling memories."""
name = "recall_memories"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Recall memories relevant to one or more queries.
This tool searches through stored memories and returns relevant matches.
Supports different scopes: session, project, or global memories.
Multiple queries can be run in parallel for efficiency.
Usage:
recall_memories(queries=["user preferences", "previous conversations"])
recall_memories(queries=["project requirements"], scope="project")
recall_memories(queries=["coding standards"], scope="global")
"""
@override
@auto_timeout("memory_tools")
async def call(
self,
ctx: MCPContext,
queries: List[str],
limit: int = 10,
scope: str = "project",
) -> str:
"""Recall memories matching queries.
Args:
ctx: MCP context
queries: Search queries
limit: Max results per query
Returns:
Formatted memory results
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Searching for {len(queries)} queries")
all_results = []
for query in queries:
# Use hanzo-memory's search_memories method
results = self.service.search_memories(
user_id=self.user_id,
query=query,
project_id=self.project_id,
limit=limit,
)
all_results.extend(results)
# Deduplicate by memory_id
seen = set()
unique_results = []
for result in all_results:
if result.memory_id not in seen:
seen.add(result.memory_id)
unique_results.append(result)
if not unique_results:
return "No relevant memories found."
# Format results
formatted = [f"Found {len(unique_results)} relevant memories:\n"]
for i, memory in enumerate(unique_results, 1):
score = getattr(memory, "similarity_score", 0.0)
formatted.append(f"{i}. {memory.content} (relevance: {score:.2f})")
return "\n".join(formatted)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def recall_memories(ctx: MCPContext, queries: List[str], limit: int = 10, scope: str = "project") -> str:
return await tool_self.call(ctx, queries=queries, limit=limit, scope=scope)
@final
class CreateMemoriesTool(MemoryToolBase):
"""Tool for creating memories."""
name = "create_memories"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Save one or more new pieces of information to memory.
This tool creates new memories from provided statements.
Each statement is stored as a separate memory.
Usage:
create_memories(statements=["User prefers dark mode", "User works in Python"])
"""
@override
@auto_timeout("memory_tools")
async def call(self, ctx: MCPContext, statements: List[str]) -> str:
"""Create new memories.
Args:
ctx: MCP context
statements: Statements to memorize
Returns:
Success message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Creating {len(statements)} memories")
created_memories = []
for statement in statements:
# Use hanzo-memory's create_memory method
memory = self.service.create_memory(
user_id=self.user_id,
project_id=self.project_id,
content=statement,
metadata={"type": "statement"},
)
created_memories.append(memory)
return f"Successfully created {len(created_memories)} new memories."
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def create_memories(ctx: MCPContext, statements: List[str]) -> str:
return await tool_self.call(ctx, statements=statements)
@final
class UpdateMemoriesTool(MemoryToolBase):
"""Tool for updating memories."""
name = "update_memories"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Update existing memories with corrected information.
This tool updates memories by ID with new content.
Usage:
update_memories(updates=[
{"id": "mem_1", "statement": "User prefers light mode"},
{"id": "mem_2", "statement": "User primarily works in TypeScript"}
])
"""
@override
@auto_timeout("memory_tools")
async def call(self, ctx: MCPContext, updates: List[Dict[str, str]]) -> str:
"""Update memories.
Args:
ctx: MCP context
updates: List of {id, statement} dicts
Returns:
Success message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Updating {len(updates)} memories")
# Note: hanzo-memory's update methods are not fully implemented yet
# For now, we'll track what would be updated
success_count = 0
for update in updates:
memory_id = update.get("id")
statement = update.get("statement")
if memory_id and statement:
# The hanzo-memory service doesn't have update implemented yet
# When it's implemented, we would call:
# success = self.service.update_memory(self.user_id, memory_id, content=statement)
await tool_ctx.warning(f"Memory update not fully implemented in hanzo-memory yet: {memory_id}")
success_count += 1
return f"Would update {success_count} of {len(updates)} memories (update not fully implemented in hanzo-memory yet)."
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def update_memories(ctx: MCPContext, updates: List[Dict[str, str]]) -> str:
return await tool_self.call(ctx, updates=updates)
@final
class DeleteMemoriesTool(MemoryToolBase):
"""Tool for deleting memories."""
name = "delete_memories"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Delete memories that are no longer relevant or incorrect.
This tool removes memories by their IDs.
Usage:
delete_memories(ids=["mem_1", "mem_2"])
"""
@override
@auto_timeout("memory_tools")
async def call(self, ctx: MCPContext, ids: List[str]) -> str:
"""Delete memories.
Args:
ctx: MCP context
ids: Memory IDs to delete
Returns:
Success message
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
await tool_ctx.info(f"Deleting {len(ids)} memories")
success_count = 0
for memory_id in ids:
# Use hanzo-memory's delete_memory method
success = self.service.delete_memory(self.user_id, memory_id)
if success:
success_count += 1
return f"Successfully deleted {success_count} of {len(ids)} memories."
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def delete_memories(ctx: MCPContext, ids: List[str]) -> str:
return await tool_self.call(ctx, ids=ids)
@final
class ManageMemoriesTool(MemoryToolBase):
"""Tool for managing memories atomically."""
name = "manage_memories"
@property
@override
def description(self) -> str:
"""Get the tool description."""
return """Create, update, and/or delete memories in a single atomic operation.
This is the preferred way to modify memories as it allows multiple
operations to be performed together.
Usage:
manage_memories(
creations=["New fact 1", "New fact 2"],
updates=[{"id": "mem_1", "statement": "Updated fact"}],
deletions=["mem_old1", "mem_old2"]
)
"""
@override
@auto_timeout("memory_tools")
async def call(
self,
ctx: MCPContext,
creations: Optional[List[str]] = None,
updates: Optional[List[Dict[str, str]]] = None,
deletions: Optional[List[str]] = None,
) -> str:
"""Manage memories atomically.
Args:
ctx: MCP context
creations: Statements to create
updates: Memories to update
deletions: Memory IDs to delete
Returns:
Summary of operations
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
results = []
# Create memories
if creations:
await tool_ctx.info(f"Creating {len(creations)} memories")
created = []
for statement in creations:
memory = self.service.create_memory(
user_id=self.user_id,
project_id=self.project_id,
content=statement,
metadata={"type": "statement"},
)
created.append(memory)
results.append(f"Created {len(created)} memories")
# Update memories
if updates:
await tool_ctx.info(f"Updating {len(updates)} memories")
success_count = 0
for update in updates:
memory_id = update.get("id")
statement = update.get("statement")
if memory_id and statement:
# Update not fully implemented in hanzo-memory yet
await tool_ctx.warning(f"Memory update not fully implemented: {memory_id}")
success_count += 1
results.append(f"Would update {success_count} memories (update pending implementation)")
# Delete memories
if deletions:
await tool_ctx.info(f"Deleting {len(deletions)} memories")
success_count = 0
for memory_id in deletions:
success = self.service.delete_memory(self.user_id, memory_id)
if success:
success_count += 1
results.append(f"Deleted {success_count} memories")
if not results:
return "No memory operations performed."
return "Memory operations completed: " + ", ".join(results)
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server."""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def manage_memories(
ctx: MCPContext,
creations: Optional[List[str]] = None,
updates: Optional[List[Dict[str, str]]] = None,
deletions: Optional[List[str]] = None,
) -> str:
return await tool_self.call(ctx, creations=creations, updates=updates, deletions=deletions)
# Export all memory tool classes
MEMORY_TOOL_CLASSES = [
RecallMemoriesTool,
CreateMemoriesTool,
UpdateMemoriesTool,
DeleteMemoriesTool,
ManageMemoriesTool,
]
+28
View File
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-memory"
version = "0.1.0"
description = "Memory and knowledge base tools for Hanzo MCP"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
keywords = ["hanzo", "tools", "memory", "knowledge", "mcp", "ai"]
dependencies = [
"hanzo-tools-core>=0.1.0",
"mcp>=1.25.0",
"pydantic>=2.12.5",
]
[project.optional-dependencies]
full = ["hanzo-memory>=0.1.0"]
[project.entry-points."hanzo.tools"]
memory = "hanzo_tools.memory:TOOLS"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]

Some files were not shown because too many files have changed in this diff Show More