mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 11:49:36 +00:00
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.
85 lines
2.9 KiB
Python
85 lines
2.9 KiB
Python
"""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"
|