autodev-cli 1.4.151 → 1.4.153

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.
@@ -1,267 +1,362 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
- # gimp_mcp_server.py — AutoDev auto-start GIMP 3.x MCP bridge
5
- #
6
- # Derived from libreearth/gimp-mcp @49c46dd (GPL-3.0); modified by AutoDev to
7
- # auto-start as a GIMP 3.x PERSISTENT extension. The upstream plugin registered a
8
- # manual "<Image>/Filters/Development/ → Start MCP Server" ImageProcedure that the
9
- # user had to click; this variant registers a PERSISTENT extension so GIMP starts
10
- # the socket server automatically on launch — no menu click, no open image needed.
11
- #
12
- # The socket / command-marshalling logic (start_server, handle_client,
13
- # execute_command, GLib.idle_add onto GIMP's main thread) is kept from upstream.
14
- # ONLY the registration + run() model changed to PERSISTENT + defensive ordering.
15
- #
16
- # ---------------------------------------------------------------------------
17
- # GIMP 3.x plugin layout (REQUIRED): this file must live at
18
- # <gimp-config>/<ver>/plug-ins/gimp_mcp_server/gimp_mcp_server.py
19
- # i.e. inside a sub-folder whose name == this file's basename. A loose .py is
20
- # IGNORED by GIMP 3.x. On Linux/macOS it must ALSO be executable (chmod 0o755).
21
2
  # ---------------------------------------------------------------------------
3
+ # gimp_mcp_server.py — vendored, USER-VERIFIED GIMP 3.2 MCP bridge (plug-in).
22
4
  #
23
- # Copyright (C) 2023 libreearth (upstream, GPL-3.0)
24
- # Modifications Copyright (C) 2025 AutoDev
25
- #
26
- # This program is free software: you can redistribute it and/or modify it under
27
- # the terms of the GNU General Public License as published by the Free Software
28
- # Foundation, either version 3 of the License, or (at your option) any later
29
- # version.
5
+ # Derived from libreearth/gimp-mcp (GPL-3.0; see media/gimp/LICENSE) and fixed by
6
+ # the user / AutoDev for GIMP 3.2 (Store / MSIX). Verified working on real GIMP
7
+ # 3.2.44: a menu-triggered PLUGIN (Tools -> MCP / Filters -> Development -> Start
8
+ # MCP Server) that listens on 127.0.0.1:9876 and drives GIMP. Keep the "GIMP MCP
9
+ # Server" window open — GLib.MainLoop() holds the out-of-process plug-in alive.
30
10
  #
31
- # This program is distributed in the hope that it will be useful, but WITHOUT ANY
32
- # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
33
- # PARTICULAR PURPOSE. See the GNU General Public License for more details.
34
- #
35
- # You should have received a copy of the GNU General Public License along with
36
- # this program. If not, see <https://www.gnu.org/licenses/>. A copy is vendored
37
- # next to this file as LICENSE.
11
+ # This is the user's verified file; the body below is kept byte-for-byte. The
12
+ # lines in THIS block are a provenance comment only — do not edit the code below.
13
+ # ---------------------------------------------------------------------------
38
14
 
39
- import os
40
- import sys
41
- import json
42
- import socket
43
- import threading
44
- import traceback
45
- import datetime
15
+ # GIMP MCP Server Plugin (GIMP 3.2)
16
+ # Listens on localhost:9876 so the libreearth gimp-mcp client can drive GIMP.
46
17
 
47
18
  import gi
48
19
  gi.require_version('Gimp', '3.0')
49
20
  from gi.repository import Gimp
50
21
  from gi.repository import GLib
22
+ from gi.repository import Gio
23
+ from gi.repository import GObject
51
24
 
25
+ import io
26
+ import json
27
+ import os
28
+ import socket
29
+ import sys
30
+ import threading
31
+ import traceback
52
32
 
