mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+76
-2
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Generated
+1705
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)
|
||||
@@ -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 []
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user