gimp-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gimp_mcp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """MCP server for inspecting and editing GIMP 3 projects."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ # Single source of truth: the `version` field of pyproject.toml.
7
+ __version__ = version("gimp-mcp")
8
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
9
+ __version__ = "0+unknown"
gimp_mcp/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
gimp_mcp/bridge.py ADDED
@@ -0,0 +1,334 @@
1
+ """Client side of the local GIMP bridge (asyncio, Unix domain socket).
2
+
3
+ Guarantees:
4
+
5
+ * Requests are serialized: at most one request is in flight per client, so
6
+ mutations reach GIMP in the order the MCP server issued them and responses
7
+ can never be attributed to the wrong request.
8
+ * Every wait is bounded (connect, handshake, request).
9
+ * When a request times out or the connection drops before its response
10
+ arrives, the connection is discarded and the error says the outcome is
11
+ ``unknown``: the operation may still have run inside GIMP.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import os
20
+ import select
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from . import __version__
26
+ from .errors import INSTALL_HINT, GimpMcpError
27
+ from .versions import Compatibility, check_plugin_version
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ PROTOCOL_VERSION = 1
32
+ MIN_GIMP_VERSION = (3, 2)
33
+ MAX_RESPONSE_BYTES = 64 * 1024 * 1024
34
+ SOCKET_ENV = "GIMP_MCP_SOCKET"
35
+ FLATPAK_APP = "org.gimp.GIMP"
36
+ _POLLRDHUP = getattr(select, "POLLRDHUP", 0)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BridgeEndpoint:
41
+ socket_path: Path
42
+ token_path: Path
43
+
44
+ @classmethod
45
+ def for_socket(cls, socket_path: Path) -> BridgeEndpoint:
46
+ return cls(socket_path=socket_path, token_path=Path(str(socket_path) + ".token"))
47
+
48
+ @classmethod
49
+ def default(cls, environ: dict[str, str] | None = None) -> BridgeEndpoint:
50
+ return cls.for_socket(default_socket_path(environ))
51
+
52
+
53
+ def default_socket_path(environ: dict[str, str] | None = None) -> Path:
54
+ """Socket path shared by the plug-in and the server (same rules on both sides)."""
55
+ env = os.environ if environ is None else environ
56
+ explicit = env.get(SOCKET_ENV)
57
+ if explicit:
58
+ return Path(explicit)
59
+ runtime = env.get("XDG_RUNTIME_DIR")
60
+ if runtime and os.path.isdir(runtime):
61
+ native = Path(runtime) / "gimp-mcp" / "bridge.sock"
62
+ # A Flatpak GIMP (org.gimp.GIMP) serves under the app directory shared with the host.
63
+ flatpak = Path(runtime) / "app" / FLATPAK_APP / "gimp-mcp" / "bridge.sock"
64
+ if not native.exists() and flatpak.exists():
65
+ return flatpak
66
+ return native
67
+ return Path("/tmp") / f"gimp-mcp-{os.getuid()}" / "bridge.sock"
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SessionInfo:
72
+ gimp_version: str
73
+ session_id: str
74
+ pid: int | None
75
+ protocol: int
76
+ bridge_version: str | None = None
77
+
78
+ @classmethod
79
+ def from_wire(cls, data: dict[str, Any]) -> SessionInfo:
80
+ return cls(
81
+ gimp_version=str(data.get("gimp_version", "")),
82
+ session_id=str(data.get("session_id", "")),
83
+ pid=data.get("pid"),
84
+ protocol=int(data.get("protocol", 0)),
85
+ bridge_version=data.get("bridge_version"),
86
+ )
87
+
88
+
89
+ def parse_version(text: str) -> tuple[int, ...]:
90
+ parts: list[int] = []
91
+ for piece in text.split("."):
92
+ digits = "".join(ch for ch in piece if ch.isdigit())
93
+ if not digits:
94
+ break
95
+ parts.append(int(digits))
96
+ return tuple(parts)
97
+
98
+
99
+ class BridgeClient:
100
+ def __init__(
101
+ self,
102
+ endpoint: BridgeEndpoint | None = None,
103
+ *,
104
+ connect_timeout: float = 3.0,
105
+ request_timeout: float = 60.0,
106
+ ):
107
+ self.endpoint = endpoint or BridgeEndpoint.default()
108
+ self.connect_timeout = connect_timeout
109
+ self.request_timeout = request_timeout
110
+ self.session: SessionInfo | None = None
111
+ self._reader: asyncio.StreamReader | None = None
112
+ self._writer: asyncio.StreamWriter | None = None
113
+ self._lock = asyncio.Lock()
114
+ self._next_id = 0
115
+
116
+ # -- public API ----------------------------------------------------------------
117
+
118
+ async def call(
119
+ self,
120
+ method: str,
121
+ params: dict[str, Any] | None = None,
122
+ *,
123
+ timeout: float | None = None,
124
+ mutating: bool = False,
125
+ ) -> Any:
126
+ async with self._lock:
127
+ await self._ensure_connected()
128
+ try:
129
+ return await self._request(method, params or {}, timeout or self.request_timeout, mutating)
130
+ except GimpMcpError as error:
131
+ # A read can safely be retried once on a fresh connection when the
132
+ # previous one turned out to be dead (e.g. GIMP was restarted).
133
+ if mutating or error.code != "DISCONNECTED":
134
+ raise
135
+ await self._ensure_connected()
136
+ return await self._request(method, params or {}, timeout or self.request_timeout, mutating)
137
+
138
+ async def connect(self) -> SessionInfo:
139
+ async with self._lock:
140
+ await self._ensure_connected()
141
+ assert self.session is not None
142
+ return self.session
143
+
144
+ async def close(self) -> None:
145
+ writer, self._reader, self._writer = self._writer, None, None
146
+ if writer is not None:
147
+ writer.close()
148
+ try:
149
+ await asyncio.wait_for(writer.wait_closed(), timeout=1.0)
150
+ except (TimeoutError, OSError):
151
+ pass
152
+
153
+ # -- internals -----------------------------------------------------------------
154
+
155
+ async def _ensure_connected(self) -> None:
156
+ if (
157
+ self._writer is not None
158
+ and self._reader is not None
159
+ and not self._writer.is_closing()
160
+ and not self._reader.at_eof()
161
+ and not self._peer_went_away()
162
+ ):
163
+ return
164
+ await self.close()
165
+ socket_path = self.endpoint.socket_path
166
+ if not socket_path.exists():
167
+ raise GimpMcpError(
168
+ "GIMP_NOT_RUNNING",
169
+ f"no GIMP bridge socket at {socket_path}",
170
+ hint=INSTALL_HINT,
171
+ )
172
+ try:
173
+ token = self.endpoint.token_path.read_text(encoding="ascii").strip()
174
+ except OSError as exc:
175
+ raise GimpMcpError(
176
+ "BRIDGE_UNAVAILABLE",
177
+ f"cannot read bridge token {self.endpoint.token_path}: {exc.strerror}",
178
+ hint=INSTALL_HINT,
179
+ ) from exc
180
+ try:
181
+ reader, writer = await asyncio.wait_for(
182
+ asyncio.open_unix_connection(str(socket_path), limit=MAX_RESPONSE_BYTES),
183
+ timeout=self.connect_timeout,
184
+ )
185
+ except TimeoutError as exc:
186
+ raise GimpMcpError(
187
+ "BRIDGE_UNAVAILABLE",
188
+ f"timed out connecting to {socket_path} after {self.connect_timeout:g}s",
189
+ hint="GIMP may be busy or frozen. " + INSTALL_HINT,
190
+ ) from exc
191
+ except OSError as exc:
192
+ raise GimpMcpError(
193
+ "GIMP_NOT_RUNNING",
194
+ f"cannot connect to {socket_path}: {exc.strerror or exc}",
195
+ hint="The socket is stale: GIMP probably exited. " + INSTALL_HINT,
196
+ ) from exc
197
+ self._reader, self._writer = reader, writer
198
+ try:
199
+ hello = await self._request(
200
+ "hello", {"token": token, "protocol": PROTOCOL_VERSION}, self.connect_timeout, False
201
+ )
202
+ except GimpMcpError as exc:
203
+ await self.close()
204
+ if exc.code in ("TIMEOUT", "DISCONNECTED"):
205
+ raise GimpMcpError(
206
+ "BRIDGE_UNAVAILABLE",
207
+ f"bridge handshake failed: {exc.message}",
208
+ hint=INSTALL_HINT,
209
+ ) from exc
210
+ raise
211
+ session = SessionInfo.from_wire(hello)
212
+ if session.protocol != PROTOCOL_VERSION:
213
+ await self.close()
214
+ raise GimpMcpError(
215
+ "VERSION_INCOMPATIBLE",
216
+ f"bridge speaks protocol {session.protocol}, this server needs {PROTOCOL_VERSION}",
217
+ hint="Reinstall the plug-in with `gimp-mcp install-plugin` and restart GIMP.",
218
+ )
219
+ if parse_version(session.gimp_version)[:2] < MIN_GIMP_VERSION:
220
+ await self.close()
221
+ minimum = ".".join(map(str, MIN_GIMP_VERSION))
222
+ raise GimpMcpError(
223
+ "VERSION_INCOMPATIBLE",
224
+ f"GIMP {session.gimp_version} is not supported (minimum {minimum})",
225
+ hint=f"Install GIMP {minimum} or newer.",
226
+ details={"gimp_version": session.gimp_version, "minimum": minimum},
227
+ )
228
+ verdict = check_plugin_version(__version__, session.bridge_version)
229
+ if verdict is Compatibility.INCOMPATIBLE:
230
+ await self.close()
231
+ raise GimpMcpError(
232
+ "PLUGIN_VERSION_MISMATCH",
233
+ f"the GIMP plug-in is version {session.bridge_version or 'unknown'} but this server is {__version__}",
234
+ hint="Run `gimp-mcp install-plugin`, then restart GIMP.",
235
+ details={"plugin_version": session.bridge_version or "unknown", "server_version": __version__},
236
+ )
237
+ if verdict is Compatibility.COMPATIBLE:
238
+ log.warning(
239
+ "GIMP plug-in %s differs from server %s (compatible patch versions); "
240
+ "run `gimp-mcp install-plugin` to align them",
241
+ session.bridge_version,
242
+ __version__,
243
+ )
244
+ if self.session is not None and self.session.session_id != session.session_id:
245
+ log.info("GIMP session changed: %s -> %s", self.session.session_id, session.session_id)
246
+ self.session = session
247
+
248
+ def _peer_went_away(self) -> bool:
249
+ """Detect a dead idle connection (e.g. GIMP restarted) before sending.
250
+
251
+ The bridge never sends unsolicited data, so an idle connection that is
252
+ readable or hung up has been closed by the other side.
253
+ """
254
+ assert self._writer is not None
255
+ sock = self._writer.get_extra_info("socket")
256
+ if sock is None:
257
+ return False
258
+ poller = select.poll()
259
+ poller.register(sock.fileno(), select.POLLIN | select.POLLHUP | select.POLLERR | _POLLRDHUP)
260
+ return bool(poller.poll(0))
261
+
262
+ async def _request(self, method: str, params: dict[str, Any], timeout: float, mutating: bool) -> Any:
263
+ assert self._reader is not None and self._writer is not None
264
+ self._next_id += 1
265
+ request_id = self._next_id
266
+ payload = json.dumps({"id": request_id, "method": method, "params": params}, separators=(",", ":"))
267
+ unknown = "unknown" if mutating else None
268
+ try:
269
+ self._writer.write(payload.encode("utf-8") + b"\n")
270
+ await asyncio.wait_for(self._writer.drain(), timeout=timeout)
271
+ line = await asyncio.wait_for(self._reader.readline(), timeout=timeout)
272
+ except asyncio.CancelledError:
273
+ # The caller gave up (e.g. the MCP request was cancelled). GIMP may
274
+ # still run the operation; its late answer must never be read as
275
+ # the answer to a later request, so drop the connection.
276
+ await self.close()
277
+ raise
278
+ except TimeoutError as exc:
279
+ await self.close()
280
+ raise GimpMcpError(
281
+ "TIMEOUT",
282
+ f"GIMP did not answer {method!r} within {timeout:g}s",
283
+ hint="GIMP may still be working. Inspect the image before retrying a modification.",
284
+ outcome=unknown,
285
+ ) from exc
286
+ except (OSError, asyncio.IncompleteReadError, ValueError) as exc:
287
+ await self.close()
288
+ raise GimpMcpError(
289
+ "DISCONNECTED",
290
+ f"connection to GIMP lost during {method!r}: {exc}",
291
+ hint="GIMP may have quit or crashed. " + INSTALL_HINT,
292
+ outcome=unknown,
293
+ ) from exc
294
+ if not line:
295
+ await self.close()
296
+ raise GimpMcpError(
297
+ "DISCONNECTED",
298
+ f"GIMP closed the connection during {method!r}",
299
+ hint="GIMP may have quit or crashed. " + INSTALL_HINT,
300
+ outcome=unknown,
301
+ )
302
+ try:
303
+ response = json.loads(line)
304
+ except ValueError as exc:
305
+ await self.close()
306
+ raise GimpMcpError("PROTOCOL_ERROR", f"invalid response from bridge: {exc}", outcome=unknown) from exc
307
+ if response.get("id") != request_id:
308
+ await self.close()
309
+ raise GimpMcpError(
310
+ "PROTOCOL_ERROR",
311
+ f"response id {response.get('id')!r} does not match request {request_id}",
312
+ outcome=unknown,
313
+ )
314
+ if "error" in response:
315
+ error = response["error"] or {}
316
+ code = str(error.get("code", "GIMP_ERROR"))
317
+ if code in ("UNAUTHORIZED", "PROTOCOL_ERROR", "REQUEST_TOO_LARGE"):
318
+ await self.close()
319
+ # Operations raise anticipated errors before changing anything; an
320
+ # unexpected GIMP failure may have happened half-way through.
321
+ if not mutating:
322
+ outcome = None
323
+ elif code == "GIMP_ERROR":
324
+ outcome = "unknown"
325
+ else:
326
+ outcome = "not_applied"
327
+ raise GimpMcpError(
328
+ code,
329
+ str(error.get("message", "unknown bridge error")),
330
+ details=error.get("details") or {},
331
+ hint=error.get("hint"),
332
+ outcome=outcome,
333
+ )
334
+ return response.get("result")
gimp_mcp/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ """Command line entry point: ``gimp-mcp [serve|install-plugin|uninstall-plugin|doctor]``.
2
+
3
+ stdout is reserved for the MCP protocol when serving; everything else goes to
4
+ stderr.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import logging
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from .bridge import BridgeClient, BridgeEndpoint
17
+ from .errors import GimpMcpError
18
+ from .installer import default_gimp_config_dir, install_plugin, uninstall_plugin
19
+
20
+
21
+ def _configure_logging(verbose: bool) -> None:
22
+ logging.basicConfig(
23
+ level=logging.DEBUG if verbose else logging.WARNING,
24
+ stream=sys.stderr,
25
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
26
+ force=True,
27
+ )
28
+
29
+
30
+ def cmd_serve(args: argparse.Namespace) -> int:
31
+ from .server import build_server
32
+
33
+ endpoint = BridgeEndpoint.for_socket(Path(args.socket)) if args.socket else BridgeEndpoint.default()
34
+ server = build_server(BridgeClient(endpoint, request_timeout=args.timeout))
35
+ _configure_logging(args.verbose)
36
+ server.run("stdio")
37
+ return 0
38
+
39
+
40
+ def cmd_install(args: argparse.Namespace) -> int:
41
+ config = Path(args.gimp_dir) if args.gimp_dir else default_gimp_config_dir()
42
+ target = install_plugin(config)
43
+ print(f"Installed the gimp-mcp bridge plug-in into {target}", file=sys.stderr)
44
+ print("Restart GIMP to load it.", file=sys.stderr)
45
+ return 0
46
+
47
+
48
+ def cmd_uninstall(args: argparse.Namespace) -> int:
49
+ config = Path(args.gimp_dir) if args.gimp_dir else default_gimp_config_dir()
50
+ removed = uninstall_plugin(config)
51
+ print("Removed." if removed else f"Nothing installed in {config}.", file=sys.stderr)
52
+ return 0
53
+
54
+
55
+ def cmd_doctor(args: argparse.Namespace) -> int:
56
+ import os
57
+
58
+ from .doctor import Probe, render_troubleshooting, run_doctor
59
+
60
+ if args.troubleshooting:
61
+ sys.stdout.write(render_troubleshooting())
62
+ return 0
63
+ endpoint = BridgeEndpoint.for_socket(Path(args.socket)) if args.socket else BridgeEndpoint.default()
64
+ probe = Probe(env=dict(os.environ), endpoint=endpoint, gimp_dir=Path(args.gimp_dir) if args.gimp_dir else None)
65
+ try:
66
+ code, output = asyncio.run(run_doctor(probe, as_json=args.json))
67
+ except Exception as exc: # the doctor must never crash silently
68
+ print(f"error: the doctor could not run: {type(exc).__name__}: {exc}", file=sys.stderr)
69
+ return 2
70
+ print(output, file=sys.stdout if args.json else sys.stderr)
71
+ return code
72
+
73
+
74
+ def cmd_inventory(args: argparse.Namespace) -> int:
75
+ import json
76
+
77
+ endpoint = BridgeEndpoint.for_socket(Path(args.socket)) if args.socket else BridgeEndpoint.default()
78
+
79
+ async def fetch() -> dict[str, object]:
80
+ client = BridgeClient(endpoint, request_timeout=args.timeout)
81
+ try:
82
+ return await client.call("pdb.inventory")
83
+ finally:
84
+ await client.close()
85
+
86
+ data = asyncio.run(fetch())
87
+ procedures = data["procedures"]
88
+ assert isinstance(procedures, list)
89
+ text = json.dumps(data, indent=1, sort_keys=True, ensure_ascii=False) + "\n"
90
+ if args.output:
91
+ Path(args.output).write_text(text, encoding="utf-8")
92
+ incomplete = sum(1 for p in procedures if "error" in p)
93
+ print(
94
+ f"{len(procedures)} procedures ({incomplete} with incomplete metadata) written to {args.output}",
95
+ file=sys.stderr,
96
+ )
97
+ else:
98
+ sys.stdout.write(text)
99
+ return 0
100
+
101
+
102
+ def cmd_coverage(args: argparse.Namespace) -> int:
103
+ from .coverage import render_matrix
104
+
105
+ text = render_matrix(Path(args.inventory))
106
+ if args.output:
107
+ Path(args.output).write_text(text, encoding="utf-8")
108
+ else:
109
+ sys.stdout.write(text)
110
+ return 0
111
+
112
+
113
+ def cmd_setup(args: argparse.Namespace) -> int:
114
+ import os
115
+
116
+ from .setup import CLIENT_KEYS, SetupOptions, run_setup
117
+
118
+ endpoint = BridgeEndpoint.for_socket(Path(args.socket)) if args.socket else BridgeEndpoint.default()
119
+ clients: set[str]
120
+ if args.clients in (None, "all"):
121
+ clients = set(CLIENT_KEYS)
122
+ elif args.clients == "none":
123
+ clients = set()
124
+ else:
125
+ clients = {name.strip() for name in args.clients.split(",") if name.strip()}
126
+ unknown = clients - set(CLIENT_KEYS)
127
+ if unknown:
128
+ print(f"error: unknown client(s): {', '.join(sorted(unknown))}", file=sys.stderr)
129
+ return 2
130
+
131
+ def ask(question: str) -> bool:
132
+ try:
133
+ return input(question).strip().lower().startswith("y")
134
+ except EOFError:
135
+ return False
136
+
137
+ def say(text: str) -> None:
138
+ print(text, file=sys.stderr)
139
+
140
+ options = SetupOptions(
141
+ env=dict(os.environ),
142
+ endpoint=endpoint,
143
+ ask=ask,
144
+ say=say,
145
+ assume_yes=args.yes,
146
+ interactive=sys.stdin.isatty() and not args.yes,
147
+ run_doctor=not args.no_doctor,
148
+ gimp_dir=Path(args.gimp_dir) if args.gimp_dir else None,
149
+ clients=clients,
150
+ )
151
+ return asyncio.run(run_setup(options))
152
+
153
+
154
+ def cmd_uninstall_all(args: argparse.Namespace) -> int:
155
+ import os
156
+
157
+ from .setup import SetupOptions, run_uninstall
158
+
159
+ def ask(question: str) -> bool:
160
+ try:
161
+ return input(question).strip().lower().startswith("y")
162
+ except EOFError:
163
+ return False
164
+
165
+ def say(text: str) -> None:
166
+ print(text, file=sys.stderr)
167
+
168
+ options = SetupOptions(
169
+ env=dict(os.environ),
170
+ endpoint=BridgeEndpoint.default(),
171
+ ask=ask,
172
+ say=say,
173
+ assume_yes=args.yes,
174
+ interactive=sys.stdin.isatty() and not args.yes,
175
+ run_doctor=False,
176
+ gimp_dir=Path(args.gimp_dir) if args.gimp_dir else None,
177
+ )
178
+ return asyncio.run(run_uninstall(options))
179
+
180
+
181
+ def build_parser() -> argparse.ArgumentParser:
182
+ parser = argparse.ArgumentParser(prog="gimp-mcp", description="MCP server for GIMP 3")
183
+ parser.add_argument("--version", action="version", version=f"gimp-mcp {__version__}")
184
+ sub = parser.add_subparsers(dest="command")
185
+
186
+ serve = sub.add_parser("serve", help="run the MCP server on stdio (default)")
187
+ serve.add_argument("--socket", help="bridge socket path (default: $GIMP_MCP_SOCKET or runtime dir)")
188
+ serve.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds")
189
+ serve.add_argument("-v", "--verbose", action="store_true", help="debug logs on stderr")
190
+ serve.set_defaults(func=cmd_serve)
191
+
192
+ for name, func, text in (
193
+ ("install-plugin", cmd_install, "install the bridge plug-in into GIMP's config directory"),
194
+ ("uninstall-plugin", cmd_uninstall, "remove the bridge plug-in"),
195
+ ):
196
+ cmd = sub.add_parser(name, help=text)
197
+ cmd.add_argument("--gimp-dir", help="GIMP config dir (default: $GIMP3_DIRECTORY or ~/.config/GIMP/3.2)")
198
+ cmd.set_defaults(func=func)
199
+
200
+ doctor = sub.add_parser(
201
+ "doctor",
202
+ help="diagnose the installation and the connection to GIMP (exit 0 ok, 1 failures, 2 doctor error)",
203
+ )
204
+ doctor.add_argument("--socket")
205
+ doctor.add_argument("--gimp-dir", help="GIMP profile to check (default: the one GIMP uses)")
206
+ doctor.add_argument("--json", action="store_true", help="machine-readable result on stdout")
207
+ doctor.add_argument("--troubleshooting", action="store_true", help="print the troubleshooting table (Markdown)")
208
+ doctor.set_defaults(func=cmd_doctor)
209
+
210
+ setup = sub.add_parser(
211
+ "setup",
212
+ help="install the plug-in in GIMP's profile and register the server in your MCP clients",
213
+ description=(
214
+ "Detect GIMP, install or update the plug-in, register the MCP server in Claude Code, Claude Desktop, "
215
+ "VS Code and Cursor (each change is shown and needs your consent), then check the result. Safe to "
216
+ "run again."
217
+ ),
218
+ )
219
+ setup.add_argument("--yes", action="store_true", help="apply every change without asking (for scripts)")
220
+ setup.add_argument(
221
+ "--clients",
222
+ help="comma-separated clients to configure: claude-code, claude-desktop, vscode, cursor; or all / none "
223
+ "(default: all)",
224
+ )
225
+ setup.add_argument("--gimp-dir", help="GIMP profile (default: the one GIMP uses)")
226
+ setup.add_argument("--socket", help="bridge socket path (default: $GIMP_MCP_SOCKET or runtime dir)")
227
+ setup.add_argument("--no-doctor", action="store_true", help="skip the final doctor checks")
228
+ setup.set_defaults(func=cmd_setup)
229
+
230
+ uninstall = sub.add_parser(
231
+ "uninstall",
232
+ help="remove the plug-in from every GIMP profile and the MCP client entries gimp-mcp created",
233
+ description=(
234
+ "Remove what gimp-mcp added: the plug-in in every GIMP profile where it is installed and the 'gimp' "
235
+ "entries it created in MCP clients (entries pointing elsewhere are left). Each removal is shown and "
236
+ "needs your consent (or --yes). The package itself is removed with your installer (printed at the end)."
237
+ ),
238
+ )
239
+ uninstall.add_argument("--yes", action="store_true", help="remove without asking")
240
+ uninstall.add_argument("--gimp-dir", help="also consider this GIMP profile")
241
+ uninstall.set_defaults(func=cmd_uninstall_all)
242
+
243
+ inventory = sub.add_parser("inventory", help="dump the procedure database of the running GIMP as JSON")
244
+ inventory.add_argument("--socket")
245
+ inventory.add_argument("--output", help="file to write (default: stdout)")
246
+ inventory.add_argument("--timeout", type=float, default=300.0)
247
+ inventory.set_defaults(func=cmd_inventory)
248
+
249
+ coverage = sub.add_parser("coverage", help="render the coverage matrix (Markdown) from an inventory")
250
+ coverage.add_argument("--inventory", required=True)
251
+ coverage.add_argument("--output")
252
+ coverage.set_defaults(func=cmd_coverage)
253
+ return parser
254
+
255
+
256
+ def main(argv: list[str] | None = None) -> int:
257
+ parser = build_parser()
258
+ args = parser.parse_args(argv)
259
+ if args.command is None:
260
+ args = parser.parse_args(["serve", *(argv or [])])
261
+ try:
262
+ return args.func(args)
263
+ except GimpMcpError as error:
264
+ print(f"error: {error}", file=sys.stderr)
265
+ if error.hint:
266
+ print(f"hint: {error.hint}", file=sys.stderr)
267
+ return 2
268
+ except KeyboardInterrupt:
269
+ return 130
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main())
gimp_mcp/colors.py ADDED
@@ -0,0 +1,48 @@
1
+ """Explicit colors: '#rgb', '#rrggbb', '#rrggbbaa' or a few names. Values are sRGB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from .errors import GimpMcpError
9
+
10
+ NAMED = {
11
+ "white": "#ffffff",
12
+ "black": "#000000",
13
+ "red": "#ff0000",
14
+ "green": "#00ff00",
15
+ "blue": "#0000ff",
16
+ "transparent": "#00000000",
17
+ }
18
+ _HEX = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
19
+
20
+ COLOR_DESCRIPTION = "sRGB color as '#rrggbb', '#rrggbbaa', '#rgb' or one of: " + ", ".join(NAMED)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Rgba:
25
+ r: float
26
+ g: float
27
+ b: float
28
+ a: float
29
+
30
+ def as_tuple(self) -> tuple[float, float, float, float]:
31
+ return (self.r, self.g, self.b, self.a)
32
+
33
+ def to_wire(self) -> list[float]:
34
+ return list(self.as_tuple())
35
+
36
+
37
+ def parse_color(text: str) -> Rgba:
38
+ value = NAMED.get(text.strip().lower(), text.strip())
39
+ match = _HEX.match(value)
40
+ if match is None:
41
+ raise GimpMcpError("INVALID_ARGUMENT", f"invalid color {text!r}", hint=COLOR_DESCRIPTION)
42
+ digits = match.group(1)
43
+ if len(digits) == 3:
44
+ digits = "".join(ch * 2 for ch in digits)
45
+ if len(digits) == 6:
46
+ digits += "ff"
47
+ r, g, b, a = (int(digits[i : i + 2], 16) / 255 for i in range(0, 8, 2))
48
+ return Rgba(r, g, b, a)