53
- # ---------------------------------------------------------------------------
54
- # Logging — an auto-start extension has NO console, so every diagnostic goes to
55
- # a log file the user can read. We resolve it next to this plug-in first (always
56
- # writable if the plug-in loaded at all), falling back to the GIMP config dir.
57
- # Nothing here may raise: a logging failure must never stop GIMP from launching.
58
- # ---------------------------------------------------------------------------
59
- def _log_path():
60
- try:
61
- here = os.path.dirname(os.path.abspath(__file__))
62
- return os.path.join(here, 'gimp-mcp-server.log')
63
- except Exception:
33
+ HOST = '127.0.0.1'
34
+ PORT = 9876
35
+ # Store GIMP redirects GLib config dir into the MSIX LocalCache. Also write
36
+ # to the real roaming profile so the log is findable.
37
+ _LOG_PATHS = [
38
+ os.path.join(GLib.get_user_config_dir(), 'GIMP', '3.2', 'gimp_mcp_server.log'),
39
+ os.path.join(os.environ.get('APPDATA', ''), 'GIMP', '3.2', 'gimp_mcp_server.log'),
40
+ ]
41
+
42
+ _server_lock = threading.Lock()
43
+ _server_started = False
44
+ _exec_globals = None
45
+ _main_loop = None
46
+
47
+
48
+ def _log(msg):
49
+ line = msg.rstrip() + '\n'
50
+ for path in _LOG_PATHS:
51
+ if not path:
52
+ continue
64
53
  try:
65
- return os.path.join(GLib.get_user_config_dir(), 'gimp-mcp-server.log')
54
+ os.makedirs(os.path.dirname(path), exist_ok=True)
55
+ with open(path, 'a', encoding='utf-8') as fh:
56
+ fh.write(line)
66
57
  except Exception:
67
- return os.path.join(os.path.expanduser('~'), 'gimp-mcp-server.log')
58
+ pass
59
+ print(msg, flush=True)
68
60
 
69
61
 
70
- _LOG_FILE = _log_path()
62
+ def N_(message):
63
+ return message
71
64
 
72
65
 
73
- def log(msg):
74
- """Best-effort append to the log file AND stderr. Never raises."""
75
- line = '[%s] %s' % (datetime.datetime.now().isoformat(timespec='seconds'), msg)
76
- try:
77
- with open(_LOG_FILE, 'a', encoding='utf-8') as fh:
78
- fh.write(line + '\n')
79
- except Exception:
80
- pass
66
+ def _(message):
67
+ return GLib.dgettext(None, message)
68
+
69
+
70
+ def _serialize(obj):
71
+ if obj is None or isinstance(obj, (str, int, float, bool)):
72
+ return obj
73
+ if isinstance(obj, (list, tuple)):
74
+ return [_serialize(item) for item in obj]
75
+ if isinstance(obj, dict):
76
+ return {str(k): _serialize(v) for k, v in obj.items()}
77
+ data = {'type': obj.__class__.__name__}
78
+ if hasattr(obj, 'get_id'):
79
+ try:
80
+ data['id'] = int(obj.get_id())
81
+ except Exception:
82
+ pass
83
+ elif hasattr(obj, 'ID'):
84
+ data['id'] = obj.ID
85
+ if hasattr(obj, 'get_name'):
86
+ try:
87
+ data['name'] = obj.get_name()
88
+ except Exception:
89
+ pass
90
+ if 'id' not in data and len(data) == 1:
91
+ return str(obj)
92
+ return data
93
+
94
+
95
+ def _resolve_api(api_path):
96
+ parts = [p for p in str(api_path).split('.') if p]
97
+ if parts and parts[0] == 'Gimp':
98
+ parts = parts[1:]
99
+ elif len(parts) >= 3 and parts[0] == 'gi' and parts[1] == 'repository' and parts[2] == 'Gimp':
100
+ parts = parts[3:]
101
+
102
+ aliases = {
103
+ 'list_images': 'get_images',
104
+ 'list_fonts': 'get_fonts',
105
+ 'fonts_get_list': 'get_fonts',
106
+ }
107
+
108
+ if not parts:
109
+ return Gimp
110
+
111
+ obj = Gimp
112
+ for part in parts:
113
+ part = aliases.get(part, part)
114
+ obj = getattr(obj, part)
115
+ return obj
116
+
117
+
118
+ def _exec_namespace():
119
+ global _exec_globals
120
+ if _exec_globals is None:
121
+ ns = {
122
+ '__builtins__': __builtins__,
123
+ '__name__': '__main__',
124
+ 'Gimp': Gimp,
125
+ 'GLib': GLib,
126
+ 'Gio': Gio,
127
+ 'GObject': GObject,
128
+ 'sys': sys,
129
+ 'os': os,
130
+ 'json': json,
131
+ }
132
+ try:
133
+ gi.require_version('Gegl', '0.4')
134
+ from gi.repository import Gegl
135
+ ns['Gegl'] = Gegl
136
+ except Exception as exc:
137
+ _log(f'Gegl import failed: {exc}')
138
+ try:
139
+ gi.require_version('Babl', '0.1')
140
+ from gi.repository import Babl
141
+ ns['Babl'] = Babl
142
+ except Exception:
143
+ pass
144
+ _exec_globals = ns
145
+ return _exec_globals
146
+
147
+
148
+ def _extract_code(args, kwargs):
149
+ if kwargs.get('code'):
150
+ return str(kwargs['code'])
151
+ if not args:
152
+ return ''
153
+ if len(args) >= 2 and args[0] in ('exec', 'eval', 'pyGObject-console'):
154
+ payload = args[1]
155
+ if isinstance(payload, (list, tuple)):
156
+ return '\n'.join(str(item) for item in payload)
157
+ return str(payload)
158
+ if isinstance(args[0], (list, tuple)):
159
+ return '\n'.join(str(item) for item in args[0])
160
+ return str(args[0])
161
+
162
+
163
+ def _run_python(code):
164
+ ns = _exec_namespace()
165
+ stdout = io.StringIO()
166
+ old_stdout = sys.stdout
167
+ sys.stdout = stdout
81
168
  try:
