commit 1a08d70cf953595d08d11ce5ac80523cded69200 Author: Hanzo AI Date: Wed Jun 17 21:10:54 2026 +0000 gimp-mcp: clean-room BSD-3 GIMP PDB bridge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c58d1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ +*.xcf~ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a443363 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026 Hanzo AI + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f9f7d70 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +hanzo-gimp-mcp -- clean-room BSD-3 implementation by Hanzo AI; no GPL lineage. +Drives GIMP via its documented PDB API. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..d398cec --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,133 @@ +# Hanzo GIMP Bridge Protocol + +A line-oriented JSON protocol over a plain TCP socket. The bridge plug-in +(`hanzo_gimp_bridge.py`) runs inside GIMP 3.0 and listens, by default, on +`127.0.0.1:9876` (override with the `HANZO_GIMP_PORT` / `HANZO_GIMP_HOST` +environment variables before starting GIMP). + +This is a **clean-room BSD-3** design. Every operation maps onto GIMP's own +documented Procedural Database (PDB) and the `Gimp.*` GObject-Introspection +API. There is no GPL lineage. + +## Framing + +* One JSON object per line, UTF-8, terminated by a single `\n`. +* The client sends one **request** object per line. +* The server replies with exactly one **response** object per request, + newline-terminated. +* Multiple requests may be pipelined on one connection. + +### Request + +```json +{"id": , "method": "", "params": { ... }} +``` + +* `id` — any JSON value; echoed back so clients can correlate replies. +* `method` — one of the methods below. +* `params` — method-specific object (may be omitted / empty). + +### Response + +Success: + +```json +{"id": , "result": } +``` + +Error: + +```json +{"id": , "error": {"message": "", "type": ""}} +``` + +## Object ids + +GIMP 3.0 PDB procedures operate on live objects (images, layers, drawables, +paths). Because JSON can only carry primitives, the bridge exchanges **integer +ids**. Where a PDB procedure expects an `Image`, `Item`, `Drawable`, `Layer`, +`Channel`, or `Path`, pass its integer id and the bridge rehydrates the live +object. Returned objects are likewise reduced to integer ids. + +## Methods + +| Method | Params | Result | +|-------------------|------------------------------------------|--------| +| `version` | — | `{bridge, gimp, protocol}` | +| `pdb_call` | `procedure: str`, `args: list` | the procedure's return value(s) (ids for objects), or `null` | +| `open_image` | `path: str` | `{image_id, width, height}` | +| `export` | `image_id: int`, `path: str` | `{image_id, path, saved}` | +| `new_image` | `width: int`, `height: int` | `{image_id, width, height}` | +| `image_info` | `image_id: int` | `{image_id, width, height, base_type, precision, layers, n_layers, file, dirty}` | +| `list_procedures` | `prefix?: str` | `{count, procedures: [str, ...]}` | +| `flatten` | `image_id: int` | `{image_id, layer_id}` | + +### `pdb_call` — the core power + +`pdb_call` is a generic invocation of any procedure in GIMP's PDB. Anything +GIMP can do is reachable through it. The bridge: + +1. Looks up the procedure by name. +2. If the first PDB argument is a run-mode and you did not supply one, + prepends `NONINTERACTIVE`. +3. Coerces integer ids in `args` to the live `Image` / `Item` / `Gio.File` + objects the procedure expects. +4. Runs it and returns the values after the PDB status code (object results + reduced to ids). A non-`SUCCESS` status becomes an `error` response. + +Use `list_procedures` to discover names (e.g. prefix `"gimp-image-"`). + +## Examples + +Assuming a raw TCP connection to `127.0.0.1:9876`: + +**Version** + +``` +--> {"id": 1, "method": "version"} +<-- {"id": 1, "result": {"bridge": "1.0.0", "gimp": "3.0.0", "protocol": "hanzo-gimp/1"}} +``` + +**Open an image** + +``` +--> {"id": 2, "method": "open_image", "params": {"path": "/tmp/in.png"}} +<-- {"id": 2, "result": {"image_id": 1, "width": 1920, "height": 1080}} +``` + +**Generic PDB call — Gaussian blur on the active drawable** + +``` +--> {"id": 3, "method": "pdb_call", + "params": {"procedure": "plug-in-gauss", "args": [1, , 15, 15, 0]}} +<-- {"id": 3, "result": null} +``` + +**Flatten and export** + +``` +--> {"id": 4, "method": "flatten", "params": {"image_id": 1}} +<-- {"id": 4, "result": {"image_id": 1, "layer_id": 7}} +--> {"id": 5, "method": "export", "params": {"image_id": 1, "path": "/tmp/out.png"}} +<-- {"id": 5, "result": {"image_id": 1, "path": "/tmp/out.png", "saved": true}} +``` + +**Discover procedures** + +``` +--> {"id": 6, "method": "list_procedures", "params": {"prefix": "gimp-image-"}} +<-- {"id": 6, "result": {"count": 42, "procedures": ["gimp-image-add-layer", ...]}} +``` + +**Error shape** + +``` +--> {"id": 7, "method": "pdb_call", "params": {"procedure": "no-such-proc", "args": []}} +<-- {"id": 7, "error": {"message": "unknown PDB procedure: no-such-proc", "type": "ValueError"}} +``` + +## Concurrency + +GIMP/GEGL are single-threaded. The bridge accepts connections on worker +threads but marshals every request onto GIMP's GLib main loop, so operations +execute serially in the order GIMP receives them. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2576a5 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# hanzo-gimp-mcp + +A **clean-room, BSD-3-licensed** bridge that lets external tools drive +[GIMP](https://www.gimp.org/) 3.0 programmatically. + +It ships one GIMP 3.0 Python plug-in, `hanzo_gimp_bridge.py`, which registers +itself in GIMP and runs a small **JSON-over-TCP server** inside the running +GIMP process. Clients connect to that socket and invoke GIMP through its +documented **Procedural Database (PDB)** and `Gimp.*` GObject-Introspection +API. + +* Protocol: see [`PROTOCOL.md`](./PROTOCOL.md). +* The Hanzo MCP `gimp` tool (TypeScript) and `hanzo-tools-gimp` (Python) + connect to this bridge and speak the same protocol. + +## Clean-room / licensing + +This implementation is an **original, clean-room rewrite**. It was designed +solely from GIMP's own public API documentation (the PDB and the GIMP 3.0 +Python/GI bindings). It has **no GPL lineage** and reuses no code from any +prior GPL GIMP-MCP implementation. + +* License: **BSD-3-Clause** (see [`LICENSE`](./LICENSE)). +* Copyright (c) 2026 Hanzo AI. +* See [`NOTICE`](./NOTICE). + +GIMP itself remains GPL and is a separate program; this bridge merely talks to +it over a socket using its public, documented interfaces — the same way any +external automation client would. + +## Install + +GIMP 3.0 loads Python plug-ins from per-user plug-in directories. Each plug-in +must live in **its own folder whose name matches the script**, and the script +must be **executable**. + +### Linux + +```sh +mkdir -p ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge +cp hanzo_gimp_bridge.py ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge/ +chmod +x ~/.config/GIMP/3.0/plug-ins/hanzo_gimp_bridge/hanzo_gimp_bridge.py +``` + +### macOS + +```sh +mkdir -p "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge" +cp hanzo_gimp_bridge.py "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge/" +chmod +x "~/Library/Application Support/GIMP/3.0/plug-ins/hanzo_gimp_bridge/hanzo_gimp_bridge.py" +``` + +### Windows + +Copy `hanzo_gimp_bridge.py` into: + +``` +%APPDATA%\GIMP\3.0\plug-ins\hanzo_gimp_bridge\hanzo_gimp_bridge.py +``` + +You can also confirm/extend the search paths in GIMP under +**Edit -> Preferences -> Folders -> Plug-ins**. + +## Run + +1. Start GIMP 3.0. (Optionally set the port first: + `HANZO_GIMP_PORT=9876 gimp`. `HANZO_GIMP_HOST` defaults to `127.0.0.1`.) +2. In GIMP, run **Filters -> Hanzo -> Start Hanzo Bridge**. GIMP shows a + message like `Hanzo GIMP bridge listening on 127.0.0.1:9876`. +3. The bridge now accepts JSON-line requests on that port. + +A headless variant is possible with GIMP's batch mode; the simplest reliable +setup is an interactive GIMP session with the bridge started from the menu. + +## Quick test + +With the bridge running, from any shell: + +```sh +printf '{"id":1,"method":"version"}\n' | nc 127.0.0.1 9876 +``` + +You should get back a `version` result. + +## Connecting from Hanzo + +* **TypeScript MCP**: the `gimp` tool in `hanzo/mcp` + (`src/tools/gimp.ts`) exposes actions + (`version`, `open`, `export`, `pdb_call`, `new_image`, `image_info`, + `list_procedures`, `flatten`) and forwards them to this bridge. +* **Python**: `hanzo-tools-gimp` provides `GimpTool` with the same action + surface. + +Both default to `127.0.0.1:9876` and accept `host` / `port` overrides. + +## Methods at a glance + +`version`, `pdb_call`, `open_image`, `export`, `new_image`, `image_info`, +`list_procedures`, `flatten`. `pdb_call` is the generic escape hatch: it can +invoke **any** PDB procedure, so anything GIMP can do is reachable. See +[`PROTOCOL.md`](./PROTOCOL.md) for full details and examples. diff --git a/hanzo_gimp_bridge.py b/hanzo_gimp_bridge.py new file mode 100644 index 0000000..82150ba --- /dev/null +++ b/hanzo_gimp_bridge.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Hanzo AI +# +# hanzo_gimp_bridge.py -- clean-room BSD-3 GIMP 3.0 bridge plug-in. +# +# This is a GIMP 3.0 Python plug-in (GObject-Introspection style) that runs a +# small line-oriented JSON TCP server inside GIMP. External clients (the Hanzo +# MCP "gimp" tool, hanzo-tools-gimp, or anything that can open a socket) drive +# GIMP entirely through its documented Procedural Database (PDB) and the +# Gimp.* introspected API. No GPL lineage: every call below is derived from +# GIMP's own public API documentation. +# +# Protocol (see PROTOCOL.md): +# request : {"id": , "method": , "params": {<...>}}\n +# response : {"id": , "result": }\n +# or {"id": , "error": {"message": , "type": }}\n +# One JSON object per line, UTF-8, newline-terminated. + +from __future__ import annotations + +import json +import os +import socket +import sys +import threading +import traceback +from typing import Any + +import gi + +gi.require_version("Gimp", "3.0") +from gi.repository import Gimp, Gio, GLib, GObject # noqa: E402 + +# --------------------------------------------------------------------------- +# Defaults / configuration +# --------------------------------------------------------------------------- + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 9876 +BRIDGE_VERSION = "1.0.0" + +# Procedure registered inside GIMP to start the bridge from the Filters menu. +PROC_START = "hanzo-gimp-bridge-start" + + +# --------------------------------------------------------------------------- +# GIMP value <-> JSON marshalling +# --------------------------------------------------------------------------- + +def _gimp_object_to_id(obj: Any) -> Any: + """Reduce a GIMP object (image/drawable/layer/...) to its integer id. + + GIMP 3.0 PDB procedures accept/return live objects, but a JSON wire + protocol can only carry primitives, so we exchange integer ids and + rehydrate them on demand via ``Gimp.Image.get_by_id`` and friends. + """ + if obj is None: + return None + for getter in ("get_id",): + fn = getattr(obj, getter, None) + if callable(fn): + try: + return int(fn()) + except Exception: + pass + return obj + + +def _json_default(obj: Any) -> Any: + """Best-effort JSON fallback for non-primitive GIMP/GLib values.""" + reduced = _gimp_object_to_id(obj) + if reduced is not obj: + return reduced + if isinstance(obj, (bytes, bytearray)): + return list(obj) + return str(obj) + + +def _image_by_id(image_id: int) -> Gimp.Image: + img = Gimp.Image.get_by_id(int(image_id)) + if img is None: + raise ValueError(f"no image with id {image_id}") + return img + + +# --------------------------------------------------------------------------- +# Generic PDB invocation +# --------------------------------------------------------------------------- + +def _coerce_arg(spec: GObject.ParamSpec, value: Any) -> Any: + """Coerce a JSON value to the type a PDB argument expects. + + Integer ids are rehydrated into the GIMP object the PDB wants + (Image / Drawable / Layer / Item), so callers can pass plain ints. + """ + if value is None: + return None + + value_type = spec.value_type + + # Object arguments: accept an int id, fetch the live object. + if GObject.type_is_a(value_type, Gimp.Image.__gtype__): + return Gimp.Image.get_by_id(int(value)) + if GObject.type_is_a(value_type, Gimp.Item.__gtype__): + # Covers Drawable, Layer, Channel, Vectors/Path -- all Items. + return Gimp.Item.get_by_id(int(value)) + if GObject.type_is_a(value_type, Gio.File.__gtype__): + return Gio.File.new_for_path(str(value)) + + return value + + +def _pdb_call(procedure: str, args: list[Any]) -> Any: + """Invoke an arbitrary PDB procedure -- the core power of the bridge. + + Args: + procedure: PDB procedure name, e.g. "gimp-image-flatten". + args: Positional arguments in PDB order. Integer ids are accepted + where the procedure expects a GIMP object. + + Returns: + ``None`` if the procedure returns only a status, + a single value if it returns exactly one result, + a list of values if it returns several. + GIMP objects are reduced to their integer ids. + """ + pdb = Gimp.get_pdb() + proc = pdb.lookup_procedure(procedure) + if proc is None: + raise ValueError(f"unknown PDB procedure: {procedure}") + + arg_specs = proc.get_arguments() + config = proc.create_config() + + # The first PDB arg of most procedures is a run-mode; default it to + # NONINTERACTIVE when the caller did not supply it. + supplied = list(args) + if arg_specs and arg_specs[0].value_type == Gimp.RunMode.__gtype__: + if len(supplied) < len(arg_specs): + supplied = [int(Gimp.RunMode.NONINTERACTIVE)] + supplied + + for spec, value in zip(arg_specs, supplied): + config.set_property(spec.get_name(), _coerce_arg(spec, value)) + + result = proc.run(config) + + status = result.index(0) + if status != Gimp.PDBStatusType.SUCCESS: + msg = "" + try: + msg = result.index(1) + except Exception: + pass + raise RuntimeError(f"PDB {procedure} failed: {status.value_nick}: {msg}") + + # Collect return values after the status code. + out: list[Any] = [] + for i in range(1, result.length()): + out.append(_gimp_object_to_id(result.index(i))) + + if not out: + return None + if len(out) == 1: + return out[0] + return out + + +# --------------------------------------------------------------------------- +# High-level convenience methods +# --------------------------------------------------------------------------- + +def _open_image(path: str) -> dict[str, Any]: + gfile = Gio.File.new_for_path(path) + image = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, gfile) + return { + "image_id": _gimp_object_to_id(image), + "width": image.get_width(), + "height": image.get_height(), + } + + +def _export(image_id: int, path: str) -> dict[str, Any]: + image = _image_by_id(image_id) + gfile = Gio.File.new_for_path(path) + # Gimp.file_save dispatches to the right exporter by file extension and + # works for the common formats (png/jpg/tiff/webp/...). It exports a + # flattened copy for non-native formats; the .xcf path is GIMP-native. + Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, image, gfile, None) + return {"image_id": int(image_id), "path": path, "saved": True} + + +def _new_image(width: int, height: int) -> dict[str, Any]: + image = Gimp.Image.new(int(width), int(height), Gimp.ImageBaseType.RGB) + return { + "image_id": _gimp_object_to_id(image), + "width": image.get_width(), + "height": image.get_height(), + } + + +def _image_info(image_id: int) -> dict[str, Any]: + image = _image_by_id(image_id) + layers = [_gimp_object_to_id(l) for l in image.get_layers()] + gfile = image.get_file() + return { + "image_id": int(image_id), + "width": image.get_width(), + "height": image.get_height(), + "base_type": image.get_base_type().value_nick, + "precision": image.get_precision().value_nick, + "layers": layers, + "n_layers": len(layers), + "file": gfile.get_path() if gfile is not None else None, + "dirty": bool(image.is_dirty()), + } + + +def _list_procedures(prefix: str | None = None) -> dict[str, Any]: + pdb = Gimp.get_pdb() + names = list(pdb.query_procedures("", "", "", "", "", "", "")) + if prefix: + names = [n for n in names if n.startswith(prefix)] + names.sort() + return {"count": len(names), "procedures": names} + + +def _flatten(image_id: int) -> dict[str, Any]: + image = _image_by_id(image_id) + layer = image.flatten() + return {"image_id": int(image_id), "layer_id": _gimp_object_to_id(layer)} + + +def _version() -> dict[str, Any]: + return { + "bridge": BRIDGE_VERSION, + "gimp": Gimp.version(), + "protocol": "hanzo-gimp/1", + } + + +# --------------------------------------------------------------------------- +# Method dispatch +# --------------------------------------------------------------------------- + +def dispatch(method: str, params: dict[str, Any]) -> Any: + """Route a single JSON-protocol request to its handler.""" + params = params or {} + + if method == "version": + return _version() + if method == "pdb_call": + return _pdb_call(params["procedure"], params.get("args", [])) + if method == "open_image": + return _open_image(params["path"]) + if method == "export": + return _export(params["image_id"], params["path"]) + if method == "new_image": + return _new_image(params["width"], params["height"]) + if method == "image_info": + return _image_info(params["image_id"]) + if method == "list_procedures": + return _list_procedures(params.get("prefix")) + if method == "flatten": + return _flatten(params["image_id"]) + + raise ValueError(f"unknown method: {method}") + + +# --------------------------------------------------------------------------- +# TCP JSON line server +# --------------------------------------------------------------------------- + +class BridgeServer: + """Line-oriented JSON TCP server that runs GIMP requests on the main loop. + + GIMP/GEGL are not thread-safe, so every request is marshalled onto GIMP's + GLib main loop via ``GLib.idle_add`` and the socket thread blocks on the + result. This keeps all PDB work on the thread GIMP expects. + """ + + def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT): + self.host = host + self.port = port + self._sock: socket.socket | None = None + self._thread: threading.Thread | None = None + self._running = False + + def start(self) -> None: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind((self.host, self.port)) + self._sock.listen(8) + self._running = True + self._thread = threading.Thread(target=self._accept_loop, daemon=True) + self._thread.start() + sys.stderr.write( + f"[hanzo-gimp-bridge] listening on {self.host}:{self.port}\n" + ) + sys.stderr.flush() + + def _accept_loop(self) -> None: + assert self._sock is not None + while self._running: + try: + conn, _addr = self._sock.accept() + except OSError: + break + threading.Thread( + target=self._handle_conn, args=(conn,), daemon=True + ).start() + + def _handle_conn(self, conn: socket.socket) -> None: + with conn: + buf = b"" + while self._running: + try: + chunk = conn.recv(65536) + except OSError: + break + if not chunk: + break + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + if not line.strip(): + continue + reply = self._process_line(line) + try: + conn.sendall(reply) + except OSError: + return + + def _process_line(self, line: bytes) -> bytes: + req_id: Any = None + try: + req = json.loads(line.decode("utf-8")) + req_id = req.get("id") + method = req.get("method") + params = req.get("params") or {} + if not method: + raise ValueError("missing method") + result = self._run_on_main(method, params) + payload = {"id": req_id, "result": result} + except Exception as exc: # noqa: BLE001 -- report any failure to client + payload = { + "id": req_id, + "error": { + "message": str(exc), + "type": type(exc).__name__, + }, + } + return (json.dumps(payload, default=_json_default) + "\n").encode("utf-8") + + def _run_on_main(self, method: str, params: dict[str, Any]) -> Any: + """Run ``dispatch`` on GIMP's main loop and block for the result.""" + done = threading.Event() + box: dict[str, Any] = {} + + def _invoke() -> bool: + try: + box["result"] = dispatch(method, params) + except Exception as exc: # noqa: BLE001 + box["error"] = exc + box["traceback"] = traceback.format_exc() + finally: + done.set() + return GLib.SOURCE_REMOVE + + GLib.idle_add(_invoke) + done.wait() + if "error" in box: + sys.stderr.write(box.get("traceback", "")) + sys.stderr.flush() + raise box["error"] + return box.get("result") + + def stop(self) -> None: + self._running = False + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + + +# Keep a module-level reference so the server outlives do_run. +_SERVER: BridgeServer | None = None + + +def _resolve_port() -> int: + env = os.environ.get("HANZO_GIMP_PORT") + if env: + try: + return int(env) + except ValueError: + pass + return DEFAULT_PORT + + +def _resolve_host() -> str: + return os.environ.get("HANZO_GIMP_HOST", DEFAULT_HOST) + + +def start_bridge() -> BridgeServer: + """Start (once) and return the module-level bridge server.""" + global _SERVER + if _SERVER is None: + _SERVER = BridgeServer(host=_resolve_host(), port=_resolve_port()) + _SERVER.start() + return _SERVER + + +# --------------------------------------------------------------------------- +# GIMP 3.0 plug-in registration +# --------------------------------------------------------------------------- + +class HanzoGimpBridge(Gimp.PlugIn): + """GIMP 3.0 plug-in that exposes GIMP over a JSON TCP socket.""" + + # -- Gimp.PlugIn vfuncs -------------------------------------------------- + + def do_query_procedures(self) -> list[str]: + return [PROC_START] + + def do_create_procedure(self, name: str) -> Gimp.Procedure: + if name != PROC_START: + return None + + procedure = Gimp.Procedure.new( + self, + name, + Gimp.PDBProcType.PLUGIN, + self.run_start, + None, + ) + procedure.set_menu_label("Start Hanzo Bridge") + procedure.add_menu_path("/Filters/Hanzo") + procedure.set_documentation( + "Start the Hanzo GIMP JSON socket bridge", + "Runs a local JSON-over-TCP server that drives GIMP through its " + "PDB API. Clean-room BSD-3, no GPL lineage.", + name, + ) + procedure.set_attribution("Hanzo AI", "Hanzo AI", "2026") + return procedure + + # -- Procedure run callback --------------------------------------------- + + def run_start(self, procedure, run_mode, image, drawables, config, run_data): + try: + server = start_bridge() + Gimp.message( + f"Hanzo GIMP bridge listening on " + f"{server.host}:{server.port}" + ) + return procedure.new_return_values( + Gimp.PDBStatusType.SUCCESS, GLib.Error() + ) + except Exception as exc: # noqa: BLE001 + return procedure.new_return_values( + Gimp.PDBStatusType.EXECUTION_ERROR, + GLib.Error(str(exc)), + ) + + +Gimp.main(HanzoGimpBridge.__gtype__, sys.argv) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf948c5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "hanzo-gimp-mcp" +version = "1.0.0" +description = "Clean-room BSD-3 GIMP 3.0 bridge: a GIMP plug-in exposing a JSON-over-TCP protocol that drives GIMP through its documented PDB API." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } +authors = [{ name = "Hanzo AI", email = "dev@hanzo.ai" }] +keywords = ["hanzo", "gimp", "mcp", "pdb", "image", "bridge", "bsd-3-clause"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Graphics", +] + +[project.urls] +Homepage = "https://github.com/hanzoai/gimp-mcp" +Repository = "https://github.com/hanzoai/gimp-mcp" + +# This package ships a single GIMP 3.0 plug-in module that you copy into the +# GIMP plug-ins directory (see README). It is not a conventional importable +# library, so no package discovery is configured.