Files
zeekay 7e25c192c6 feat(tools): fix BaseTool dispatch + achieve 100% HIP-0300 conformance
- Fix BaseTool.register() to bypass FastMCP's broken **kwargs introspection
  by creating Tool objects manually with Pydantic extra="allow" models
- Add PARAM_ALIASES support for cross-implementation parity (path→uri)
- Fix ExecTool to use ShellExecutor.run_shell() (correct 5-tuple API)
- Fix CodeTool tree-sitter import for tree-sitter-language-pack 0.25+
- Fix MemoryTool fallback to markdown backend when knowledge service unavailable
- Add ExecTool to shell TOOLS list
- Remove redundant register() overrides from code/fetch/plan/vcs/fs tools
- Add code, network, mode test categories to conformance suite (23 cases)
- Add parallel benchmark (benchmark_parallel.py)

Results: Python 23/23 (100%), 350/350 parallel calls async-safe
2026-03-10 19:41:02 -07:00

380 lines
11 KiB
Python

"""Shell tools package for Hanzo AI.
Minimal, orthogonal shell execution with uvloop for high performance.
SHELL DETECTION:
By default, only the user's active shell is exposed to MCP. This avoids
cluttering the tool list with shells the user doesn't use.
Detection order:
1. HANZO_MCP_SHELL env var (e.g., "zsh", "bash", "fish")
2. HANZO_MCP_FORCE_SHELL env var (e.g., "/opt/homebrew/bin/zsh")
3. --shell CLI flag (e.g., --shell=/path/to/fish)
4. Invoking shell (the shell that launched this process)
5. Login shell (from passwd/Directory Services)
6. $SHELL environment variable
On macOS with Homebrew, this will prefer /opt/homebrew/bin/zsh over /bin/zsh.
If detection fails, falls back to 'shell' tool (smart auto-detect).
Core tools:
- <detected>: Your active shell (zsh, bash, fish, or dash)
- ps: Process management (list, kill, logs)
HTTP/Data tools:
- curl: HTTP client without shell escaping issues
- jq: JSON processor without shell escaping issues
- wget: File/site downloads with mirroring support
Convenience tools:
- npx: Node package execution with auto-backgrounding
- uvx: Python package execution with auto-backgrounding
- open: Open files/URLs in system apps
Auto-backgrounding: Commands exceeding timeout automatically background.
Configure via: export HANZO_AUTO_BACKGROUND_TIMEOUT=30 (default: 30s)
export HANZO_AUTO_BACKGROUND_TIMEOUT=0 (disabled)
Use ps --logs <id> to view, ps --kill <id> to stop.
Performance: Uses uvloop on macOS/Linux for 2-4x faster async I/O.
Falls back to standard asyncio on Windows.
"""
# Use hanzo-async for unified async I/O and uvloop configuration
from hanzo_async import using_uvloop, configure_loop
# Auto-configure uvloop on import
configure_loop()
_using_uvloop = using_uvloop()
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolRegistry, PermissionManager
from hanzo_tools.shell.jq_tool import JqTool
from hanzo_tools.shell.ps_tool import PsTool, ps_tool
# Core tools
from hanzo_tools.shell.cmd_tool import (
CmdNode,
CmdTool,
CmdResult,
NodeStatus,
cmd_tool,
create_cmd_tool,
)
# Backwards compatibility - DagTool is now CmdTool
from hanzo_tools.shell.dag_tool import DagNode, DagTool, DagResult, create_dag_tool
from hanzo_tools.shell.npx_tool import NpxTool, npx_tool
from hanzo_tools.shell.truncate import (
truncate_lines,
estimate_tokens,
truncate_response,
)
from hanzo_tools.shell.uvx_tool import UvxTool, uvx_tool
# HTTP/Data tools (no shell escaping issues)
from hanzo_tools.shell.curl_tool import CurlTool
# HIP-0300: Unified exec tool
from hanzo_tools.shell.exec_tool import ExecTool, exec_tool
# Convenience tools
from hanzo_tools.shell.open_tool import OpenTool, open_tool
from hanzo_tools.shell.shellflow import (
parse as parse_shellflow,
compile as compile_shellflow,
)
from hanzo_tools.shell.wget_tool import WgetTool
from hanzo_tools.shell.shell_tools import (
CshTool,
KshTool,
ZshTool,
BashTool,
DashTool,
FishTool,
TcshTool,
ShellTool,
csh_tool,
ksh_tool,
zsh_tool,
bash_tool,
dash_tool,
fish_tool,
tcsh_tool,
shell_tool,
)
# Base classes
from hanzo_tools.shell.base_process import (
AUTO_BACKGROUND_TIMEOUT,
ShellExecutor,
BaseBinaryTool,
BaseScriptTool,
ProcessManager,
BaseProcessTool,
AutoBackgroundExecutor,
get_shell_executor,
)
# Shell detection
from hanzo_tools.shell.shell_detect import (
SUPPORTED_SHELLS,
ShellInfo,
detect_shells,
get_active_shell,
clear_shell_cache,
resolve_shell_path,
get_shell_tool_class,
get_cached_active_shell,
)
# Session management
from hanzo_tools.shell.session_storage import (
SessionInfo,
SessionStorage,
clear_all_sessions,
cleanup_expired_sessions,
)
def _get_detected_shell_tools() -> list:
"""
Get shell tools list with only the detected active shell.
This reduces tool clutter by only exposing the user's configured shell.
Override with:
- HANZO_MCP_SHELL=bash (shell name)
- HANZO_MCP_FORCE_SHELL=/path/to/shell (full path)
- HANZO_MCP_ALL_SHELLS=1 (expose all shells)
"""
import os
# Allow exposing all shells via env var (for debugging/testing)
if os.environ.get("HANZO_MCP_ALL_SHELLS", "").lower() in ("1", "true", "yes"):
return [
ZshTool,
BashTool,
FishTool,
DashTool,
KshTool,
TcshTool,
CshTool,
PsTool,
NpxTool,
UvxTool,
OpenTool,
CurlTool,
JqTool,
WgetTool,
]
# Detect active shell
shell_name, shell_path = get_cached_active_shell()
# Get the appropriate shell tool class
shell_tool_class = get_shell_tool_class(shell_name)
# Base tools (always included) - no cmd, just the detected shell
# ExecTool = HIP-0300 unified exec tool (action-routed: exec, ps, kill, logs)
tools = [ExecTool, PsTool, NpxTool, UvxTool, OpenTool, CurlTool, JqTool, WgetTool]
# Add detected shell tool, or fall back to ShellTool (smart auto-detect)
if shell_tool_class:
tools.insert(0, shell_tool_class) # Put detected shell first
else:
tools.insert(0, ShellTool) # Fallback to smart shell
return tools
# Tools list for entry point discovery
# Only the detected shell is included (not all 4 shell variants)
# Override with HANZO_MCP_ALL_SHELLS=1 to expose all shells
TOOLS = _get_detected_shell_tools()
__all__ = [
# uvloop status
"_using_uvloop",
# HIP-0300: Unified exec tool
"ExecTool",
"exec_tool",
# Base classes
"ProcessManager",
"AutoBackgroundExecutor",
"ShellExecutor",
"get_shell_executor",
"BaseProcessTool",
"BaseBinaryTool",
"BaseScriptTool",
# Session management
"SessionStorage",
"SessionInfo",
"cleanup_expired_sessions",
"clear_all_sessions",
# Utilities
"truncate_response",
"truncate_lines",
"estimate_tokens",
# Shell detection
"ShellInfo",
"detect_shells",
"get_active_shell",
"get_cached_active_shell",
"clear_shell_cache",
"get_shell_tool_class",
"resolve_shell_path",
"SUPPORTED_SHELLS",
# Legacy tools
"CmdTool",
"CmdResult",
"CmdNode",
"NodeStatus",
"cmd_tool",
"create_cmd_tool",
"PsTool",
"ps_tool",
"ZshTool",
"zsh_tool",
"ShellTool",
"shell_tool",
"BashTool",
"bash_tool",
"FishTool",
"fish_tool",
"DashTool",
"dash_tool",
"KshTool",
"ksh_tool",
"TcshTool",
"tcsh_tool",
"CshTool",
"csh_tool",
# Backwards compatibility
"DagTool",
"DagResult",
"DagNode",
"create_dag_tool",
# Convenience tools
"OpenTool",
"open_tool",
"NpxTool",
"npx_tool",
"UvxTool",
"uvx_tool",
# HTTP/Data tools
"CurlTool",
"JqTool",
"WgetTool",
# Registration
"TOOLS",
"get_shell_tools",
"register_shell_tools",
]
def get_shell_tools(
permission_manager: PermissionManager,
all_tools: dict[str, BaseTool] | None = None,
shell_override: str | None = None,
) -> list[BaseTool]:
"""Create instances of shell tools.
Only the user's detected/configured shell is included to reduce tool clutter.
Args:
permission_manager: Permission manager for access control
all_tools: Dict of all registered tools (for tool invocations)
shell_override: Override shell (name like "zsh" or path like "/path/to/fish")
Returns:
List of shell tool instances
"""
import os
# Set permission manager for convenience tools
npx_tool.permission_manager = permission_manager
uvx_tool.permission_manager = permission_manager
# Base tools list (no cmd - just the detected shell and utilities)
tools = [
ps_tool, # Process management
npx_tool, # Node packages
uvx_tool, # Python packages
open_tool, # Open files/URLs
]
# Check for all-shells mode
if os.environ.get("HANZO_MCP_ALL_SHELLS", "").lower() in ("1", "true", "yes"):
tools.insert(0, ZshTool(tools=all_tools or {}))
tools.insert(1, BashTool(tools=all_tools or {}))
tools.insert(2, FishTool(tools=all_tools or {}))
tools.insert(3, DashTool(tools=all_tools or {}))
tools.insert(4, KshTool(tools=all_tools or {}))
tools.insert(5, TcshTool(tools=all_tools or {}))
tools.insert(6, CshTool(tools=all_tools or {}))
return tools
# Determine which shell to expose
if shell_override:
# Handle path override (e.g., --shell=/opt/homebrew/bin/fish)
if "/" in shell_override:
shell_name = os.path.basename(shell_override)
os.environ["HANZO_MCP_FORCE_SHELL"] = shell_override
else:
shell_name = shell_override.lower()
else:
shell_name, _ = get_cached_active_shell()
# Create and add detected shell tool, or fall back to ShellTool
shell_tool_class = get_shell_tool_class(shell_name)
if shell_tool_class:
shell_instance = shell_tool_class(tools=all_tools or {})
tools.insert(0, shell_instance) # Put detected shell first
else:
tools.insert(0, ShellTool(tools=all_tools or {})) # Fallback to smart shell
return tools
def register_shell_tools(
mcp_server: FastMCP,
permission_manager: PermissionManager,
all_tools: dict[str, BaseTool] | None = None,
shell_override: str | None = None,
) -> list[BaseTool]:
"""Register shell tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
all_tools: Dict of all registered tools (for cmd tool invocations)
shell_override: Override shell (name like "zsh" or path like "/path/to/fish")
Returns:
List of registered tools
"""
tools = get_shell_tools(permission_manager, all_tools, shell_override)
ToolRegistry.register_tools(mcp_server, tools)
return 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.
Supports:
- shell_override: Override detected shell (name or path)
- permission_manager: Permission manager instance
- all_tools: Dict of all registered tools
"""
from hanzo_tools.core import PermissionManager
permission_manager = kwargs.get("permission_manager") or PermissionManager()
all_tools = kwargs.get("all_tools")
shell_override = kwargs.get("shell_override") or kwargs.get("shell")
return register_shell_tools(
mcp_server, permission_manager, all_tools, shell_override
)