mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
chore: run ruff format on all hanzo packages
This commit is contained in:
@@ -19,6 +19,7 @@ from pathlib import Path
|
||||
# This is a no-op on Windows or if uvloop is not installed
|
||||
try:
|
||||
from hanzo_mcp.utils.event_loop import configure_event_loop
|
||||
|
||||
configure_event_loop(quiet=True)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -41,13 +41,16 @@ def _get_fast_mcp():
|
||||
if _FastMCP is None:
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
|
||||
_FastMCP = FastMCP
|
||||
except ImportError:
|
||||
try:
|
||||
from mcp.server import FastMCP
|
||||
|
||||
_FastMCP = FastMCP
|
||||
except ImportError:
|
||||
from mcp import FastMCP
|
||||
|
||||
_FastMCP = FastMCP
|
||||
return _FastMCP
|
||||
|
||||
@@ -57,6 +60,7 @@ def _get_enhanced_fast_mcp():
|
||||
global _EnhancedFastMCP
|
||||
if _EnhancedFastMCP is None:
|
||||
from hanzo_mcp.server_enhanced import EnhancedFastMCP
|
||||
|
||||
_EnhancedFastMCP = EnhancedFastMCP
|
||||
return _EnhancedFastMCP
|
||||
|
||||
@@ -66,6 +70,7 @@ def _get_permission_manager():
|
||||
global _PermissionManager
|
||||
if _PermissionManager is None:
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
|
||||
_PermissionManager = PermissionManager
|
||||
return _PermissionManager
|
||||
|
||||
@@ -75,6 +80,7 @@ def _get_session_storage():
|
||||
global _SessionStorage
|
||||
if _SessionStorage is None:
|
||||
from hanzo_mcp.tools.shell.session_storage import SessionStorage
|
||||
|
||||
_SessionStorage = SessionStorage
|
||||
return _SessionStorage
|
||||
|
||||
@@ -84,6 +90,7 @@ def _get_register_all_tools():
|
||||
global _register_all_tools
|
||||
if _register_all_tools is None:
|
||||
from hanzo_mcp.tools import register_all_tools
|
||||
|
||||
_register_all_tools = register_all_tools
|
||||
return _register_all_tools
|
||||
|
||||
@@ -93,6 +100,7 @@ def _get_register_all_prompts():
|
||||
global _register_all_prompts
|
||||
if _register_all_prompts is None:
|
||||
from hanzo_mcp.prompts import register_all_prompts
|
||||
|
||||
_register_all_prompts = register_all_prompts
|
||||
return _register_all_prompts
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ def _check_lsp_available() -> bool:
|
||||
if _LSP_TOOL_AVAILABLE is None:
|
||||
try:
|
||||
from hanzo_mcp.tools.lsp import LSPTool # noqa: F401
|
||||
|
||||
_LSP_TOOL_AVAILABLE = True
|
||||
except ImportError:
|
||||
_LSP_TOOL_AVAILABLE = False
|
||||
@@ -47,6 +48,7 @@ def _check_refactor_available() -> bool:
|
||||
if _REFACTOR_TOOL_AVAILABLE is None:
|
||||
try:
|
||||
from hanzo_mcp.tools.refactor import RefactorTool # noqa: F401
|
||||
|
||||
_REFACTOR_TOOL_AVAILABLE = True
|
||||
except ImportError:
|
||||
_REFACTOR_TOOL_AVAILABLE = False
|
||||
@@ -59,6 +61,7 @@ def _check_memory_available() -> bool:
|
||||
if _MEMORY_TOOLS_AVAILABLE is None:
|
||||
try:
|
||||
from hanzo_mcp.tools.memory import register_memory_tools # noqa: F401
|
||||
|
||||
_MEMORY_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_MEMORY_TOOLS_AVAILABLE = False
|
||||
@@ -71,6 +74,7 @@ def _check_browser_available() -> bool:
|
||||
if _BROWSER_TOOL_AVAILABLE is None:
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
|
||||
_BROWSER_TOOL_AVAILABLE = True
|
||||
except ImportError:
|
||||
_BROWSER_TOOL_AVAILABLE = False
|
||||
@@ -396,6 +400,7 @@ def register_all_tools(
|
||||
# Register system tools (always enabled)
|
||||
# Version tool
|
||||
from hanzo_mcp.tools.common.version_tool import register_version_tool
|
||||
|
||||
register_version_tool(mcp_server)
|
||||
|
||||
# Tool install tool (for dynamic tool management)
|
||||
@@ -510,6 +515,7 @@ def register_all_tools(
|
||||
if any(memory_enabled.values()) and _check_memory_available():
|
||||
try:
|
||||
from hanzo_mcp.tools.memory import register_memory_tools
|
||||
|
||||
memory_tools = register_memory_tools(
|
||||
mcp_server, permission_manager, user_id="default", project_id="default"
|
||||
)
|
||||
@@ -524,6 +530,7 @@ def register_all_tools(
|
||||
if is_tool_enabled("lsp", True) and _check_lsp_available():
|
||||
try:
|
||||
from hanzo_mcp.tools.lsp import create_lsp_tool
|
||||
|
||||
tool = create_lsp_tool()
|
||||
tool.register(mcp_server)
|
||||
all_tools[tool.name] = tool
|
||||
@@ -534,6 +541,7 @@ def register_all_tools(
|
||||
if is_tool_enabled("refactor", True) and _check_refactor_available():
|
||||
try:
|
||||
from hanzo_mcp.tools.refactor import create_refactor_tool
|
||||
|
||||
tool = create_refactor_tool()
|
||||
tool.register(mcp_server)
|
||||
all_tools[tool.name] = tool
|
||||
@@ -544,6 +552,7 @@ def register_all_tools(
|
||||
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
|
||||
|
||||
@@ -163,7 +163,7 @@ class MCPToolAdapter(Tool):
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -462,11 +462,11 @@ AGENT RESPONSES:
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ Basic usage:
|
||||
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
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ from hanzo_mcp.tools.browser.browser_tool import (
|
||||
|
||||
__all__ = [
|
||||
"BrowserTool",
|
||||
"BrowserPool",
|
||||
"BrowserPool",
|
||||
"create_browser_tool",
|
||||
"launch_browser_server",
|
||||
"PLAYWRIGHT_AVAILABLE",
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydantic import Field
|
||||
# Playwright import with graceful fallback
|
||||
try:
|
||||
from playwright.async_api import Page, Browser, Playwright, BrowserContext, async_playwright
|
||||
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
@@ -29,18 +30,18 @@ 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)
|
||||
"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"),
|
||||
]
|
||||
@@ -48,17 +49,17 @@ Action = Annotated[
|
||||
|
||||
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
|
||||
@@ -68,7 +69,7 @@ class BrowserPool:
|
||||
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."""
|
||||
@@ -76,7 +77,7 @@ class BrowserPool:
|
||||
if cls._instance is None:
|
||||
cls._instance = BrowserPool()
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
async def shutdown(cls) -> None:
|
||||
"""Shutdown the browser pool."""
|
||||
@@ -84,41 +85,36 @@ class BrowserPool:
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
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}")
|
||||
@@ -155,12 +151,12 @@ class BrowserPool:
|
||||
)
|
||||
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:
|
||||
@@ -169,14 +165,14 @@ class BrowserPool:
|
||||
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:
|
||||
@@ -184,13 +180,13 @@ class BrowserPool:
|
||||
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
|
||||
@@ -198,11 +194,11 @@ class BrowserPool:
|
||||
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
|
||||
@@ -210,19 +206,19 @@ class BrowserPool:
|
||||
|
||||
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()
|
||||
@@ -230,7 +226,7 @@ class BrowserTool:
|
||||
headless=self.headless,
|
||||
cdp_endpoint=self.cdp_endpoint,
|
||||
)
|
||||
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str,
|
||||
@@ -266,7 +262,7 @@ class BrowserTool:
|
||||
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":
|
||||
@@ -283,9 +279,9 @@ class BrowserTool:
|
||||
"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"}
|
||||
@@ -382,10 +378,7 @@ class BrowserTool:
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(pool.pages),
|
||||
"tabs": [
|
||||
{"index": i, "url": p.url}
|
||||
for i, p in enumerate(pool.pages)
|
||||
],
|
||||
"tabs": [{"index": i, "url": p.url} for i, p in enumerate(pool.pages)],
|
||||
}
|
||||
|
||||
else:
|
||||
@@ -394,13 +387,13 @@ class BrowserTool:
|
||||
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,
|
||||
@@ -458,12 +451,12 @@ class BrowserTool:
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -472,28 +465,28 @@ def create_browser_tool(headless: bool = True, cdp_endpoint: Optional[str] = Non
|
||||
|
||||
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,
|
||||
@@ -503,9 +496,9 @@ async def launch_browser_server(port: int = 9222, headless: bool = False) -> str
|
||||
"--no-sandbox",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Keep browser running
|
||||
endpoint = f"http://localhost:{port}"
|
||||
logger.info(f"Browser server launched at {endpoint}")
|
||||
|
||||
|
||||
return endpoint
|
||||
|
||||
@@ -37,24 +37,25 @@ def register_critic_tool(mcp_server: FastMCP) -> list[BaseTool]:
|
||||
|
||||
def register_batch_tool(mcp_server: FastMCP, tools: dict[str, BaseTool]) -> None:
|
||||
"""DEPRECATED: Use `dag` tool from shell package instead.
|
||||
|
||||
|
||||
The dag tool provides DAG execution with serial, parallel, and mixed modes.
|
||||
It can invoke any tool, not just shell commands.
|
||||
|
||||
|
||||
Migration:
|
||||
# Old (batch)
|
||||
batch(invocations=[{"tool_name": "read", "input": {...}}])
|
||||
|
||||
# New (dag)
|
||||
|
||||
# New (dag)
|
||||
dag([{"tool": "read", "input": {...}}], parallel=True)
|
||||
"""
|
||||
warnings.warn(
|
||||
"batch tool is deprecated in 1.0.0. Use 'dag' from shell tools instead. "
|
||||
"dag([{\"tool\": \"name\", \"input\": {...}}], parallel=True)",
|
||||
'dag([{"tool": "name", "input": {...}}], parallel=True)',
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# Still register for backward compatibility
|
||||
from hanzo_mcp.tools.common.batch_tool import BatchTool
|
||||
|
||||
batch_tool = BatchTool(tools)
|
||||
ToolRegistry.register_tool(mcp_server, batch_tool)
|
||||
|
||||
@@ -176,7 +176,7 @@ def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutMa
|
||||
# No process manager - just report timeout
|
||||
timeout_formatted = format_timeout(tool_timeout)
|
||||
return f"Operation timed out after {timeout_formatted}. Backgrounding unavailable."
|
||||
|
||||
|
||||
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
|
||||
log_file = await timeout_manager.process_manager.create_log_file(process_id)
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
|
||||
Action = Annotated[
|
||||
Literal[
|
||||
"install", # Install a tool package
|
||||
"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
|
||||
"upgrade", # Upgrade package(s)
|
||||
"reload", # Hot-reload a package
|
||||
"list", # List installed packages
|
||||
"self_update", # Update hanzo-mcp itself
|
||||
],
|
||||
Field(description="Action to perform"),
|
||||
]
|
||||
@@ -31,6 +31,7 @@ Action = Annotated[
|
||||
|
||||
class ToolInstallParams(TypedDict, total=False):
|
||||
"""Parameters for tool install actions."""
|
||||
|
||||
action: str
|
||||
package: str
|
||||
source: str
|
||||
@@ -40,16 +41,16 @@ class ToolInstallParams(TypedDict, total=False):
|
||||
@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:
|
||||
@@ -75,7 +76,7 @@ Sources:
|
||||
- git: Install from git URL
|
||||
- local: Install from local path
|
||||
"""
|
||||
|
||||
|
||||
@override
|
||||
@auto_timeout("tool_install")
|
||||
async def call(
|
||||
@@ -86,27 +87,28 @@ Sources:
|
||||
"""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 (
|
||||
@@ -115,83 +117,79 @@ Sources:
|
||||
)
|
||||
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")]
|
||||
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'}"
|
||||
)
|
||||
return f"✓ Reloaded {package}\nTools: {', '.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', '')}"
|
||||
)
|
||||
return f"✓ Updated hanzo-mcp from v{result.get('current_version')}\n{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,
|
||||
@@ -201,10 +199,10 @@ Sources:
|
||||
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")
|
||||
|
||||
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class ToolPackage:
|
||||
"""Represents an installed tool package."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
source: str # "pypi", "git", "local"
|
||||
@@ -35,17 +36,17 @@ class ToolPackage:
|
||||
|
||||
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
|
||||
@@ -54,10 +55,10 @@ class ToolRegistry:
|
||||
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."""
|
||||
@@ -65,15 +66,15 @@ class ToolRegistry:
|
||||
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():
|
||||
@@ -91,7 +92,7 @@ class ToolRegistry:
|
||||
)
|
||||
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)
|
||||
@@ -110,7 +111,7 @@ class ToolRegistry:
|
||||
}
|
||||
with open(self._config_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
async def install(
|
||||
self,
|
||||
package: str,
|
||||
@@ -119,13 +120,13 @@ class ToolRegistry:
|
||||
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
|
||||
"""
|
||||
@@ -137,78 +138,89 @@ class ToolRegistry:
|
||||
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),
|
||||
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,
|
||||
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,
|
||||
@@ -217,10 +229,10 @@ class ToolRegistry:
|
||||
enabled=True,
|
||||
)
|
||||
self._save_config()
|
||||
|
||||
|
||||
# Hot-reload the package
|
||||
await self.reload_package(pkg_name)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"package": pkg_name,
|
||||
@@ -228,63 +240,67 @@ class ToolRegistry:
|
||||
"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,
|
||||
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())
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -292,46 +308,45 @@ class ToolRegistry:
|
||||
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("-", "_"))
|
||||
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:
|
||||
@@ -341,7 +356,7 @@ class ToolRegistry:
|
||||
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)):
|
||||
@@ -355,67 +370,67 @@ class ToolRegistry:
|
||||
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 [
|
||||
@@ -429,11 +444,11 @@ class ToolRegistry:
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -441,9 +441,7 @@ class LSPTool(BaseTool):
|
||||
"typeDefinition": {"dynamicRegistration": True, "linkSupport": True},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{"uri": f"file://{server.root_uri}", "name": Path(server.root_uri).name}
|
||||
],
|
||||
"workspaceFolders": [{"uri": f"file://{server.root_uri}", "name": Path(server.root_uri).name}],
|
||||
}
|
||||
|
||||
# Send initialize request
|
||||
@@ -474,10 +472,7 @@ class LSPTool(BaseTool):
|
||||
# Read headers until empty line
|
||||
headers = {}
|
||||
while True:
|
||||
line = await asyncio.wait_for(
|
||||
server.process.stdout.readline(),
|
||||
timeout=timeout
|
||||
)
|
||||
line = await asyncio.wait_for(server.process.stdout.readline(), timeout=timeout)
|
||||
if not line:
|
||||
return None
|
||||
line_str = line.decode("utf-8").strip()
|
||||
@@ -493,10 +488,7 @@ class LSPTool(BaseTool):
|
||||
return None
|
||||
|
||||
# Read content
|
||||
content = await asyncio.wait_for(
|
||||
server.process.stdout.read(content_length),
|
||||
timeout=timeout
|
||||
)
|
||||
content = await asyncio.wait_for(server.process.stdout.read(content_length), timeout=timeout)
|
||||
return json.loads(content.decode("utf-8"))
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
@@ -506,7 +498,9 @@ class LSPTool(BaseTool):
|
||||
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]]:
|
||||
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:
|
||||
return None
|
||||
@@ -514,6 +508,7 @@ class LSPTool(BaseTool):
|
||||
return None
|
||||
|
||||
import time
|
||||
|
||||
async with server.lock:
|
||||
try:
|
||||
# Serialize request
|
||||
@@ -849,7 +844,9 @@ class LSPTool(BaseTool):
|
||||
file_path = change["textDocument"]["uri"].replace("file://", "")
|
||||
changes[file_path] = [
|
||||
{
|
||||
"range": self._parse_location({"uri": change["textDocument"]["uri"], "range": edit["range"]}),
|
||||
"range": self._parse_location(
|
||||
{"uri": change["textDocument"]["uri"], "range": edit["range"]}
|
||||
),
|
||||
"newText": edit["newText"],
|
||||
}
|
||||
for edit in change.get("edits", [])
|
||||
@@ -922,10 +919,7 @@ class LSPTool(BaseTool):
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ def _check_memory_available() -> bool:
|
||||
if MEMORY_AVAILABLE is None:
|
||||
try:
|
||||
import hanzo_memory # noqa: F401
|
||||
|
||||
MEMORY_AVAILABLE = True
|
||||
except ImportError:
|
||||
MEMORY_AVAILABLE = False
|
||||
@@ -44,10 +45,10 @@ def _get_lazy_memory_service() -> "MemoryService":
|
||||
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"
|
||||
"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
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class RefactorResult:
|
||||
@dataclass
|
||||
class FileCache:
|
||||
"""Cache for file contents to avoid redundant I/O."""
|
||||
|
||||
content: str
|
||||
lines: List[str]
|
||||
mtime: float
|
||||
@@ -302,6 +303,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
parallel: Enable parallel processing (default: True)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
valid_actions = [
|
||||
@@ -317,9 +319,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
]
|
||||
|
||||
if action not in valid_actions:
|
||||
return MCPResourceDocument(
|
||||
data={"error": f"Invalid action. Must be one of: {', '.join(valid_actions)}"}
|
||||
)
|
||||
return MCPResourceDocument(data={"error": f"Invalid action. Must be one of: {', '.join(valid_actions)}"})
|
||||
|
||||
# Resolve file path if provided
|
||||
file_path = str(Path(file).resolve()) if file else None
|
||||
@@ -384,7 +384,8 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"rg",
|
||||
"--json",
|
||||
"--word-regexp",
|
||||
"--max-count", str(RIPGREP_BATCH_SIZE),
|
||||
"--max-count",
|
||||
str(RIPGREP_BATCH_SIZE),
|
||||
"--no-ignore-vcs", # Respect .gitignore
|
||||
]
|
||||
|
||||
@@ -427,13 +428,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
submatches = match_data.get("submatches", [])
|
||||
for submatch in submatches:
|
||||
col = submatch.get("start", 0)
|
||||
references.append(RefactorLocation(
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
column=col,
|
||||
text=identifier,
|
||||
context=context,
|
||||
))
|
||||
references.append(
|
||||
RefactorLocation(
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
column=col,
|
||||
text=identifier,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@@ -502,13 +505,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
refs = []
|
||||
for i, line in enumerate(cache.lines):
|
||||
for match in pattern.finditer(line):
|
||||
refs.append(RefactorLocation(
|
||||
file=file_path,
|
||||
line=i + 1,
|
||||
column=match.start(),
|
||||
text=identifier,
|
||||
context=line.strip(),
|
||||
))
|
||||
refs.append(
|
||||
RefactorLocation(
|
||||
file=file_path,
|
||||
line=i + 1,
|
||||
column=match.start(),
|
||||
text=identifier,
|
||||
context=line.strip(),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
# Run all file scans concurrently
|
||||
@@ -545,7 +550,9 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
# Get the identifier at the position
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
return RefactorResult(success=False, action="rename", errors=[f"Cannot read file or line {line} out of range"])
|
||||
return RefactorResult(
|
||||
success=False, action="rename", errors=[f"Cannot read file or line {line} out of range"]
|
||||
)
|
||||
|
||||
target_line = cache.lines[line - 1]
|
||||
old_name = self._get_identifier_at(target_line, column)
|
||||
@@ -560,9 +567,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
references = await self._find_all_references_parallel(file_path, old_name, project_root)
|
||||
|
||||
if not references:
|
||||
return RefactorResult(
|
||||
success=False, action="rename", errors=[f"No references found for '{old_name}'"]
|
||||
)
|
||||
return RefactorResult(success=False, action="rename", errors=[f"No references found for '{old_name}'"])
|
||||
|
||||
# Group by file for efficient batch edits
|
||||
changes_by_file: Dict[str, List[RefactorChange]] = defaultdict(list)
|
||||
@@ -582,13 +587,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
preview_data = []
|
||||
for file, changes in changes_by_file.items():
|
||||
for change in changes[:50]: # Limit preview per file
|
||||
preview_data.append({
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"column": change.column,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"column": change.column,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -670,12 +677,14 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
preview_data = []
|
||||
for file, changes in list(all_changes.items())[:20]: # Limit files in preview
|
||||
for change in changes[:10]:
|
||||
preview_data.append({
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -767,9 +776,9 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
line = lines[change.line - 1]
|
||||
# Verify the old text matches
|
||||
actual = line[change.column:change.column + len(change.old_text)]
|
||||
actual = line[change.column : change.column + len(change.old_text)]
|
||||
if actual == change.old_text:
|
||||
new_line = line[:change.column] + change.new_text + line[change.column + len(change.old_text):]
|
||||
new_line = line[: change.column] + change.new_text + line[change.column + len(change.old_text) :]
|
||||
lines[change.line - 1] = new_line
|
||||
applied += 1
|
||||
|
||||
@@ -809,17 +818,12 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
identifier = self._get_identifier_at(cache.lines[line - 1], column)
|
||||
if not identifier:
|
||||
return RefactorResult(
|
||||
success=False, action="find_references", errors=["No identifier found at position"]
|
||||
)
|
||||
return RefactorResult(success=False, action="find_references", errors=["No identifier found at position"])
|
||||
|
||||
project_root = self._find_project_root(file_path)
|
||||
references = await self._find_all_references_parallel(file_path, identifier, project_root)
|
||||
|
||||
ref_data = [
|
||||
{"file": r.file, "line": r.line, "column": r.column, "context": r.context}
|
||||
for r in references
|
||||
]
|
||||
ref_data = [{"file": r.file, "line": r.line, "column": r.column, "context": r.context} for r in references]
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -856,12 +860,10 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
return RefactorResult(success=False, action="extract_function", errors=["Cannot read file"])
|
||||
|
||||
if start_line < 1 or end_line > len(cache.lines):
|
||||
return RefactorResult(
|
||||
success=False, action="extract_function", errors=["Line range out of bounds"]
|
||||
)
|
||||
return RefactorResult(success=False, action="extract_function", errors=["Line range out of bounds"])
|
||||
|
||||
# Extract the code block
|
||||
extracted_lines = cache.lines[start_line - 1:end_line]
|
||||
extracted_lines = cache.lines[start_line - 1 : end_line]
|
||||
extracted_code = "\n".join(extracted_lines)
|
||||
|
||||
# Detect language and indentation
|
||||
@@ -887,14 +889,19 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
action="extract_function",
|
||||
preview=[
|
||||
{"type": "new_function", "code": new_function, "insert_at": f"Before line {start_line}"},
|
||||
{"type": "replacement", "lines": f"{start_line}-{end_line}", "old_code": extracted_code, "new_code": function_call},
|
||||
{
|
||||
"type": "replacement",
|
||||
"lines": f"{start_line}-{end_line}",
|
||||
"old_code": extracted_code,
|
||||
"new_code": function_call,
|
||||
},
|
||||
],
|
||||
message=f"Would extract lines {start_line}-{end_line} to function '{new_name}'",
|
||||
)
|
||||
|
||||
# Apply the extraction
|
||||
try:
|
||||
new_lines = cache.lines[:start_line - 1]
|
||||
new_lines = cache.lines[: start_line - 1]
|
||||
new_lines.append(new_function)
|
||||
new_lines.append("")
|
||||
new_lines.append(function_call)
|
||||
@@ -930,9 +937,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
success=False, action="extract_variable", errors=["file, line and column are required"]
|
||||
)
|
||||
if not new_name:
|
||||
return RefactorResult(
|
||||
success=False, action="extract_variable", errors=["new_name is required"]
|
||||
)
|
||||
return RefactorResult(success=False, action="extract_variable", errors=["new_name is required"])
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
@@ -1000,9 +1005,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
) -> RefactorResult:
|
||||
"""Inline a variable or function at all usage sites."""
|
||||
if not file_path or not line or column is None:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=["file, line and column are required"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=["file, line and column are required"])
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
@@ -1012,9 +1015,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
identifier = self._get_identifier_at(target_line, column)
|
||||
|
||||
if not identifier:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=["No identifier found at position"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=["No identifier found at position"])
|
||||
|
||||
language = self._get_language(file_path)
|
||||
definition = self._find_definition(cache.content, identifier, language)
|
||||
@@ -1031,16 +1032,13 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
usages = [u for u in usages if not (u.file == file_path and u.line == definition["line"])]
|
||||
|
||||
if not usages:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=[f"No usages found for '{identifier}'"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=[f"No usages found for '{identifier}'"])
|
||||
|
||||
inline_value = definition["value"]
|
||||
|
||||
if preview:
|
||||
preview_data = [
|
||||
{"file": u.file, "line": u.line, "replace": identifier, "with": inline_value}
|
||||
for u in usages[:20]
|
||||
{"file": u.file, "line": u.line, "replace": identifier, "with": inline_value} for u in usages[:20]
|
||||
]
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -1144,7 +1142,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
# Remove from source file
|
||||
try:
|
||||
new_lines = cache.lines[:block["start"] - 1] + cache.lines[block["end"]:]
|
||||
new_lines = cache.lines[: block["start"] - 1] + cache.lines[block["end"] :]
|
||||
await loop.run_in_executor(None, self._write_file_sync, file_path, "\n".join(new_lines))
|
||||
await self._invalidate_cache(file_path)
|
||||
except Exception as e:
|
||||
@@ -1178,8 +1176,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"""
|
||||
if not file_path or not line:
|
||||
return RefactorResult(
|
||||
success=False, action="change_signature",
|
||||
errors=["file and line are required to locate the function"]
|
||||
success=False, action="change_signature", errors=["file and line are required to locate the function"]
|
||||
)
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
@@ -1192,8 +1189,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
func_info = self._parse_function_signature(cache.lines, line, language)
|
||||
if not func_info:
|
||||
return RefactorResult(
|
||||
success=False, action="change_signature",
|
||||
errors=[f"No function signature found at line {line}"]
|
||||
success=False, action="change_signature", errors=[f"No function signature found at line {line}"]
|
||||
)
|
||||
|
||||
func_name = func_info["name"]
|
||||
@@ -1217,22 +1213,26 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
changes_by_file: Dict[str, List[RefactorChange]] = defaultdict(list)
|
||||
|
||||
# First, change the function definition
|
||||
old_sig_lines = cache.lines[signature_line - 1:signature_end_line]
|
||||
old_sig_lines = cache.lines[signature_line - 1 : signature_end_line]
|
||||
old_sig = "\n".join(old_sig_lines)
|
||||
|
||||
changes_by_file[file_path].append(RefactorChange(
|
||||
file=file_path,
|
||||
line=signature_line,
|
||||
column=0,
|
||||
end_line=signature_end_line,
|
||||
end_column=len(cache.lines[signature_end_line - 1]) if signature_end_line <= len(cache.lines) else 0,
|
||||
old_text=old_sig,
|
||||
new_text=new_signature,
|
||||
description="Update function signature",
|
||||
))
|
||||
changes_by_file[file_path].append(
|
||||
RefactorChange(
|
||||
file=file_path,
|
||||
line=signature_line,
|
||||
column=0,
|
||||
end_line=signature_end_line,
|
||||
end_column=len(cache.lines[signature_end_line - 1]) if signature_end_line <= len(cache.lines) else 0,
|
||||
old_text=old_sig,
|
||||
new_text=new_signature,
|
||||
description="Update function signature",
|
||||
)
|
||||
)
|
||||
|
||||
# Then update all call sites
|
||||
call_changes = await self._update_call_sites(call_sites, func_name, params, new_params, param_mapping, changes, language)
|
||||
call_changes = await self._update_call_sites(
|
||||
call_sites, func_name, params, new_params, param_mapping, changes, language
|
||||
)
|
||||
for call_change in call_changes:
|
||||
changes_by_file[call_change.file].append(call_change)
|
||||
|
||||
@@ -1240,17 +1240,24 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
if preview:
|
||||
preview_data = [
|
||||
{"type": "signature", "file": file_path, "line": signature_line,
|
||||
"old": old_sig.strip(), "new": new_signature.strip()}
|
||||
{
|
||||
"type": "signature",
|
||||
"file": file_path,
|
||||
"line": signature_line,
|
||||
"old": old_sig.strip(),
|
||||
"new": new_signature.strip(),
|
||||
}
|
||||
]
|
||||
for call_change in call_changes[:20]:
|
||||
preview_data.append({
|
||||
"type": "call_site",
|
||||
"file": call_change.file,
|
||||
"line": call_change.line,
|
||||
"old": call_change.old_text,
|
||||
"new": call_change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"type": "call_site",
|
||||
"file": call_change.file,
|
||||
"line": call_change.line,
|
||||
"old": call_change.old_text,
|
||||
"new": call_change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -1259,19 +1266,23 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
changes_applied=total_changes,
|
||||
preview=preview_data,
|
||||
message=f"Would update signature of '{func_name}' and {len(call_changes)} call sites",
|
||||
stats={"call_sites_found": len(call_sites), "params_before": len(params), "params_after": len(new_params)},
|
||||
stats={
|
||||
"call_sites_found": len(call_sites),
|
||||
"params_before": len(params),
|
||||
"params_after": len(new_params),
|
||||
},
|
||||
)
|
||||
|
||||
# Apply all changes
|
||||
result = await self._apply_signature_changes_to_files(changes_by_file, cache, file_path, signature_line, signature_end_line, new_signature)
|
||||
result = await self._apply_signature_changes_to_files(
|
||||
changes_by_file, cache, file_path, signature_line, signature_end_line, new_signature
|
||||
)
|
||||
result.action = "change_signature"
|
||||
result.message = f"Updated signature of '{func_name}' and {len(call_changes)} call sites"
|
||||
result.stats = {"call_sites_updated": len(call_changes)}
|
||||
return result
|
||||
|
||||
def _parse_function_signature(
|
||||
self, lines: List[str], line_num: int, language: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def _parse_function_signature(self, lines: List[str], line_num: int, language: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a function signature at the given line."""
|
||||
if line_num > len(lines):
|
||||
return None
|
||||
@@ -1667,16 +1678,18 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
new_call = f"{func_name}({', '.join(new_args)})"
|
||||
|
||||
if new_call != call_text:
|
||||
call_changes.append(RefactorChange(
|
||||
file=site.file,
|
||||
line=site.line,
|
||||
column=call_start,
|
||||
end_line=site.line, # Simplified - assuming single line
|
||||
end_column=call_start + len(call_text),
|
||||
old_text=call_text,
|
||||
new_text=new_call,
|
||||
description=f"Update call to {func_name}",
|
||||
))
|
||||
call_changes.append(
|
||||
RefactorChange(
|
||||
file=site.file,
|
||||
line=site.line,
|
||||
column=call_start,
|
||||
end_line=site.line, # Simplified - assuming single line
|
||||
end_column=call_start + len(call_text),
|
||||
old_text=call_text,
|
||||
new_text=new_call,
|
||||
description=f"Update call to {func_name}",
|
||||
)
|
||||
)
|
||||
|
||||
return call_changes
|
||||
|
||||
@@ -1698,7 +1711,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
paren_count = 1
|
||||
pos = paren_start + 1
|
||||
current_line = line_idx
|
||||
call_text = line[start_col:paren_start + 1]
|
||||
call_text = line[start_col : paren_start + 1]
|
||||
|
||||
while paren_count > 0:
|
||||
if pos >= len(lines[current_line]):
|
||||
@@ -1720,12 +1733,12 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
# Include the final character
|
||||
if current_line == line_idx:
|
||||
call_text = line[start_col:paren_start + 1 + (pos - paren_start - 1)]
|
||||
call_text = line[start_col : paren_start + 1 + (pos - paren_start - 1)]
|
||||
|
||||
# Simple single-line extraction
|
||||
end = line.find(")", paren_start)
|
||||
if end >= 0:
|
||||
return line[start_col:end + 1], end + 1
|
||||
return line[start_col : end + 1], end + 1
|
||||
|
||||
return None, 0
|
||||
|
||||
@@ -1738,7 +1751,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
start = call_text.find("(")
|
||||
end = call_text.rfind(")")
|
||||
if start >= 0 and end > start:
|
||||
args_str = call_text[start + 1:end]
|
||||
args_str = call_text[start + 1 : end]
|
||||
else:
|
||||
return []
|
||||
else:
|
||||
@@ -1838,13 +1851,13 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
# Handle signature change specially (may span multiple lines)
|
||||
if target_file == file_path and change.line == sig_line:
|
||||
# Replace signature lines
|
||||
lines = lines[:sig_line - 1] + [new_signature] + lines[sig_end_line:]
|
||||
lines = lines[: sig_line - 1] + [new_signature] + lines[sig_end_line:]
|
||||
changes_applied += 1
|
||||
else:
|
||||
# Normal single-line change
|
||||
if change.line <= len(lines):
|
||||
line = lines[change.line - 1]
|
||||
new_line = line[:change.column] + change.new_text + line[change.end_column:]
|
||||
new_line = line[: change.column] + change.new_text + line[change.end_column :]
|
||||
lines[change.line - 1] = new_line
|
||||
changes_applied += 1
|
||||
|
||||
@@ -1949,12 +1962,56 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"""Find variables used in a code block."""
|
||||
identifiers = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b", code))
|
||||
keywords = {
|
||||
"if", "else", "for", "while", "def", "class", "return", "import", "from",
|
||||
"in", "and", "or", "not", "True", "False", "None", "async", "await",
|
||||
"try", "except", "finally", "with", "as", "is", "lambda", "yield",
|
||||
"break", "continue", "pass", "raise", "global", "nonlocal", "assert", "del",
|
||||
"function", "const", "let", "var", "new", "this", "super", "extends",
|
||||
"func", "type", "struct", "interface", "package", "fn", "mut", "pub",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"while",
|
||||
"def",
|
||||
"class",
|
||||
"return",
|
||||
"import",
|
||||
"from",
|
||||
"in",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"True",
|
||||
"False",
|
||||
"None",
|
||||
"async",
|
||||
"await",
|
||||
"try",
|
||||
"except",
|
||||
"finally",
|
||||
"with",
|
||||
"as",
|
||||
"is",
|
||||
"lambda",
|
||||
"yield",
|
||||
"break",
|
||||
"continue",
|
||||
"pass",
|
||||
"raise",
|
||||
"global",
|
||||
"nonlocal",
|
||||
"assert",
|
||||
"del",
|
||||
"function",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"new",
|
||||
"this",
|
||||
"super",
|
||||
"extends",
|
||||
"func",
|
||||
"type",
|
||||
"struct",
|
||||
"interface",
|
||||
"package",
|
||||
"fn",
|
||||
"mut",
|
||||
"pub",
|
||||
}
|
||||
return identifiers - keywords
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
# Check if ffind library is available
|
||||
try:
|
||||
from ffind.ffind import search as ffind_search
|
||||
|
||||
FFIND_AVAILABLE = True
|
||||
except ImportError:
|
||||
FFIND_AVAILABLE = False
|
||||
|
||||
@@ -18,12 +18,14 @@ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
# Check available search backends
|
||||
try:
|
||||
import tree_sitter
|
||||
|
||||
TREESITTER_AVAILABLE = True
|
||||
except ImportError:
|
||||
TREESITTER_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from hanzo_mcp.tools.lsp.lsp_tool import LSP_SERVERS, LSPTool
|
||||
|
||||
LSP_AVAILABLE = True
|
||||
except ImportError:
|
||||
LSP_AVAILABLE = False
|
||||
@@ -32,6 +34,7 @@ except ImportError:
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Unified search result."""
|
||||
|
||||
file_path: str
|
||||
line_number: int
|
||||
column: int
|
||||
@@ -65,16 +68,16 @@ class SearchResult:
|
||||
|
||||
class SearchTool(BaseTool):
|
||||
"""THE search tool - finds anything, fast.
|
||||
|
||||
|
||||
Runs ALL available search engines in parallel:
|
||||
- grep (ripgrep) - fast text/regex
|
||||
- ast (tree-sitter) - code structure
|
||||
- lsp - precise references
|
||||
- lsp - precise references
|
||||
- file - filename matching
|
||||
- git - history search
|
||||
|
||||
|
||||
No toggles. No configuration. Just search.
|
||||
|
||||
|
||||
For manual control: exec.parallel([grep(...), ast(...), lsp(...)])
|
||||
"""
|
||||
|
||||
@@ -103,7 +106,9 @@ class SearchTool(BaseTool):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
capture_output=True, cwd=path or ".", timeout=2,
|
||||
capture_output=True,
|
||||
cwd=path or ".",
|
||||
timeout=2,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
@@ -148,19 +153,19 @@ class SearchTool(BaseTool):
|
||||
timed("grep", self._grep_search(pattern, path, include, exclude, max_per_engine, context_lines)),
|
||||
timed("file", self._file_search(pattern, path, include, exclude, max_per_engine)),
|
||||
]
|
||||
|
||||
|
||||
if TREESITTER_AVAILABLE:
|
||||
tasks.append(timed("ast", self._ast_search(pattern, path, include, exclude, max_per_engine, context_lines)))
|
||||
|
||||
|
||||
if LSP_AVAILABLE:
|
||||
tasks.append(timed("lsp", self._lsp_search(pattern, path, include, max_per_engine)))
|
||||
|
||||
|
||||
if is_git_repo and self.git_available:
|
||||
tasks.append(timed("git", self._git_search(pattern, path, max_per_engine)))
|
||||
|
||||
# Execute all in parallel
|
||||
results_list = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
for result in results_list:
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
@@ -172,7 +177,7 @@ class SearchTool(BaseTool):
|
||||
# Dedupe and rank
|
||||
unique = self._deduplicate(all_results)
|
||||
ranked = self._rank(unique, pattern)
|
||||
|
||||
|
||||
stats["total"] = len(all_results)
|
||||
stats["unique"] = len(ranked)
|
||||
|
||||
@@ -181,16 +186,18 @@ class SearchTool(BaseTool):
|
||||
end_idx = start_idx + page_size
|
||||
page_results = ranked[start_idx:end_idx]
|
||||
|
||||
return MCPResourceDocument(data={
|
||||
"results": [r.to_dict() for r in page_results],
|
||||
"stats": stats,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": len(ranked),
|
||||
"has_next": end_idx < len(ranked),
|
||||
},
|
||||
})
|
||||
return MCPResourceDocument(
|
||||
data={
|
||||
"results": [r.to_dict() for r in page_results],
|
||||
"stats": stats,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": len(ranked),
|
||||
"has_next": end_idx < len(ranked),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@auto_timeout("search")
|
||||
async def call(self, ctx=None, **kwargs) -> str:
|
||||
@@ -213,16 +220,26 @@ class SearchTool(BaseTool):
|
||||
) -> str:
|
||||
"""Search everything in parallel."""
|
||||
return await self.call(
|
||||
pattern=pattern, path=path, include=include, exclude=exclude,
|
||||
max_results=max_results, context_lines=context_lines,
|
||||
page=page, page_size=page_size,
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
include=include,
|
||||
exclude=exclude,
|
||||
max_results=max_results,
|
||||
context_lines=context_lines,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# --- Search Engines ---
|
||||
|
||||
async def _grep_search(
|
||||
self, pattern: str, path: str, include: Optional[str],
|
||||
exclude: Optional[str], max_results: int, context_lines: int,
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
exclude: Optional[str],
|
||||
max_results: int,
|
||||
context_lines: int,
|
||||
) -> List[SearchResult]:
|
||||
"""Text search using ripgrep."""
|
||||
if not self.ripgrep_available:
|
||||
@@ -248,18 +265,24 @@ class SearchTool(BaseTool):
|
||||
lines_data = m.get("lines", {})
|
||||
match_text = lines_data.get("text", "") if isinstance(lines_data, dict) else str(lines_data)
|
||||
path_data = m.get("path", {})
|
||||
file_path = path_data.get("text", str(path_data)) if isinstance(path_data, dict) else str(path_data)
|
||||
file_path = (
|
||||
path_data.get("text", str(path_data)) if isinstance(path_data, dict) else str(path_data)
|
||||
)
|
||||
submatches = m.get("submatches", [{}])
|
||||
column = submatches[0].get("start", 0) if submatches else 0
|
||||
|
||||
results.append(SearchResult(
|
||||
file_path=file_path,
|
||||
line_number=m.get("line_number", 0),
|
||||
column=column,
|
||||
match_text=match_text.strip() if isinstance(match_text, str) else str(match_text),
|
||||
context_before=[], context_after=[],
|
||||
match_type="grep", score=1.0,
|
||||
))
|
||||
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=file_path,
|
||||
line_number=m.get("line_number", 0),
|
||||
column=column,
|
||||
match_text=match_text.strip() if isinstance(match_text, str) else str(match_text),
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="grep",
|
||||
score=1.0,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
@@ -267,8 +290,13 @@ class SearchTool(BaseTool):
|
||||
return results
|
||||
|
||||
async def _ast_search(
|
||||
self, pattern: str, path: str, include: Optional[str],
|
||||
exclude: Optional[str], max_results: int, context_lines: int,
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
exclude: Optional[str],
|
||||
max_results: int,
|
||||
context_lines: int,
|
||||
) -> List[SearchResult]:
|
||||
"""AST search using tree-sitter via grep-ast."""
|
||||
try:
|
||||
@@ -278,7 +306,7 @@ class SearchTool(BaseTool):
|
||||
|
||||
results = []
|
||||
search_path = Path(path or ".")
|
||||
|
||||
|
||||
# Use ripgrep to find candidate files first (fast)
|
||||
files = []
|
||||
if self.ripgrep_available:
|
||||
@@ -291,7 +319,7 @@ class SearchTool(BaseTool):
|
||||
if exclude:
|
||||
cmd.extend(["--glob", f"!{exclude}"])
|
||||
cmd.extend([pattern, str(search_path)])
|
||||
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
@@ -306,63 +334,88 @@ class SearchTool(BaseTool):
|
||||
tc = TreeContext(str(file_path), code, color=False, verbose=False, line_number=True)
|
||||
matches = tc.grep(pattern, ignore_case=False)
|
||||
lines = code.split("\n")
|
||||
|
||||
|
||||
for line_num in matches:
|
||||
results.append(SearchResult(
|
||||
file_path=str(file_path),
|
||||
line_number=line_num,
|
||||
column=0,
|
||||
match_text=lines[line_num - 1] if 0 < line_num <= len(lines) else "",
|
||||
context_before=lines[max(0, line_num - context_lines - 1):line_num - 1],
|
||||
context_after=lines[line_num:min(len(lines), line_num + context_lines)],
|
||||
match_type="ast", score=0.9, node_type="ast_match",
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=str(file_path),
|
||||
line_number=line_num,
|
||||
column=0,
|
||||
match_text=lines[line_num - 1] if 0 < line_num <= len(lines) else "",
|
||||
context_before=lines[max(0, line_num - context_lines - 1) : line_num - 1],
|
||||
context_after=lines[line_num : min(len(lines), line_num + context_lines)],
|
||||
match_type="ast",
|
||||
score=0.9,
|
||||
node_type="ast_match",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
async def _file_search(
|
||||
self, pattern: str, path: str, include: Optional[str],
|
||||
exclude: Optional[str], max_results: int,
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
exclude: Optional[str],
|
||||
max_results: int,
|
||||
) -> List[SearchResult]:
|
||||
"""Filename search."""
|
||||
results = []
|
||||
try:
|
||||
from hanzo_mcp.tools.search.find_tool import FindTool
|
||||
|
||||
find = FindTool()
|
||||
find_result = await find.run(
|
||||
pattern=pattern, path=path, type="file",
|
||||
max_results=max_results, regex=False, fuzzy=False, case_sensitive=False,
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
type="file",
|
||||
max_results=max_results,
|
||||
regex=False,
|
||||
fuzzy=False,
|
||||
case_sensitive=False,
|
||||
)
|
||||
if find_result.data and "results" in find_result.data:
|
||||
for f in find_result.data["results"]:
|
||||
results.append(SearchResult(
|
||||
file_path=f["path"], line_number=1, column=0,
|
||||
match_text=f["name"], context_before=[], context_after=[],
|
||||
match_type="file", score=1.0,
|
||||
semantic_context=f"File: {f.get('extension', '')} ({f.get('size', 0)} bytes)",
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=f["path"],
|
||||
line_number=1,
|
||||
column=0,
|
||||
match_text=f["name"],
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="file",
|
||||
score=1.0,
|
||||
semantic_context=f"File: {f.get('extension', '')} ({f.get('size', 0)} bytes)",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
async def _lsp_search(
|
||||
self, pattern: str, path: str, include: Optional[str], max_results: int,
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
max_results: int,
|
||||
) -> List[SearchResult]:
|
||||
"""LSP reference search."""
|
||||
if not LSP_AVAILABLE:
|
||||
return []
|
||||
|
||||
|
||||
results = []
|
||||
try:
|
||||
root_path = Path(path).resolve()
|
||||
|
||||
|
||||
# Find files containing pattern
|
||||
cmd = ["rg", "--files-with-matches", "-l", pattern]
|
||||
if include:
|
||||
cmd.extend(["--glob", include])
|
||||
cmd.append(str(root_path))
|
||||
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
matching_files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
@@ -376,18 +429,26 @@ class SearchTool(BaseTool):
|
||||
for line_num, line in enumerate(f, 1):
|
||||
col = line.find(pattern)
|
||||
if col >= 0:
|
||||
lsp_result = await lsp.run(action="references", file=file_path, line=line_num, character=col)
|
||||
lsp_result = await lsp.run(
|
||||
action="references", file=file_path, line=line_num, character=col
|
||||
)
|
||||
if lsp_result.data and "references" in lsp_result.data:
|
||||
for ref in lsp_result.data["references"][:max_results]:
|
||||
ref_path = ref.get("uri", "").replace("file://", "")
|
||||
ref_line = ref.get("range", {}).get("start", {}).get("line", 0) + 1
|
||||
results.append(SearchResult(
|
||||
file_path=ref_path, line_number=ref_line,
|
||||
column=ref.get("range", {}).get("start", {}).get("character", 0),
|
||||
match_text=pattern, context_before=[], context_after=[],
|
||||
match_type="lsp", score=0.95,
|
||||
semantic_context=f"LSP ref to '{pattern}'",
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=ref_path,
|
||||
line_number=ref_line,
|
||||
column=ref.get("range", {}).get("start", {}).get("character", 0),
|
||||
match_text=pattern,
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="lsp",
|
||||
score=0.95,
|
||||
semantic_context=f"LSP ref to '{pattern}'",
|
||||
)
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
@@ -402,46 +463,64 @@ class SearchTool(BaseTool):
|
||||
# git log -S (pickaxe)
|
||||
cmd = ["git", "log", f"-S{pattern}", "--oneline", f"-n{max_results}", "--pretty=format:%h|%s|%an|%ar"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=path or ".", timeout=10)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) >= 4:
|
||||
commit, subject, author, date = parts
|
||||
results.append(SearchResult(
|
||||
file_path=f"git://{commit}", line_number=0, column=0,
|
||||
match_text=subject.strip(), context_before=[], context_after=[],
|
||||
match_type="git", score=0.85,
|
||||
semantic_context=f"Commit by {author} ({date})",
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=f"git://{commit}",
|
||||
line_number=0,
|
||||
column=0,
|
||||
match_text=subject.strip(),
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="git",
|
||||
score=0.85,
|
||||
semantic_context=f"Commit by {author} ({date})",
|
||||
)
|
||||
)
|
||||
|
||||
# git grep
|
||||
if len(results) < max_results:
|
||||
grep_cmd = ["git", "grep", "-n", "-I", f"--max-count={max_results - len(results)}", pattern]
|
||||
grep_result = subprocess.run(grep_cmd, capture_output=True, text=True, cwd=path or ".", timeout=10)
|
||||
|
||||
|
||||
if grep_result.returncode == 0:
|
||||
for line in grep_result.stdout.strip().split("\n"):
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
fp, ln, content = parts
|
||||
results.append(SearchResult(
|
||||
file_path=str(Path(path or ".").resolve() / fp),
|
||||
line_number=int(ln) if ln.isdigit() else 1,
|
||||
column=0, match_text=content.strip(),
|
||||
context_before=[], context_after=[],
|
||||
match_type="git", score=0.9,
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=str(Path(path or ".").resolve() / fp),
|
||||
line_number=int(ln) if ln.isdigit() else 1,
|
||||
column=0,
|
||||
match_text=content.strip(),
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="git",
|
||||
score=0.9,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
async def _python_grep(
|
||||
self, pattern: str, path: str, include: Optional[str],
|
||||
exclude: Optional[str], max_results: int, context_lines: int,
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
exclude: Optional[str],
|
||||
max_results: int,
|
||||
context_lines: int,
|
||||
) -> List[SearchResult]:
|
||||
"""Fallback Python text search."""
|
||||
import re
|
||||
|
||||
results = []
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
@@ -461,13 +540,18 @@ class SearchTool(BaseTool):
|
||||
break
|
||||
match = regex.search(line)
|
||||
if match:
|
||||
results.append(SearchResult(
|
||||
file_path=str(file_path), line_number=i + 1, column=match.start(),
|
||||
match_text=line.strip(),
|
||||
context_before=lines[max(0, i - context_lines):i],
|
||||
context_after=lines[i + 1:i + 1 + context_lines],
|
||||
match_type="grep", score=1.0,
|
||||
))
|
||||
results.append(
|
||||
SearchResult(
|
||||
file_path=str(file_path),
|
||||
line_number=i + 1,
|
||||
column=match.start(),
|
||||
match_text=line.strip(),
|
||||
context_before=lines[max(0, i - context_lines) : i],
|
||||
context_after=lines[i + 1 : i + 1 + context_lines],
|
||||
match_type="grep",
|
||||
score=1.0,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -53,17 +53,17 @@ def get_shell_tools(
|
||||
"""
|
||||
# Create dag tool with access to other tools
|
||||
dag = create_dag_tool(tools=all_tools or {})
|
||||
|
||||
|
||||
# Set permission manager for convenience tools
|
||||
npx_tool.permission_manager = permission_manager
|
||||
uvx_tool.permission_manager = permission_manager
|
||||
|
||||
return [
|
||||
dag, # DAG execution for complex workflows
|
||||
zsh_tool, # Simple single-command zsh (with auto-backgrounding)
|
||||
ps_tool, # Process management
|
||||
npx_tool, # Node packages
|
||||
uvx_tool, # Python packages
|
||||
dag, # DAG execution for complex workflows
|
||||
zsh_tool, # Simple single-command zsh (with auto-backgrounding)
|
||||
ps_tool, # Process management
|
||||
npx_tool, # Node packages
|
||||
uvx_tool, # Python packages
|
||||
open_tool, # Open files/URLs
|
||||
]
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class AutoBackgroundExecutor:
|
||||
"""
|
||||
# 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
|
||||
|
||||
@@ -132,7 +132,7 @@ class AutoBackgroundExecutor:
|
||||
if wait_task in done:
|
||||
# Process completed within timeout
|
||||
return_code = await wait_task
|
||||
|
||||
|
||||
# Give read_task a short time to finish (0.5s max)
|
||||
# This prevents hanging if stdout has issues
|
||||
try:
|
||||
|
||||
@@ -281,12 +281,7 @@ class BaseProcessTool(BaseTool):
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
async def _write_output_to_log(
|
||||
self,
|
||||
process: asyncio.subprocess.Process,
|
||||
log_file: Path,
|
||||
process_id: str
|
||||
) -> None:
|
||||
async def _write_output_to_log(self, process: asyncio.subprocess.Process, log_file: Path, process_id: str) -> None:
|
||||
"""Write process output to log file in background.
|
||||
|
||||
Uses aiofiles for non-blocking file I/O to avoid blocking the event loop.
|
||||
|
||||
@@ -60,14 +60,14 @@ bash "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
@override
|
||||
def get_interpreter(self) -> str:
|
||||
"""Get the bash interpreter.
|
||||
|
||||
|
||||
Respects HANZO_MCP_FORCE_SHELL environment variable to override.
|
||||
"""
|
||||
# Check for forced shell override
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Try to find bash on Windows (Git Bash, WSL, etc.)
|
||||
bash_paths = [
|
||||
|
||||
@@ -28,6 +28,7 @@ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
|
||||
class NodeStatus(Enum):
|
||||
"""Execution status of a DAG node."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
@@ -38,6 +39,7 @@ class NodeStatus(Enum):
|
||||
@dataclass
|
||||
class DagResult:
|
||||
"""Result from a single DAG node execution."""
|
||||
|
||||
node_id: str
|
||||
command: str
|
||||
stdout: str
|
||||
@@ -51,6 +53,7 @@ class DagResult:
|
||||
@dataclass
|
||||
class DagNode:
|
||||
"""A node in the execution DAG."""
|
||||
|
||||
id: str
|
||||
command: Union[str, Dict[str, Any]]
|
||||
depends_on: List[str] = field(default_factory=list)
|
||||
@@ -64,18 +67,18 @@ Command = Union[str, Dict[str, Any], List[Any]]
|
||||
|
||||
class DagTool(BaseTool):
|
||||
"""DAG-based execution with proper dependency ordering.
|
||||
|
||||
|
||||
Execute commands (shell or tools) with directed acyclic graph semantics.
|
||||
Supports serial, parallel, and complex mixed execution patterns.
|
||||
|
||||
|
||||
USAGE PATTERNS:
|
||||
|
||||
|
||||
1. Serial (default) - sequential execution:
|
||||
dag(["ls", "pwd", "git status"])
|
||||
|
||||
|
||||
2. Parallel - concurrent execution:
|
||||
dag(["npm install", "cargo build"], parallel=True)
|
||||
|
||||
|
||||
3. DAG - mixed serial and parallel blocks:
|
||||
dag([
|
||||
"mkdir -p dist", # Step 1
|
||||
@@ -86,13 +89,13 @@ class DagTool(BaseTool):
|
||||
]},
|
||||
"zip -r package.zip dist/" # Step 3 (after all of step 2)
|
||||
])
|
||||
|
||||
|
||||
4. Tool invocations - not just shell:
|
||||
dag([
|
||||
{"tool": "read", "input": {"file_path": "config.json"}},
|
||||
{"tool": "search", "input": {"pattern": "TODO"}},
|
||||
])
|
||||
|
||||
|
||||
5. Named nodes with explicit dependencies:
|
||||
dag([
|
||||
{"id": "setup", "run": "mkdir -p dist"},
|
||||
@@ -100,13 +103,13 @@ class DagTool(BaseTool):
|
||||
{"id": "test", "run": "pytest", "after": ["setup"]},
|
||||
{"id": "package", "run": "tar -czf out.tar.gz dist/", "after": ["copy", "test"]},
|
||||
])
|
||||
|
||||
|
||||
Uses zsh for shell execution.
|
||||
"""
|
||||
|
||||
def __init__(self, tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh"):
|
||||
"""Initialize DAG execution tool.
|
||||
|
||||
|
||||
Args:
|
||||
tools: Dict of available MCP tools for tool invocations
|
||||
default_shell: Default shell (zsh, bash, sh)
|
||||
@@ -114,10 +117,10 @@ class DagTool(BaseTool):
|
||||
super().__init__()
|
||||
self.tools = tools or {}
|
||||
self.default_shell = self._resolve_shell(default_shell)
|
||||
|
||||
|
||||
def _resolve_shell(self, preferred: str) -> str:
|
||||
"""Resolve shell - prefer zsh, fallback to bash.
|
||||
|
||||
|
||||
Search order:
|
||||
1. Homebrew paths (Apple Silicon + Intel)
|
||||
2. Standard system paths
|
||||
@@ -125,15 +128,15 @@ class DagTool(BaseTool):
|
||||
"""
|
||||
# Prefer zsh, then bash
|
||||
shell_priority = ["zsh", "bash"]
|
||||
|
||||
|
||||
# Homebrew + system paths
|
||||
search_paths = [
|
||||
"/opt/homebrew/bin", # Apple Silicon Homebrew
|
||||
"/usr/local/bin", # Intel Homebrew
|
||||
"/bin", # System
|
||||
"/usr/bin", # System
|
||||
"/opt/homebrew/bin", # Apple Silicon Homebrew
|
||||
"/usr/local/bin", # Intel Homebrew
|
||||
"/bin", # System
|
||||
"/usr/bin", # System
|
||||
]
|
||||
|
||||
|
||||
for shell in shell_priority:
|
||||
# Check explicit paths first
|
||||
for prefix in search_paths:
|
||||
@@ -144,7 +147,7 @@ class DagTool(BaseTool):
|
||||
found = shutil.which(shell)
|
||||
if found:
|
||||
return found
|
||||
|
||||
|
||||
return "sh" # Bare minimum fallback
|
||||
|
||||
@property
|
||||
@@ -205,7 +208,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
start_time = datetime.now()
|
||||
node_id = f"shell_{id(cmd)}"
|
||||
process_manager = ProcessManager()
|
||||
|
||||
|
||||
# Defensive check - should never happen but provides better error message
|
||||
if process_manager is None:
|
||||
raise RuntimeError("ProcessManager() returned None - singleton initialization failed")
|
||||
@@ -221,7 +224,9 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
log_file = await process_manager.create_log_file(process_id)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell, "-c", cmd,
|
||||
shell,
|
||||
"-c",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
@@ -229,10 +234,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
exit_code = proc.returncode or 0
|
||||
@@ -270,8 +272,8 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=f"[backgrounded] Process {process_id} (PID {proc.pid}) running in background.\n"
|
||||
f"Use: ps --logs {process_id} # view output\n"
|
||||
f"Use: ps --kill {process_id} # stop process",
|
||||
f"Use: ps --logs {process_id} # view output\n"
|
||||
f"Use: ps --kill {process_id} # stop process",
|
||||
stderr="",
|
||||
status=NodeStatus.SUCCESS, # Backgrounding is success, not failure
|
||||
exit_code=0,
|
||||
@@ -292,11 +294,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
async def _capture_background_output(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
log_file: Path
|
||||
) -> None:
|
||||
async def _capture_background_output(self, proc: asyncio.subprocess.Process, log_file: Path) -> None:
|
||||
"""Capture output from backgrounded process to log file (async)."""
|
||||
try:
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
@@ -322,16 +320,11 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
# Silently handle errors in background capture
|
||||
pass
|
||||
|
||||
async def _run_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_input: Dict[str, Any],
|
||||
ctx: MCPContext
|
||||
) -> DagResult:
|
||||
async def _run_tool(self, tool_name: str, tool_input: Dict[str, Any], ctx: MCPContext) -> DagResult:
|
||||
"""Run an MCP tool invocation."""
|
||||
start_time = datetime.now()
|
||||
node_id = f"tool_{tool_name}"
|
||||
|
||||
|
||||
if tool_name not in self.tools:
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
@@ -343,12 +336,12 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
duration_ms=0,
|
||||
node_type="tool",
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
tool = self.tools[tool_name]
|
||||
result = await tool.call(ctx, **tool_input)
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
|
||||
return DagResult(
|
||||
node_id=node_id,
|
||||
command=f"tool:{tool_name}",
|
||||
@@ -382,36 +375,29 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
timeout: int,
|
||||
) -> DagResult:
|
||||
"""Execute a single DAG node."""
|
||||
|
||||
|
||||
# String command = shell execution
|
||||
if isinstance(cmd, str):
|
||||
return await self._run_shell(cmd, shell, cwd, env, timeout)
|
||||
|
||||
|
||||
# Dict command - could be tool, parallel, or named node
|
||||
if isinstance(cmd, dict):
|
||||
# Tool invocation: {"tool": "name", "input": {...}}
|
||||
if "tool" in cmd:
|
||||
return await self._run_tool(
|
||||
cmd["tool"],
|
||||
cmd.get("input", {}),
|
||||
ctx
|
||||
)
|
||||
|
||||
return await self._run_tool(cmd["tool"], cmd.get("input", {}), ctx)
|
||||
|
||||
# Parallel block: {"parallel": [...]}
|
||||
if "parallel" in cmd:
|
||||
parallel_cmds = cmd["parallel"]
|
||||
tasks = [
|
||||
self._execute_node(c, ctx, shell, cwd, env, timeout)
|
||||
for c in parallel_cmds
|
||||
]
|
||||
tasks = [self._execute_node(c, ctx, shell, cwd, env, timeout) for c in parallel_cmds]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Combine parallel results
|
||||
combined_stdout = []
|
||||
combined_stderr = []
|
||||
all_success = True
|
||||
total_duration = 0
|
||||
|
||||
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
combined_stderr.append(f"[{i}] Error: {r}")
|
||||
@@ -424,7 +410,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
if r.status != NodeStatus.SUCCESS:
|
||||
all_success = False
|
||||
total_duration = max(total_duration, r.duration_ms)
|
||||
|
||||
|
||||
return DagResult(
|
||||
node_id=f"parallel_{len(parallel_cmds)}",
|
||||
command=f"parallel[{len(parallel_cmds)} tasks]",
|
||||
@@ -435,12 +421,12 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
duration_ms=total_duration,
|
||||
node_type="parallel",
|
||||
)
|
||||
|
||||
|
||||
# Named node: {"id": "x", "run": "...", "after": [...]}
|
||||
if "run" in cmd:
|
||||
run_cmd = cmd["run"]
|
||||
return await self._execute_node(run_cmd, ctx, shell, cwd, env, timeout)
|
||||
|
||||
|
||||
# Unknown dict format
|
||||
return DagResult(
|
||||
node_id="unknown",
|
||||
@@ -452,7 +438,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
duration_ms=0,
|
||||
node_type="unknown",
|
||||
)
|
||||
|
||||
|
||||
# Unknown type
|
||||
return DagResult(
|
||||
node_id="unknown",
|
||||
@@ -481,7 +467,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Execute commands with DAG semantics.
|
||||
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
commands: List of commands (strings, tool dicts, or parallel blocks)
|
||||
@@ -492,25 +478,23 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
timeout: Timeout per command in seconds
|
||||
strict: Stop on first error
|
||||
quiet: Suppress stdout in output
|
||||
|
||||
|
||||
Returns:
|
||||
Combined execution output
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
tool_ctx.set_tool_info(self.name)
|
||||
|
||||
|
||||
shell = shell or self.default_shell
|
||||
results: List[DagResult] = []
|
||||
|
||||
|
||||
if parallel:
|
||||
# Run all commands in parallel
|
||||
tasks = [
|
||||
self._execute_node(cmd, ctx, shell, cwd, env, timeout)
|
||||
for cmd in commands
|
||||
]
|
||||
tasks = [self._execute_node(cmd, ctx, shell, cwd, env, timeout) for cmd in commands]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [
|
||||
r if not isinstance(r, Exception)
|
||||
r
|
||||
if not isinstance(r, Exception)
|
||||
else DagResult(
|
||||
node_id=f"error_{i}",
|
||||
command=str(commands[i]),
|
||||
@@ -528,11 +512,11 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
for cmd in commands:
|
||||
result = await self._execute_node(cmd, ctx, shell, cwd, env, timeout)
|
||||
results.append(result)
|
||||
|
||||
|
||||
# Stop on error if strict
|
||||
if strict and result.status == NodeStatus.FAILED:
|
||||
break
|
||||
|
||||
|
||||
# Format output
|
||||
return self._format_output(results, quiet)
|
||||
|
||||
@@ -541,26 +525,26 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
output_parts = []
|
||||
total_duration = 0
|
||||
failed_count = 0
|
||||
|
||||
|
||||
for r in results:
|
||||
total_duration += r.duration_ms
|
||||
|
||||
|
||||
if r.status == NodeStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
|
||||
# Add stdout unless quiet
|
||||
if r.stdout and not quiet:
|
||||
output_parts.append(r.stdout.rstrip())
|
||||
|
||||
|
||||
# Always show stderr
|
||||
if r.stderr:
|
||||
output_parts.append(f"[stderr] {r.stderr.rstrip()}")
|
||||
|
||||
|
||||
# Add summary for multi-command execution
|
||||
if len(results) > 1:
|
||||
status = "✓" if failed_count == 0 else f"✗ ({failed_count} failed)"
|
||||
output_parts.append(f"\n[dag] {len(results)} nodes, {total_duration}ms, {status}")
|
||||
|
||||
|
||||
return "\n".join(output_parts) if output_parts else "(no output)"
|
||||
|
||||
@override
|
||||
@@ -571,37 +555,15 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def dag_handler(
|
||||
commands: Annotated[
|
||||
List[Any],
|
||||
Field(description="Commands to execute (strings, tool dicts, or parallel blocks)")
|
||||
List[Any], Field(description="Commands to execute (strings, tool dicts, or parallel blocks)")
|
||||
],
|
||||
parallel: Annotated[
|
||||
bool,
|
||||
Field(description="Run all commands in parallel", default=False)
|
||||
] = False,
|
||||
shell: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Shell to use (default: zsh)", default=None)
|
||||
] = None,
|
||||
cwd: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Working directory", default=None)
|
||||
] = None,
|
||||
env: Annotated[
|
||||
Optional[Dict[str, str]],
|
||||
Field(description="Environment variables", default=None)
|
||||
] = None,
|
||||
timeout: Annotated[
|
||||
int,
|
||||
Field(description="Timeout per command (seconds)", default=120)
|
||||
] = 120,
|
||||
strict: Annotated[
|
||||
bool,
|
||||
Field(description="Stop on first error", default=False)
|
||||
] = False,
|
||||
quiet: Annotated[
|
||||
bool,
|
||||
Field(description="Suppress stdout", default=False)
|
||||
] = False,
|
||||
parallel: Annotated[bool, Field(description="Run all commands in parallel", default=False)] = False,
|
||||
shell: Annotated[Optional[str], Field(description="Shell to use (default: zsh)", default=None)] = None,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=120)] = 120,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_self.call(
|
||||
@@ -617,9 +579,6 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
)
|
||||
|
||||
|
||||
def create_dag_tool(
|
||||
tools: Optional[Dict[str, BaseTool]] = None,
|
||||
default_shell: str = "zsh"
|
||||
) -> DagTool:
|
||||
def create_dag_tool(tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh") -> DagTool:
|
||||
"""Factory to create DAG execution tool."""
|
||||
return DagTool(tools, default_shell)
|
||||
|
||||
@@ -13,10 +13,9 @@ import warnings
|
||||
|
||||
# Emit deprecation warning on import
|
||||
warnings.warn(
|
||||
"npx_background.py uses blocking subprocess.Popen. "
|
||||
"Consider using npx_tool.NpxTool for async execution.",
|
||||
"npx_background.py uses blocking subprocess.Popen. Consider using npx_tool.NpxTool for async execution.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -124,16 +124,12 @@ Examples:
|
||||
# Show all system processes
|
||||
await tool_ctx.info("Listing all system processes")
|
||||
# Run in thread pool to avoid blocking event loop
|
||||
return await asyncio.to_thread(
|
||||
self._list_system_processes, filter_name, show_details
|
||||
)
|
||||
return await asyncio.to_thread(self._list_system_processes, filter_name, show_details)
|
||||
else:
|
||||
# Show only background processes
|
||||
await tool_ctx.info("Listing background processes")
|
||||
# Run in thread pool to avoid blocking event loop
|
||||
return await asyncio.to_thread(
|
||||
self._list_background_processes, filter_name, show_details
|
||||
)
|
||||
return await asyncio.to_thread(self._list_background_processes, filter_name, show_details)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Failed to list processes: {str(e)}")
|
||||
|
||||
@@ -24,6 +24,7 @@ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Process information."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
cmd: str
|
||||
@@ -35,9 +36,9 @@ class ProcessInfo:
|
||||
|
||||
class PsTool(BaseTool):
|
||||
"""Process management - list, kill, logs.
|
||||
|
||||
|
||||
USAGE:
|
||||
|
||||
|
||||
ps() # List all processes
|
||||
ps(id="abc123") # Get specific process info
|
||||
ps(kill="abc123") # Kill process (SIGTERM)
|
||||
@@ -45,17 +46,17 @@ class PsTool(BaseTool):
|
||||
ps(logs="abc123") # Get process logs
|
||||
ps(logs="abc123", n=50) # Last 50 lines
|
||||
"""
|
||||
|
||||
|
||||
_instance: Optional["PsTool"] = None
|
||||
_process_manager: Optional[ProcessManager] = None
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton - share process manager across instances."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._process_manager = ProcessManager()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.process_manager = self._process_manager
|
||||
@@ -82,14 +83,16 @@ USAGE:
|
||||
"""Get all tracked processes."""
|
||||
processes = []
|
||||
for proc_id, info in self.process_manager.list_processes().items():
|
||||
processes.append(ProcessInfo(
|
||||
id=proc_id,
|
||||
pid=info.get("pid", 0),
|
||||
cmd=info.get("cmd", ""),
|
||||
running=info.get("running", False),
|
||||
exit_code=info.get("return_code"),
|
||||
log_file=Path(info["log_file"]) if info.get("log_file") else None,
|
||||
))
|
||||
processes.append(
|
||||
ProcessInfo(
|
||||
id=proc_id,
|
||||
pid=info.get("pid", 0),
|
||||
cmd=info.get("cmd", ""),
|
||||
running=info.get("running", False),
|
||||
exit_code=info.get("return_code"),
|
||||
log_file=Path(info["log_file"]) if info.get("log_file") else None,
|
||||
)
|
||||
)
|
||||
return processes
|
||||
|
||||
def _get_process(self, proc_id: str) -> Optional[ProcessInfo]:
|
||||
@@ -104,7 +107,7 @@ USAGE:
|
||||
process = self.process_manager.get_process(proc_id)
|
||||
if not process:
|
||||
return f"Process not found: {proc_id}"
|
||||
|
||||
|
||||
try:
|
||||
process.send_signal(sig)
|
||||
sig_name = signal.Signals(sig).name
|
||||
@@ -119,15 +122,15 @@ USAGE:
|
||||
log_file = self.process_manager.get_log_file(proc_id)
|
||||
if not log_file or not log_file.exists():
|
||||
return f"No logs for process: {proc_id}"
|
||||
|
||||
|
||||
try:
|
||||
async with aiofiles.open(log_file, "r") as f:
|
||||
content = await f.read()
|
||||
lines = content.splitlines()
|
||||
|
||||
|
||||
if len(lines) > n:
|
||||
lines = lines[-n:]
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"Error reading logs: {e}"
|
||||
@@ -136,15 +139,15 @@ USAGE:
|
||||
"""Format process list for display."""
|
||||
if not processes:
|
||||
return "No background processes"
|
||||
|
||||
|
||||
lines = ["PID ID STATUS CMD"]
|
||||
lines.append("-" * 60)
|
||||
|
||||
|
||||
for p in processes:
|
||||
status = "running" if p.running else f"exit({p.exit_code})"
|
||||
cmd = p.cmd[:30] + "..." if len(p.cmd) > 30 else p.cmd
|
||||
lines.append(f"{p.pid:<7} {p.id:<15} {status:<9} {cmd}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@override
|
||||
@@ -160,7 +163,7 @@ USAGE:
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Process management.
|
||||
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
id: Get specific process info
|
||||
@@ -168,18 +171,18 @@ USAGE:
|
||||
logs: Get logs for process
|
||||
sig: Signal number for kill (default: 15/SIGTERM)
|
||||
n: Number of log lines to show
|
||||
|
||||
|
||||
Returns:
|
||||
Process information or action result
|
||||
"""
|
||||
# Kill action
|
||||
if kill:
|
||||
return await self._kill_process(kill, sig)
|
||||
|
||||
|
||||
# Logs action
|
||||
if logs:
|
||||
return await self._get_logs(logs, n)
|
||||
|
||||
|
||||
# Get specific process
|
||||
if id:
|
||||
proc = self._get_process(id)
|
||||
@@ -187,7 +190,7 @@ USAGE:
|
||||
return f"Process not found: {id}"
|
||||
status = "running" if proc.running else f"exited ({proc.exit_code})"
|
||||
return f"ID: {proc.id}\nPID: {proc.pid}\nStatus: {status}\nCommand: {proc.cmd}"
|
||||
|
||||
|
||||
# Default: list all
|
||||
return self._format_list(self._list_processes())
|
||||
|
||||
@@ -198,26 +201,11 @@ USAGE:
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def ps_handler(
|
||||
id: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Get specific process by ID", default=None)
|
||||
] = None,
|
||||
kill: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Kill process by ID", default=None)
|
||||
] = None,
|
||||
logs: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Get logs for process ID", default=None)
|
||||
] = None,
|
||||
sig: Annotated[
|
||||
int,
|
||||
Field(description="Signal for kill (default: 15/SIGTERM)", default=15)
|
||||
] = 15,
|
||||
n: Annotated[
|
||||
int,
|
||||
Field(description="Number of log lines", default=100)
|
||||
] = 100,
|
||||
id: Annotated[Optional[str], Field(description="Get specific process by ID", default=None)] = None,
|
||||
kill: Annotated[Optional[str], Field(description="Kill process by ID", default=None)] = None,
|
||||
logs: Annotated[Optional[str], Field(description="Get logs for process ID", default=None)] = None,
|
||||
sig: Annotated[int, Field(description="Signal for kill (default: 15/SIGTERM)", default=15)] = 15,
|
||||
n: Annotated[int, Field(description="Number of log lines", default=100)] = 100,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, id=id, kill=kill, logs=logs, sig=sig, n=n)
|
||||
|
||||
@@ -16,7 +16,7 @@ warnings.warn(
|
||||
"run_background.py uses blocking subprocess.Popen. "
|
||||
"Consider using base_process.BaseProcessTool for async execution.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import os
|
||||
|
||||
@@ -575,7 +575,10 @@ class StreamingCommandTool(BaseProcessTool):
|
||||
try:
|
||||
# Use tail command for efficiency (async to avoid blocking)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"tail", "-n", str(lines or 20), str(output_file),
|
||||
"tail",
|
||||
"-n",
|
||||
str(lines or 20),
|
||||
str(output_file),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -13,10 +13,9 @@ import warnings
|
||||
|
||||
# Emit deprecation warning on import
|
||||
warnings.warn(
|
||||
"uvx_background.py uses blocking subprocess. "
|
||||
"Consider using uvx_tool.UvxTool for async execution.",
|
||||
"uvx_background.py uses blocking subprocess. Consider using uvx_tool.UvxTool for async execution.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -67,14 +67,14 @@ zsh "npm run dev" --cwd ./frontend # Auto-backgrounds if needed"""
|
||||
@override
|
||||
def get_interpreter(self) -> str:
|
||||
"""Get the zsh interpreter path.
|
||||
|
||||
|
||||
Respects HANZO_MCP_FORCE_SHELL environment variable to override.
|
||||
"""
|
||||
# Check for forced shell override
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Try to find zsh on Windows (WSL, Git Bash, etc.)
|
||||
zsh_paths = [
|
||||
@@ -153,14 +153,14 @@ class ShellTool(BaseScriptTool):
|
||||
|
||||
def _detect_best_shell(self) -> str:
|
||||
"""Detect the best available shell.
|
||||
|
||||
|
||||
Respects HANZO_MCP_FORCE_SHELL environment variable to override.
|
||||
"""
|
||||
# Check for forced shell override first
|
||||
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
|
||||
if force_shell:
|
||||
return force_shell
|
||||
|
||||
|
||||
# Check for zsh first
|
||||
if shutil.which("zsh"):
|
||||
# Also check if .zshrc exists
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
@dataclass
|
||||
class MCPResourceDocument:
|
||||
"""Resource document returned by MCP tools.
|
||||
|
||||
|
||||
Output format options:
|
||||
- to_json_string(): Clean JSON without outer wrapper (default)
|
||||
- to_readable_string(): Human-readable formatted text
|
||||
@@ -27,16 +27,17 @@ class MCPResourceDocument:
|
||||
def to_json_string(self) -> str:
|
||||
"""Convert to clean JSON string - just the data, not wrapped."""
|
||||
import json
|
||||
|
||||
# Return just the data content, not wrapped in {"data": ...}
|
||||
# This makes output cleaner when MCP adds its own wrapper
|
||||
return json.dumps({"result": self.data}, indent=2)
|
||||
|
||||
|
||||
def to_readable_string(self) -> str:
|
||||
"""Convert to human-readable formatted string for display."""
|
||||
import json
|
||||
|
||||
|
||||
lines = []
|
||||
|
||||
|
||||
# Format the main data
|
||||
if isinstance(self.data, dict):
|
||||
# Handle common result structures
|
||||
@@ -44,7 +45,7 @@ class MCPResourceDocument:
|
||||
results = self.data["results"]
|
||||
stats = self.data.get("stats", {})
|
||||
pagination = self.data.get("pagination", {})
|
||||
|
||||
|
||||
# Header with stats
|
||||
if stats:
|
||||
query = stats.get("query", "")
|
||||
@@ -56,7 +57,7 @@ class MCPResourceDocument:
|
||||
else:
|
||||
lines.append(f"# Found {total} results")
|
||||
lines.append("")
|
||||
|
||||
|
||||
# Results
|
||||
for i, result in enumerate(results[:50], 1): # Limit display
|
||||
if isinstance(result, dict):
|
||||
@@ -64,7 +65,7 @@ class MCPResourceDocument:
|
||||
line = result.get("line", "")
|
||||
match = result.get("match", result.get("text", ""))
|
||||
rtype = result.get("type", "")
|
||||
|
||||
|
||||
if file_path:
|
||||
loc = f"{file_path}:{line}" if line else file_path
|
||||
lines.append(f"{i}. {loc}")
|
||||
@@ -78,7 +79,7 @@ class MCPResourceDocument:
|
||||
lines.append(f"{i}. {json.dumps(result)}")
|
||||
else:
|
||||
lines.append(f"{i}. {result}")
|
||||
|
||||
|
||||
# Pagination info
|
||||
if pagination:
|
||||
page = pagination.get("page", 1)
|
||||
@@ -97,12 +98,12 @@ class MCPResourceDocument:
|
||||
else:
|
||||
# Non-dict data - just dump as JSON
|
||||
lines.append(json.dumps(self.data, indent=2))
|
||||
|
||||
|
||||
# Add metadata footer if present
|
||||
if self.metadata:
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
for key, value in self.metadata.items():
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -11,33 +11,34 @@ from typing import Optional
|
||||
|
||||
def configure_event_loop(*, quiet: bool = False) -> Optional[str]:
|
||||
"""Configure the event loop with uvloop if available.
|
||||
|
||||
|
||||
This should be called early in the application startup, before
|
||||
any async code runs.
|
||||
|
||||
|
||||
Args:
|
||||
quiet: If True, suppress info messages about uvloop status
|
||||
|
||||
|
||||
Returns:
|
||||
The name of the event loop policy being used, or None if default
|
||||
"""
|
||||
# uvloop is not available on Windows
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
|
||||
# Install uvloop as the default event loop policy
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
|
||||
if not quiet:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Using uvloop {uvloop.__version__} for event loop")
|
||||
|
||||
|
||||
return f"uvloop-{uvloop.__version__}"
|
||||
|
||||
|
||||
except ImportError:
|
||||
# uvloop not installed, use default asyncio event loop
|
||||
return None
|
||||
@@ -45,7 +46,7 @@ def configure_event_loop(*, quiet: bool = False) -> Optional[str]:
|
||||
|
||||
def get_event_loop_info() -> dict:
|
||||
"""Get information about the current event loop configuration.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with event loop details
|
||||
"""
|
||||
@@ -53,24 +54,22 @@ def get_event_loop_info() -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
|
||||
info = {
|
||||
"loop_class": type(loop).__name__ if loop else None,
|
||||
"loop_module": type(loop).__module__ if loop else None,
|
||||
"platform": sys.platform,
|
||||
}
|
||||
|
||||
|
||||
# Check if uvloop is being used
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
info["uvloop_available"] = True
|
||||
info["uvloop_version"] = uvloop.__version__
|
||||
info["using_uvloop"] = isinstance(
|
||||
asyncio.get_event_loop_policy(),
|
||||
uvloop.EventLoopPolicy
|
||||
)
|
||||
info["using_uvloop"] = isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy)
|
||||
except ImportError:
|
||||
info["uvloop_available"] = False
|
||||
info["using_uvloop"] = False
|
||||
|
||||
|
||||
return info
|
||||
|
||||
@@ -268,11 +268,11 @@ class TestPaginationPerformance:
|
||||
|
||||
# Test pagination creation with new API
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# Use the new AutoPaginatedResponse API
|
||||
content_str = "\n".join([str(item) for item in large_data])
|
||||
response = AutoPaginatedResponse.create_response(content_str)
|
||||
|
||||
|
||||
pagination_time = time.time() - start_time
|
||||
|
||||
assert "content" in response # New API returns content dict
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestRefactorTool:
|
||||
def temp_python_file(self):
|
||||
"""Create a temporary Python file for testing."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''def old_function(x, y):
|
||||
f.write("""def old_function(x, y):
|
||||
result = x + y
|
||||
return result
|
||||
|
||||
@@ -30,7 +30,7 @@ def caller():
|
||||
class MyClass:
|
||||
def method(self):
|
||||
return old_function(3, 4)
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
yield temp_path
|
||||
@@ -43,7 +43,7 @@ class MyClass:
|
||||
def temp_js_file(self):
|
||||
"""Create a temporary JavaScript file for testing."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f:
|
||||
f.write('''function oldFunction(x, y) {
|
||||
f.write("""function oldFunction(x, y) {
|
||||
const result = x + y;
|
||||
return result;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function caller() {
|
||||
const value = oldFunction(1, 2);
|
||||
return value;
|
||||
}
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
yield temp_path
|
||||
@@ -209,7 +209,7 @@ function caller() {
|
||||
async def test_organize_imports_python(self, tool):
|
||||
"""Test organize imports for Python file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''import os
|
||||
f.write("""import os
|
||||
from typing import List
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
@@ -217,7 +217,7 @@ import json
|
||||
|
||||
def main():
|
||||
pass
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
@@ -255,13 +255,13 @@ def main():
|
||||
async def test_change_signature_add_parameter_preview(self, tool):
|
||||
"""Test adding a parameter with preview."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''def greet(name):
|
||||
f.write("""def greet(name):
|
||||
return f"Hello, {name}!"
|
||||
|
||||
def main():
|
||||
msg = greet("World")
|
||||
print(msg)
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
@@ -289,11 +289,11 @@ def main():
|
||||
async def test_change_signature_rename_parameter(self, tool):
|
||||
"""Test renaming a parameter."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''def compute(x, y):
|
||||
f.write("""def compute(x, y):
|
||||
return x + y
|
||||
|
||||
result = compute(1, 2)
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
@@ -315,9 +315,9 @@ result = compute(1, 2)
|
||||
async def test_change_signature_no_function(self, tool):
|
||||
"""Test change_signature when no function at line."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''x = 10
|
||||
f.write("""x = 10
|
||||
y = 20
|
||||
''')
|
||||
""")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
|
||||
@@ -201,9 +201,9 @@ async def test_search_no_vector():
|
||||
# Vector search has been removed - tool should be fast and lightweight
|
||||
search_tool = create_search_tool()
|
||||
# No vector-related attributes should exist
|
||||
assert not hasattr(search_tool, '_enable_vector_index')
|
||||
assert not hasattr(search_tool, 'embedder')
|
||||
assert not hasattr(search_tool, 'vector_db')
|
||||
assert not hasattr(search_tool, "_enable_vector_index")
|
||||
assert not hasattr(search_tool, "embedder")
|
||||
assert not hasattr(search_tool, "vector_db")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -24,8 +24,8 @@ import pytest
|
||||
# Maximum allowed import times (in seconds)
|
||||
# These are intentionally generous to avoid flaky tests
|
||||
MAX_MODULE_IMPORT_TIME = 0.5 # hanzo_mcp.tools module
|
||||
MAX_TOTAL_IMPORT_TIME = 1.0 # All core imports combined
|
||||
MAX_CLI_STARTUP_TIME = 3.0 # CLI --help should respond quickly
|
||||
MAX_TOTAL_IMPORT_TIME = 1.0 # All core imports combined
|
||||
MAX_CLI_STARTUP_TIME = 3.0 # CLI --help should respond quickly
|
||||
|
||||
|
||||
class TestImportPerformance:
|
||||
@@ -37,13 +37,13 @@ class TestImportPerformance:
|
||||
This is critical because slow imports cause MCP to hang.
|
||||
The tools module must NOT import heavy dependencies at load time.
|
||||
"""
|
||||
code = '''
|
||||
code = """
|
||||
import time
|
||||
start = time.time()
|
||||
import hanzo_mcp.tools
|
||||
elapsed = time.time() - start
|
||||
print(f"ELAPSED:{elapsed:.3f}")
|
||||
'''
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
@@ -69,7 +69,7 @@ print(f"ELAPSED:{elapsed:.3f}")
|
||||
|
||||
def test_no_heavy_imports_at_module_level(self):
|
||||
"""Test that heavy packages are NOT imported at module load time."""
|
||||
code = '''
|
||||
code = """
|
||||
import sys
|
||||
# Clear any cached modules
|
||||
for mod in list(sys.modules.keys()):
|
||||
@@ -90,7 +90,7 @@ if heavy_modules:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("PASS:No heavy modules imported")
|
||||
'''
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
@@ -105,7 +105,7 @@ else:
|
||||
|
||||
def test_total_import_time(self):
|
||||
"""Test that all core imports combined are fast."""
|
||||
code = '''
|
||||
code = """
|
||||
import time
|
||||
start = time.time()
|
||||
import hanzo_mcp
|
||||
@@ -113,7 +113,7 @@ import hanzo_mcp.tools
|
||||
from hanzo_mcp.server import create_server
|
||||
elapsed = time.time() - start
|
||||
print(f"ELAPSED:{elapsed:.3f}")
|
||||
'''
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
@@ -152,8 +152,7 @@ class TestCLIPerformance:
|
||||
|
||||
assert result.returncode == 0, f"CLI help failed: {result.stderr}"
|
||||
assert elapsed < MAX_CLI_STARTUP_TIME, (
|
||||
f"CLI --help took {elapsed:.2f}s (max: {MAX_CLI_STARTUP_TIME}s). "
|
||||
"This is too slow for MCP connections."
|
||||
f"CLI --help took {elapsed:.2f}s (max: {MAX_CLI_STARTUP_TIME}s). This is too slow for MCP connections."
|
||||
)
|
||||
|
||||
def test_cli_does_not_hang(self):
|
||||
@@ -171,10 +170,7 @@ class TestCLIPerformance:
|
||||
)
|
||||
assert "hanzo" in result.stdout.lower() or "mcp" in result.stdout.lower()
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail(
|
||||
"CLI hung for more than 10 seconds! "
|
||||
"This indicates slow imports causing MCP to hang."
|
||||
)
|
||||
pytest.fail("CLI hung for more than 10 seconds! This indicates slow imports causing MCP to hang.")
|
||||
|
||||
|
||||
class TestLazyImportPattern:
|
||||
@@ -182,7 +178,7 @@ class TestLazyImportPattern:
|
||||
|
||||
def test_memory_tools_use_lazy_import(self):
|
||||
"""Test that memory tools use lazy imports."""
|
||||
code = '''
|
||||
code = """
|
||||
import sys
|
||||
|
||||
# Import the memory tools module
|
||||
@@ -193,7 +189,7 @@ if 'hanzo_memory' in sys.modules:
|
||||
print("FAIL:hanzo_memory imported at module load")
|
||||
sys.exit(1)
|
||||
print("PASS:hanzo_memory not imported at module load")
|
||||
'''
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
@@ -203,17 +199,16 @@ print("PASS:hanzo_memory not imported at module load")
|
||||
|
||||
if "FAIL:" in result.stdout:
|
||||
pytest.fail(
|
||||
"memory_tools.py imports hanzo_memory at module load time. "
|
||||
"Use lazy imports with TYPE_CHECKING pattern."
|
||||
"memory_tools.py imports hanzo_memory at module load time. Use lazy imports with TYPE_CHECKING pattern."
|
||||
)
|
||||
|
||||
def test_search_tool_no_heavy_deps(self):
|
||||
"""Test that search tool does not import heavy ML dependencies.
|
||||
|
||||
|
||||
Vector search with sentence_transformers has been removed to keep MCP lightweight.
|
||||
If semantic search is needed, use external services (hanzo-node, hanzo desktop).
|
||||
"""
|
||||
code = '''
|
||||
code = """
|
||||
import sys
|
||||
|
||||
# Import the search tool module
|
||||
@@ -224,7 +219,7 @@ if 'sentence_transformers' in sys.modules:
|
||||
print("FAIL:sentence_transformers imported - should be removed from search_tool")
|
||||
sys.exit(1)
|
||||
print("PASS:no heavy ML dependencies in search_tool")
|
||||
'''
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
@@ -234,8 +229,7 @@ print("PASS:no heavy ML dependencies in search_tool")
|
||||
|
||||
if "FAIL:" in result.stdout:
|
||||
pytest.fail(
|
||||
"search_tool.py imports sentence_transformers. "
|
||||
"Vector search has been removed - keep MCP lightweight."
|
||||
"search_tool.py imports sentence_transformers. Vector search has been removed - keep MCP lightweight."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -12,7 +12,7 @@ Install:
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.agent import register_tools, TOOLS
|
||||
|
||||
|
||||
# Register with MCP server
|
||||
register_tools(mcp_server)
|
||||
|
||||
@@ -44,12 +44,14 @@ _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}")
|
||||
@@ -101,7 +103,7 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
|
||||
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:
|
||||
|
||||
@@ -163,7 +163,7 @@ class MCPToolAdapter(Tool):
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -462,11 +462,11 @@ AGENT RESPONSES:
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
|
||||
@@ -48,7 +48,7 @@ def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""
|
||||
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]
|
||||
@@ -56,7 +56,7 @@ def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,7 @@ from hanzo_tools.core import BaseTool
|
||||
# Playwright import with graceful fallback
|
||||
try:
|
||||
from playwright.async_api import Page, Browser, Playwright, BrowserContext, async_playwright
|
||||
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
@@ -32,18 +33,18 @@ 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)
|
||||
"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"),
|
||||
]
|
||||
@@ -95,15 +96,10 @@ class BrowserPool:
|
||||
) -> Page:
|
||||
"""Ensure browser is running, return current page."""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"Playwright not installed. Run: pip install playwright && playwright install chromium"
|
||||
)
|
||||
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
|
||||
not self._initialized or self._page is None or self._browser is None or self._cdp_endpoint != cdp_endpoint
|
||||
)
|
||||
|
||||
if needs_init:
|
||||
@@ -413,10 +409,7 @@ Examples:
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(pool.pages),
|
||||
"tabs": [
|
||||
{"index": i, "url": p.url}
|
||||
for i, p in enumerate(pool.pages)
|
||||
],
|
||||
"tabs": [{"index": i, "url": p.url} for i, p in enumerate(pool.pages)],
|
||||
}
|
||||
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -16,6 +16,7 @@ _tools = []
|
||||
|
||||
try:
|
||||
from .config_tool import ConfigTool
|
||||
|
||||
_tools.append(ConfigTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ConfigTool not available: {e}")
|
||||
@@ -23,6 +24,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .mode_tool import ModeTool
|
||||
|
||||
_tools.append(ModeTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ModeTool not available: {e}")
|
||||
@@ -41,14 +43,14 @@ __all__ = [
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register config tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
tool_name = getattr(tool_class, "name", tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
@@ -56,5 +58,5 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
|
||||
return registered
|
||||
|
||||
@@ -47,15 +47,11 @@ def with_error_logging(tool_name: str) -> Callable:
|
||||
)
|
||||
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."
|
||||
f"Error executing tool '{tool_name}': {error_msg}\n\nCheck 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 f"Error executing tool '{tool_name}': {str(e)}\n\nCheck logs at ~/.hanzo/mcp/logs/ for details."
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -73,10 +69,7 @@ def handle_connection_errors(
|
||||
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"]
|
||||
):
|
||||
if any(name in error_name for name in ["ClosedResourceError", "ConnectionError", "BrokenPipeError"]):
|
||||
return f"Client disconnected during operation: {error_name}"
|
||||
raise
|
||||
|
||||
@@ -88,20 +81,20 @@ class BaseTool(ABC):
|
||||
|
||||
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(...):
|
||||
@@ -164,6 +157,7 @@ class FileSystemTool(BaseTool, ABC):
|
||||
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:
|
||||
@@ -178,7 +172,7 @@ class ToolRegistry:
|
||||
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
|
||||
@@ -188,8 +182,9 @@ class ToolRegistry:
|
||||
"""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:
|
||||
@@ -209,9 +204,10 @@ class ToolRegistry:
|
||||
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)
|
||||
|
||||
@@ -12,44 +12,44 @@ 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
|
||||
@@ -57,10 +57,10 @@ class ToolContext:
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@ DEFAULT_TIMEOUTS: dict[str, int] = {
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -30,7 +30,7 @@ def get_timeout(tool_name: str) -> int:
|
||||
return int(env_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
return DEFAULT_TIMEOUTS.get(tool_name, DEFAULT_TIMEOUTS["default"])
|
||||
|
||||
|
||||
@@ -39,19 +39,20 @@ def auto_timeout(
|
||||
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),
|
||||
@@ -59,9 +60,9 @@ def auto_timeout(
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return f"Tool '{tool_name}' timed out after {effective_timeout}s"
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -72,19 +73,20 @@ def retry(
|
||||
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)
|
||||
@@ -93,9 +95,9 @@ def retry(
|
||||
if attempt < max_attempts - 1:
|
||||
await asyncio.sleep(current_delay)
|
||||
current_delay *= backoff
|
||||
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -7,17 +7,17 @@ from pathlib import Path
|
||||
|
||||
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')
|
||||
@@ -28,48 +28,45 @@ class PermissionManager:
|
||||
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',
|
||||
".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
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -77,11 +74,11 @@ class PermissionManager:
|
||||
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)
|
||||
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
||||
@dataclass
|
||||
class MCPResourceDocument:
|
||||
"""Resource document returned by MCP tools.
|
||||
|
||||
|
||||
Output format options:
|
||||
- to_json_string(): Clean JSON format (default for structured data)
|
||||
- to_readable_string(): Human-readable formatted text for display
|
||||
@@ -29,21 +29,21 @@ class MCPResourceDocument:
|
||||
"""Convert to clean JSON string."""
|
||||
# Return wrapped in "result" for consistency
|
||||
return json.dumps({"result": self.data}, indent=2)
|
||||
|
||||
|
||||
def to_readable_string(self) -> str:
|
||||
"""Convert to human-readable formatted string for display.
|
||||
|
||||
|
||||
Optimized for readability in Claude Code output panels.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
|
||||
|
||||
if isinstance(self.data, dict):
|
||||
# Handle search/find results with "results" array
|
||||
if "results" in self.data:
|
||||
results = self.data["results"]
|
||||
stats = self.data.get("stats", {})
|
||||
pagination = self.data.get("pagination", {})
|
||||
|
||||
|
||||
# Header with stats
|
||||
if stats:
|
||||
query = stats.get("query", stats.get("pattern", ""))
|
||||
@@ -60,7 +60,7 @@ class MCPResourceDocument:
|
||||
else:
|
||||
lines.append(f"# Found {len(results)} results")
|
||||
lines.append("")
|
||||
|
||||
|
||||
# Format each result
|
||||
for i, result in enumerate(results[:50], 1):
|
||||
if isinstance(result, dict):
|
||||
@@ -69,7 +69,7 @@ class MCPResourceDocument:
|
||||
line_num = result.get("line", result.get("line_number", ""))
|
||||
match_text = result.get("match", result.get("text", result.get("content", "")))
|
||||
result_type = result.get("type", "")
|
||||
|
||||
|
||||
if file_path:
|
||||
loc = f"{file_path}:{line_num}" if line_num else file_path
|
||||
lines.append(f"{i}. {loc}")
|
||||
@@ -83,7 +83,7 @@ class MCPResourceDocument:
|
||||
lines.append(f"{i}. {json.dumps(result, default=str)}")
|
||||
else:
|
||||
lines.append(f"{i}. {result}")
|
||||
|
||||
|
||||
# Show pagination info
|
||||
if pagination:
|
||||
page = pagination.get("page", 1)
|
||||
@@ -92,7 +92,7 @@ class MCPResourceDocument:
|
||||
if has_next and total > 0:
|
||||
total_pages = (total // 50) + 1
|
||||
lines.append(f"\n... page {page} of {total_pages} ({total} total)")
|
||||
|
||||
|
||||
# Handle command execution results
|
||||
elif "output" in self.data or "stdout" in self.data or "stderr" in self.data:
|
||||
# Shell command output
|
||||
@@ -100,28 +100,28 @@ class MCPResourceDocument:
|
||||
stdout = self.data.get("output", self.data.get("stdout", ""))
|
||||
stderr = self.data.get("stderr", "")
|
||||
elapsed = self.data.get("elapsed", self.data.get("time_ms", ""))
|
||||
|
||||
|
||||
if exit_code == 0:
|
||||
lines.append(f"✓ Command succeeded")
|
||||
else:
|
||||
lines.append(f"✗ Command failed (exit {exit_code})")
|
||||
|
||||
|
||||
if elapsed:
|
||||
lines.append(f"Time: {elapsed}ms" if isinstance(elapsed, (int, float)) else f"Time: {elapsed}")
|
||||
lines.append("")
|
||||
|
||||
|
||||
if stdout:
|
||||
lines.append(stdout.rstrip())
|
||||
if stderr:
|
||||
lines.append("\n--- stderr ---")
|
||||
lines.append(stderr.rstrip())
|
||||
|
||||
|
||||
# Handle error results
|
||||
elif "error" in self.data:
|
||||
lines.append(f"Error: {self.data['error']}")
|
||||
if "details" in self.data:
|
||||
lines.append(f"Details: {self.data['details']}")
|
||||
|
||||
|
||||
# Generic dict - format as key-value pairs
|
||||
else:
|
||||
for key, value in self.data.items():
|
||||
@@ -130,7 +130,7 @@ class MCPResourceDocument:
|
||||
lines.append(json.dumps(value, indent=2, default=str))
|
||||
else:
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
|
||||
elif isinstance(self.data, list):
|
||||
# List data - format as numbered items
|
||||
for i, item in enumerate(self.data[:50], 1):
|
||||
@@ -140,16 +140,16 @@ class MCPResourceDocument:
|
||||
lines.append(f"{i}. {item}")
|
||||
if len(self.data) > 50:
|
||||
lines.append(f"\n... {len(self.data) - 50} more items")
|
||||
|
||||
|
||||
else:
|
||||
# Scalar or other - just convert to string
|
||||
lines.append(str(self.data))
|
||||
|
||||
|
||||
# Add metadata footer if present
|
||||
if self.metadata:
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
for key, value in self.metadata.items():
|
||||
lines.append(f"{key}: {value}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -8,9 +8,10 @@ from dataclasses import dataclass
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of a validation check."""
|
||||
|
||||
is_valid: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.is_valid
|
||||
|
||||
@@ -23,14 +24,14 @@ def validate_path_parameter(
|
||||
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
|
||||
"""
|
||||
@@ -39,36 +40,36 @@ def validate_path_parameter(
|
||||
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,
|
||||
@@ -89,25 +90,26 @@ def validate_string_parameter(
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ class TestToolPackages:
|
||||
from hanzo_tools.core import BaseTool, ToolContext
|
||||
from hanzo_tools.core.decorators import auto_timeout
|
||||
from hanzo_tools.core.permissions import PermissionManager
|
||||
|
||||
assert BaseTool is not None
|
||||
assert ToolContext is not None
|
||||
assert PermissionManager is not None
|
||||
@@ -22,91 +23,106 @@ class TestToolPackages:
|
||||
def test_filesystem_tools(self):
|
||||
"""Test hanzo-tools-filesystem has 7 tools."""
|
||||
from hanzo_tools.filesystem import TOOLS
|
||||
|
||||
assert len(TOOLS) == 7
|
||||
names = {t.name for t in TOOLS}
|
||||
expected = {'read', 'write', 'edit', 'tree', 'find', 'search', 'ast'}
|
||||
expected = {"read", "write", "edit", "tree", "find", "search", "ast"}
|
||||
assert names == expected
|
||||
|
||||
def test_shell_tools(self):
|
||||
"""Test hanzo-tools-shell has 7 tools."""
|
||||
from hanzo_tools.shell import TOOLS
|
||||
|
||||
assert len(TOOLS) == 7
|
||||
names = {t.name for t in TOOLS}
|
||||
expected = {'dag', 'ps', 'zsh', 'shell', 'npx', 'uvx', 'open'}
|
||||
expected = {"dag", "ps", "zsh", "shell", "npx", "uvx", "open"}
|
||||
assert names == expected
|
||||
|
||||
def test_memory_tools(self):
|
||||
"""Test hanzo-tools-memory has 9 tools."""
|
||||
from hanzo_tools.memory import TOOLS
|
||||
|
||||
assert len(TOOLS) == 9
|
||||
|
||||
def test_todo_tools(self):
|
||||
"""Test hanzo-tools-todo has 1 tool."""
|
||||
from hanzo_tools.todo import TOOLS
|
||||
|
||||
assert len(TOOLS) == 1
|
||||
assert TOOLS[0].name == 'todo'
|
||||
assert TOOLS[0].name == "todo"
|
||||
|
||||
def test_reasoning_tools(self):
|
||||
"""Test hanzo-tools-reasoning has 2 tools."""
|
||||
from hanzo_tools.reasoning import TOOLS
|
||||
|
||||
assert len(TOOLS) == 2
|
||||
names = {t.name for t in TOOLS}
|
||||
assert names == {'think', 'critic'}
|
||||
assert names == {"think", "critic"}
|
||||
|
||||
def test_lsp_tools(self):
|
||||
"""Test hanzo-tools-lsp has 1 tool."""
|
||||
from hanzo_tools.lsp import TOOLS
|
||||
|
||||
assert len(TOOLS) == 1
|
||||
assert TOOLS[0].name == 'lsp'
|
||||
assert TOOLS[0].name == "lsp"
|
||||
|
||||
def test_refactor_tools(self):
|
||||
"""Test hanzo-tools-refactor has 1 tool."""
|
||||
from hanzo_tools.refactor import TOOLS
|
||||
|
||||
assert len(TOOLS) == 1
|
||||
assert TOOLS[0].name == 'refactor'
|
||||
assert TOOLS[0].name == "refactor"
|
||||
|
||||
def test_database_tools(self):
|
||||
"""Test hanzo-tools-database has 8 tools."""
|
||||
from hanzo_tools.database import TOOLS
|
||||
|
||||
assert len(TOOLS) == 8
|
||||
|
||||
def test_agent_tools(self):
|
||||
"""Test hanzo-tools-agent has 10-12 tools.
|
||||
|
||||
|
||||
Some CLI tools may not import on all platforms.
|
||||
"""
|
||||
from hanzo_tools.agent import TOOLS
|
||||
|
||||
# Allow 10-12 tools depending on platform (some CLI agents may not import)
|
||||
assert 10 <= len(TOOLS) <= 12, f"Expected 10-12 agent tools, got {len(TOOLS)}"
|
||||
|
||||
def test_jupyter_tools(self):
|
||||
"""Test hanzo-tools-jupyter has 1 tool."""
|
||||
from hanzo_tools.jupyter import TOOLS
|
||||
|
||||
assert len(TOOLS) == 1
|
||||
|
||||
def test_editor_tools(self):
|
||||
"""Test hanzo-tools-editor has 3 tools."""
|
||||
from hanzo_tools.editor import TOOLS
|
||||
|
||||
assert len(TOOLS) == 3
|
||||
|
||||
def test_browser_tools(self):
|
||||
"""Test hanzo-tools-browser has 1 tool."""
|
||||
from hanzo_tools.browser import TOOLS
|
||||
|
||||
assert len(TOOLS) == 1
|
||||
|
||||
def test_config_tools(self):
|
||||
"""Test hanzo-tools-config has 2 tools."""
|
||||
from hanzo_tools.config import TOOLS
|
||||
|
||||
assert len(TOOLS) == 2
|
||||
|
||||
def test_mcp_tools(self):
|
||||
"""Test hanzo-tools-mcp has 4 tools."""
|
||||
from hanzo_tools.mcp_tools import TOOLS
|
||||
|
||||
assert len(TOOLS) == 4
|
||||
|
||||
def test_llm_tools(self):
|
||||
"""Test hanzo-tools-llm imports (tools depend on litellm)."""
|
||||
from hanzo_tools.llm import TOOLS, LLM_AVAILABLE
|
||||
|
||||
# LLM tools are optional, depend on litellm
|
||||
if LLM_AVAILABLE:
|
||||
assert len(TOOLS) >= 1
|
||||
@@ -116,6 +132,7 @@ class TestToolPackages:
|
||||
def test_vector_tools(self):
|
||||
"""Test hanzo-tools-vector imports (tools depend on heavy deps)."""
|
||||
from hanzo_tools.vector import TOOLS, VECTOR_AVAILABLE
|
||||
|
||||
# Vector tools are optional, depend on faiss/qdrant
|
||||
if VECTOR_AVAILABLE:
|
||||
assert len(TOOLS) >= 1
|
||||
@@ -126,28 +143,32 @@ class TestToolPackages:
|
||||
class TestToolImportSpeed:
|
||||
"""Test that tool imports are fast (no blocking)."""
|
||||
|
||||
@pytest.mark.parametrize("module,max_time", [
|
||||
("hanzo_tools.core", 1.0),
|
||||
("hanzo_tools.filesystem", 1.0),
|
||||
("hanzo_tools.shell", 1.0),
|
||||
("hanzo_tools.memory", 1.0),
|
||||
("hanzo_tools.todo", 1.0),
|
||||
("hanzo_tools.reasoning", 1.0),
|
||||
("hanzo_tools.lsp", 1.0),
|
||||
("hanzo_tools.refactor", 1.0),
|
||||
("hanzo_tools.database", 1.0),
|
||||
("hanzo_tools.jupyter", 1.0),
|
||||
("hanzo_tools.editor", 1.0),
|
||||
("hanzo_tools.browser", 1.0),
|
||||
("hanzo_tools.config", 1.0),
|
||||
("hanzo_tools.mcp_tools", 1.0),
|
||||
("hanzo_tools.llm", 2.0), # LLM has litellm, allow more time
|
||||
("hanzo_tools.vector", 2.0), # Vector has heavy deps
|
||||
("hanzo_tools.agent", 2.0), # Agent has litellm, allow more time
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"module,max_time",
|
||||
[
|
||||
("hanzo_tools.core", 1.0),
|
||||
("hanzo_tools.filesystem", 1.0),
|
||||
("hanzo_tools.shell", 1.0),
|
||||
("hanzo_tools.memory", 1.0),
|
||||
("hanzo_tools.todo", 1.0),
|
||||
("hanzo_tools.reasoning", 1.0),
|
||||
("hanzo_tools.lsp", 1.0),
|
||||
("hanzo_tools.refactor", 1.0),
|
||||
("hanzo_tools.database", 1.0),
|
||||
("hanzo_tools.jupyter", 1.0),
|
||||
("hanzo_tools.editor", 1.0),
|
||||
("hanzo_tools.browser", 1.0),
|
||||
("hanzo_tools.config", 1.0),
|
||||
("hanzo_tools.mcp_tools", 1.0),
|
||||
("hanzo_tools.llm", 2.0), # LLM has litellm, allow more time
|
||||
("hanzo_tools.vector", 2.0), # Vector has heavy deps
|
||||
("hanzo_tools.agent", 2.0), # Agent has litellm, allow more time
|
||||
],
|
||||
)
|
||||
def test_import_speed(self, module, max_time):
|
||||
"""Test that imports complete quickly."""
|
||||
import importlib
|
||||
|
||||
start = time.time()
|
||||
importlib.import_module(module)
|
||||
elapsed = time.time() - start
|
||||
@@ -160,32 +181,32 @@ class TestToolAsync:
|
||||
def test_all_tools_async(self):
|
||||
"""Verify all tool .call() methods are async."""
|
||||
packages = [
|
||||
'hanzo_tools.filesystem',
|
||||
'hanzo_tools.shell',
|
||||
'hanzo_tools.memory',
|
||||
'hanzo_tools.todo',
|
||||
'hanzo_tools.reasoning',
|
||||
'hanzo_tools.lsp',
|
||||
'hanzo_tools.refactor',
|
||||
'hanzo_tools.database',
|
||||
'hanzo_tools.agent',
|
||||
'hanzo_tools.jupyter',
|
||||
'hanzo_tools.editor',
|
||||
'hanzo_tools.browser',
|
||||
'hanzo_tools.config',
|
||||
'hanzo_tools.mcp_tools',
|
||||
'hanzo_tools.llm',
|
||||
'hanzo_tools.vector',
|
||||
"hanzo_tools.filesystem",
|
||||
"hanzo_tools.shell",
|
||||
"hanzo_tools.memory",
|
||||
"hanzo_tools.todo",
|
||||
"hanzo_tools.reasoning",
|
||||
"hanzo_tools.lsp",
|
||||
"hanzo_tools.refactor",
|
||||
"hanzo_tools.database",
|
||||
"hanzo_tools.agent",
|
||||
"hanzo_tools.jupyter",
|
||||
"hanzo_tools.editor",
|
||||
"hanzo_tools.browser",
|
||||
"hanzo_tools.config",
|
||||
"hanzo_tools.mcp_tools",
|
||||
"hanzo_tools.llm",
|
||||
"hanzo_tools.vector",
|
||||
]
|
||||
|
||||
for pkg_name in packages:
|
||||
import importlib
|
||||
|
||||
pkg = importlib.import_module(pkg_name)
|
||||
tools = getattr(pkg, 'TOOLS', [])
|
||||
tools = getattr(pkg, "TOOLS", [])
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'call'):
|
||||
assert asyncio.iscoroutinefunction(tool.call), \
|
||||
f"{pkg_name}.{tool.name}.call() is not async"
|
||||
if hasattr(tool, "call"):
|
||||
assert asyncio.iscoroutinefunction(tool.call), f"{pkg_name}.{tool.name}.call() is not async"
|
||||
|
||||
|
||||
class TestTotalToolCount:
|
||||
@@ -193,53 +214,51 @@ class TestTotalToolCount:
|
||||
|
||||
def test_total_tool_count(self):
|
||||
"""Verify we have the expected total tools across all packages.
|
||||
|
||||
|
||||
Some packages have optional dependencies, so counts may vary.
|
||||
Core packages should have exact counts, optional packages are flexible.
|
||||
"""
|
||||
# Core packages with exact counts
|
||||
core_packages = [
|
||||
('hanzo_tools.filesystem', 7),
|
||||
('hanzo_tools.shell', 7),
|
||||
('hanzo_tools.browser', 1),
|
||||
('hanzo_tools.memory', 9),
|
||||
('hanzo_tools.todo', 1),
|
||||
('hanzo_tools.reasoning', 2),
|
||||
('hanzo_tools.lsp', 1),
|
||||
('hanzo_tools.refactor', 1),
|
||||
('hanzo_tools.database', 8),
|
||||
('hanzo_tools.jupyter', 1),
|
||||
('hanzo_tools.editor', 3),
|
||||
('hanzo_tools.config', 2),
|
||||
('hanzo_tools.mcp_tools', 4),
|
||||
("hanzo_tools.filesystem", 7),
|
||||
("hanzo_tools.shell", 7),
|
||||
("hanzo_tools.browser", 1),
|
||||
("hanzo_tools.memory", 9),
|
||||
("hanzo_tools.todo", 1),
|
||||
("hanzo_tools.reasoning", 2),
|
||||
("hanzo_tools.lsp", 1),
|
||||
("hanzo_tools.refactor", 1),
|
||||
("hanzo_tools.database", 8),
|
||||
("hanzo_tools.jupyter", 1),
|
||||
("hanzo_tools.editor", 3),
|
||||
("hanzo_tools.config", 2),
|
||||
("hanzo_tools.mcp_tools", 4),
|
||||
]
|
||||
|
||||
|
||||
# Packages with variable counts
|
||||
variable_packages = [
|
||||
('hanzo_tools.agent', 10, 12), # 10-12 depending on platform
|
||||
('hanzo_tools.llm', 0, 4), # 0-4 depending on litellm
|
||||
('hanzo_tools.vector', 0, 3), # 0-3 depending on vector deps
|
||||
("hanzo_tools.agent", 10, 12), # 10-12 depending on platform
|
||||
("hanzo_tools.llm", 0, 4), # 0-4 depending on litellm
|
||||
("hanzo_tools.vector", 0, 3), # 0-3 depending on vector deps
|
||||
]
|
||||
|
||||
total = 0
|
||||
import importlib
|
||||
|
||||
|
||||
# Check core packages (exact counts)
|
||||
for pkg_name, expected_count in core_packages:
|
||||
pkg = importlib.import_module(pkg_name)
|
||||
tools = getattr(pkg, 'TOOLS', [])
|
||||
tools = getattr(pkg, "TOOLS", [])
|
||||
actual = len(tools)
|
||||
assert actual == expected_count, \
|
||||
f"{pkg_name}: expected {expected_count} tools, got {actual}"
|
||||
assert actual == expected_count, f"{pkg_name}: expected {expected_count} tools, got {actual}"
|
||||
total += actual
|
||||
|
||||
# Check variable packages (range allowed)
|
||||
for pkg_name, min_count, max_count in variable_packages:
|
||||
pkg = importlib.import_module(pkg_name)
|
||||
tools = getattr(pkg, 'TOOLS', [])
|
||||
tools = getattr(pkg, "TOOLS", [])
|
||||
actual = len(tools)
|
||||
assert min_count <= actual <= max_count, \
|
||||
f"{pkg_name}: expected {min_count}-{max_count} tools, got {actual}"
|
||||
assert min_count <= actual <= max_count, f"{pkg_name}: expected {min_count}-{max_count} tools, got {actual}"
|
||||
total += actual
|
||||
|
||||
# Core tools: 47 (7+7+1+9+1+2+1+1+8+1+3+2+4)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -18,7 +18,7 @@ Install:
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.database import register_tools, TOOLS
|
||||
|
||||
|
||||
# Register with MCP server
|
||||
register_tools(mcp_server, permission_manager)
|
||||
"""
|
||||
@@ -99,7 +99,7 @@ def register_tools(
|
||||
]
|
||||
|
||||
for tool in tool_instances:
|
||||
tool_name = tool.name if hasattr(tool, 'name') else tool.__class__.__name__.lower()
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -11,7 +11,7 @@ Install:
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.editor import register_tools, TOOLS
|
||||
|
||||
|
||||
# Register with MCP server
|
||||
register_tools(mcp_server)
|
||||
"""
|
||||
@@ -52,7 +52,7 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
|
||||
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()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
|
||||
@@ -16,10 +16,10 @@ Install:
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -46,7 +46,7 @@ TOOLS = [
|
||||
__all__ = [
|
||||
# Tool classes
|
||||
"ReadTool",
|
||||
"WriteTool",
|
||||
"WriteTool",
|
||||
"EditTool",
|
||||
"TreeTool",
|
||||
"FindTool",
|
||||
@@ -60,23 +60,23 @@ __all__ = [
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
@@ -87,5 +87,5 @@ def register_tools(mcp_server, permission_manager, enabled_tools: dict[str, bool
|
||||
tool = tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
|
||||
|
||||
return registered
|
||||
|
||||
@@ -24,6 +24,7 @@ def _get_tree_context():
|
||||
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
|
||||
|
||||
@@ -80,30 +81,48 @@ class ASTToolParams(TypedDict, total=False):
|
||||
|
||||
# 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
|
||||
".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
|
||||
}
|
||||
|
||||
|
||||
@@ -175,12 +194,14 @@ Searches code structure intelligently, understanding syntax and providing semant
|
||||
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):
|
||||
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):
|
||||
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)):
|
||||
|
||||
@@ -12,9 +12,9 @@ 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.
|
||||
@@ -28,10 +28,10 @@ Args:
|
||||
Returns:
|
||||
Success message with diff or error
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager):
|
||||
super().__init__(permission_manager)
|
||||
|
||||
|
||||
@auto_timeout("edit")
|
||||
async def call(
|
||||
self,
|
||||
@@ -46,53 +46,50 @@ Returns:
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
return f"Successfully edited {file_path}\nReplaced {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")],
|
||||
|
||||
@@ -13,9 +13,9 @@ 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.
|
||||
@@ -29,12 +29,19 @@ Args:
|
||||
Returns:
|
||||
List of matching paths
|
||||
"""
|
||||
|
||||
|
||||
IGNORED_DIRS = {
|
||||
'.git', '__pycache__', 'node_modules', '.venv', 'venv',
|
||||
'.idea', '.vscode', '.mypy_cache', '.pytest_cache',
|
||||
".git",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
".idea",
|
||||
".vscode",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
}
|
||||
|
||||
|
||||
@auto_timeout("find")
|
||||
async def call(
|
||||
self,
|
||||
@@ -47,28 +54,28 @@ Returns:
|
||||
) -> 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:
|
||||
@@ -77,22 +84,22 @@ Returns:
|
||||
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")],
|
||||
@@ -102,6 +109,4 @@ Returns:
|
||||
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
|
||||
)
|
||||
return await tool_instance.call(ctx, pattern=pattern, path=path, type=type, max_results=max_results)
|
||||
|
||||
@@ -12,9 +12,9 @@ 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.
|
||||
@@ -27,10 +27,10 @@ Args:
|
||||
Returns:
|
||||
File contents with line numbers
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager):
|
||||
super().__init__(permission_manager)
|
||||
|
||||
|
||||
@auto_timeout("read")
|
||||
async def call(
|
||||
self,
|
||||
@@ -45,26 +45,26 @@ Returns:
|
||||
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]
|
||||
|
||||
selected = lines[offset : offset + limit]
|
||||
|
||||
# Format with line numbers
|
||||
output_lines = []
|
||||
for i, line in enumerate(selected, start=offset + 1):
|
||||
@@ -73,22 +73,22 @@ Returns:
|
||||
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")],
|
||||
|
||||
@@ -14,9 +14,9 @@ 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.
|
||||
@@ -33,7 +33,7 @@ Args:
|
||||
Returns:
|
||||
Matching lines with file paths and line numbers
|
||||
"""
|
||||
|
||||
|
||||
@auto_timeout("search")
|
||||
async def call(
|
||||
self,
|
||||
@@ -47,25 +47,21 @@ Returns:
|
||||
) -> 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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
return await self._search_with_python(pattern, root, include, context_lines, max_results)
|
||||
|
||||
async def _search_with_rg(
|
||||
self,
|
||||
pattern: str,
|
||||
@@ -81,15 +77,15 @@ Returns:
|
||||
"--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,
|
||||
@@ -97,7 +93,7 @@ Returns:
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout or "No matches found"
|
||||
elif result.returncode == 1:
|
||||
@@ -106,7 +102,7 @@ Returns:
|
||||
return None # Fall back to Python
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
async def _search_with_python(
|
||||
self,
|
||||
pattern: str,
|
||||
@@ -117,60 +113,60 @@ Returns:
|
||||
) -> 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")],
|
||||
|
||||
@@ -12,9 +12,9 @@ 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.
|
||||
@@ -27,14 +27,25 @@ Args:
|
||||
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',
|
||||
".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,
|
||||
@@ -46,18 +57,18 @@ Returns:
|
||||
) -> 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,
|
||||
@@ -69,39 +80,33 @@ Returns:
|
||||
"""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
|
||||
]
|
||||
|
||||
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
|
||||
)
|
||||
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")],
|
||||
@@ -110,6 +115,4 @@ Returns:
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
"""Display directory tree structure."""
|
||||
return await tool_instance.call(
|
||||
ctx, path=path, depth=depth, include_filtered=include_filtered
|
||||
)
|
||||
return await tool_instance.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
|
||||
|
||||
@@ -12,9 +12,9 @@ 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).
|
||||
@@ -26,10 +26,10 @@ Args:
|
||||
Returns:
|
||||
Success message or error
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager):
|
||||
super().__init__(permission_manager)
|
||||
|
||||
|
||||
@auto_timeout("write")
|
||||
async def call(
|
||||
self,
|
||||
@@ -42,29 +42,29 @@ Returns:
|
||||
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")],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -8,7 +8,7 @@ Install:
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.jupyter import register_tools, TOOLS
|
||||
|
||||
|
||||
# Register with MCP server
|
||||
register_tools(mcp_server, permission_manager)
|
||||
"""
|
||||
@@ -46,7 +46,7 @@ def register_tools(
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
|
||||
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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
|
||||
@@ -18,6 +18,7 @@ _tools = []
|
||||
|
||||
try:
|
||||
from .llm_tool import LLMTool
|
||||
|
||||
_tools.append(LLMTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLMTool not available: {e}")
|
||||
@@ -25,6 +26,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .llm_unified import UnifiedLLMTool
|
||||
|
||||
_tools.append(UnifiedLLMTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"UnifiedLLMTool not available: {e}")
|
||||
@@ -32,6 +34,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .consensus_tool import ConsensusTool
|
||||
|
||||
_tools.append(ConsensusTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ConsensusTool not available: {e}")
|
||||
@@ -39,6 +42,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .llm_manage import LLMManageTool
|
||||
|
||||
_tools.append(LLMManageTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLMManageTool not available: {e}")
|
||||
@@ -61,14 +65,14 @@ __all__ = [
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register LLM tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
tool_name = getattr(tool_class, "name", tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
@@ -76,5 +80,5 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
|
||||
return registered
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
@@ -137,7 +137,7 @@ 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.
|
||||
@@ -317,11 +317,14 @@ class LSPTool(BaseTool):
|
||||
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]]:
|
||||
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)
|
||||
@@ -361,7 +364,15 @@ class LSPTool(BaseTool):
|
||||
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:
|
||||
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:
|
||||
@@ -369,20 +380,36 @@ class LSPTool(BaseTool):
|
||||
|
||||
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())})
|
||||
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})
|
||||
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})
|
||||
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"])})
|
||||
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)
|
||||
@@ -394,8 +421,15 @@ class LSPTool(BaseTool):
|
||||
|
||||
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:
|
||||
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:
|
||||
@@ -414,7 +448,9 @@ class LSPTool(BaseTool):
|
||||
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}}
|
||||
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]:
|
||||
@@ -430,7 +466,15 @@ class LSPTool(BaseTool):
|
||||
"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]:
|
||||
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}"
|
||||
@@ -438,20 +482,34 @@ class LSPTool(BaseTool):
|
||||
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}}
|
||||
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]}
|
||||
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}}}
|
||||
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"]
|
||||
@@ -462,7 +520,12 @@ class LSPTool(BaseTool):
|
||||
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}}
|
||||
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"]
|
||||
@@ -472,12 +535,29 @@ class LSPTool(BaseTool):
|
||||
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)}
|
||||
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}}
|
||||
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"]
|
||||
@@ -490,23 +570,46 @@ class LSPTool(BaseTool):
|
||||
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,
|
||||
"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}}
|
||||
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)}
|
||||
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 {
|
||||
"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}"}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -18,6 +18,7 @@ _tools = []
|
||||
|
||||
try:
|
||||
from .mcp_tool import MCPTool
|
||||
|
||||
_tools.append(MCPTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"MCPTool not available: {e}")
|
||||
@@ -25,6 +26,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .mcp_add import McpAddTool
|
||||
|
||||
_tools.append(McpAddTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpAddTool not available: {e}")
|
||||
@@ -32,6 +34,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .mcp_remove import McpRemoveTool
|
||||
|
||||
_tools.append(McpRemoveTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpRemoveTool not available: {e}")
|
||||
@@ -39,6 +42,7 @@ except ImportError as e:
|
||||
|
||||
try:
|
||||
from .mcp_stats import McpStatsTool
|
||||
|
||||
_tools.append(McpStatsTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpStatsTool not available: {e}")
|
||||
@@ -59,14 +63,14 @@ __all__ = [
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register MCP tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
tool_name = getattr(tool_class, "name", tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
@@ -74,5 +78,5 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
|
||||
return registered
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
@@ -68,10 +68,7 @@ def get_memory_tools(
|
||||
Returns:
|
||||
List of tool instances
|
||||
"""
|
||||
return [
|
||||
cls(user_id=user_id, project_id=project_id, **kwargs)
|
||||
for cls in ALL_TOOL_CLASSES
|
||||
]
|
||||
return [cls(user_id=user_id, project_id=project_id, **kwargs) for cls in ALL_TOOL_CLASSES]
|
||||
|
||||
|
||||
def register_memory_tools(
|
||||
|
||||
@@ -30,6 +30,7 @@ def _check_memory_available() -> bool:
|
||||
if MEMORY_AVAILABLE is None:
|
||||
try:
|
||||
import hanzo_memory # noqa: F401
|
||||
|
||||
MEMORY_AVAILABLE = True
|
||||
except ImportError:
|
||||
MEMORY_AVAILABLE = False
|
||||
@@ -42,10 +43,10 @@ def _get_lazy_memory_service() -> "MemoryService":
|
||||
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"
|
||||
"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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
import pkgutil
|
||||
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
|
||||
@@ -19,10 +19,10 @@ Install:
|
||||
|
||||
Usage:
|
||||
from hanzo_tools.refactor import register_tools, TOOLS
|
||||
|
||||
|
||||
# Register with MCP server
|
||||
register_tools(mcp_server)
|
||||
|
||||
|
||||
# Or access tool directly
|
||||
from hanzo_tools.refactor import RefactorTool
|
||||
"""
|
||||
@@ -56,7 +56,7 @@ def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = tool_class.name if hasattr(tool_class, 'name') else tool_class.__name__.lower()
|
||||
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()
|
||||
|
||||
@@ -94,6 +94,7 @@ class RefactorResult:
|
||||
@dataclass
|
||||
class FileCache:
|
||||
"""Cache for file contents to avoid redundant I/O."""
|
||||
|
||||
content: str
|
||||
lines: List[str]
|
||||
mtime: float
|
||||
@@ -302,6 +303,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
parallel: Enable parallel processing (default: True)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
valid_actions = [
|
||||
@@ -317,9 +319,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
]
|
||||
|
||||
if action not in valid_actions:
|
||||
return MCPResourceDocument(
|
||||
data={"error": f"Invalid action. Must be one of: {', '.join(valid_actions)}"}
|
||||
)
|
||||
return MCPResourceDocument(data={"error": f"Invalid action. Must be one of: {', '.join(valid_actions)}"})
|
||||
|
||||
# Resolve file path if provided
|
||||
file_path = str(Path(file).resolve()) if file else None
|
||||
@@ -384,7 +384,8 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"rg",
|
||||
"--json",
|
||||
"--word-regexp",
|
||||
"--max-count", str(RIPGREP_BATCH_SIZE),
|
||||
"--max-count",
|
||||
str(RIPGREP_BATCH_SIZE),
|
||||
"--no-ignore-vcs", # Respect .gitignore
|
||||
]
|
||||
|
||||
@@ -427,13 +428,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
submatches = match_data.get("submatches", [])
|
||||
for submatch in submatches:
|
||||
col = submatch.get("start", 0)
|
||||
references.append(RefactorLocation(
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
column=col,
|
||||
text=identifier,
|
||||
context=context,
|
||||
))
|
||||
references.append(
|
||||
RefactorLocation(
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
column=col,
|
||||
text=identifier,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@@ -502,13 +505,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
refs = []
|
||||
for i, line in enumerate(cache.lines):
|
||||
for match in pattern.finditer(line):
|
||||
refs.append(RefactorLocation(
|
||||
file=file_path,
|
||||
line=i + 1,
|
||||
column=match.start(),
|
||||
text=identifier,
|
||||
context=line.strip(),
|
||||
))
|
||||
refs.append(
|
||||
RefactorLocation(
|
||||
file=file_path,
|
||||
line=i + 1,
|
||||
column=match.start(),
|
||||
text=identifier,
|
||||
context=line.strip(),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
# Run all file scans concurrently
|
||||
@@ -545,7 +550,9 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
# Get the identifier at the position
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
return RefactorResult(success=False, action="rename", errors=[f"Cannot read file or line {line} out of range"])
|
||||
return RefactorResult(
|
||||
success=False, action="rename", errors=[f"Cannot read file or line {line} out of range"]
|
||||
)
|
||||
|
||||
target_line = cache.lines[line - 1]
|
||||
old_name = self._get_identifier_at(target_line, column)
|
||||
@@ -560,9 +567,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
references = await self._find_all_references_parallel(file_path, old_name, project_root)
|
||||
|
||||
if not references:
|
||||
return RefactorResult(
|
||||
success=False, action="rename", errors=[f"No references found for '{old_name}'"]
|
||||
)
|
||||
return RefactorResult(success=False, action="rename", errors=[f"No references found for '{old_name}'"])
|
||||
|
||||
# Group by file for efficient batch edits
|
||||
changes_by_file: Dict[str, List[RefactorChange]] = defaultdict(list)
|
||||
@@ -582,13 +587,15 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
preview_data = []
|
||||
for file, changes in changes_by_file.items():
|
||||
for change in changes[:50]: # Limit preview per file
|
||||
preview_data.append({
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"column": change.column,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"column": change.column,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -670,12 +677,14 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
preview_data = []
|
||||
for file, changes in list(all_changes.items())[:20]: # Limit files in preview
|
||||
for change in changes[:10]:
|
||||
preview_data.append({
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"file": file,
|
||||
"line": change.line,
|
||||
"old": change.old_text,
|
||||
"new": change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -767,9 +776,9 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
line = lines[change.line - 1]
|
||||
# Verify the old text matches
|
||||
actual = line[change.column:change.column + len(change.old_text)]
|
||||
actual = line[change.column : change.column + len(change.old_text)]
|
||||
if actual == change.old_text:
|
||||
new_line = line[:change.column] + change.new_text + line[change.column + len(change.old_text):]
|
||||
new_line = line[: change.column] + change.new_text + line[change.column + len(change.old_text) :]
|
||||
lines[change.line - 1] = new_line
|
||||
applied += 1
|
||||
|
||||
@@ -809,17 +818,12 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
identifier = self._get_identifier_at(cache.lines[line - 1], column)
|
||||
if not identifier:
|
||||
return RefactorResult(
|
||||
success=False, action="find_references", errors=["No identifier found at position"]
|
||||
)
|
||||
return RefactorResult(success=False, action="find_references", errors=["No identifier found at position"])
|
||||
|
||||
project_root = self._find_project_root(file_path)
|
||||
references = await self._find_all_references_parallel(file_path, identifier, project_root)
|
||||
|
||||
ref_data = [
|
||||
{"file": r.file, "line": r.line, "column": r.column, "context": r.context}
|
||||
for r in references
|
||||
]
|
||||
ref_data = [{"file": r.file, "line": r.line, "column": r.column, "context": r.context} for r in references]
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -856,12 +860,10 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
return RefactorResult(success=False, action="extract_function", errors=["Cannot read file"])
|
||||
|
||||
if start_line < 1 or end_line > len(cache.lines):
|
||||
return RefactorResult(
|
||||
success=False, action="extract_function", errors=["Line range out of bounds"]
|
||||
)
|
||||
return RefactorResult(success=False, action="extract_function", errors=["Line range out of bounds"])
|
||||
|
||||
# Extract the code block
|
||||
extracted_lines = cache.lines[start_line - 1:end_line]
|
||||
extracted_lines = cache.lines[start_line - 1 : end_line]
|
||||
extracted_code = "\n".join(extracted_lines)
|
||||
|
||||
# Detect language and indentation
|
||||
@@ -887,14 +889,19 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
action="extract_function",
|
||||
preview=[
|
||||
{"type": "new_function", "code": new_function, "insert_at": f"Before line {start_line}"},
|
||||
{"type": "replacement", "lines": f"{start_line}-{end_line}", "old_code": extracted_code, "new_code": function_call},
|
||||
{
|
||||
"type": "replacement",
|
||||
"lines": f"{start_line}-{end_line}",
|
||||
"old_code": extracted_code,
|
||||
"new_code": function_call,
|
||||
},
|
||||
],
|
||||
message=f"Would extract lines {start_line}-{end_line} to function '{new_name}'",
|
||||
)
|
||||
|
||||
# Apply the extraction
|
||||
try:
|
||||
new_lines = cache.lines[:start_line - 1]
|
||||
new_lines = cache.lines[: start_line - 1]
|
||||
new_lines.append(new_function)
|
||||
new_lines.append("")
|
||||
new_lines.append(function_call)
|
||||
@@ -930,9 +937,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
success=False, action="extract_variable", errors=["file, line and column are required"]
|
||||
)
|
||||
if not new_name:
|
||||
return RefactorResult(
|
||||
success=False, action="extract_variable", errors=["new_name is required"]
|
||||
)
|
||||
return RefactorResult(success=False, action="extract_variable", errors=["new_name is required"])
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
@@ -1000,9 +1005,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
) -> RefactorResult:
|
||||
"""Inline a variable or function at all usage sites."""
|
||||
if not file_path or not line or column is None:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=["file, line and column are required"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=["file, line and column are required"])
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
if not cache or line > len(cache.lines):
|
||||
@@ -1012,9 +1015,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
identifier = self._get_identifier_at(target_line, column)
|
||||
|
||||
if not identifier:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=["No identifier found at position"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=["No identifier found at position"])
|
||||
|
||||
language = self._get_language(file_path)
|
||||
definition = self._find_definition(cache.content, identifier, language)
|
||||
@@ -1031,16 +1032,13 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
usages = [u for u in usages if not (u.file == file_path and u.line == definition["line"])]
|
||||
|
||||
if not usages:
|
||||
return RefactorResult(
|
||||
success=False, action="inline", errors=[f"No usages found for '{identifier}'"]
|
||||
)
|
||||
return RefactorResult(success=False, action="inline", errors=[f"No usages found for '{identifier}'"])
|
||||
|
||||
inline_value = definition["value"]
|
||||
|
||||
if preview:
|
||||
preview_data = [
|
||||
{"file": u.file, "line": u.line, "replace": identifier, "with": inline_value}
|
||||
for u in usages[:20]
|
||||
{"file": u.file, "line": u.line, "replace": identifier, "with": inline_value} for u in usages[:20]
|
||||
]
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -1144,7 +1142,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
# Remove from source file
|
||||
try:
|
||||
new_lines = cache.lines[:block["start"] - 1] + cache.lines[block["end"]:]
|
||||
new_lines = cache.lines[: block["start"] - 1] + cache.lines[block["end"] :]
|
||||
await loop.run_in_executor(None, self._write_file_sync, file_path, "\n".join(new_lines))
|
||||
await self._invalidate_cache(file_path)
|
||||
except Exception as e:
|
||||
@@ -1178,8 +1176,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"""
|
||||
if not file_path or not line:
|
||||
return RefactorResult(
|
||||
success=False, action="change_signature",
|
||||
errors=["file and line are required to locate the function"]
|
||||
success=False, action="change_signature", errors=["file and line are required to locate the function"]
|
||||
)
|
||||
|
||||
cache = await self._get_file_cached(file_path)
|
||||
@@ -1192,8 +1189,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
func_info = self._parse_function_signature(cache.lines, line, language)
|
||||
if not func_info:
|
||||
return RefactorResult(
|
||||
success=False, action="change_signature",
|
||||
errors=[f"No function signature found at line {line}"]
|
||||
success=False, action="change_signature", errors=[f"No function signature found at line {line}"]
|
||||
)
|
||||
|
||||
func_name = func_info["name"]
|
||||
@@ -1217,22 +1213,26 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
changes_by_file: Dict[str, List[RefactorChange]] = defaultdict(list)
|
||||
|
||||
# First, change the function definition
|
||||
old_sig_lines = cache.lines[signature_line - 1:signature_end_line]
|
||||
old_sig_lines = cache.lines[signature_line - 1 : signature_end_line]
|
||||
old_sig = "\n".join(old_sig_lines)
|
||||
|
||||
changes_by_file[file_path].append(RefactorChange(
|
||||
file=file_path,
|
||||
line=signature_line,
|
||||
column=0,
|
||||
end_line=signature_end_line,
|
||||
end_column=len(cache.lines[signature_end_line - 1]) if signature_end_line <= len(cache.lines) else 0,
|
||||
old_text=old_sig,
|
||||
new_text=new_signature,
|
||||
description="Update function signature",
|
||||
))
|
||||
changes_by_file[file_path].append(
|
||||
RefactorChange(
|
||||
file=file_path,
|
||||
line=signature_line,
|
||||
column=0,
|
||||
end_line=signature_end_line,
|
||||
end_column=len(cache.lines[signature_end_line - 1]) if signature_end_line <= len(cache.lines) else 0,
|
||||
old_text=old_sig,
|
||||
new_text=new_signature,
|
||||
description="Update function signature",
|
||||
)
|
||||
)
|
||||
|
||||
# Then update all call sites
|
||||
call_changes = await self._update_call_sites(call_sites, func_name, params, new_params, param_mapping, changes, language)
|
||||
call_changes = await self._update_call_sites(
|
||||
call_sites, func_name, params, new_params, param_mapping, changes, language
|
||||
)
|
||||
for call_change in call_changes:
|
||||
changes_by_file[call_change.file].append(call_change)
|
||||
|
||||
@@ -1240,17 +1240,24 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
if preview:
|
||||
preview_data = [
|
||||
{"type": "signature", "file": file_path, "line": signature_line,
|
||||
"old": old_sig.strip(), "new": new_signature.strip()}
|
||||
{
|
||||
"type": "signature",
|
||||
"file": file_path,
|
||||
"line": signature_line,
|
||||
"old": old_sig.strip(),
|
||||
"new": new_signature.strip(),
|
||||
}
|
||||
]
|
||||
for call_change in call_changes[:20]:
|
||||
preview_data.append({
|
||||
"type": "call_site",
|
||||
"file": call_change.file,
|
||||
"line": call_change.line,
|
||||
"old": call_change.old_text,
|
||||
"new": call_change.new_text,
|
||||
})
|
||||
preview_data.append(
|
||||
{
|
||||
"type": "call_site",
|
||||
"file": call_change.file,
|
||||
"line": call_change.line,
|
||||
"old": call_change.old_text,
|
||||
"new": call_change.new_text,
|
||||
}
|
||||
)
|
||||
|
||||
return RefactorResult(
|
||||
success=True,
|
||||
@@ -1259,19 +1266,23 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
changes_applied=total_changes,
|
||||
preview=preview_data,
|
||||
message=f"Would update signature of '{func_name}' and {len(call_changes)} call sites",
|
||||
stats={"call_sites_found": len(call_sites), "params_before": len(params), "params_after": len(new_params)},
|
||||
stats={
|
||||
"call_sites_found": len(call_sites),
|
||||
"params_before": len(params),
|
||||
"params_after": len(new_params),
|
||||
},
|
||||
)
|
||||
|
||||
# Apply all changes
|
||||
result = await self._apply_signature_changes_to_files(changes_by_file, cache, file_path, signature_line, signature_end_line, new_signature)
|
||||
result = await self._apply_signature_changes_to_files(
|
||||
changes_by_file, cache, file_path, signature_line, signature_end_line, new_signature
|
||||
)
|
||||
result.action = "change_signature"
|
||||
result.message = f"Updated signature of '{func_name}' and {len(call_changes)} call sites"
|
||||
result.stats = {"call_sites_updated": len(call_changes)}
|
||||
return result
|
||||
|
||||
def _parse_function_signature(
|
||||
self, lines: List[str], line_num: int, language: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def _parse_function_signature(self, lines: List[str], line_num: int, language: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a function signature at the given line."""
|
||||
if line_num > len(lines):
|
||||
return None
|
||||
@@ -1667,16 +1678,18 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
new_call = f"{func_name}({', '.join(new_args)})"
|
||||
|
||||
if new_call != call_text:
|
||||
call_changes.append(RefactorChange(
|
||||
file=site.file,
|
||||
line=site.line,
|
||||
column=call_start,
|
||||
end_line=site.line, # Simplified - assuming single line
|
||||
end_column=call_start + len(call_text),
|
||||
old_text=call_text,
|
||||
new_text=new_call,
|
||||
description=f"Update call to {func_name}",
|
||||
))
|
||||
call_changes.append(
|
||||
RefactorChange(
|
||||
file=site.file,
|
||||
line=site.line,
|
||||
column=call_start,
|
||||
end_line=site.line, # Simplified - assuming single line
|
||||
end_column=call_start + len(call_text),
|
||||
old_text=call_text,
|
||||
new_text=new_call,
|
||||
description=f"Update call to {func_name}",
|
||||
)
|
||||
)
|
||||
|
||||
return call_changes
|
||||
|
||||
@@ -1698,7 +1711,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
paren_count = 1
|
||||
pos = paren_start + 1
|
||||
current_line = line_idx
|
||||
call_text = line[start_col:paren_start + 1]
|
||||
call_text = line[start_col : paren_start + 1]
|
||||
|
||||
while paren_count > 0:
|
||||
if pos >= len(lines[current_line]):
|
||||
@@ -1720,12 +1733,12 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
|
||||
# Include the final character
|
||||
if current_line == line_idx:
|
||||
call_text = line[start_col:paren_start + 1 + (pos - paren_start - 1)]
|
||||
call_text = line[start_col : paren_start + 1 + (pos - paren_start - 1)]
|
||||
|
||||
# Simple single-line extraction
|
||||
end = line.find(")", paren_start)
|
||||
if end >= 0:
|
||||
return line[start_col:end + 1], end + 1
|
||||
return line[start_col : end + 1], end + 1
|
||||
|
||||
return None, 0
|
||||
|
||||
@@ -1738,7 +1751,7 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
start = call_text.find("(")
|
||||
end = call_text.rfind(")")
|
||||
if start >= 0 and end > start:
|
||||
args_str = call_text[start + 1:end]
|
||||
args_str = call_text[start + 1 : end]
|
||||
else:
|
||||
return []
|
||||
else:
|
||||
@@ -1838,13 +1851,13 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
# Handle signature change specially (may span multiple lines)
|
||||
if target_file == file_path and change.line == sig_line:
|
||||
# Replace signature lines
|
||||
lines = lines[:sig_line - 1] + [new_signature] + lines[sig_end_line:]
|
||||
lines = lines[: sig_line - 1] + [new_signature] + lines[sig_end_line:]
|
||||
changes_applied += 1
|
||||
else:
|
||||
# Normal single-line change
|
||||
if change.line <= len(lines):
|
||||
line = lines[change.line - 1]
|
||||
new_line = line[:change.column] + change.new_text + line[change.end_column:]
|
||||
new_line = line[: change.column] + change.new_text + line[change.end_column :]
|
||||
lines[change.line - 1] = new_line
|
||||
changes_applied += 1
|
||||
|
||||
@@ -1949,12 +1962,56 @@ Find references: refactor("find_references", file="f.py", line=10, column=5)"""
|
||||
"""Find variables used in a code block."""
|
||||
identifiers = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b", code))
|
||||
keywords = {
|
||||
"if", "else", "for", "while", "def", "class", "return", "import", "from",
|
||||
"in", "and", "or", "not", "True", "False", "None", "async", "await",
|
||||
"try", "except", "finally", "with", "as", "is", "lambda", "yield",
|
||||
"break", "continue", "pass", "raise", "global", "nonlocal", "assert", "del",
|
||||
"function", "const", "let", "var", "new", "this", "super", "extends",
|
||||
"func", "type", "struct", "interface", "package", "fn", "mut", "pub",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"while",
|
||||
"def",
|
||||
"class",
|
||||
"return",
|
||||
"import",
|
||||
"from",
|
||||
"in",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"True",
|
||||
"False",
|
||||
"None",
|
||||
"async",
|
||||
"await",
|
||||
"try",
|
||||
"except",
|
||||
"finally",
|
||||
"with",
|
||||
"as",
|
||||
"is",
|
||||
"lambda",
|
||||
"yield",
|
||||
"break",
|
||||
"continue",
|
||||
"pass",
|
||||
"raise",
|
||||
"global",
|
||||
"nonlocal",
|
||||
"assert",
|
||||
"del",
|
||||
"function",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"new",
|
||||
"this",
|
||||
"super",
|
||||
"extends",
|
||||
"func",
|
||||
"type",
|
||||
"struct",
|
||||
"interface",
|
||||
"package",
|
||||
"fn",
|
||||
"mut",
|
||||
"pub",
|
||||
}
|
||||
return identifiers - keywords
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
|
||||
@@ -95,11 +95,11 @@ def get_shell_tools(
|
||||
uvx_tool.permission_manager = permission_manager
|
||||
|
||||
return [
|
||||
dag, # DAG execution for complex workflows
|
||||
zsh_tool, # Zsh shell execution (with auto-backgrounding)
|
||||
ps_tool, # Process management
|
||||
npx_tool, # Node packages
|
||||
uvx_tool, # Python packages
|
||||
dag, # DAG execution for complex workflows
|
||||
zsh_tool, # Zsh shell execution (with auto-backgrounding)
|
||||
ps_tool, # Process management
|
||||
npx_tool, # Node packages
|
||||
uvx_tool, # Python packages
|
||||
open_tool, # Open files/URLs
|
||||
]
|
||||
|
||||
@@ -126,10 +126,11 @@ def register_shell_tools(
|
||||
|
||||
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register all shell tools with the MCP server.
|
||||
|
||||
|
||||
This is the standard entry point called by the tool discovery system.
|
||||
"""
|
||||
from hanzo_tools.core import PermissionManager
|
||||
|
||||
permission_manager = kwargs.get("permission_manager") or PermissionManager()
|
||||
all_tools = kwargs.get("all_tools")
|
||||
return register_shell_tools(mcp_server, permission_manager, all_tools)
|
||||
|
||||
@@ -127,10 +127,11 @@ class AutoBackgroundExecutor:
|
||||
"""
|
||||
# Use passed timeout or default
|
||||
effective_timeout = timeout if timeout is not None else self.default_timeout
|
||||
|
||||
|
||||
# Fast path for tests
|
||||
if os.getenv("HANZO_MCP_FAST_TESTS") == "1":
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_args,
|
||||
@@ -172,6 +173,7 @@ class AutoBackgroundExecutor:
|
||||
output_lines = []
|
||||
|
||||
try:
|
||||
|
||||
async def read_output():
|
||||
if process.stdout:
|
||||
async for line in process.stdout:
|
||||
@@ -359,12 +361,7 @@ class BaseProcessTool(BaseTool):
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
async def _write_output_to_log(
|
||||
self,
|
||||
process: asyncio.subprocess.Process,
|
||||
log_file: Path,
|
||||
process_id: str
|
||||
) -> None:
|
||||
async def _write_output_to_log(self, process: asyncio.subprocess.Process, log_file: Path, process_id: str) -> None:
|
||||
"""Write process output to log file in background."""
|
||||
try:
|
||||
async with aiofiles.open(log_file, "w") as f:
|
||||
|
||||
@@ -25,6 +25,7 @@ from hanzo_tools.shell.base_process import ProcessManager
|
||||
|
||||
class NodeStatus(Enum):
|
||||
"""Execution status of a DAG node."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
@@ -35,6 +36,7 @@ class NodeStatus(Enum):
|
||||
@dataclass
|
||||
class DagResult:
|
||||
"""Result from a single DAG node execution."""
|
||||
|
||||
node_id: str
|
||||
command: str
|
||||
stdout: str
|
||||
@@ -48,6 +50,7 @@ class DagResult:
|
||||
@dataclass
|
||||
class DagNode:
|
||||
"""A node in the execution DAG."""
|
||||
|
||||
id: str
|
||||
command: Union[str, Dict[str, Any]]
|
||||
depends_on: List[str] = field(default_factory=list)
|
||||
@@ -177,7 +180,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
start_time = datetime.now()
|
||||
node_id = f"shell_{id(cmd)}"
|
||||
process_manager = ProcessManager()
|
||||
|
||||
|
||||
# Defensive check - should never happen but provides better error message
|
||||
if process_manager is None:
|
||||
raise RuntimeError("ProcessManager() returned None - singleton initialization failed")
|
||||
@@ -191,7 +194,9 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
log_file = await process_manager.create_log_file(process_id)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell, "-c", cmd,
|
||||
shell,
|
||||
"-c",
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
@@ -199,10 +204,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
|
||||
duration = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
exit_code = proc.returncode or 0
|
||||
@@ -235,8 +237,8 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
node_id=node_id,
|
||||
command=cmd,
|
||||
stdout=f"[backgrounded] Process {process_id} (PID {proc.pid}) running in background.\n"
|
||||
f"Use: ps --logs {process_id} # view output\n"
|
||||
f"Use: ps --kill {process_id} # stop process",
|
||||
f"Use: ps --logs {process_id} # view output\n"
|
||||
f"Use: ps --kill {process_id} # stop process",
|
||||
stderr="",
|
||||
status=NodeStatus.SUCCESS,
|
||||
exit_code=0,
|
||||
@@ -257,14 +259,11 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
node_type="shell",
|
||||
)
|
||||
|
||||
async def _capture_background_output(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
log_file: Path
|
||||
) -> None:
|
||||
async def _capture_background_output(self, proc: asyncio.subprocess.Process, log_file: Path) -> None:
|
||||
"""Capture output from backgrounded process to log file."""
|
||||
try:
|
||||
async with aiofiles.open(log_file, "a") as f:
|
||||
|
||||
async def read_stream(stream, prefix: str):
|
||||
if stream:
|
||||
while True:
|
||||
@@ -284,12 +283,7 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _run_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_input: Dict[str, Any],
|
||||
ctx: MCPContext
|
||||
) -> DagResult:
|
||||
async def _run_tool(self, tool_name: str, tool_input: Dict[str, Any], ctx: MCPContext) -> DagResult:
|
||||
"""Run an MCP tool invocation."""
|
||||
start_time = datetime.now()
|
||||
node_id = f"tool_{tool_name}"
|
||||
@@ -350,18 +344,11 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
|
||||
if isinstance(cmd, dict):
|
||||
if "tool" in cmd:
|
||||
return await self._run_tool(
|
||||
cmd["tool"],
|
||||
cmd.get("input", {}),
|
||||
ctx
|
||||
)
|
||||
return await self._run_tool(cmd["tool"], cmd.get("input", {}), ctx)
|
||||
|
||||
if "parallel" in cmd:
|
||||
parallel_cmds = cmd["parallel"]
|
||||
tasks = [
|
||||
self._execute_node(c, ctx, shell, cwd, env, timeout)
|
||||
for c in parallel_cmds
|
||||
]
|
||||
tasks = [self._execute_node(c, ctx, shell, cwd, env, timeout) for c in parallel_cmds]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
combined_stdout = []
|
||||
@@ -442,13 +429,11 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
results: List[DagResult] = []
|
||||
|
||||
if parallel:
|
||||
tasks = [
|
||||
self._execute_node(cmd, ctx, shell, cwd, env, timeout)
|
||||
for cmd in commands
|
||||
]
|
||||
tasks = [self._execute_node(cmd, ctx, shell, cwd, env, timeout) for cmd in commands]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = [
|
||||
r if not isinstance(r, Exception)
|
||||
r
|
||||
if not isinstance(r, Exception)
|
||||
else DagResult(
|
||||
node_id=f"error_{i}",
|
||||
command=str(commands[i]),
|
||||
@@ -503,37 +488,15 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def dag_handler(
|
||||
commands: Annotated[
|
||||
List[Any],
|
||||
Field(description="Commands to execute (strings, tool dicts, or parallel blocks)")
|
||||
List[Any], Field(description="Commands to execute (strings, tool dicts, or parallel blocks)")
|
||||
],
|
||||
parallel: Annotated[
|
||||
bool,
|
||||
Field(description="Run all commands in parallel", default=False)
|
||||
] = False,
|
||||
shell: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Shell to use (default: zsh)", default=None)
|
||||
] = None,
|
||||
cwd: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Working directory", default=None)
|
||||
] = None,
|
||||
env: Annotated[
|
||||
Optional[Dict[str, str]],
|
||||
Field(description="Environment variables", default=None)
|
||||
] = None,
|
||||
timeout: Annotated[
|
||||
int,
|
||||
Field(description="Timeout per command (seconds)", default=120)
|
||||
] = 120,
|
||||
strict: Annotated[
|
||||
bool,
|
||||
Field(description="Stop on first error", default=False)
|
||||
] = False,
|
||||
quiet: Annotated[
|
||||
bool,
|
||||
Field(description="Suppress stdout", default=False)
|
||||
] = False,
|
||||
parallel: Annotated[bool, Field(description="Run all commands in parallel", default=False)] = False,
|
||||
shell: Annotated[Optional[str], Field(description="Shell to use (default: zsh)", default=None)] = None,
|
||||
cwd: Annotated[Optional[str], Field(description="Working directory", default=None)] = None,
|
||||
env: Annotated[Optional[Dict[str, str]], Field(description="Environment variables", default=None)] = None,
|
||||
timeout: Annotated[int, Field(description="Timeout per command (seconds)", default=120)] = 120,
|
||||
strict: Annotated[bool, Field(description="Stop on first error", default=False)] = False,
|
||||
quiet: Annotated[bool, Field(description="Suppress stdout", default=False)] = False,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_self.call(
|
||||
@@ -549,9 +512,6 @@ backgrounded. Use ps tool to monitor: ps --logs <id>, ps --kill <id>"""
|
||||
)
|
||||
|
||||
|
||||
def create_dag_tool(
|
||||
tools: Optional[Dict[str, BaseTool]] = None,
|
||||
default_shell: str = "zsh"
|
||||
) -> DagTool:
|
||||
def create_dag_tool(tools: Optional[Dict[str, BaseTool]] = None, default_shell: str = "zsh") -> DagTool:
|
||||
"""Factory to create DAG execution tool."""
|
||||
return DagTool(tools, default_shell)
|
||||
|
||||
@@ -78,6 +78,7 @@ open /path/to/image.png"""
|
||||
raise RuntimeError("No suitable file opener found on Linux")
|
||||
elif system == "windows":
|
||||
import os
|
||||
|
||||
os.startfile(str(file_path))
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
@@ -23,6 +23,7 @@ from hanzo_tools.shell.base_process import ProcessManager
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Process information."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
cmd: str
|
||||
@@ -78,14 +79,16 @@ USAGE:
|
||||
"""Get all tracked processes."""
|
||||
processes = []
|
||||
for proc_id, info in self.process_manager.list_processes().items():
|
||||
processes.append(ProcessInfo(
|
||||
id=proc_id,
|
||||
pid=info.get("pid", 0),
|
||||
cmd=info.get("cmd", ""),
|
||||
running=info.get("running", False),
|
||||
exit_code=info.get("return_code"),
|
||||
log_file=Path(info["log_file"]) if info.get("log_file") else None,
|
||||
))
|
||||
processes.append(
|
||||
ProcessInfo(
|
||||
id=proc_id,
|
||||
pid=info.get("pid", 0),
|
||||
cmd=info.get("cmd", ""),
|
||||
running=info.get("running", False),
|
||||
exit_code=info.get("return_code"),
|
||||
log_file=Path(info["log_file"]) if info.get("log_file") else None,
|
||||
)
|
||||
)
|
||||
return processes
|
||||
|
||||
def _get_process(self, proc_id: str) -> Optional[ProcessInfo]:
|
||||
@@ -190,26 +193,11 @@ USAGE:
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def ps_handler(
|
||||
id: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Get specific process by ID", default=None)
|
||||
] = None,
|
||||
kill: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Kill process by ID", default=None)
|
||||
] = None,
|
||||
logs: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Get logs for process ID", default=None)
|
||||
] = None,
|
||||
sig: Annotated[
|
||||
int,
|
||||
Field(description="Signal for kill (default: 15/SIGTERM)", default=15)
|
||||
] = 15,
|
||||
n: Annotated[
|
||||
int,
|
||||
Field(description="Number of log lines", default=100)
|
||||
] = 100,
|
||||
id: Annotated[Optional[str], Field(description="Get specific process by ID", default=None)] = None,
|
||||
kill: Annotated[Optional[str], Field(description="Kill process by ID", default=None)] = None,
|
||||
logs: Annotated[Optional[str], Field(description="Get logs for process ID", default=None)] = None,
|
||||
sig: Annotated[int, Field(description="Signal for kill (default: 15/SIGTERM)", default=15)] = 15,
|
||||
n: Annotated[int, Field(description="Number of log lines", default=100)] = 100,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, id=id, kill=kill, logs=logs, sig=sig, n=n)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
@@ -114,10 +114,7 @@ class TodoStorage:
|
||||
for session_id, session_data in cls._sessions.items():
|
||||
todos = session_data.get("todos", [])
|
||||
# Check for unfinished todos
|
||||
has_unfinished = any(
|
||||
todo.get("status") in ["pending", "in_progress"]
|
||||
for todo in todos
|
||||
)
|
||||
has_unfinished = any(todo.get("status") in ["pending", "in_progress"] for todo in todos)
|
||||
if has_unfinished:
|
||||
last_updated = session_data.get("last_updated", 0)
|
||||
if last_updated > latest_timestamp:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
"""Hanzo Tools namespace package."""
|
||||
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
|
||||
@@ -18,10 +18,13 @@ VECTOR_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from .index_tool import IndexTool
|
||||
|
||||
_tools.append(IndexTool)
|
||||
from .vector_index import VectorIndexTool
|
||||
|
||||
_tools.append(VectorIndexTool)
|
||||
from .vector_search import VectorSearchTool
|
||||
|
||||
_tools.append(VectorSearchTool)
|
||||
VECTOR_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
@@ -44,14 +47,14 @@ def register_tools(mcp_server, permission_manager=None, enabled_tools: dict[str,
|
||||
if not VECTOR_AVAILABLE:
|
||||
logger.warning("Vector tools not available - missing dependencies")
|
||||
return []
|
||||
|
||||
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
tool_name = getattr(tool_class, "name", tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class(permission_manager) if permission_manager else tool_class()
|
||||
@@ -59,5 +62,5 @@ def register_tools(mcp_server, permission_manager=None, enabled_tools: dict[str,
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
|
||||
return registered
|
||||
|
||||
Reference in New Issue
Block a user