mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
- 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
380 lines
11 KiB
Python
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
|
|
)
|