mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hanzo-mcp"
|
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."
|
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -30,11 +30,11 @@ dependencies = [
|
|||||||
"hanzo-async>=0.1.3",
|
"hanzo-async>=0.1.3",
|
||||||
"uvloop>=0.22.1; sys_platform != 'win32'",
|
"uvloop>=0.22.1; sys_platform != 'win32'",
|
||||||
# Hanzo tool infrastructure (all tools)
|
# Hanzo tool infrastructure (all tools)
|
||||||
"hanzo-tools>=0.3.0",
|
"hanzo-tools>=0.3.2", # 0.3.2 owns hanzo_tools.core exclusively + adds ToolImage (native MCP images)
|
||||||
"hanzo-tools-fs>=0.1.0",
|
"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); older shadowed by legacy core<0.3.0
|
"hanzo-tools-shell>=0.6.5", # 0.6.5 pairs with hanzo-tools>=0.3.0 (ToolError)
|
||||||
"hanzo-tools-core>=0.3.0", # hard floor: legacy split package must carry ToolError (pre-0.3.0 breaks shell import)
|
"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.8", # 3 peer tools (browser/cdp/playwright) over zapd consumer (~/.zap/run/zapd.sock)
|
"hanzo-tools-browser[playwright]>=0.5.9", # 0.5.9 emits screenshots as native MCP ImageContent
|
||||||
"hanzo-tools-memory>=0.2.0",
|
"hanzo-tools-memory>=0.2.0",
|
||||||
"hanzo-tools-todo>=0.1.0",
|
"hanzo-tools-todo>=0.1.0",
|
||||||
"hanzo-tools-reasoning>=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 pydantic import Field
|
||||||
from mcp.server import FastMCP
|
from mcp.server import FastMCP
|
||||||
|
|
||||||
from hanzo_tools.core import BaseTool
|
from hanzo_tools.core import BaseTool, ToolImage
|
||||||
|
|
||||||
# Playwright import with graceful fallback
|
# Playwright import with graceful fallback
|
||||||
try:
|
try:
|
||||||
@@ -371,7 +371,7 @@ async def _bidi_dispatch(
|
|||||||
return {"success": True, "source": "bidi"}
|
return {"success": True, "source": "bidi"}
|
||||||
if action == "screenshot":
|
if action == "screenshot":
|
||||||
c = await _ctx()
|
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":
|
if action == "evaluate":
|
||||||
c = await _ctx()
|
c = await _ctx()
|
||||||
return {"success": True, "source": "bidi", "result": _val(await client.script_evaluate(c, code or ""))}
|
return {"success": True, "source": "bidi", "result": _val(await client.script_evaluate(c, code or ""))}
|
||||||
@@ -1852,7 +1852,7 @@ CATEGORIES:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"format": "png",
|
"format": "png",
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"base64": base64.b64encode(data).decode(),
|
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif action == "pdf":
|
elif action == "pdf":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hanzo-tools-browser"
|
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."
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
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)
|
|
||||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hanzo-tools-core"
|
name = "hanzo-tools-core"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
description = "DEPRECATED: Use hanzo-tools instead. This package re-exports from hanzo-tools for backwards compatibility."
|
description = "DEPRECATED: Use hanzo-tools instead. Empty compatibility shim that installs hanzo-tools."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
@@ -16,15 +16,18 @@ classifiers = [
|
|||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
]
|
]
|
||||||
keywords = ["hanzo", "tools", "mcp", "ai"]
|
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 = [
|
dependencies = [
|
||||||
"hanzo-tools>=0.3.0",
|
"hanzo-tools>=0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Homepage" = "https://github.com/hanzoai/python-sdk"
|
"Homepage" = "https://github.com/hanzoai/python-sdk"
|
||||||
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
|
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools]
|
||||||
where = ["."]
|
# Empty shim — no importable packages or modules of its own.
|
||||||
include = ["hanzo_tools*"]
|
py-modules = []
|
||||||
|
|||||||
Generated
-1500
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 (
|
from hanzo_tools.core import (
|
||||||
BaseTool,
|
BaseTool,
|
||||||
|
ToolImage,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
PermissionManager,
|
PermissionManager,
|
||||||
@@ -250,6 +251,22 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise InvalidParamsError(f"Not a file: {path}", param="uri")
|
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:
|
try:
|
||||||
async with aiofiles.open(
|
async with aiofiles.open(
|
||||||
path, "r", encoding=encoding, errors="replace"
|
path, "r", encoding=encoding, errors="replace"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hanzo-tools-fs"
|
name = "hanzo-tools-fs"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
description = "Filesystem tools for Hanzo AI - read, write, edit, search, find"
|
description = "Filesystem tools for Hanzo AI - read, write, edit, search, find"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Generated
-1739
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
|
BaseTool, # Unified HIP-0300 base class
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
ToolError,
|
ToolError,
|
||||||
|
ToolImage,
|
||||||
ActionHandler,
|
ActionHandler,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
@@ -27,6 +28,7 @@ from hanzo_tools.core.unified import (
|
|||||||
file_uri,
|
file_uri,
|
||||||
content_hash,
|
content_hash,
|
||||||
)
|
)
|
||||||
|
from hanzo_tools.core.id_tool import IdTool, id_tool
|
||||||
from hanzo_tools.core.decorators import auto_timeout
|
from hanzo_tools.core.decorators import auto_timeout
|
||||||
from hanzo_tools.core.validation import ValidationResult, validate_path_parameter
|
from hanzo_tools.core.validation import ValidationResult, validate_path_parameter
|
||||||
from hanzo_tools.core.permissions import PermissionManager
|
from hanzo_tools.core.permissions import PermissionManager
|
||||||
@@ -41,6 +43,7 @@ __all__ = [
|
|||||||
"ActionHandler",
|
"ActionHandler",
|
||||||
"ErrorCode",
|
"ErrorCode",
|
||||||
"ToolError",
|
"ToolError",
|
||||||
|
"ToolImage",
|
||||||
"ConflictError",
|
"ConflictError",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"InvalidParamsError",
|
"InvalidParamsError",
|
||||||
@@ -48,6 +51,9 @@ __all__ = [
|
|||||||
"Range",
|
"Range",
|
||||||
"content_hash",
|
"content_hash",
|
||||||
"file_uri",
|
"file_uri",
|
||||||
|
# Identity tool
|
||||||
|
"IdTool",
|
||||||
|
"id_tool",
|
||||||
# Context
|
# Context
|
||||||
"ToolContext",
|
"ToolContext",
|
||||||
"create_tool_context",
|
"create_tool_context",
|
||||||
|
|||||||
@@ -51,6 +51,39 @@ class Paging:
|
|||||||
total: int | None = None
|
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
|
@dataclass
|
||||||
class ToolError(Exception):
|
class ToolError(Exception):
|
||||||
"""Structured error for tool operations."""
|
"""Structured error for tool operations."""
|
||||||
@@ -440,9 +473,9 @@ class BaseTool(_BaseToolABC):
|
|||||||
# Handler function — receives flat validated args
|
# Handler function — receives flat validated args
|
||||||
tool_ref = self
|
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)
|
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)
|
metadata = FuncMetadata(arg_model=_FlexArgs)
|
||||||
|
|
||||||
@@ -463,6 +496,44 @@ class BaseTool(_BaseToolABC):
|
|||||||
# Utility functions for composability
|
# 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:
|
def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
|
||||||
"""Generate content hash for file identity."""
|
"""Generate content hash for file identity."""
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hanzo-tools"
|
name = "hanzo-tools"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
description = "Hanzo AI tools - core infrastructure and tool bundles"
|
description = "Hanzo AI tools - core infrastructure and tool bundles"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user