82
- sys.stderr.write(line + '\n')
83
- except Exception:
84
- pass
169
+ exec(code, ns)
170
+ finally:
171
+ sys.stdout = old_stdout
172
+ printed = stdout.getvalue()
173
+ result = ns.get('_result')
174
+ if result is not None:
175
+ return {'printed': printed, 'result': _serialize(result)}
176
+ return {'printed': printed, 'result': None}
85
177
 
86
178
 
87
179
  class MCPPlugin(Gimp.PlugIn):
88
- # ── Registration ──────────────────────────────────────────────────────
180
+ def do_set_i18n(self, name):
181
+ return False
182
+
89
183
  def do_query_procedures(self):
90
- return ["plug-in-mcp-server-autostart"]
184
+ return ['plug-in-mcp-server']
91
185
 
92
186
  def do_create_procedure(self, name):
93
- # PERSISTENT + no args + no menu = GIMP auto-starts this extension at
94
- # launch. Adding ANY argument, a menu label, or a menu path would turn
95
- # it back into a user-invoked plug-in and disable auto-start.
96
187
  procedure = Gimp.Procedure.new(
97
- self, name, Gimp.PDBProcType.PERSISTENT, self.run, None,
188
+ self, name, Gimp.PDBProcType.PLUGIN, self.run, None
98
189
  )
99
- try:
100
- procedure.set_documentation(
101
- "AutoDev MCP server (auto-start)",
102
- "Auto-starts a localhost:9876 socket bridge so external MCP "
103
- "clients can drive GIMP. Derived from libreearth/gimp-mcp "
104
- "(GPL-3.0), modified by AutoDev to run as a PERSISTENT extension.",
105
- name,
106
- )
107
- procedure.set_attribution("AutoDev", "AutoDev (GPL-3.0)", "2025")
108
- except Exception as e:
109
- # Attribution/doc setters are cosmetic — never let a binding
110
- # mismatch here prevent the procedure from registering.
111
- log("do_create_procedure: non-fatal setter error: %s" % e)
190
+ procedure.set_menu_label(_('Start MCP Server'))
191
+ procedure.set_documentation(
192
+ _('Starts an MCP server to control GIMP externally'),
193
+ _('Listens on 127.0.0.1:9876 for the GIMP MCP client'),
194
+ name,
195
+ )
196
+ procedure.set_attribution('GIMP MCP', 'GIMP MCP', '2026')
197
+ procedure.set_sensitivity_mask(Gimp.ProcedureSensitivityMask.ALWAYS)
198
+ procedure.add_enum_argument(
199
+ 'run-mode',
200
+ _('Run mode'),
201
+ _('The run mode'),
202
+ Gimp.RunMode,
203
+ Gimp.RunMode.INTERACTIVE,
204
+ GObject.ParamFlags.READWRITE,
205
+ )
206
+ procedure.add_menu_path('<Image>/Tools/MCP')
207
+ procedure.add_menu_path('<Image>/Filters/Development/')
112
208
  return procedure
