feat(tools): hanzo-tools-gimp (clean-room BSD-3)

This commit is contained in:
2026-06-17 21:53:05 +00:00
parent 9d8ed326be
commit 5e7a50767b
6 changed files with 620 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# hanzo-tools-gimp
Unified **GIMP automation** tool for Hanzo AI (HIP-0300).
Drives [GIMP](https://www.gimp.org/) 3.0 through the **Hanzo GIMP bridge** — a
clean-room, **BSD-3** GIMP plug-in (see `hanzo/gimp-mcp`) that exposes GIMP's
Procedural Database (PDB) over a line-oriented JSON TCP socket. This package is
the Python client for that bridge.
This is a clean-room implementation with **no GPL lineage**: both the bridge
and this client use GIMP's documented, public PDB / GObject-Introspection API.
## Install
```sh
pip install hanzo-tools-gimp
```
## Prerequisites
The Hanzo GIMP bridge plug-in must be running inside GIMP. Briefly:
1. Copy `hanzo_gimp_bridge.py` (from `hanzo/gimp-mcp`) into your GIMP 3.0
plug-ins directory (one folder per plug-in, script made executable).
2. Start GIMP and run **Filters → Hanzo → Start Hanzo Bridge**.
3. The bridge listens on `127.0.0.1:9876` by default (override with
`HANZO_GIMP_PORT` / `HANZO_GIMP_HOST`).
See the `gimp-mcp` repo's `README.md` and `PROTOCOL.md` for full details.
## Usage
```python
from hanzo_tools.gimp import GimpTool, register_tools, TOOLS
# Register with a FastMCP server
register_tools(mcp_server) # optional host=..., port=...
# Or use the tool directly
tool = GimpTool(host="127.0.0.1", port=9876)
```
The tool exposes the same action surface as the Hanzo MCP TypeScript `gimp`
tool:
| Action | Params | Result |
|-------------------|---------------------------------|--------|
| `version` | — | `{bridge, gimp, protocol}` |
| `open` | `path` | `{image_id, width, height}` |
| `export` | `image_id`, `path` | `{image_id, path, saved}` |
| `pdb_call` | `procedure`, `args` | the procedure's return value(s) |
| `new_image` | `width`, `height` | `{image_id, width, height}` |
| `image_info` | `image_id` | `{image_id, width, height, ...}` |
| `list_procedures` | `prefix?` | `{count, procedures}` |
| `flatten` | `image_id` | `{image_id, layer_id}` |
Every action also accepts `host` / `port` overrides.
`pdb_call` is the generic escape hatch — it can invoke **any** PDB procedure,
so anything GIMP can do is reachable.
## License
**BSD-3-Clause**. Copyright (c) 2026 Hanzo AI.
@@ -0,0 +1,4 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -0,0 +1,64 @@
"""GIMP automation tools for Hanzo AI (HIP-0300).
Drives GIMP 3.0 through the Hanzo GIMP bridge -- a clean-room, BSD-3 GIMP
plug-in that exposes GIMP's Procedural Database (PDB) over a JSON TCP socket.
No GPL lineage.
Tools:
- gimp: Unified GIMP automation tool (HIP-0300)
- version: Bridge + GIMP version
- open: Load an image file
- export: Export/save an image
- pdb_call: Invoke any PDB procedure (the core power)
- new_image: Create a blank RGB image
- image_info: Inspect an image
- list_procedures: Enumerate PDB procedures
- flatten: Flatten an image
Install:
pip install hanzo-tools-gimp
Usage:
from hanzo_tools.gimp import register_tools, TOOLS, GimpTool
# Register with an MCP server
register_tools(mcp_server)
# Or use the unified tool directly
tool = GimpTool(host="127.0.0.1", port=9876)
Requires the Hanzo GIMP bridge plug-in (hanzo/gimp-mcp) running inside GIMP.
"""
from hanzo_tools.core import BaseTool, ToolRegistry
from .gimp_tool import DEFAULT_HOST, DEFAULT_PORT, GimpTool, gimp_tool
# Export list for tool discovery - HIP-0300 unified tool
TOOLS = [GimpTool]
__all__ = [
"GimpTool",
"gimp_tool",
"DEFAULT_HOST",
"DEFAULT_PORT",
"register_tools",
"TOOLS",
]
def register_tools(mcp_server, **kwargs) -> list[BaseTool]:
"""Register gimp tools with the MCP server.
Args:
mcp_server: The FastMCP server instance.
**kwargs: Optional ``host`` / ``port`` for the GIMP bridge.
Returns:
List of registered tool instances.
"""
host = kwargs.get("host")
port = kwargs.get("port")
tool = GimpTool(host=host, port=port)
ToolRegistry.register_tool(mcp_server, tool)
return [tool]
@@ -0,0 +1,299 @@
"""Unified GIMP automation tool for the HIP-0300 architecture.
This module provides a single unified ``gimp`` tool that drives GIMP 3.0
through the Hanzo GIMP bridge -- a clean-room, BSD-3 GIMP plug-in that exposes
GIMP's Procedural Database (PDB) over a line-oriented JSON TCP socket.
Actions (matching the TypeScript ``gimp`` tool):
- version: bridge + GIMP version
- open: load an image file
- export: export/save an image to a path
- pdb_call: invoke any PDB procedure (the core power)
- new_image: create a blank RGB image
- image_info: inspect an image
- list_procedures: enumerate PDB procedures (optional prefix filter)
- flatten: flatten an image
Following Unix philosophy: one tool for the GIMP-automation axis. No GPL
lineage -- the bridge and this client speak GIMP's documented public API.
"""
import asyncio
import json
import os
from typing import Any, ClassVar
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
InvalidParamsError,
ToolError,
)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 9876
class GimpTool(BaseTool):
"""Unified GIMP automation tool (HIP-0300).
Connects to the Hanzo GIMP bridge over TCP and drives GIMP through its
PDB. The bridge default endpoint is ``127.0.0.1:9876``; every action
accepts ``host`` / ``port`` overrides.
Actions: version, open, export, pdb_call, new_image, image_info,
list_procedures, flatten.
"""
name: ClassVar[str] = "gimp"
VERSION: ClassVar[str] = "1.0.0"
def __init__(self, host: str | None = None, port: int | None = None):
super().__init__()
self.host = host or os.environ.get("HANZO_GIMP_HOST", DEFAULT_HOST)
env_port = os.environ.get("HANZO_GIMP_PORT")
self.port = port or (int(env_port) if env_port else DEFAULT_PORT)
self._register_gimp_actions()
@property
def description(self) -> str:
return """Unified GIMP automation tool (HIP-0300).
Drives GIMP 3.0 via the clean-room BSD-3 bridge (JSON-over-TCP to the GIMP PDB).
Actions:
- version: Bridge and GIMP version
- open: Load an image file (returns image_id, width, height)
- export: Export/save an image to a path
- pdb_call: Invoke any PDB procedure -- the generic core power
- new_image: Create a blank RGB image
- image_info: Inspect an image (size, layers, precision, file, dirty)
- list_procedures: Enumerate PDB procedures (optional prefix filter)
- flatten: Flatten an image
Requires the Hanzo GIMP bridge plug-in running inside GIMP
(default 127.0.0.1:9876).
"""
async def _bridge_request(
self,
method: str,
params: dict[str, Any],
host: str | None = None,
port: int | None = None,
timeout: float = 60.0,
) -> Any:
"""Send one JSON request to the GIMP bridge and return its result.
Opens a short-lived connection, writes one newline-terminated JSON
request, reads one newline-terminated JSON reply.
Raises:
ToolError: on connection failure, timeout, malformed reply, or an
error response from the bridge.
"""
host = host or self.host
port = port or self.port
request = json.dumps({"id": 1, "method": method, "params": params}) + "\n"
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout
)
except (OSError, asyncio.TimeoutError) as exc:
raise ToolError(
code="NOT_FOUND",
message=f"GIMP bridge connection failed ({host}:{port}): {exc}",
details={"host": host, "port": port},
)
try:
writer.write(request.encode("utf-8"))
await writer.drain()
line = await asyncio.wait_for(reader.readline(), timeout=timeout)
except asyncio.TimeoutError:
raise ToolError(code="TIMEOUT", message="GIMP bridge timed out")
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
if not line:
raise ToolError(
code="INTERNAL_ERROR", message="GIMP bridge closed without replying"
)
try:
msg = json.loads(line.decode("utf-8"))
except json.JSONDecodeError as exc:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Invalid response from GIMP bridge: {exc}",
)
if isinstance(msg, dict) and msg.get("error"):
err = msg["error"]
raise ToolError(
code="INTERNAL_ERROR",
message=err.get("message", "GIMP bridge error"),
details={"type": err.get("type")},
)
return msg.get("result") if isinstance(msg, dict) else msg
def _register_gimp_actions(self):
"""Register all GIMP actions."""
@self.action("version", "Get bridge and GIMP version")
async def version(
ctx: MCPContext,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Return the bridge version, GIMP version, and protocol id."""
return await self._bridge_request("version", {}, host, port)
@self.action("open", "Open (load) an image file")
async def open_image(
ctx: MCPContext,
path: str,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Load an image from disk.
Args:
path: Filesystem path to the image.
Returns:
``{image_id, width, height}``.
"""
if not path:
raise InvalidParamsError("path required", param="path")
return await self._bridge_request("open_image", {"path": path}, host, port)
@self.action("export", "Export/save an image to a path")
async def export(
ctx: MCPContext,
image_id: int,
path: str,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Export an image; the format is chosen by the file extension.
Args:
image_id: GIMP image id.
path: Destination path (e.g. ``out.png``, ``out.xcf``).
"""
if image_id is None:
raise InvalidParamsError("image_id required", param="image_id")
if not path:
raise InvalidParamsError("path required", param="path")
return await self._bridge_request(
"export", {"image_id": image_id, "path": path}, host, port
)
@self.action("pdb_call", "Invoke any PDB procedure (generic core power)")
async def pdb_call(
ctx: MCPContext,
procedure: str,
args: list | None = None,
host: str | None = None,
port: int | None = None,
) -> Any:
"""Invoke an arbitrary GIMP PDB procedure.
Anything GIMP can do is reachable here. Integer ids may be passed
where a procedure expects a GIMP object (image/drawable/layer/...).
Args:
procedure: PDB procedure name, e.g. ``gimp-image-flatten``.
args: Positional arguments in PDB order.
"""
if not procedure:
raise InvalidParamsError("procedure required", param="procedure")
return await self._bridge_request(
"pdb_call", {"procedure": procedure, "args": args or []}, host, port
)
@self.action("new_image", "Create a blank RGB image")
async def new_image(
ctx: MCPContext,
width: int,
height: int,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Create a new blank RGB image.
Args:
width: Image width in pixels.
height: Image height in pixels.
Returns:
``{image_id, width, height}``.
"""
if width is None or height is None:
raise InvalidParamsError(
"width and height required", param="width"
)
return await self._bridge_request(
"new_image", {"width": width, "height": height}, host, port
)
@self.action("image_info", "Inspect an image")
async def image_info(
ctx: MCPContext,
image_id: int,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Return image size, layers, precision, source file, and dirty flag."""
if image_id is None:
raise InvalidParamsError("image_id required", param="image_id")
return await self._bridge_request(
"image_info", {"image_id": image_id}, host, port
)
@self.action("list_procedures", "Enumerate PDB procedures")
async def list_procedures(
ctx: MCPContext,
prefix: str | None = None,
host: str | None = None,
port: int | None = None,
) -> dict:
"""List PDB procedure names, optionally filtered by ``prefix``.
Returns:
``{count, procedures: [...]}``.
"""
params = {"prefix": prefix} if prefix else {}
return await self._bridge_request(
"list_procedures", params, host, port
)
@self.action("flatten", "Flatten an image")
async def flatten(
ctx: MCPContext,
image_id: int,
host: str | None = None,
port: int | None = None,
) -> dict:
"""Flatten all layers of an image into one.
Returns:
``{image_id, layer_id}``.
"""
if image_id is None:
raise InvalidParamsError("image_id required", param="image_id")
return await self._bridge_request(
"flatten", {"image_id": image_id}, host, port
)
# Backward compatibility / convenience handle.
gimp_tool = GimpTool
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hanzo-tools-gimp"
version = "1.0.0"
description = "Unified GIMP automation tool for Hanzo AI (HIP-0300) — clean-room BSD-3 client for the GIMP PDB bridge"
readme = "README.md"
license = { text = "BSD-3-Clause" }
requires-python = ">=3.12"
authors = [
{ name = "Hanzo AI", email = "[email protected]" },
]
keywords = [
"hanzo",
"mcp",
"tools",
"gimp",
"pdb",
"image",
"automation",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Graphics",
]
dependencies = [
"hanzo-tools>=0.3.0",
"mcp>=1.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.entry-points."hanzo.tools"]
gimp = "hanzo_tools.gimp:TOOLS"
[project.urls]
Homepage = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_tools"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -0,0 +1,123 @@
"""Tests for hanzo-tools-gimp."""
import pytest
class TestImports:
"""Test that all modules can be imported."""
def test_import_package(self):
from hanzo_tools import gimp
assert gimp is not None
def test_import_tools(self):
from hanzo_tools.gimp import TOOLS
assert len(TOOLS) == 1
def test_import_gimp_tool(self):
from hanzo_tools.gimp import GimpTool
assert GimpTool.name == "gimp"
def test_register_tools_callable(self):
from hanzo_tools.gimp import register_tools
assert callable(register_tools)
class TestGimpTool:
"""Tests for GimpTool."""
@pytest.fixture
def tool(self):
from hanzo_tools.gimp import GimpTool
return GimpTool()
def test_name(self, tool):
assert tool.name == "gimp"
def test_version(self, tool):
assert tool.VERSION
def test_has_description(self, tool):
assert tool.description
assert "gimp" in tool.description.lower()
def test_default_endpoint(self, tool):
assert tool.host == "127.0.0.1"
assert tool.port == 9876
def test_custom_endpoint(self):
from hanzo_tools.gimp import GimpTool
t = GimpTool(host="10.0.0.5", port=12345)
assert t.host == "10.0.0.5"
assert t.port == 12345
def test_action_surface(self, tool):
# The tool must register exactly the documented actions
# (plus the BaseTool built-ins: help, schema, status).
expected = {
"version",
"open",
"export",
"pdb_call",
"new_image",
"image_info",
"list_procedures",
"flatten",
}
registered = set(tool._handlers.keys())
assert expected <= registered, expected - registered
def test_builtin_actions_present(self, tool):
registered = set(tool._handlers.keys())
assert {"help", "schema", "status"} <= registered
class TestSchema:
"""Schema generation from the registered actions."""
@pytest.fixture
def tool(self):
from hanzo_tools.gimp import GimpTool
return GimpTool()
@pytest.mark.asyncio
async def test_schema_action_returns_schemas(self, tool):
env = await tool.call(None, "schema")
assert env["ok"] is True
schemas = env["data"]["schemas"]
assert "pdb_call" in schemas
# pdb_call requires a "procedure" string parameter.
pdb = schemas["pdb_call"]
assert pdb["properties"]["procedure"]["type"] == "string"
assert "procedure" in pdb["required"]
class TestBridgeErrors:
"""Behavioral tests that do not require a running GIMP."""
@pytest.fixture
def tool(self):
from hanzo_tools.gimp import GimpTool
# Point at a closed port so connection fails fast.
return GimpTool(host="127.0.0.1", port=9)
@pytest.mark.asyncio
async def test_connection_failure_is_reported(self, tool):
# call() catches ToolError and returns an error envelope.
env = await tool.call(None, "version")
assert env["ok"] is False
assert env["error"] is not None
@pytest.mark.asyncio
async def test_missing_required_param(self, tool):
# pdb_call without a procedure -> InvalidParamsError -> error envelope.
env = await tool.call(None, "pdb_call")
assert env["ok"] is False