feat(mcp): native MCP ImageContent — tools return images the client can SEE

HIP-0300 tools serialize results to JSON text, which flattened images into
useless base64-in-text. Add a ToolImage value type + one converter in
BaseTool.register: any ToolImage anywhere in a result becomes a real
mcp.types.ImageContent block; text-only results stay byte-for-byte JSON.
Emitters wired: fs read of image files, browser screenshots (bidi+playwright).
This gives hanzo-mcp vision parity with zai-mcp — an agent with native vision
(Claude) sees pixels directly, no per-task vision tool needed.

Also decomplect the core-package collision that caused this class of bug:
hanzo-tools-core shipped a DUPLICATE hanzo_tools/core/ that nondeterministically
shadowed canonical hanzo-tools (dropping ToolImage, the sophisticated register,
etc. — same shadow class as the 0.3.0 error-reexport gap). Finish the
'merge core into hanzo-tools' refactor: move id_tool into hanzo-tools, make
hanzo-tools-core a pure empty metapackage. Now ONE package owns hanzo_tools.core.

Versions: hanzo-tools 0.3.2, -core 0.3.1 (shim), -browser 0.5.9, -fs 0.3.4,
hanzo-mcp 0.15.12 (floors bumped). Verified: fs read PNG -> [text, image];
fs stat -> [text]; 29 tools intact; 9 tests pass.
This commit is contained in:
2026-07-03 00:45:49 -07:00
parent f34b5535d1
commit 85bf05c796
22 changed files with 202 additions and 4594 deletions
+6 -6
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.15.11"
version = "0.15.12"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md"
requires-python = ">=3.12"
@@ -30,11 +30,11 @@ dependencies = [
"hanzo-async>=0.1.3",
"uvloop>=0.22.1; sys_platform != 'win32'",
# Hanzo tool infrastructure (all tools)
"hanzo-tools>=0.3.0",
"hanzo-tools-fs>=0.1.0",
"hanzo-tools-shell>=0.6.5", # 0.6.5 pairs with hanzo-tools>=0.3.0 (ToolError); older shadowed by legacy core<0.3.0
"hanzo-tools-core>=0.3.0", # hard floor: legacy split package must carry ToolError (pre-0.3.0 breaks shell import)
"hanzo-tools-browser[playwright]>=0.5.8", # 3 peer tools (browser/cdp/playwright) over zapd consumer (~/.zap/run/zapd.sock)
"hanzo-tools>=0.3.2", # 0.3.2 owns hanzo_tools.core exclusively + adds ToolImage (native MCP images)
"hanzo-tools-fs>=0.3.4", # 0.3.4 returns image files as native MCP ImageContent
"hanzo-tools-shell>=0.6.5", # 0.6.5 pairs with hanzo-tools>=0.3.0 (ToolError)
"hanzo-tools-core>=0.3.1", # 0.3.1 is the EMPTY shim — no longer shadows canonical hanzo-tools.core
"hanzo-tools-browser[playwright]>=0.5.9", # 0.5.9 emits screenshots as native MCP ImageContent
"hanzo-tools-memory>=0.2.0",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
@@ -0,0 +1,84 @@
"""Native MCP ImageContent: tools return images the client can *see*.
HIP-0300 tools serialize results to JSON text. That flattened images into
useless base64-in-text. The fix: a ``ToolImage`` value type + one converter in
``BaseTool.register`` that emits a real ``mcp.types.ImageContent`` block for any
image anywhere in a result — while text-only results stay byte-for-byte JSON.
This guards the contract end to end: the converter, and the emitting tools
(fs image read; browser screenshots wire the same ``ToolImage``).
"""
import base64
import struct
import zlib
from hanzo_tools.core import ToolImage
from hanzo_tools.core.unified import _result_to_mcp
def _tiny_png() -> bytes:
def chunk(t: bytes, d: bytes) -> bytes:
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
raw = b"".join(b"\x00" + bytes((255, 0, 0)) for _ in range(2))
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 2, 1, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b"")
)
def test_text_only_result_is_unchanged_json_string():
out = _result_to_mcp({"ok": True, "data": {"x": 1}, "error": None})
assert isinstance(out, str)
assert '"x": 1' in out
def test_image_result_becomes_text_plus_imagecontent():
from mcp.types import ImageContent, TextContent
img = ToolImage.from_bytes(_tiny_png(), "image/png", alt="probe")
out = _result_to_mcp({"ok": True, "data": {"shot": img}, "error": None})
assert isinstance(out, list) and len(out) == 2
text, image = out
assert isinstance(text, TextContent)
assert isinstance(image, ImageContent)
assert image.mimeType == "image/png"
# data is real base64 that decodes back to a PNG
assert base64.b64decode(image.data)[:8] == b"\x89PNG\r\n\x1a\n"
# the image bytes never leak into the text — only a compact placeholder
assert "__image__" in text.text
assert img.data not in text.text
def test_images_are_collected_recursively():
from mcp.types import ImageContent
img = ToolImage.from_bytes(_tiny_png(), "image/png")
out = _result_to_mcp({"data": {"frames": [img, {"inner": img}]}})
assert isinstance(out, list)
assert sum(1 for b in out if isinstance(b, ImageContent)) == 2
def test_fs_read_of_image_emits_toolimage(tmp_path):
"""The fs tool returns image files as a ToolImage (native vision path)."""
import asyncio
from hanzo_tools.core import PermissionManager
from hanzo_tools.fs.fs_tool import FsTool
png = tmp_path / "probe.png"
png.write_bytes(_tiny_png())
pm = PermissionManager()
pm.add_allowed_path(str(tmp_path))
tool = FsTool(permission_manager=pm)
result = asyncio.run(tool.call(None, action="read", uri=str(png)))
data = result.get("data", result)
assert isinstance(data.get("image"), ToolImage)
assert data["mime"] == "image/png"
@@ -31,7 +31,7 @@ from dataclasses import field, dataclass
from pydantic import Field
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool
from hanzo_tools.core import BaseTool, ToolImage
# Playwright import with graceful fallback
try:
@@ -371,7 +371,7 @@ async def _bidi_dispatch(
return {"success": True, "source": "bidi"}
if action == "screenshot":
c = await _ctx()
return {"success": True, "source": "bidi", "screenshot": await client.capture_screenshot(c)}
return {"success": True, "source": "bidi", "image": ToolImage(data=await client.capture_screenshot(c), mime_type="image/png", alt="screenshot")}
if action == "evaluate":
c = await _ctx()
return {"success": True, "source": "bidi", "result": _val(await client.script_evaluate(c, code or ""))}
@@ -1852,7 +1852,7 @@ CATEGORIES:
"success": True,
"format": "png",
"size": len(data),
"base64": base64.b64encode(data).decode(),
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"),
}
elif action == "pdf":
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-browser"
version = "0.5.8"
version = "0.5.9"
description = "Browser automation tools — zapd consumer. The MCP hosts no server; it routes CDP commands to the browser extension through the shared local zapd router (~/.zap/run/zapd.sock). No in-process server, no HTTP bridge, no mDNS, no port pool."
readme = "README.md"
requires-python = ">=3.12"
@@ -1,25 +0,0 @@
"""Hanzo Tools - Modular tool packages for AI agents.
This is the namespace package for all hanzo-tools-* packages.
Each tool category is a separate installable package:
pip install hanzo-tools-core # Base infrastructure
pip install hanzo-tools-filesystem # File operations
pip install hanzo-tools-shell # Shell/command execution
pip install hanzo-tools-browser # Browser automation
pip install hanzo-tools-agent # Agent orchestration
pip install hanzo-tools-llm # LLM integrations
pip install hanzo-tools-database # Database tools
pip install hanzo-tools-memory # Memory/knowledge base
pip install hanzo-tools-editor # Editor integrations
pip install hanzo-tools-jupyter # Jupyter notebook tools
pip install hanzo-tools-lsp # Language server protocol
pip install hanzo-tools-refactor # Code refactoring
pip install hanzo-tools-vector # Vector search
pip install hanzo-tools-todo # Task management
Or install all at once:
pip install hanzo-tools[all]
"""
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -1,67 +0,0 @@
"""DEPRECATED: Use hanzo-tools instead.
This package re-exports from hanzo_tools.core for backwards compatibility.
"""
import warnings
warnings.warn(
"hanzo-tools-core is deprecated. Use hanzo-tools instead: pip install hanzo-tools",
DeprecationWarning,
stacklevel=2,
)
# Re-export everything from hanzo-tools
from hanzo_tools.core.base import (
BaseTool as BaseToolABC, # Low-level ABC
ToolRegistry,
FileSystemTool,
)
from hanzo_tools.core.types import MCPResourceDocument
from hanzo_tools.core.context import ToolContext, create_tool_context
from hanzo_tools.core.id_tool import IdTool, id_tool
from hanzo_tools.core.unified import (
Range,
Paging,
BaseTool, # HIP-0300 unified tool - use this
ErrorCode,
ToolError,
ActionHandler,
ConflictError,
NotFoundError,
InvalidParamsError,
file_uri,
content_hash,
)
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.permissions import PermissionManager
__all__ = [
# HIP-0300 Base class - use this for new tools
"BaseTool",
# Low-level ABC (for FileSystemTool etc)
"BaseToolABC",
"FileSystemTool",
"ToolRegistry",
# HIP-0300 helpers
"ActionHandler",
"ToolError",
"ConflictError",
"NotFoundError",
"InvalidParamsError",
"Paging",
"Range",
"ErrorCode",
"content_hash",
"file_uri",
# Identity tool
"IdTool",
"id_tool",
# Types
"MCPResourceDocument",
# Context
"PermissionManager",
"ToolContext",
"create_tool_context",
"auto_timeout",
]
@@ -1,251 +0,0 @@
"""Base classes for Hanzo tool packages.
Provides the foundation for all tool implementations:
- BaseTool: Abstract base class defining the tool interface
- FileSystemTool: Base class for filesystem operations
- ToolRegistry: Central registry for tool management
"""
import inspect
import logging
import functools
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, final
from pathlib import Path
from collections.abc import Awaitable
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
logger = logging.getLogger(__name__)
def with_error_logging(tool_name: str) -> Callable:
"""Decorator to add comprehensive error logging to tool functions.
Args:
tool_name: Name of the tool for logging purposes
Returns:
Decorator function
"""
log_dir = Path.home() / ".hanzo" / "mcp" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
def decorator(func: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> str:
try:
return await func(*args, **kwargs)
except TypeError as e:
error_msg = str(e)
if "takes" in error_msg and "positional argument" in error_msg:
sig = inspect.signature(func)
logger.error(
f"Tool {tool_name} call signature mismatch: "
f"expected {func.__name__}{sig}, got args={args}, kwargs={kwargs}"
)
logger.exception(f"Tool {tool_name} TypeError: {e}")
return f"Error executing tool '{tool_name}': {error_msg}\n\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\nCheck logs at ~/.hanzo/mcp/logs/ for details."
return wrapper
return decorator
def handle_connection_errors(
func: Callable[..., Awaitable[str]],
) -> Callable[..., Awaitable[str]]:
"""Decorator to handle connection errors gracefully."""
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> str:
try:
return await func(*args, **kwargs)
except Exception as e:
error_name = type(e).__name__
if any(
name in error_name
for name in [
"ClosedResourceError",
"ConnectionError",
"BrokenPipeError",
]
):
return f"Client disconnected during operation: {error_name}"
raise
return wrapper
class BaseTool(ABC):
"""Abstract base class for all Hanzo tools.
All tool packages must implement this interface to be compatible
with the hanzo-mcp server and tool registry.
Example:
class MyTool(BaseTool):
@property
def name(self) -> str:
return "my_tool"
@property
def description(self) -> str:
return "Does something useful"
async def call(self, ctx, **params) -> str:
return "Result"
def register(self, mcp_server):
@mcp_server.tool()
async def my_tool(...):
return await self.call(...)
"""
@property
@abstractmethod
def name(self) -> str:
"""Get the tool name as it appears in the MCP server."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Get detailed description of the tool's purpose and usage."""
pass
@abstractmethod
async def call(self, ctx: MCPContext, **params: Any) -> Any:
"""Execute the tool with the given parameters.
Args:
ctx: MCP context for the tool call
**params: Tool parameters provided by the caller
Returns:
Tool execution result
"""
pass
@abstractmethod
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Must create a wrapper function with explicit parameters
that calls this tool's call method.
Args:
mcp_server: The FastMCP server instance
"""
pass
class FileSystemTool(BaseTool, ABC):
"""Base class for filesystem-related tools.
Provides common functionality for working with files and directories,
including permission checking and path validation.
"""
def __init__(self, permission_manager: "PermissionManager | None" = None) -> None:
"""Initialize filesystem tool.
Args:
permission_manager: Permission manager for access control (auto-created if None)
"""
if permission_manager is None:
from hanzo_tools.core.permissions import PermissionManager
permission_manager = PermissionManager()
self.permission_manager = permission_manager
def validate_path(self, path: str, param_name: str = "path") -> "ValidationResult":
"""Validate a path parameter."""
from hanzo_tools.core.validation import validate_path_parameter
return validate_path_parameter(path, param_name)
def is_path_allowed(self, path: str) -> bool:
"""Check if a path is allowed according to permission settings."""
return self.permission_manager.is_path_allowed(path)
@final
class ToolRegistry:
"""Registry for Hanzo tools.
Provides functionality for registering tool implementations
with an MCP server, with support for enable/disable states.
"""
# Class-level storage for tool states
_enabled_tools: ClassVar[dict[str, bool]] = {}
_config_loaded: ClassVar[bool] = False
@classmethod
def _load_config(cls) -> None:
"""Load tool enable/disable states from config."""
if cls._config_loaded:
return
import json
config_file = Path.home() / ".hanzo" / "mcp" / "tool_states.json"
if config_file.exists():
try:
with open(config_file) as f:
cls._enabled_tools = json.load(f)
except Exception:
pass
cls._config_loaded = True
@classmethod
def is_tool_enabled(cls, tool_name: str) -> bool:
"""Check if a tool is enabled."""
cls._load_config()
return cls._enabled_tools.get(tool_name, True) # Enabled by default
@classmethod
def set_tool_enabled(
cls, tool_name: str, enabled: bool, persist: bool = True
) -> None:
"""Enable or disable a tool."""
import json
cls._load_config()
cls._enabled_tools[tool_name] = enabled
if persist:
config_file = Path.home() / ".hanzo" / "mcp" / "tool_states.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w") as f:
json.dump(cls._enabled_tools, f, indent=2)
@staticmethod
def register_tool(mcp_server: FastMCP, tool: BaseTool) -> None:
"""Register a tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
tool: The tool to register
"""
if ToolRegistry.is_tool_enabled(tool.name):
tool.register(mcp_server)
logger.debug(f"Registered tool: {tool.name}")
else:
logger.debug(f"Skipped disabled tool: {tool.name}")
@staticmethod
def register_tools(mcp_server: FastMCP, tools: list[BaseTool]) -> None:
"""Register multiple tools with the MCP server."""
for tool in tools:
ToolRegistry.register_tool(mcp_server, tool)
# Import PermissionManager type for type hints
from hanzo_tools.core.validation import ValidationResult
from hanzo_tools.core.permissions import PermissionManager
@@ -1,67 +0,0 @@
"""Tool execution context utilities."""
import logging
from typing import Any, Optional
from dataclasses import field, dataclass
from mcp.server.fastmcp import Context as MCPContext
logger = logging.getLogger(__name__)
@dataclass
class ToolContext:
"""Extended context for tool execution.
Provides utilities for logging, progress reporting,
and accessing the MCP context.
"""
mcp_ctx: MCPContext
tool_name: Optional[str] = None
metadata: dict[str, Any] = field(default_factory=dict)
async def set_tool_info(self, tool_name: str) -> None:
"""Set the current tool name for logging."""
self.tool_name = tool_name
async def info(self, message: str) -> None:
"""Log an info message."""
logger.info(f"[{self.tool_name or 'tool'}] {message}")
async def warning(self, message: str) -> None:
"""Log a warning message."""
logger.warning(f"[{self.tool_name or 'tool'}] {message}")
async def error(self, message: str) -> None:
"""Log an error message."""
logger.error(f"[{self.tool_name or 'tool'}] {message}")
async def debug(self, message: str) -> None:
"""Log a debug message."""
logger.debug(f"[{self.tool_name or 'tool'}] {message}")
async def progress(self, current: int, total: int, message: str = "") -> None:
"""Report progress."""
pct = (current / total * 100) if total > 0 else 0
await self.info(f"Progress: {current}/{total} ({pct:.1f}%) {message}")
def get(self, key: str, default: Any = None) -> Any:
"""Get a metadata value."""
return self.metadata.get(key, default)
def set(self, key: str, value: Any) -> None:
"""Set a metadata value."""
self.metadata[key] = value
def create_tool_context(mcp_ctx: MCPContext) -> ToolContext:
"""Create a ToolContext from an MCP context.
Args:
mcp_ctx: The MCP context from the tool call
Returns:
Extended ToolContext for tool execution
"""
return ToolContext(mcp_ctx=mcp_ctx)
@@ -1,103 +0,0 @@
"""Decorators for tool functions."""
import os
import asyncio
import functools
from typing import Any, Callable, Optional
from collections.abc import Awaitable
# Default timeouts per tool (can be overridden via env vars)
DEFAULT_TIMEOUTS: dict[str, int] = {
"read": 30,
"write": 60,
"edit": 60,
"search": 120,
"dag": 600,
"browser": 300,
"default": 120,
}
def get_timeout(tool_name: str) -> int:
"""Get timeout for a tool.
Checks environment variable HANZO_TIMEOUT_{TOOL_NAME} first,
then falls back to defaults.
"""
env_key = f"HANZO_TIMEOUT_{tool_name.upper()}"
if env_val := os.environ.get(env_key):
try:
return int(env_val)
except ValueError:
pass
return DEFAULT_TIMEOUTS.get(tool_name, DEFAULT_TIMEOUTS["default"])
def auto_timeout(
tool_name: str,
timeout: Optional[int] = None,
) -> Callable:
"""Decorator to add automatic timeout to async tool functions.
Args:
tool_name: Name of the tool (for logging and config)
timeout: Override timeout in seconds (optional)
Returns:
Decorator that wraps the function with timeout handling
"""
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
effective_timeout = timeout or get_timeout(tool_name)
try:
return await asyncio.wait_for(
func(*args, **kwargs),
timeout=effective_timeout,
)
except asyncio.TimeoutError:
return f"Tool '{tool_name}' timed out after {effective_timeout}s"
return wrapper
return decorator
def retry(
max_attempts: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
exceptions: tuple = (Exception,),
) -> Callable:
"""Decorator to add retry logic to async functions.
Args:
max_attempts: Maximum number of attempts
delay: Initial delay between retries (seconds)
backoff: Multiplier for delay after each attempt
exceptions: Tuple of exceptions to catch and retry
"""
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
current_delay = delay
last_exception = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
await asyncio.sleep(current_delay)
current_delay *= backoff
raise last_exception
return wrapper
return decorator
@@ -1,86 +0,0 @@
"""Permission management for filesystem tools."""
import os
from typing import Optional
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')
"""
self.allowed_paths: list[Path] = []
if allowed_paths:
for p in allowed_paths:
self.allowed_paths.append(Path(p).resolve())
else:
self.allowed_paths.append(Path.cwd())
self.deny_patterns = deny_patterns or [
".git",
"__pycache__",
".pyc",
"node_modules",
".env",
".secrets",
]
def is_path_allowed(self, path: str | Path) -> bool:
"""Check if a path is allowed.
Args:
path: Path to check
Returns:
True if path is within allowed paths and not denied
"""
try:
resolved = Path(path).resolve()
# Check if path is under any allowed path
is_under_allowed = any(
self._is_subpath(resolved, allowed) for allowed in self.allowed_paths
)
if not is_under_allowed:
return False
# Check deny patterns
path_str = str(resolved)
for pattern in self.deny_patterns:
if pattern in path_str:
return False
return True
except Exception:
return False
def _is_subpath(self, path: Path, parent: Path) -> bool:
"""Check if path is under parent."""
try:
path.relative_to(parent)
return True
except ValueError:
return False
def add_allowed_path(self, path: str | Path) -> None:
"""Add a path to the allowed list."""
self.allowed_paths.append(Path(path).resolve())
def add_deny_pattern(self, pattern: str) -> None:
"""Add a deny pattern."""
self.deny_patterns.append(pattern)
@@ -1,171 +0,0 @@
"""Type definitions for Hanzo tool packages."""
import json
from typing import Any, Dict, List, Optional
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
- to_dict(): Full dict structure with data/metadata
"""
data: Dict[str, Any]
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary format with data/metadata structure."""
result = {"data": self.data}
if self.metadata:
result["metadata"] = self.metadata
return result
def to_json_string(self) -> str:
"""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", ""))
total = stats.get("total", len(results))
time_ms = stats.get("time_ms", {})
if time_ms:
if isinstance(time_ms, dict):
total_time = sum(time_ms.values())
else:
total_time = time_ms
lines.append(
f"# Search: '{query}' ({total} results, {total_time}ms)"
)
else:
lines.append(f"# Found {total} results for '{query}'")
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):
# Common patterns for search results
file_path = result.get("file", result.get("path", ""))
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}")
if match_text:
# Truncate long matches for readability
preview = (
match_text[:200] + "..."
if len(match_text) > 200
else match_text
)
lines.append(f" {preview}")
if result_type:
lines.append(f" [{result_type}]")
else:
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)
total = pagination.get("total", 0)
has_next = pagination.get("has_next", False)
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
exit_code = self.data.get("exit_code", self.data.get("returncode", 0))
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():
if isinstance(value, (dict, list)):
lines.append(f"{key}:")
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):
if isinstance(item, dict):
lines.append(f"{i}. {json.dumps(item, default=str)}")
else:
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)
@@ -1,449 +0,0 @@
"""Base tool class for HIP-0300 architecture.
Provides the foundation for orthogonal, composable tools following Unix philosophy:
- Permissive input, strict output
- Action routing with alias resolution
- Unified response envelope with ok/data/error/meta
- Built-in help and schema actions
- Typed error codes with Unix exit code semantics
- Paging/cursor support for large results
- Composable via stable identifiers (uri, hash, range, ref)
Design principles:
- Input parsing = permissive (accept many spellings/forms)
- Output + semantics = strict (one canonical shape)
- Aliases never appear in output; only canonical action names
Reference: HIP-0300 Unified MCP Tools Architecture
"""
import json
import hashlib
import inspect
from abc import abstractmethod
from typing import Any, Literal, TypeVar, Callable, ClassVar, get_type_hints
from dataclasses import field, dataclass
from collections.abc import Awaitable
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from .base import BaseTool as _BaseToolABC
# Error codes for structured error handling
ErrorCode = Literal[
"UNKNOWN_ACTION", # Action not found
"INVALID_PARAMS", # Invalid parameters
"NOT_FOUND", # Resource not found
"CONFLICT", # Precondition failed (e.g., base_hash mismatch)
"PERMISSION_DENIED", # Access denied
"TIMEOUT", # Operation timed out
"INTERNAL_ERROR", # Unexpected error
]
@dataclass
class Paging:
"""Pagination info for large results."""
cursor: str | None = None
more: bool = False
total: int | None = None
@dataclass
class ToolError(Exception):
"""Structured error for tool operations."""
code: ErrorCode
message: str
details: dict[str, Any] = field(default_factory=dict)
class ConflictError(ToolError):
"""Precondition failed (e.g., base_hash mismatch)."""
def __init__(
self, message: str, expected: str | None = None, actual: str | None = None
):
details = {}
if expected:
details["expected"] = expected
if actual:
details["actual"] = actual
super().__init__(code="CONFLICT", message=message, details=details)
class NotFoundError(ToolError):
"""Resource not found."""
def __init__(self, message: str, uri: str | None = None):
details = {"uri": uri} if uri else {}
super().__init__(code="NOT_FOUND", message=message, details=details)
class InvalidParamsError(ToolError):
"""Invalid parameters."""
def __init__(
self, message: str, param: str | None = None, expected: str | None = None
):
details = {}
if param:
details["param"] = param
if expected:
details["expected"] = expected
super().__init__(code="INVALID_PARAMS", message=message, details=details)
@dataclass
class ActionHandler:
"""Metadata for a registered action handler."""
name: str
handler: Callable[..., Awaitable[Any]]
description: str
schema: dict[str, Any] | None = None
examples: list[str] = field(default_factory=list)
class BaseTool(_BaseToolABC):
"""Base class for HIP-0300 unified tools with action routing.
Design principles:
- Permissive input, strict output
- Action routing with alias resolution
- Unified response envelope: {ok, data, error, meta}
- Aliases never appear in output; only canonical action names
Usage:
class FsTool(BaseTool):
name = "fs"
description = "Filesystem operations"
def __init__(self):
super().__init__()
self._register_actions()
def _register_actions(self):
@self.action("read", "Read file contents")
async def read(ctx, uri: str, range: dict | None = None) -> dict:
content = await read_file(uri)
return {"text": content, "hash": hash_content(content)}
@self.action("apply_patch", "Edit file with precondition")
async def apply_patch(ctx, uri: str, patch: str, base_hash: str) -> dict:
if get_hash(uri) != base_hash:
raise ConflictError("base_hash mismatch", expected=base_hash)
# Apply patch...
return {"uri": uri, "hash": new_hash}
"""
# Subclasses must define these
name: ClassVar[str]
# Version for meta envelope
VERSION: ClassVar[str] = "0.12.0"
def __init__(self):
self._handlers: dict[str, ActionHandler] = {}
self._register_builtin_actions()
def _register_builtin_actions(self):
"""Register built-in help and schema actions."""
@self.action("help", "List available actions with descriptions")
async def help_action(ctx: MCPContext) -> dict:
actions = {}
for name, handler in self._handlers.items():
if name not in ("help", "schema", "status"):
actions[name] = {
"description": handler.description,
"examples": handler.examples,
}
return {"actions": actions, "tool": self.name}
@self.action("schema", "Get JSON Schema for action parameters")
async def schema_action(
ctx: MCPContext, action_name: str | None = None
) -> dict:
if action_name:
handler = self._handlers.get(action_name)
if not handler:
raise ToolError(
code="UNKNOWN_ACTION",
message=f"Unknown action: {action_name}",
details={"available": list(self._handlers.keys())},
)
return {"action": action_name, "schema": handler.schema or {}}
schemas = {}
for name, handler in self._handlers.items():
if name not in ("help", "schema", "status"):
schemas[name] = handler.schema or {}
return {"schemas": schemas}
@self.action("status", "Get tool status and version")
async def status_action(ctx: MCPContext) -> dict:
return {
"tool": self.name,
"version": self.VERSION,
"enabled": True,
"actions": list(self._handlers.keys()),
}
def action(
self,
name: str,
description: str = "",
schema: dict[str, Any] | None = None,
examples: list[str] | None = None,
) -> Callable:
"""Decorator to register an action handler.
Args:
name: Action name (e.g., "read", "apply_patch")
description: Human-readable description
schema: Optional JSON Schema for parameters
examples: Optional list of example usages
Returns:
Decorator function
"""
def decorator(
fn: Callable[..., Awaitable[Any]],
) -> Callable[..., Awaitable[Any]]:
# Auto-generate schema from type hints if not provided
auto_schema = schema
if auto_schema is None:
auto_schema = self._generate_schema(fn)
self._handlers[name] = ActionHandler(
name=name,
handler=fn,
description=description or fn.__doc__ or "",
schema=auto_schema,
examples=examples or [],
)
return fn
return decorator
def _generate_schema(self, fn: Callable) -> dict[str, Any]:
"""Generate JSON Schema from function signature and type hints."""
try:
hints = get_type_hints(fn)
except Exception:
hints = {}
sig = inspect.signature(fn)
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "ctx"):
continue
param_schema: dict[str, Any] = {}
hint = hints.get(param_name)
if hint is str:
param_schema["type"] = "string"
elif hint is int:
param_schema["type"] = "integer"
elif hint is float:
param_schema["type"] = "number"
elif hint is bool:
param_schema["type"] = "boolean"
elif hint is dict or (
hasattr(hint, "__origin__") and hint.__origin__ is dict
):
param_schema["type"] = "object"
elif hint is list or (
hasattr(hint, "__origin__") and hint.__origin__ is list
):
param_schema["type"] = "array"
else:
param_schema["type"] = "string" # Default to string
properties[param_name] = param_schema
if param.default is inspect.Parameter.empty:
required.append(param_name)
return {
"type": "object",
"properties": properties,
"required": required,
}
def _envelope(
self,
data: Any,
action: str | None = None,
paging: Paging | None = None,
backend: str | None = None,
trace_id: str | None = None,
) -> dict[str, Any]:
"""Wrap result in unified response envelope."""
meta: dict[str, Any] = {
"tool": self.name,
"version": self.VERSION,
}
if action:
meta["action"] = action
if backend:
meta["backend"] = backend
if trace_id:
meta["trace_id"] = trace_id
if paging:
meta["paging"] = {
"cursor": paging.cursor,
"more": paging.more,
}
if paging.total is not None:
meta["paging"]["total"] = paging.total
else:
meta["paging"] = {"cursor": None, "more": False}
return {
"ok": True,
"data": data,
"error": None,
"meta": meta,
}
def _error(
self,
code: ErrorCode,
message: str,
**details: Any,
) -> dict[str, Any]:
"""Create error response envelope."""
error_dict: dict[str, Any] = {
"code": code,
"message": message,
}
if details:
error_dict.update(details)
return {
"ok": False,
"data": None,
"error": error_dict,
"meta": {"tool": self.name, "version": self.VERSION},
}
@property
@abstractmethod
def description(self) -> str:
"""Tool description for MCP registration."""
pass
async def call(
self, ctx: MCPContext, action: str = "help", **kwargs: Any
) -> dict[str, Any]:
"""Execute tool with action routing.
Args:
ctx: MCP context
action: Action to execute (default: "help")
**kwargs: Action parameters
Returns:
Unified response envelope
"""
if action not in self._handlers:
return self._error(
"UNKNOWN_ACTION",
f"Unknown action: {action}",
available=list(self._handlers.keys()),
)
handler = self._handlers[action]
try:
result = await handler.handler(ctx, **kwargs)
return self._envelope(result, action=action)
except ToolError as e:
return self._error(e.code, e.message, **e.details)
except Exception as e:
return self._error("INTERNAL_ERROR", str(e))
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Creates a single MCP tool with action parameter for routing.
"""
tool_name = self.name
tool_description = (
f"{self.description}\n\nActions: {', '.join(self._handlers.keys())}"
)
@mcp_server.tool(name=tool_name, description=tool_description)
async def handler(
ctx: MCPContext,
action: str = "help",
**kwargs: Any,
) -> str:
result = await self.call(ctx, action=action, **kwargs)
return json.dumps(result, indent=2, default=str)
# Utility functions for composability
def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
"""Generate content hash for file identity."""
if isinstance(content, str):
content = content.encode("utf-8")
h = hashlib.new(algorithm)
h.update(content)
return f"{algorithm}:{h.hexdigest()}"
def file_uri(path: str) -> str:
"""Convert path to file:// URI."""
from pathlib import Path
abs_path = Path(path).resolve()
return f"file://{abs_path}"
@dataclass
class Range:
"""Text range for composability.
0-based line and column numbers.
"""
start_line: int
start_col: int = 0
end_line: int | None = None
end_col: int | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dict format for serialization."""
result = {
"start": {"line": self.start_line, "col": self.start_col},
}
if self.end_line is not None:
result["end"] = {
"line": self.end_line,
"col": self.end_col or 0,
}
return result
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Range":
"""Create Range from dict."""
start = d.get("start", {})
end = d.get("end")
return cls(
start_line=start.get("line", 0),
start_col=start.get("col", 0),
end_line=end.get("line") if end else None,
end_col=end.get("col") if end else None,
)
@@ -1,115 +0,0 @@
"""Validation utilities for tool parameters."""
from typing import Optional
from pathlib import Path
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
def validate_path_parameter(
path: str,
param_name: str = "path",
must_exist: bool = False,
must_be_file: bool = False,
must_be_dir: bool = False,
) -> ValidationResult:
"""Validate a path parameter.
Args:
path: Path string to validate
param_name: Name of the parameter for error messages
must_exist: If True, path must exist
must_be_file: If True, path must be a file
must_be_dir: If True, path must be a directory
Returns:
ValidationResult with status and error if any
"""
if not path:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is required",
)
if not path.strip():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} cannot be empty",
)
try:
p = Path(path)
if must_exist and not p.exists():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} does not exist: {path}",
)
if must_be_file and p.exists() and not p.is_file():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is not a file: {path}",
)
if must_be_dir and p.exists() and not p.is_dir():
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is not a directory: {path}",
)
return ValidationResult(is_valid=True)
except Exception as e:
return ValidationResult(
is_valid=False,
error_message=f"Invalid {param_name}: {e}",
)
def validate_string_parameter(
value: str,
param_name: str,
min_length: int = 0,
max_length: Optional[int] = None,
pattern: Optional[str] = None,
) -> ValidationResult:
"""Validate a string parameter."""
if not value:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} is required",
)
if len(value) < min_length:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} must be at least {min_length} characters",
)
if max_length and len(value) > max_length:
return ValidationResult(
is_valid=False,
error_message=f"{param_name} must be at most {max_length} characters",
)
if pattern:
import re
if not re.match(pattern, value):
return ValidationResult(
is_valid=False,
error_message=f"{param_name} does not match required pattern",
)
return ValidationResult(is_valid=True)
+10 -7
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-core"
version = "0.3.0"
description = "DEPRECATED: Use hanzo-tools instead. This package re-exports from hanzo-tools for backwards compatibility."
version = "0.3.1"
description = "DEPRECATED: Use hanzo-tools instead. Empty compatibility shim that installs hanzo-tools."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
@@ -16,15 +16,18 @@ classifiers = [
"Operating System :: OS Independent",
]
keywords = ["hanzo", "tools", "mcp", "ai"]
# Just depends on hanzo-tools - re-exports everything
# Pure metapackage: ships no modules, only pulls in canonical hanzo-tools.
# It used to bundle a duplicate hanzo_tools/core/ that SHADOWED hanzo-tools
# (nondeterministic collision → missing symbols like ToolImage). Canonical
# hanzo-tools now owns hanzo_tools.core exclusively; this just redirects.
dependencies = [
"hanzo-tools>=0.3.0",
"hanzo-tools>=0.3.2",
]
[project.urls]
"Homepage" = "https://github.com/hanzoai/python-sdk"
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_tools*"]
[tool.setuptools]
# Empty shim — no importable packages or modules of its own.
py-modules = []
-1500
View File
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolImage,
ConflictError,
NotFoundError,
PermissionManager,
@@ -250,6 +251,22 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
if not path.is_file():
raise InvalidParamsError(f"Not a file: {path}", param="uri")
# Images are returned as native MCP image content so a vision-capable
# client sees the pixels — reading them as text yields garbage.
import mimetypes
mime, _ = mimetypes.guess_type(str(path))
if mime and mime.startswith("image/"):
async with aiofiles.open(path, "rb") as f:
raw = await f.read()
return {
"uri": file_uri(str(path)),
"hash": content_hash(raw),
"mime": mime,
"size": len(raw),
"image": ToolImage.from_bytes(raw, mime, alt=path.name),
}
try:
async with aiofiles.open(
path, "r", encoding=encoding, errors="replace"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-fs"
version = "0.3.3"
version = "0.3.4"
description = "Filesystem tools for Hanzo AI - read, write, edit, search, find"
readme = "README.md"
requires-python = ">=3.12"
-1739
View File
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ from hanzo_tools.core.unified import (
BaseTool, # Unified HIP-0300 base class
ErrorCode,
ToolError,
ToolImage,
ActionHandler,
ConflictError,
NotFoundError,
@@ -27,6 +28,7 @@ from hanzo_tools.core.unified import (
file_uri,
content_hash,
)
from hanzo_tools.core.id_tool import IdTool, id_tool
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.validation import ValidationResult, validate_path_parameter
from hanzo_tools.core.permissions import PermissionManager
@@ -41,6 +43,7 @@ __all__ = [
"ActionHandler",
"ErrorCode",
"ToolError",
"ToolImage",
"ConflictError",
"NotFoundError",
"InvalidParamsError",
@@ -48,6 +51,9 @@ __all__ = [
"Range",
"content_hash",
"file_uri",
# Identity tool
"IdTool",
"id_tool",
# Context
"ToolContext",
"create_tool_context",
+73 -2
View File
@@ -51,6 +51,39 @@ class Paging:
total: int | None = None
@dataclass
class ToolImage:
"""A visual result a tool wants the *client* to see natively.
Any action handler may return a ``ToolImage`` (alone, or nested anywhere in
its result dict/list). ``BaseTool.register`` detects it and emits a real MCP
``ImageContent`` block instead of flattening the bytes into JSON text — so an
agent with native vision (e.g. Claude) can look at the pixels directly. This
is the one canonical way to hand an image across the MCP boundary; tools that
return no image are serialized to JSON text exactly as before.
``data`` is base64-encoded image bytes; ``mime_type`` is the IANA type.
"""
data: str
mime_type: str = "image/png"
alt: str | None = None
@classmethod
def from_bytes(cls, raw: bytes, mime_type: str = "image/png", alt: str | None = None) -> "ToolImage":
"""Build a ToolImage from raw bytes (base64-encodes them)."""
import base64
return cls(data=base64.b64encode(raw).decode("ascii"), mime_type=mime_type, alt=alt)
def _placeholder(self) -> dict[str, Any]:
"""Compact stand-in kept in the JSON text so structure/metadata survive."""
p: dict[str, Any] = {"__image__": self.mime_type, "bytes": (len(self.data) * 3) // 4}
if self.alt:
p["alt"] = self.alt
return p
@dataclass
class ToolError(Exception):
"""Structured error for tool operations."""
@@ -440,9 +473,9 @@ class BaseTool(_BaseToolABC):
# Handler function — receives flat validated args
tool_ref = self
async def _handler(ctx: MCPContext, action: str = "help", **kwargs: Any) -> str:
async def _handler(ctx: MCPContext, action: str = "help", **kwargs: Any) -> Any:
result = await tool_ref.call(ctx, action=action, **kwargs)
return json.dumps(result, indent=2, default=str)
return _result_to_mcp(result)
metadata = FuncMetadata(arg_model=_FlexArgs)
@@ -463,6 +496,44 @@ class BaseTool(_BaseToolABC):
# Utility functions for composability
def _collect_images(node: Any, out: list["ToolImage"]) -> Any:
"""Walk a result, swapping each ToolImage for a JSON-safe placeholder.
Returns a structure-preserving copy with ToolImages replaced by compact
placeholders, appending the originals to ``out`` in encounter order.
"""
if isinstance(node, ToolImage):
out.append(node)
return node._placeholder()
if isinstance(node, dict):
return {k: _collect_images(v, out) for k, v in node.items()}
if isinstance(node, (list, tuple)):
return [_collect_images(v, out) for v in node]
return node
def _result_to_mcp(result: Any) -> Any:
"""Convert a tool result to an MCP tool return value.
No image → the JSON text string (unchanged from the text-only path). One or
more ``ToolImage`` anywhere in the result → a list of MCP content blocks:
the JSON text (with images replaced by placeholders) followed by one
``ImageContent`` per image, so a vision-capable client renders the pixels.
"""
images: list[ToolImage] = []
stripped = _collect_images(result, images)
text = json.dumps(stripped, indent=2, default=str)
if not images:
return text
from mcp.types import ImageContent, TextContent
blocks: list[Any] = [TextContent(type="text", text=text)]
for img in images:
blocks.append(ImageContent(type="image", data=img.data, mimeType=img.mime_type))
return blocks
def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
"""Generate content hash for file identity."""
if isinstance(content, str):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools"
version = "0.3.1"
version = "0.3.2"
description = "Hanzo AI tools - core infrastructure and tool bundles"
readme = "README.md"
requires-python = ">=3.12"