113
209
 
114
- # ── Extension entry point ─────────────────────────────────────────────
115
- def run(self, procedure, config, run_data):
116
- """PERSISTENT extension entry point.
210
+ def run(self, procedure, config, data):
211
+ global _main_loop
212
+ started, message = self.ensure_server()
213
+ _log(message)
214
+ if not started:
215
+ return procedure.new_return_values(Gimp.PDBStatusType.EXECUTION_ERROR, GLib.Error(message))
117
216
 
118
- DEFENSIVE ORDER (the brick-risk part): call persistent_ready() FIRST.
119
- Its ABSENCE is what hangs GIMP at startup, so it must run before
120
- anything that could throw. Everything after it is wrapped so a bind
121
- failure logs + degrades instead of taking GIMP core down with it.
122
- """
123
- # 1) Hand control back to GIMP core IMMEDIATELY so the UI keeps loading.
124
- try:
125
- procedure.persistent_ready()
126
- log("persistent_ready() OK — extension is live")
127
- except Exception as e:
128
- # A binding mismatch on this exact call is the known lock-up cause;
129
- # log it (do NOT re-raise) so GIMP still finishes launching.
130
- log("persistent_ready() FAILED (non-fatal): %s\n%s"
131
- % (e, traceback.format_exc()))
132
-
133
- # 2) Start the socket server on a daemon thread. A bind failure (port in
134
- # use, sandbox denial) MUST NOT crash the process or block launch.
217
+ # GIMP 3 launches Python plug-ins out of process. Returning from run()
218
+ # would exit that process and kill the socket thread. Stay alive and
219
+ # dispatch GLib.idle_add work (API calls) on this main loop.
220
+ # A visible window is required: otherwise "Start MCP Server" looks like
221
+ # it did not open anything.
135
222
  try:
136
- t = threading.Thread(target=self.start_server, daemon=True)
137
- t.start()
138
- log("MCP server thread started (listening on localhost:9876)")
139
- except Exception as e:
140
- log("failed to start MCP server thread (non-fatal): %s\n%s"
141
- % (e, traceback.format_exc()))
142
-
143
- # 3) Keep this extension process alive for the whole GIMP session so the
144
- # socket stays open and idle_add callbacks can marshal onto core.
145
- try:
146
- GLib.MainLoop().run()
147
- except Exception as e:
148
- log("main loop exited (non-fatal): %s" % e)
223
+ self._show_status_window(message)
224
+ except Exception as exc:
225
+ _log(f'Status window failed: {exc}\n{traceback.format_exc()}')
149
226
 
150
- # 4) Standard PERSISTENT success return.
227
+ _main_loop = GLib.MainLoop()
228
+ _log('Entering GLib.MainLoop to keep MCP server process alive')
229
+ try:
230
+ _main_loop.run()
231
+ except Exception as exc:
232
+ _log(f'MainLoop exited: {exc}')
151
233
  return procedure.new_return_values(Gimp.PDBStatusType.SUCCESS, GLib.Error())
152
234
 
