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 +9 -0
- gimp_mcp/__main__.py +5 -0
- gimp_mcp/bridge.py +334 -0
- gimp_mcp/cli.py +273 -0
- gimp_mcp/colors.py +48 -0
- gimp_mcp/coverage.py +457 -0
- gimp_mcp/doctor.py +514 -0
- gimp_mcp/errors.py +45 -0
- gimp_mcp/headless.py +240 -0
- gimp_mcp/ids.py +61 -0
- gimp_mcp/installer.py +52 -0
- gimp_mcp/models.py +136 -0
- gimp_mcp/paths.py +89 -0
- gimp_mcp/plugin/gimp-mcp-bridge/gimp-mcp-bridge.py +116 -0
- gimp_mcp/plugin/gimp-mcp-bridge/gmb_ops.py +1771 -0
- gimp_mcp/plugin/gimp-mcp-bridge/gmb_policy.py +283 -0
- gimp_mcp/plugin/gimp-mcp-bridge/gmb_server.py +386 -0
- gimp_mcp/plugin/gimp-mcp-bridge/gmb_undo.py +54 -0
- gimp_mcp/plugin_modules.py +24 -0
- gimp_mcp/py.typed +0 -0
- gimp_mcp/server.py +50 -0
- gimp_mcp/setup.py +574 -0
- gimp_mcp/toolkit.py +106 -0
- gimp_mcp/tools/__init__.py +0 -0
- gimp_mcp/tools/files.py +211 -0
- gimp_mcp/tools/filters.py +155 -0
- gimp_mcp/tools/history.py +153 -0
- gimp_mcp/tools/inspect.py +44 -0
- gimp_mcp/tools/layers.py +241 -0
- gimp_mcp/tools/paint.py +130 -0
- gimp_mcp/tools/pdb.py +68 -0
- gimp_mcp/tools/preview.py +85 -0
- gimp_mcp/tools/procedures.py +72 -0
- gimp_mcp/tools/selection.py +167 -0
- gimp_mcp/tools/session.py +55 -0
- gimp_mcp/tools/text.py +190 -0
- gimp_mcp/tools/transform.py +136 -0
- gimp_mcp/versions.py +36 -0
- gimp_mcp-0.2.0.dist-info/METADATA +95 -0
- gimp_mcp-0.2.0.dist-info/RECORD +43 -0
- gimp_mcp-0.2.0.dist-info/WHEEL +4 -0
- gimp_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- gimp_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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)
|