Liking cljdoc? Tell your friends :D

hive-gimp

An IAddon exposing GIMP 3.x as MCP tools, plus an optional host-side Python port through libpython-clj.

(require '[hive-gimp.core :as gimp])

(def g (gimp/connect))
(gimp/doctor g)
(gimp/invoke g "new_canvas" {:width 800 :height 600})
(gimp/exec g ["Gimp.displays_flush()"])

The one thing to know before reading further

GIMP is reached over a TCP socket. What differs between the two transports is who opens the socket, and both are supported:

HIVE_GIMP_TRANSPORTwho opens the socketneeds libpython-clj
socket (default)the JVMno
pythonthe embedded CPython, via libpython-cljyes

Both satisfy IGimpTransport; every command behaves identically through either.

The distinction that matters, and the one that trips people up:

  • libpython-clj can drive GIMP, by speaking the plugin's socket protocol from the embedded interpreter. That is what hive-gimp.transport.python does, and it is exactly the path the reference project's own bg_remove.py takes, in-process instead of as a subprocess. Verified end to end against a real interpreter (clojure -M:python -i dev/verify_python_transport.clj, 7/7) and against a real GIMP 3.2.4 (HIVE_GIMP_TRANSPORT=python, 13/13).

    Python is used there as a language, not as a string. Modules are imported, attributes read and objects called through libpython-clj interop; no Python source text is execd and nothing is compiled at runtime. That is what lets connect_ex classify failures by errno rather than by parsing an exception message, and it keeps the read buffer on the Python side (a ->jvm of Python bytes is a vector of boxed integers, so marshalling per chunk would turn a base64 image into millions of Longs).

  • libpython-clj cannot import gi.repository.Gimp and get a working GIMP. The import succeeds, which is the trap: GIMP 3 plugins run in GIMP's own embedded CPython, attached to its main loop and PDB, so the module is live and inert. Gimp.get_images() answers for a GIMP this process is not part of. There is no in-process route to a running GIMP from anything GIMP did not itself launch.

Choose python when you also want host-side pixel work in the same interpreter, so a GIMP export can be handed straight to rembg without leaving the process. Choose socket (the default) when you want no Python at all.

The other half of the Python story is hive-gimp.pixel: the image ecosystem GIMP does not ship. rembg does in one call what the reference project's bg_remove_iterative.py spends sixteen kilobytes doing through iterative fuzzy-select. Exposed as the gimp_pixel tool; remove_background_in_gimp exports, removes, and re-opens in one step.

Layout

schema          value objects, malli first. The WIRE / HIVE vocabulary split.
ports           IGimpTransport, IHostPython. Role-sized (ISP), injected (DIP).
catalog         COLLECT   reads the contract off the classpath, nothing else
contract        PROMOTE   the descriptor algebra, pure
command         PROMOTE   descriptor + args -> GimpCommand, pure
response        PROMOTE   raw plugin answer -> Outcome, pure
codec           PROMOTE   framing and JSON, pure (JVM, clojure.data.json)
wire            PROMOTE   the same protocol, portable (JVM, cljw, cljrs)
doctor.verdict  PROMOTE   the doctor's judgements, pure
client          PIPELINE  lookup, build, encode, send, decode, interpret
doctor          PIPELINE  staged preflight
pixel           PIPELINE  host-side pixel work, composed with GIMP
transport/*     BOUNDARY  the only namespaces that touch a socket or Python
tools           FACADE    the five MCP tools
core            FACADE    the public Clojure surface
addon           wiring    IAddon

Five of the pipeline's six steps are pure; the impure one is a call through a port. That is why the whole thing is tested end to end against a scripted double and why no test in this repo redefines a var.

The tool surface is data

The GIMP command contract lives in resources/hive_gimp/commands.edn, derived from the reference project by dev/extract_gimp_contract.py (78 commands, 286 parameters, read with Python's ast, never regex). Hand-written additions go in commands_extra.edn, which a regeneration will not erase.

Adding a GIMP command is a row of EDN. No new defn.

That is also why this addon publishes five MCP tools rather than eighty:

tool
gimprun any catalogued command
gimp_execarbitrary Python-Fu, the escape hatch
gimp_cataloglist, search, describe (the discovery path)
gimp_pixelhost-side pixel work (rembg, PIL) and its composition with GIMP
gimp_doctorstaged diagnosis

Eighty tool definitions with descriptions and schemas is a permanent context tax on every client that mounts the addon, whether or not it ever touches GIMP.

Things found in the reference project

Two passes found these. The generator cross-checks the commands the Python tools send against the commands the plugin handles, which is a source-only reading. dev/verify_live_gimp.clj then drives a real GIMP 3.2.4, which is where the behavioural ones showed up: reading the source told us new_canvas opens a display, and only running it showed what happens when it cannot.

Three of them are now compensated on this side of the boundary, in hive-gimp.guard (the verdicts) and hive-gimp.client (the round trips they need). dev/verify_python_defects.clj re-measures all three against a live headless GIMP, so the day the plug-in is fixed the probe says so.

  • call_api is not dispatched by the plugin. It falls through to the else branch and is executed as raw Python-Fu. The branch reads j["params"] unguarded, so a request without params raises KeyError inside GIMP.

  • pyGObject-eval does not exist. GIMP_MCP_PROTOCOL.md advertises it for evaluating expressions, but the plugin compares against the literal python-fu-eval and everything else execs. A caller following that document gets exec semantics and a list of "None". hive-gimp.exec sends the marker that works.

    Confirmed live against GIMP 3.2.4, and the two modes are genuinely different, so pick deliberately:

    mode=exec (default)   ["1 + 1"]      -> [""]        value discarded
                          ["print(6*7)"] -> ["42\n"]    stdout captured
    mode=eval             ["1 + 1"]      -> ["2"]       value returned
                          ["print(6*7)"] -> ["None"]    print's own return value
    

    The default is exec, so a bare expression sent without mode comes back as an empty string and no error. Use mode=eval for a value, or print it.

  • A headless new_canvas reports failure and leaks the image it created. _new_canvas builds the image, inserts its layer and fills it, and only then calls Gimp.Display.new(image), which returns NULL when GIMP runs with -i. A blanket except turns that into {"status": "error"}. The caller is told the command failed; a fully formed image is left inside GIMP with its id never returned. Measured, not inferred, because the count moves: two "failed" calls took len(Gimp.get_images()) from 1 to 3, at exactly the requested dimensions. The same handler also hardcodes "display_opened": True in its success payload.

    hive-gimp adopts that image instead of leaving it orphaned: new_canvas counts the open images before and after, and when a failed call left exactly one new image at the size that was asked for, the outcome is the success the plug-in should have answered, carrying the image's id, "recovered": true and an honest "display_opened": false. Two new images, or one of another size, is an ambiguity, so the plug-in's error stands: handing back the wrong image is worse than reporting the failure.

  • close_image is dead code. It calls Gimp.get_displays(), which the GIMP 3.2 PyGObject API does not have, so every call fails on every image and nothing can close an image through this plugin. What works is reaching each display by id and deleting it, which takes the image with it; that is the sweep dev/verify_live_gimp.clj uses to clean up, and it is also the fix.

    Related trap while working around it: Gimp.Image.delete() is valid only for an image with no display attached. Called on a displayed one it kills the plug-in process, and this side then sees :gimp/timeout on the next command, one step away from the cause.

  • get_image_bitmap requires region, which the contract calls optional. region.get("origin_x") is read unguarded, so omitting it raises AttributeError on None inside GIMP. Same shape as the call_api KeyError. Pass {:origin_x 0 :origin_y 0 :width w :height h}.

  • add_text substitutes a font and reports success. _resolve_font walks aliases down to Sans-serif and then the first installed font. Measured against GIMP 3.2.4: add_text with "Montserrat ExtraBold" (not installed) answered success and the layer's font read back Sans-serif. The response never echoes the font, so the substitution cannot be seen from this side after the fact. The native plug-in's place_text and add_text refuse the name instead.

    A font the caller NAMES is now checked before any text command is sent, and a font GIMP does not have is refused with :gimp/unknown-font and the nearest installed names. A font nobody named is not judged, because add_text defaults to Sans and refusing the default would break every call that simply left the font out. A lookup that cannot be made, which is the native plug-in's case, lets the call through: "I could not look" is not "it is not there", and that plug-in refuses an absent font itself.

  • list_fonts truncates at 100 names and says nothing. The plugin reads a limit and defaults it to 100; the derived contract never declared the parameter, so through hive-gimp the command could not see past the first hundred fonts and reported a truncated list as the whole catalogue. A box here has 2467. commands_extra.edn now declares limit, which matters beyond the listing itself: the font check above uses this list as its universe, and a universe capped at 100 would refuse fonts GIMP has.

  • A native-only command dies as KeyError: 'args'. place_text and place_image are answered by the native plug-in only. Sent to the reference one they reach the same undispatched else branch as call_api, and the caller gets :gimp/command-failed with the message 'args', naming neither the command nor the cause. hive-gimp reports :gimp/native-only-command instead, and says which plug-in answers them.

  • check_server and restart_server are handled by the plugin but were never exposed. The Python server spends both names on host-side connection management. They are in commands_extra.edn.

  • Response framing has no terminator. Both reference clients accumulate until the buffer parses as any JSON value, so a response truncated where the prefix happens to be valid JSON is treated as whole and the rest is read as the head of the next message. codec/complete-frame? requires a JSON object, which costs nothing and removes the failure mode.

Requirements

GIMP 3.x. This contract is derived from the GIMP 3 API (Gimp.get_images(), Gegl.Color, PyGObject). GIMP 2.10 exposes a different Python API entirely (pdb.gimp_*, gimpfu) and these commands will not work against it. gimp_doctor detects and reports this rather than letting it surface as a traceback from inside GIMP.

flatpak install flathub org.gimp.GIMP

Then install the plugin from the reference project into GIMP's own user directory, and start it with Tools > MCP > Start MCP Server.

Ask GIMP where that directory is; do not guess it. On a flatpak install the manifest grants xdg-config/GIMP:create, so GIMP writes to the host ~/.config/GIMP/<ver> and not to the sandbox's private ~/.var/app/org.gimp.GIMP/config/GIMP/<ver>, which is the path the rest of the sandbox makes look right. A plugin in the wrong directory is silent: it simply never registers, and every command then answers :gimp/unknown-command far from the real cause.

flatpak run org.gimp.GIMP -n -i -d -f --batch-interpreter python-fu-eval \
  -b 'from gi.repository import Gimp; print("USER-DIR:", Gimp.directory())'

DEST=~/.config/GIMP/3.2/plug-ins/gimp-mcp-plugin     # whatever it answered
mkdir -p "$DEST" && cp gimp-mcp-plugin.py "$DEST/" && chmod +x "$DEST"/*.py

The directory and the file must share a name (gimp-mcp-plugin/gimp-mcp-plugin.py) and the file must be executable, or GIMP 3 skips it without a word.

Configuration

Every field has a working default; a stock install needs none.

envdefault
HIVE_GIMP_HOST127.0.0.1plugin socket host
HIVE_GIMP_PORT9877plugin socket port: 9877 is the reference Python plugin, the native plug-in (native/) listens on 9878 so both can run side by side
HIVE_GIMP_TIMEOUT_MS30000read timeout for one command
HIVE_GIMP_CONNECT_TIMEOUT_MS5000connect timeout
HIVE_GIMP_TRANSPORTsocketsocket or python
HIVE_GIMP_PYTHONautodetectinterpreter for the Python transport and pixel port

An unrecognised HIVE_GIMP_TRANSPORT resolves to socket rather than throwing: this is read at mount time in a host process, and refusing to start over a typo in an optional variable trades a working default for an outage. The adapter actually chosen is reported by health and by gimp_doctor, so a typo is visible rather than silent.

Development

clojure -M:test                                     # the suite (no Python needed)
clojure -M:test:nrepl --port 7920                   # interactive
clojure -M:python -i dev/verify_python_transport.clj # live libpython-clj proof
clojure -M -i dev/verify_live_gimp.clj              # live proof against real GIMP
clojure -M -i dev/verify_python_defects.clj         # live proof of the three compensations
python3 dev/extract_gimp_contract.py resources/hive_gimp/commands.edn

verify_python_defects.clj wants a HEADLESS GIMP, because all three defects it measures only exist headless, and it wants one started without -f, because -f loads no fonts and a run with no fonts cannot tell a refusal earned by a missing font from one earned by an empty catalogue.

verify_live_gimp.clj is the one place a double is not allowed: it drives a real GIMP over the real plugin socket. Bring one up first. No GUI click is needed either way, because the plugin's run() blocks in a GLib main loop and so keeps its process alive by itself:

# headless: fine for CI, and the display suite reports itself as SKIPPED
flatpak run org.gimp.GIMP -n -i -d -f --batch-interpreter python-fu-eval \
  -b "exec(open('dev/start_mcp.py').read())"

# headed: same command without -i. A window opens; the batch still runs.
DISPLAY=:1 flatpak run org.gimp.GIMP -n --batch-interpreter python-fu-eval \
  -b "exec(open('dev/start_mcp.py').read())"

The run has three sections and the split is deliberate:

CONTRACTwhat hive-gimp promises, over a transport needing no display. Always runs, gates the exit code.
DISPLAYthe commands that open, export or flush a display. Runs only when GIMP has one, SKIPS otherwise, gates when it runs.
FINDINGSwhat the reference plugin does, defects included. Printed every run, never gates.
headed:    13 passed, 0 failed, 0 skipped, 5 findings
headless:   7 passed, 0 failed, 6 skipped, 5 findings

A skip is not a pass, and the count is on the summary line so a headless run cannot be mistaken for coverage of the display path. Findings never gate because a release of this library must not be blocked by a bug in a plugin it does not ship, and each one is phrased to report works (upstream fixed it) the day it starts working.

Whether a display exists is decided by measurement, not by reading DISPLAY out of the environment: this process is not GIMP, and the question is whether GIMP can open one.

Both transports are verified against the same live GIMP:

clojure -M -i dev/verify_live_gimp.clj                            # socket
HIVE_GIMP_TRANSPORT=python clojure -M:python -i dev/verify_live_gimp.clj  # libpython-clj

verify_python_transport.clj needs no GIMP. It runs the Python transport against a fake plugin: a JVM ServerSocket implementing the same wire protocol, including the detail that makes it interesting, which is that responses carry no terminator. If the transport can talk to that, it can talk to GIMP, because the wire is the entire contract between them.

The native plug-in: GIMP's side in Clojure, on clojurust

native/ is a GIMP 3 plug-in with no Python in it. GIMP execs a launcher that runs cljrs (clojurust) on native/src/hive_gimp/plugin/main.cljrs; a small Rust cdylib registers the GimpPlugIn subclass, enters libgimp's gimp_main, owns the socket and wraps the libgimp calls; the command table, the JSON codec and the colour normalisation are portable Clojure that run the same on the JVM, ClojureWasm and clojurust. It speaks the socket contract above, so this side reaches it with nothing but a port:

(def g (gimp/connect {:port 9878}))
(gimp/invoke g "new_canvas" {:width 320 :height 200 :fill "orange"})

It implements 25 of the catalogued commands today: composition (place_text, add_text, gradient_fill, place_image; see native/README.md), server and info (check_server, get_gimp_info), files (new_canvas, open_image, save_xcf, export_image, close_image, list_images, get_image_metadata), whole-image transforms (scale_image, crop_to_rect, rotate_image in quarter turns, flip_image, flatten_image) and layers (create_layer, list_layers, fill_layer, delete_layer, rename_layer, duplicate_layer, set_layer_properties). The Python reference plug-in still covers the rest, on its own port, and both can be installed at once.

native/build.sh && native/install.sh
dev/verify_native_plugin.sh     # live: headless flatpak GIMP 3.2.4 + this JVM client + ffmpeg pixel checks

Measured along the way, all in native/README.md: a Clojure callback from the cdylib breaks the moment it builds a vector past 32 elements, so no Clojure runs inside one; true?/false?/identical? answer wrongly on clojurust once a fn is hot; and GEGL paints an unknown colour name transparent cyan and reads rgb() channels as 0..1, both while reporting success, which the Python reference plug-in passes straight through.

The portable wire codec: hive-gimp.wire

hive-gimp.codec speaks the plug-in protocol on the JVM through clojure.data.json. hive-gimp.wire speaks the same protocol in plain Clojure over strings, for hosts with no JSON library: ClojureWasm (cljw) and clojurust (cljrs). It has five functions: parse, emit, complete-frame?, encode-request and decode-response. It depends on clojure.string and hive-gimp.shape only, and decode-response builds its hive-dsl Result as a plain map, so hive-dsl does not need to be present on the host.

It is checked in two places:

# JVM: a differential test against clojure.data.json as the oracle
clojure -M:test --focus hive-gimp.wire-test

# every host: the same spelled-out checks, 60 passes each
clojure -M dev/wire_portability.cljc
cljw -cp src dev/wire_portability.cljc
cljrs run --src-path src dev/wire_portability.cljc

The oracle test checks both directions: data.json writes and wire reads, then wire writes and data.json reads. It also damages valid JSON text (truncates it, deletes one char, or inserts one char) and requires both parsers to reach the same verdict. data.json's read-str ignores trailing input, so the oracle first reads through a reader and refuses anything left over. The remaining differences are deliberate, and documented-divergences-test pins each one:

inputclojure.data.jsonwire
text after the value ({} x)ignoredrefused
lone surrogate escape ("\ud83d")acceptedrefused
raw control char in a stringacceptedrefused (RFC 8259 §7)
sign inside \u ("\u-000")accepted (Integer/parseInt)refused
integer past the long rangeBigIntdouble
non-ASCII on write\uXXXX escaperaw UTF-8

The first row is a difference from read-str only, not from codec: codec reads through a reader and refuses anything but JSON whitespace after the value, so both codec/complete-frame? and wire/complete-frame? answer false for an object followed by the start of a second one.

The codec's shape comes from three clojurust behaviours the gate caught in the first draft (release binary built 2026-09-10):

  • clojure.string/index-of returns a UTF-8 byte offset, while subs and count work in chars. This happens in every tier, including the interpreter.
  • Once a fn is JIT-compiled (after 1000 calls), subs indexing on a non-ASCII string went out of range at an index the interpreter accepted.
  • Inside a JIT-compiled fn, an exception thrown by a callee disappeared and the call returned nil.

So parse never calls index-of on its input. It splits the input once into a vector of one-char strings, and each reader returns failure as a value instead of throwing. The last two behaviours are JIT-only: with --jit-threshold 0, the draft that still used subs and exceptions passes.

A note on Basilisp

The GIMP-side plug-in now exists in Clojure, on clojurust (above), which is what this section used to propose Basilisp for. Basilisp remains the other route: it runs in GIMP's own Python, where gi.repository.Gimp is plain interop, and it needs no Rust. The seam is still the socket contract, so either drops in behind it without one line changing on the JVM side.

License

MIT. The command contract is derived from gimp-mcp (MIT).

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close