153
- # ── Socket server (kept from upstream) ────────────────────────────────
235
+ def _show_status_window(self, message):
236
+ gi.require_version('Gtk', '3.0')
237
+ gi.require_version('GimpUi', '3.0')
238
+ from gi.repository import Gtk
239
+ from gi.repository import GimpUi
240
+
241
+ GimpUi.init('gimp_mcp_server.py')
242
+ window = Gtk.Window(title='GIMP MCP Server')
243
+ window.set_default_size(360, 120)
244
+ window.set_keep_above(False)
245
+ window.set_skip_taskbar_hint(False)
246
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
247
+ box.set_border_width(16)
248
+ label = Gtk.Label(label=message + '\nLeave this window open.')
249
+ label.set_line_wrap(True)
250
+ box.pack_start(label, True, True, 0)
251
+ button = Gtk.Button(label='Stop server')
252
+ button.connect('clicked', lambda *_: window.destroy())
253
+ box.pack_start(button, False, False, 0)
254
+ window.add(box)
255
+ window.connect('destroy', self._on_status_closed)
256
+ window.show_all()
257
+ _log('Status window opened')
258
+
259
+ def _on_status_closed(self, *_args):
260
+ global _main_loop
261
+ _log('Status window closed; stopping MCP server')
262
+ if _main_loop is not None:
263
+ _main_loop.quit()
264
+
265
+ def ensure_server(self):
266
+ global _server_started
267
+ with _server_lock:
268
+ if _server_started:
269
+ return True, f'MCP Server already listening on {HOST}:{PORT}'
270
+ try:
271
+ thread = threading.Thread(target=self.start_server, daemon=True)
272
+ thread.start()
273
+ _server_started = True
274
+ return True, f'MCP Server started on {HOST}:{PORT}'
275
+ except Exception as exc:
276
+ return False, f'Failed to start MCP Server: {exc}'
277
+
154
278
  def start_server(self):
155
- host = 'localhost'
156
- port = 9876
157
- sock = None
279
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
280
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
158
281
  try:
159
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
160
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
161
- sock.bind((host, port))
162
- sock.listen(1)
163
- log("server listening on %s:%d" % (host, port))
164
- except Exception as e:
165
- # Bind/listen failed (e.g. port already taken by Blender's MCP or a
166
- # second GIMP). Log + bail out of the thread; GIMP keeps running.
167
- log("bind/listen failed (non-fatal): %s\n%s" % (e, traceback.format_exc()))
168
- try:
169
- if sock is not None:
170
- sock.close()
171
- except Exception:
172
- pass
282
+ sock.bind((HOST, PORT))
283
+ sock.listen(8)
284
+ _log(f'Server listening on {HOST}:{PORT}')
285
+ except Exception as exc:
286
+ _log(f'Bind/listen failed: {exc}')
173
287
  return
174
288
 
175
289
  while True:
176
290
  try:
177
291
  client, addr = sock.accept()
178
- log("client connected: %s" % (addr,))
179
- threading.Thread(
180
- target=self.handle_client, args=(client,), daemon=True,
181
- ).start()
182
- except Exception as e:
183
- log("accept error: %s" % e)
292
+ _log(f'Connected to {addr}')
293
+ threading.Thread(target=self.handle_client, args=(client,), daemon=True).start()
294
+ except Exception as exc:
295
+ _log(f'Server accept error: {exc}')
184
296
  break
185
- try:
186
- sock.close()
187
- except Exception:
188
- pass
297
+ sock.close()
189
298
 
190
299
  def handle_client(self, client):
191
300
  buffer = b''
192
- while True:
193
- try:
194
- data = client.recv(1024)
195
- if not data:
301
+ try:
302
+ client.settimeout(120)
303
+ while True:
304
+ chunk = client.recv(65536)
305
+ if not chunk:
196
306
  break
197
- buffer += data
198
- try:
199
- command = json.loads(buffer.decode('utf-8'))
200
- buffer = b''
201
- # Marshal onto GIMP's main thread — the PDB is not thread-safe.
307
+ buffer += chunk
308
+ while buffer:
309
+ try:
310
+ command = json.loads(buffer.decode('utf-8'))
311
+ buffer = b''
312
+ except json.JSONDecodeError:
313
+ break
202
314
  GLib.idle_add(self.execute_command, command, client)
203
- except json.JSONDecodeError:
204
- # Partial frame — keep buffering until it parses.
205
- continue
206
- except Exception as e:
207
- log("client error: %s" % e)
208
- break
209
- try:
210
- client.close()
211
- except Exception:
212
- pass
315
+ except Exception as exc:
316
+ _log(f'Client error: {exc}')
317
+ finally:
318
+ try:
319
+ client.close()
320
+ except Exception:
321
+ pass
213
322
 
214
323
  def execute_command(self, command, client):
215
- """Runs on GIMP's main thread via idle_add. Guarded so a bad command or
216
- a wrong binding logs + returns an error frame rather than hanging."""
217
- response = {}
324
+ cmd_type = command.get('type')
325
+ params = command.get('params', {}) or {}
218
326
  try:
219
- cmd_type = command.get("type")
220
- params = command.get("params", {})
221
-
222
- if cmd_type == "call_api":
223
- api_path = params["api_path"]
224
- args = params.get("args", [])
225
- kwargs = params.get("kwargs", {})
226
-
227
- module = Gimp
228
- for part in api_path.split("."):
229
- module = getattr(module, part)
230
-
231
- result = module(*args, **kwargs)
232
-
233
- if hasattr(result, "ID"):
234
- result = {"id": result.ID}
235
- elif isinstance(result, list):
236
- result = [self.serialize_gimp_object(item) for item in result]
327
+ if cmd_type == 'ping':
328
+ response = {'status': 'success', 'result': 'pong'}
329
+ elif cmd_type == 'exec':
330
+ code = _extract_code(params.get('args', []), params.get('kwargs', {}))
331
+ if not code:
332
+ code = str(params.get('code', ''))
333
+ response = {'status': 'success', 'result': _run_python(code)}
334
+ elif cmd_type == 'call_api':
335
+ api_path = params.get('api_path', '')
336
+ args = params.get('args', []) or []
337
+ kwargs = params.get('kwargs', {}) or {}
338
+ if api_path in ('exec', 'eval', '__exec__'):
339
+ response = {'status': 'success', 'result': _run_python(_extract_code(args, kwargs))}
237
340
  else:
238
- result = str(result)
239
-
240
- response = {"status": "success", "result": result}
341
+ method = _resolve_api(api_path)
342
+ result = method(*args, **kwargs)
343
+ response = {'status': 'success', 'result': _serialize(result)}
241
344
  else:
242
- response = {"status": "error", "message": "Unknown command: %s" % cmd_type}
243
- except Exception as e:
244
- response = {"status": "error", "message": str(e),
245
- "traceback": traceback.format_exc()}
345
+ response = {'status': 'error', 'message': f'Unknown command: {cmd_type}'}
346
+ except Exception as exc:
347
+ response = {
348
+ 'status': 'error',
349
+ 'message': str(exc),
350
+ 'traceback': traceback.format_exc(),
351
+ }
352
+ _log(f'Command error: {response["message"]}\n{response["traceback"]}')
246
353
 
247
354
  try:
248
- client.sendall(json.dumps(response).encode('utf-8'))
249
- except Exception as e:
250
- log("failed to send response: %s" % e)
251
- return False # one-shot idle callback
252
-
253
- def serialize_gimp_object(self, obj):
254
- try:
255
- if hasattr(obj, "ID"):
256
- return {"id": obj.ID, "type": obj.__class__.__name__}
257
- except Exception:
258
- pass
259
- return str(obj)
355
+ payload = json.dumps(response).encode('utf-8')
356
+ client.sendall(payload)
357
+ except Exception as exc:
358
+ _log(f'Response send failed: {exc}')
359
+ return False
260
360
 
261
361
 
262
- try:
263
- Gimp.main(MCPPlugin.__gtype__, sys.argv)
264
- except Exception as _e:
265
- # If GIMP's main dispatch itself fails, log it — never let the plug-in
266
- # process take the launch down with an unhandled traceback.
267
- log("Gimp.main failed: %s\n%s" % (_e, traceback.format_exc()))
362
+ Gimp.main(MCPPlugin.__gtype__, sys.argv)