@pired/lightroom-mcp 3.1.0

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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
  4. package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
  5. package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
  6. package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
  7. package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
  8. package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
  9. package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
  10. package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
  11. package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
  12. package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
  13. package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
  14. package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
  15. package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
  16. package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
  17. package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
  18. package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
  19. package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
  20. package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
  21. package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
  22. package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
  23. package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
  24. package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
  25. package/dist/cli.d.ts +6 -0
  26. package/dist/cli.d.ts.map +1 -0
  27. package/dist/cli.js +31 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/configs/claude-code.mcp.json +9 -0
  30. package/dist/configs/claude-desktop.json +12 -0
  31. package/dist/configs/generic-stdio-client.json +10 -0
  32. package/dist/configs/mcp-proxy-http.json +9 -0
  33. package/dist/create-server.d.ts +12 -0
  34. package/dist/create-server.d.ts.map +1 -0
  35. package/dist/create-server.js +17 -0
  36. package/dist/create-server.js.map +1 -0
  37. package/dist/deepseek-harness/README.md +112 -0
  38. package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
  39. package/dist/dispatcher.d.ts +34 -0
  40. package/dist/dispatcher.d.ts.map +1 -0
  41. package/dist/dispatcher.js +72 -0
  42. package/dist/dispatcher.js.map +1 -0
  43. package/dist/heartbeat.d.ts +17 -0
  44. package/dist/heartbeat.d.ts.map +1 -0
  45. package/dist/heartbeat.js +36 -0
  46. package/dist/heartbeat.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +255 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/install-plugin.d.ts +16 -0
  52. package/dist/install-plugin.d.ts.map +1 -0
  53. package/dist/install-plugin.js +125 -0
  54. package/dist/install-plugin.js.map +1 -0
  55. package/dist/instance-lock.d.ts +5 -0
  56. package/dist/instance-lock.d.ts.map +1 -0
  57. package/dist/instance-lock.js +77 -0
  58. package/dist/instance-lock.js.map +1 -0
  59. package/dist/list-tools-handler.d.ts +16 -0
  60. package/dist/list-tools-handler.d.ts.map +1 -0
  61. package/dist/list-tools-handler.js +50 -0
  62. package/dist/list-tools-handler.js.map +1 -0
  63. package/dist/plugin-liveness.d.ts +48 -0
  64. package/dist/plugin-liveness.d.ts.map +1 -0
  65. package/dist/plugin-liveness.js +75 -0
  66. package/dist/plugin-liveness.js.map +1 -0
  67. package/dist/plugin-socket.d.ts +32 -0
  68. package/dist/plugin-socket.d.ts.map +1 -0
  69. package/dist/plugin-socket.js +97 -0
  70. package/dist/plugin-socket.js.map +1 -0
  71. package/dist/ports.d.ts +3 -0
  72. package/dist/ports.d.ts.map +1 -0
  73. package/dist/ports.js +22 -0
  74. package/dist/ports.js.map +1 -0
  75. package/dist/token.d.ts +3 -0
  76. package/dist/token.d.ts.map +1 -0
  77. package/dist/token.js +23 -0
  78. package/dist/token.js.map +1 -0
  79. package/dist/tool-contracts.d.ts +13 -0
  80. package/dist/tool-contracts.d.ts.map +1 -0
  81. package/dist/tool-contracts.js +1422 -0
  82. package/dist/tool-contracts.js.map +1 -0
  83. package/dist/tool-handler.d.ts +23 -0
  84. package/dist/tool-handler.d.ts.map +1 -0
  85. package/dist/tool-handler.js +96 -0
  86. package/dist/tool-handler.js.map +1 -0
  87. package/dist/validate-args.d.ts +7 -0
  88. package/dist/validate-args.d.ts.map +1 -0
  89. package/dist/validate-args.js +37 -0
  90. package/dist/validate-args.js.map +1 -0
  91. package/dist/version.d.ts +2 -0
  92. package/dist/version.d.ts.map +1 -0
  93. package/dist/version.js +4 -0
  94. package/dist/version.js.map +1 -0
  95. package/dist/wait-until.d.ts +7 -0
  96. package/dist/wait-until.d.ts.map +1 -0
  97. package/dist/wait-until.js +15 -0
  98. package/dist/wait-until.js.map +1 -0
  99. package/package.json +73 -0
@@ -0,0 +1,865 @@
1
+ local LrTasks = import 'LrTasks'
2
+ local LrDialogs = import 'LrDialogs'
3
+ local LrFunctionContext = import 'LrFunctionContext'
4
+ local LrSocket = import 'LrSocket'
5
+ local LrPrefs = import 'LrPrefs'
6
+ local LrView = import 'LrView'
7
+ local LrUUID = import 'LrUUID'
8
+ local LrPathUtils = import 'LrPathUtils'
9
+ local LrFileUtils = import 'LrFileUtils'
10
+
11
+ local JSON = require 'JSON'
12
+ local HandlerSearch = require 'HandlerSearch'
13
+ local HandlerCollections = require 'HandlerCollections'
14
+ local HandlerMetadata = require 'HandlerMetadata'
15
+ local HandlerOrganization = require 'HandlerOrganization'
16
+ local HandlerImport = require 'HandlerImport'
17
+ local HandlerExport = require 'HandlerExport'
18
+ local HandlerSelection = require 'HandlerSelection'
19
+ local HandlerDevelop = require 'HandlerDevelop'
20
+ local HandlerAI = require 'HandlerAI'
21
+ local HandlerSpots = require 'HandlerSpots'
22
+ local HandlerLocalAdjustments = require 'HandlerLocalAdjustments'
23
+ local HandlerWatermark = require 'HandlerWatermark'
24
+ local HandlerAIMasks = require 'HandlerAIMasks'
25
+ local HandlerCatalog = require 'HandlerCatalog'
26
+ local HandlerPreview = require 'HandlerPreview'
27
+ local Log = require 'Log'
28
+
29
+ local DEFAULT_REQUEST_PORT = 58763
30
+ local DEFAULT_RESPONSE_PORT = 58764
31
+
32
+ -- LrSocket fires these on its accept-loop when no client is attached yet.
33
+ -- Both are benign listen-side states, not real failures.
34
+ local function isNoClientError(errStr)
35
+ if errStr == "timeout" then return true end
36
+ if errStr:find("failed to open", 1, true) then return true end
37
+ return false
38
+ end
39
+
40
+ local function validPort(n)
41
+ return type(n) == "number" and n == math.floor(n) and n >= 1 and n <= 65535
42
+ end
43
+
44
+ local function readPortPrefs()
45
+ local prefs = LrPrefs.prefsForPlugin()
46
+ local req = tonumber(prefs.requestPort)
47
+ local res = tonumber(prefs.responsePort)
48
+ if not validPort(req) then req = DEFAULT_REQUEST_PORT end
49
+ if not validPort(res) then res = DEFAULT_RESPONSE_PORT end
50
+ return req, res
51
+ end
52
+
53
+ -- State on _G so it survives across re-execution of this module body
54
+ -- within the same Lua state. This body runs BOTH when PluginInit requires
55
+ -- it AND every time Lightroom loads it as the InfoProvider to render the
56
+ -- Plug-in Manager panel. A render must NOT disturb a running server, so we
57
+ -- only ever CREATE state here (when absent) — never tear it down. Teardown
58
+ -- of a stale prior instance on Reload Plug-in is handled by resetForReload,
59
+ -- which PluginInit calls (PluginInit's LrInitPlugin runs on load/reload but
60
+ -- NOT on a plain panel render). Tearing down from this body — as earlier
61
+ -- versions did when running == true — killed the live server every time the
62
+ -- Plug-in Manager was opened (issues #121, #137).
63
+ if not _G.LightroomMCP_State then
64
+ _G.LightroomMCP_State = {
65
+ running = false,
66
+ shuttingDown = false,
67
+ requestSocket = nil,
68
+ responseSocket = nil,
69
+ sendConnected = false,
70
+ receiveConnected = false,
71
+ requestsProcessed = 0,
72
+ lastEvent = nil,
73
+ log = {},
74
+ token = nil,
75
+ lastRequestTime = nil,
76
+ lastConnectedTime = nil,
77
+ needsFullRestart = false,
78
+ freshRestart = false,
79
+ inFlightRequests = 0,
80
+ }
81
+ end
82
+
83
+ local pluginState = _G.LightroomMCP_State
84
+
85
+ -- Nothing in the plugin raises this any more: the LrShutdownApp and
86
+ -- LrShutdownPlugin hooks were removed because they cost a second or more on
87
+ -- every quit and bought no teardown that Lightroom's own context cancellation
88
+ -- did not already do. The guards stay because shutdown() below is still the
89
+ -- programmatic stop, and re-registering a hook is a one-line change.
90
+ local function shutdownRequested()
91
+ return pluginState.shuttingDown == true
92
+ end
93
+
94
+ local function tokenDir()
95
+ return LrPathUtils.child(LrPathUtils.getStandardFilePath("home"), ".config")
96
+ end
97
+
98
+ local function tokenFilePath()
99
+ return LrPathUtils.child(LrPathUtils.child(tokenDir(), "lightroom-mcp"), "token")
100
+ end
101
+
102
+ local function addLog(msg)
103
+ table.insert(pluginState.log, os.date("%H:%M:%S") .. " - " .. msg)
104
+ if #pluginState.log > 100 then
105
+ table.remove(pluginState.log, 1)
106
+ end
107
+ Log.info(msg)
108
+ end
109
+
110
+ local function generateToken()
111
+ -- Two UUIDs (32 hex chars each after stripping dashes) → 256 bits of entropy.
112
+ local u1 = LrUUID.generateUUID():gsub("-", "")
113
+ local u2 = LrUUID.generateUUID():gsub("-", "")
114
+ return (u1 .. u2):lower()
115
+ end
116
+
117
+ local function writeTokenFile(token)
118
+ local dir = LrPathUtils.child(tokenDir(), "lightroom-mcp")
119
+ LrFileUtils.createAllDirectories(dir)
120
+ local path = tokenFilePath()
121
+ local fh, openErr = io.open(path, "w")
122
+ if not fh then
123
+ addLog("Token write failed: " .. tostring(openErr))
124
+ return false
125
+ end
126
+ fh:write(token)
127
+ fh:close()
128
+ -- Lightroom's Lua sandbox has no os.execute, so chmod is impossible here.
129
+ -- On macOS single-user installs ~/.config/ inherits home-dir privacy (700).
130
+ -- On Linux/multi-user systems run: chmod 700 ~/.config/lightroom-mcp
131
+ -- See README "Security" section. Token gates localhost only; threat is
132
+ -- local-user access on the same machine.
133
+ return true
134
+ end
135
+
136
+ local DISPATCH = {
137
+ -- Heartbeat no-op. The MCP server pings on this action every
138
+ -- HEARTBEAT_INTERVAL_SECONDS (server/src/index.ts) so the plugin can
139
+ -- tell a healthy-but-idle session apart from a genuinely dead one
140
+ -- without waiting out a long fixed timer. It travels through the same
141
+ -- auth + dispatch path as any other action, so consumeMessage's
142
+ -- lastRequestTime update (below) already treats it as liveness — no
143
+ -- separate heartbeat bookkeeping needed. See STALE_RECONNECT_SECONDS.
144
+ ping = function(_params) return { pong = true } end,
145
+ search_photos = HandlerSearch.searchPhotos,
146
+ list_collections = HandlerCollections.listCollections,
147
+ create_collection = HandlerCollections.createCollection,
148
+ add_to_collection = HandlerCollections.addToCollection,
149
+ get_photo_metadata = HandlerMetadata.getPhotoMetadata,
150
+ set_keywords = HandlerOrganization.setKeywords,
151
+ set_rating = HandlerOrganization.setRating,
152
+ import_photos = HandlerImport.importPhotos,
153
+ export_photos = HandlerExport.exportPhotos,
154
+ get_selected_photos = HandlerSelection.getSelectedPhotos,
155
+ -- Test-only, no matching MCP tool contract: only the raw TCP probe
156
+ -- (manual-test.mjs / the e2e playbook) can reach it. See HandlerSelection.
157
+ set_selection = HandlerSelection.setSelection,
158
+ list_develop_presets = HandlerDevelop.listDevelopPresets,
159
+ get_develop_preset = HandlerDevelop.getDevelopPreset,
160
+ compare_develop_presets = HandlerDevelop.compareDevelopPresets,
161
+ create_develop_preset = HandlerDevelop.createDevelopPreset,
162
+ export_develop_preset = HandlerDevelop.exportDevelopPreset,
163
+ apply_develop_preset = HandlerDevelop.applyDevelopPreset,
164
+ copy_develop_settings = HandlerDevelop.copyDevelopSettings,
165
+ set_develop_settings = HandlerDevelop.setDevelopSettings,
166
+ -- AI / smart adjustments (@pired/lightroom-mcp additions).
167
+ -- NOTE: 'ai_denoise' is the only long-running action of this group and is
168
+ -- covered by the server's LONG_RUNNING_TIMEOUT_MS (see index.ts).
169
+ ai_denoise = HandlerAI.aiDenoise,
170
+ set_noise_reduction = HandlerAI.setNoiseReduction,
171
+ set_white_balance = HandlerDevelop.setWhiteBalance,
172
+ set_flags = HandlerOrganization.setFlags,
173
+ get_spots = HandlerSpots.getSpots,
174
+ add_spots = HandlerSpots.addSpots,
175
+ clear_spots = HandlerSpots.clearSpots,
176
+ add_local_adjustment = HandlerLocalAdjustments.addLocalAdjustment,
177
+ read_local_adjustments = HandlerLocalAdjustments.readLocalAdjustments,
178
+ list_watermarks = HandlerWatermark.listWatermarks,
179
+ -- v2.0 — AI masking, tone curves, Auto commands, catalog extras
180
+ -- (inspired by znznzna/lightroom-cli, MIT; adapted to this plugin's
181
+ -- gate/verification conventions).
182
+ -- NOTE: 'add_ai_mask' and 'apply_auto' drive the Develop module UI and
183
+ -- can run long on batches; both are covered by ACTION_TIMEOUTS_MS.
184
+ add_ai_mask = HandlerAIMasks.addAIMask,
185
+ list_masks = HandlerAIMasks.listMasks,
186
+ remove_mask = HandlerAIMasks.removeMask,
187
+ set_tone_curve = HandlerDevelop.setToneCurve,
188
+ get_tone_curve = HandlerDevelop.getToneCurve,
189
+ apply_auto = HandlerDevelop.applyAuto,
190
+ set_color_label = HandlerOrganization.setColorLabel,
191
+ create_virtual_copies = HandlerOrganization.createVirtualCopies,
192
+ create_smart_collection = HandlerOrganization.createSmartCollection,
193
+ -- v3.0 — 100% functionality: preview gate, selection/navigation,
194
+ -- catalog inventory, batch metadata, resets, process version,
195
+ -- snapshots, range masks (lightroom-cli port, MIT).
196
+ -- NOTE: 'get_photo_preview' renders asynchronously (up to 60s inside the
197
+ -- handler); 'reset_develop'/'set_process_version'/'add_range_mask' drive
198
+ -- the Develop module — all covered by ACTION_TIMEOUTS_MS.
199
+ get_photo_preview = HandlerPreview.getPhotoPreview,
200
+ get_develop_settings = HandlerDevelop.getDevelopSettings,
201
+ reset_develop = HandlerDevelop.resetDevelop,
202
+ set_process_version = HandlerDevelop.setProcessVersion,
203
+ create_snapshot = HandlerDevelop.createSnapshot,
204
+ select_photos = HandlerSelection.selectPhotos,
205
+ navigate_photo = HandlerSelection.navigatePhoto,
206
+ get_photo_status = HandlerOrganization.getPhotoStatus,
207
+ batch_metadata = HandlerOrganization.batchMetadata,
208
+ rotate_photo = HandlerOrganization.rotatePhoto,
209
+ remove_from_catalog = HandlerOrganization.removeFromCatalog,
210
+ list_folders = HandlerCatalog.listFolders,
211
+ list_keywords = HandlerCatalog.listKeywords,
212
+ manage_view_filter = HandlerCatalog.manageViewFilter,
213
+ get_collection_photos = HandlerCollections.getCollectionPhotos,
214
+ create_collection_set = HandlerCollections.createCollectionSet,
215
+ add_range_mask = HandlerAIMasks.addRangeMask,
216
+ toggle_mask_overlay = HandlerAIMasks.toggleMaskOverlay,
217
+ }
218
+
219
+ -- Generous wait so the very first response after handshake doesn't get
220
+ -- dropped while LrSocket is still settling sendConnected on the response
221
+ -- side. Must stay below the server's dispatcher timeout (30s) so a real
222
+ -- send-side outage still surfaces as a server-side timeout, not silent
223
+ -- success.
224
+ local SEND_WAIT_SECONDS = 25
225
+ -- After this many seconds of waiting for sendConnected, request a fresh
226
+ -- response-side rebind. Recovers from states where the response listener
227
+ -- ended up bound-but-clientless without responseNeedsRebind being set —
228
+ -- observed on Windows in issue #110, where the post-rebind onConnected
229
+ -- fires but sendConnected is later false by the time sendResponse runs
230
+ -- and no event sets the rebind flag again.
231
+ local SEND_REBIND_TRIGGER_SECONDS = 5
232
+ -- The MCP server pings every HEARTBEAT_INTERVAL_SECONDS (server/src/index.ts).
233
+ -- Treat the connection as stale only after missing roughly three pings in a
234
+ -- row, rather than the old fixed 300s idle window. This is what lets a
235
+ -- healthy-but-quiet interactive session run indefinitely without tripping a
236
+ -- full restart (Automaat review, PR #151: "idle and stale connections are
237
+ -- indistinguishable") while still detecting a genuinely dead peer (Windows
238
+ -- onClosed unreliability, issue #134) in under two minutes.
239
+ local HEARTBEAT_INTERVAL_SECONDS = 30
240
+ local STALE_RECONNECT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
241
+ -- If a request is in flight when the soft threshold above is hit, give it a
242
+ -- chance to finish rather than yanking the socket out from under it — but
243
+ -- only up to this hard cap past the soft threshold. If the peer is truly
244
+ -- dead the in-flight request was already unrecoverable, so the hard cap
245
+ -- guarantees we still restart instead of waiting forever on a hung handler.
246
+ -- Bounds the blast radius of a stale-triggered restart (Automaat review,
247
+ -- PR #151: "a tool call landing in the restart window loses its response").
248
+ local STALE_RESTART_HARD_CAP_SECONDS = STALE_RECONNECT_SECONDS + 30
249
+
250
+ -- Pure decision extracted from the monitor loop so it can be unit tested
251
+ -- without driving the full async LrTasks loop (see PluginInfoProvider_spec.lua).
252
+ -- Returns whether to restart and, if so, a log-suffix noting whether the
253
+ -- hard cap (not just the soft threshold) is what triggered it.
254
+ local function shouldRestartForStaleConnection(idle, inFlightRequests, softSeconds, hardCapSeconds)
255
+ local inFlight = (inFlightRequests or 0) > 0
256
+ local pastSoft = idle > softSeconds
257
+ local pastHard = idle > hardCapSeconds
258
+ local restart = pastSoft and (not inFlight or pastHard)
259
+ local suffix = (inFlight and pastHard) and " [hard cap, request still in flight]" or ""
260
+ return restart, suffix
261
+ end
262
+
263
+ local function sendResponse(response)
264
+ if shutdownRequested() then
265
+ addLog("Drop response (shutting down) id=" .. tostring(response.id))
266
+ return
267
+ end
268
+ local waited = 0
269
+ local selfHealRequested = false
270
+ while not pluginState.sendConnected and waited < SEND_WAIT_SECONDS do
271
+ if shutdownRequested() then
272
+ addLog("Drop response (shutting down) id=" .. tostring(response.id))
273
+ return
274
+ end
275
+ if not selfHealRequested and waited >= SEND_REBIND_TRIGGER_SECONDS then
276
+ addLog("sendResponse stalled " .. SEND_REBIND_TRIGGER_SECONDS .. "s, requesting rebind id=" .. tostring(response.id))
277
+ pluginState.responseNeedsRebind = true
278
+ selfHealRequested = true
279
+ end
280
+ LrTasks.sleep(0.1)
281
+ waited = waited + 0.1
282
+ end
283
+ if not pluginState.responseSocket or not pluginState.sendConnected then
284
+ addLog("Drop response (send socket disconnected after " .. SEND_WAIT_SECONDS .. "s) id=" .. tostring(response.id))
285
+ return
286
+ end
287
+ local ok, payload = pcall(function() return JSON:encode(response) end)
288
+ if not ok then
289
+ addLog("JSON encode failed: " .. tostring(payload))
290
+ return
291
+ end
292
+ pluginState.responseSocket:send(payload .. "\n")
293
+ pluginState.requestsProcessed = pluginState.requestsProcessed + 1
294
+ end
295
+
296
+ local function dispatchAction(request)
297
+ if shutdownRequested() then return end
298
+ local id = request.id
299
+ local action = request.action
300
+ local params = request.params or {}
301
+
302
+ if request.undecodable then
303
+ addLog("Rejecting undecodable request id=" .. tostring(id))
304
+ sendResponse({ id = id, error = "Malformed request: " .. request.undecodable })
305
+ return
306
+ end
307
+
308
+ -- Heartbeat pings arrive every 30s and are pure liveness noise once the
309
+ -- connection is healthy; skip them here so they don't dominate the
310
+ -- 100-line ring buffer used by the status panel and drown out real
311
+ -- request activity.
312
+ local isHeartbeat = (action == "ping")
313
+ if not isHeartbeat then
314
+ addLog("Request id=" .. tostring(id) .. " action=" .. tostring(action))
315
+ end
316
+
317
+ -- Tracked so the stale-connection monitor can defer a restart while a
318
+ -- real request is in flight. Held through sendResponse (not just the
319
+ -- handler call) so the monitor can't yank the socket while a response
320
+ -- is still queued waiting on sendConnected (see STALE_RESTART_HARD_CAP_SECONDS).
321
+ pluginState.inFlightRequests = (pluginState.inFlightRequests or 0) + 1
322
+
323
+ local handler = DISPATCH[action]
324
+ if not handler then
325
+ sendResponse({ id = id, error = "Unknown action: " .. tostring(action) })
326
+ pluginState.inFlightRequests = math.max(0, pluginState.inFlightRequests - 1)
327
+ return
328
+ end
329
+
330
+ -- xpcall, debug.traceback, and os.getenv aren't reliably exposed by
331
+ -- Lightroom's Lua sandbox: using them in the dispatcher's error path
332
+ -- turns a handler error into a silent nil-call that never reaches the
333
+ -- client. Stick to LrTasks.pcall.
334
+ local execOk, resultOrErr = LrTasks.pcall(function()
335
+ return handler(params)
336
+ end)
337
+ if execOk then
338
+ sendResponse({ id = id, result = resultOrErr })
339
+ else
340
+ addLog("Handler " .. action .. " error: " .. tostring(resultOrErr))
341
+ sendResponse({ id = id, error = tostring(resultOrErr) })
342
+ end
343
+ pluginState.inFlightRequests = math.max(0, pluginState.inFlightRequests - 1)
344
+ end
345
+
346
+ -- Runs SYNCHRONOUSLY in onMessage. Every request must carry the current
347
+ -- token in `hello`; we authenticate per-message so connection-state
348
+ -- races (reload, dual-instance, reconnect) can't desync auth from the
349
+ -- live token.
350
+ local function consumeMessage(message)
351
+ pluginState.lastEvent = os.date("%H:%M:%S")
352
+ pluginState.lastRequestTime = os.time() -- track for stale connection detection; a
353
+ -- heartbeat ping counts as activity same as
354
+ -- any other message.
355
+
356
+ local parsedOk, request = pcall(function() return JSON:decode(message) end)
357
+ if not parsedOk or type(request) ~= "table" then
358
+ addLog("JSON decode failed: " .. tostring(message))
359
+ -- A client that cannot be decoded still deserves an answer, or it sits
360
+ -- there until its own 30s timeout with no clue why. Salvage id+token by
361
+ -- pattern: only an authenticated caller gets the error back, so
362
+ -- unauthenticated garbage stays silent as before.
363
+ local salvagedToken = message:match('"hello"%s*:%s*"([^"]*)"')
364
+ local salvagedId = message:match('"id"%s*:%s*"([^"]*)"')
365
+ if salvagedId and pluginState.token and salvagedToken == pluginState.token then
366
+ return { id = salvagedId, undecodable = tostring(request) }
367
+ end
368
+ return nil
369
+ end
370
+
371
+ if not pluginState.token or request.hello ~= pluginState.token then
372
+ -- Drop silently. We CANNOT call sendResponse here: onMessage runs
373
+ -- in a non-yielding context and sendResponse uses LrTasks.sleep.
374
+ -- Server will time out, which is correct behaviour for auth fail.
375
+ addLog("Auth failed (token mismatch) id=" .. tostring(request.id))
376
+ return nil
377
+ end
378
+
379
+ return request
380
+ end
381
+
382
+ local function startServer()
383
+ if shutdownRequested() then
384
+ addLog("Start ignored (shutting down)")
385
+ return
386
+ end
387
+ if pluginState.running then
388
+ addLog("Already running")
389
+ return
390
+ end
391
+ -- Set running immediately after the guard so check-and-set is atomic.
392
+ -- generateToken/writeTokenFile/readPortPrefs below can yield the
393
+ -- cooperative LrTasks scheduler (token file I/O), and a second caller
394
+ -- waking in that window would otherwise pass the guard too and bind a
395
+ -- second pair of LrSocket listeners on the same ports.
396
+ pluginState.running = true
397
+
398
+ pluginState.token = generateToken()
399
+ if writeTokenFile(pluginState.token) then
400
+ addLog("Token written to " .. tokenFilePath())
401
+ end
402
+
403
+ local requestPort, responsePort = readPortPrefs()
404
+ pluginState.requestPort = requestPort
405
+ pluginState.responsePort = responsePort
406
+
407
+ -- Tag this invocation. resetForReload reuses the same _G state table in
408
+ -- place, so a prior instance's async context-cleanup handler (registered
409
+ -- below) and this fresh start share one table. If that old cleanup fires
410
+ -- AFTER we rebind here it would close the new sockets and wipe the new
411
+ -- token, leaving a dead-but-running server (issues #121, #137). The
412
+ -- handler captures this id and skips teardown once superseded (mirrors
413
+ -- responseGen). Bumped in the synchronous prologue so the id is live
414
+ -- before the old cleanup can interleave with the new binds.
415
+ pluginState.instanceId = (pluginState.instanceId or 0) + 1
416
+ local instanceId = pluginState.instanceId
417
+ addLog("Starting LrSocket servers")
418
+
419
+ LrFunctionContext.postAsyncTaskWithContext("LightroomMCPServer", function(context)
420
+ context:addCleanupHandler(function()
421
+ if pluginState.instanceId ~= instanceId then
422
+ -- A newer startServer superseded this instance; its sockets
423
+ -- and token now own the shared state table. Tearing them down
424
+ -- here would kill the live server, so leave them be.
425
+ addLog("Stale cleanup skipped (instance " .. instanceId .. " superseded)")
426
+ return
427
+ end
428
+ addLog("Server task context cleanup")
429
+ if pluginState.requestSocket then
430
+ pcall(function() pluginState.requestSocket:close() end)
431
+ end
432
+ if pluginState.responseSocket then
433
+ pcall(function() pluginState.responseSocket:close() end)
434
+ end
435
+ pluginState.requestSocket = nil
436
+ pluginState.responseSocket = nil
437
+ pluginState.sendConnected = false
438
+ pluginState.receiveConnected = false
439
+ pluginState.token = nil
440
+ end)
441
+
442
+ local function bindRequest()
443
+ return LrSocket.bind {
444
+ functionContext = context,
445
+ plugin = _PLUGIN,
446
+ port = requestPort,
447
+ mode = "receive",
448
+ onConnected = function()
449
+ pluginState.receiveConnected = true
450
+ pluginState.lastConnectedTime = os.time()
451
+ -- A stale lastRequestTime from a prior session (e.g. user
452
+ -- Stop/Start after the connection sat idle >90s) must not
453
+ -- leak into this connection's idle clock, or the monitor
454
+ -- loop sees a huge idle value on the very first tick and
455
+ -- restarts immediately. Idle now starts from
456
+ -- lastConnectedTime until the first real message arrives.
457
+ pluginState.lastRequestTime = nil
458
+ if pluginState.freshRestart then
459
+ -- Sockets were fully closed and rebound by the stale-detection
460
+ -- restart (issue #134 Windows workaround). The response listener
461
+ -- already accepted a fresh MCP client; no stale send-side
462
+ -- connection to flush. Skipping the rebind prevents the
463
+ -- request->response->request cycling that delays the first
464
+ -- post-restart response past the 30 s MCP timeout.
465
+ pluginState.freshRestart = false
466
+ addLog("REQUEST socket connected (post-restart)")
467
+ else
468
+ -- New request client on a live server = new MCP session. Force
469
+ -- a response-side rebind: LrSocket send-mode does not reliably
470
+ -- notice client disconnect on Windows, so sendConnected can stay
471
+ -- true pointing at a dead socket and :send() writes to the void.
472
+ pluginState.sendConnected = false
473
+ pluginState.responseNeedsRebind = true
474
+ addLog("REQUEST socket connected")
475
+ end
476
+ end,
477
+ onMessage = function(_, message)
478
+ if shutdownRequested() then return end
479
+ local request = consumeMessage(message)
480
+ if request then
481
+ LrTasks.startAsyncTask(function()
482
+ dispatchAction(request)
483
+ end)
484
+ end
485
+ end,
486
+ onClosed = function()
487
+ pluginState.receiveConnected = false
488
+ pluginState.requestNeedsReconnect = true
489
+ addLog("REQUEST socket closed (client disconnected)")
490
+ end,
491
+ onError = function(_, err)
492
+ local errStr = tostring(err)
493
+ if isNoClientError(errStr) then
494
+ if not pluginState.receiveConnected then
495
+ pluginState.requestNeedsReconnect = true
496
+ end
497
+ else
498
+ pluginState.receiveConnected = false
499
+ pluginState.requestNeedsReconnect = true
500
+ addLog("REQUEST socket error: " .. errStr)
501
+ end
502
+ end,
503
+ }
504
+ end
505
+
506
+ -- Each rebind bumps a generation. Old-listener callbacks compare
507
+ -- their captured gen to the live one and ignore themselves if
508
+ -- stale. Without this, an onError/onClosed from the just-closed
509
+ -- listener can flag rebind AGAIN immediately after we just
510
+ -- finished rebinding, looping us out of the new client.
511
+ pluginState.responseGen = 0
512
+ -- Clear loop-control flags so a reused state table (in-place
513
+ -- resetForReload, or a panel Stop->Start) doesn't enter the monitor
514
+ -- loop with a stale reconnect/rebind pending and churn the sockets we
515
+ -- just bound on the first tick.
516
+ pluginState.requestNeedsReconnect = false
517
+ pluginState.responseNeedsRebind = false
518
+ pluginState.responseNeedsReconnect = false
519
+
520
+ -- bindResponse takes myGen explicitly so callers can pre-bump the
521
+ -- generation BEFORE calling :close() on the prior listener. On
522
+ -- platforms where LrSocket invokes onClosed synchronously during
523
+ -- close (suspected on Windows, per issue #110), a stale callback
524
+ -- would otherwise see isLive()==true and re-set responseNeedsRebind
525
+ -- after we just cleared it.
526
+ local function bindResponse(myGen)
527
+ local function isLive() return pluginState.responseGen == myGen end
528
+ return LrSocket.bind {
529
+ functionContext = context,
530
+ plugin = _PLUGIN,
531
+ port = responsePort,
532
+ mode = "send",
533
+ onConnected = function()
534
+ if not isLive() then return end
535
+ pluginState.sendConnected = true
536
+ addLog("RESPONSE socket connected")
537
+ end,
538
+ onClosed = function()
539
+ if not isLive() then return end
540
+ pluginState.sendConnected = false
541
+ pluginState.responseNeedsRebind = true
542
+ addLog("RESPONSE socket closed (gen=" .. myGen .. ")")
543
+ end,
544
+ onError = function(_, err)
545
+ if not isLive() then return end
546
+ local errStr = tostring(err)
547
+ if isNoClientError(errStr) then
548
+ if not pluginState.sendConnected then
549
+ pluginState.responseNeedsReconnect = true
550
+ end
551
+ else
552
+ pluginState.sendConnected = false
553
+ pluginState.responseNeedsRebind = true
554
+ addLog("RESPONSE socket error: " .. errStr)
555
+ end
556
+ end,
557
+ }
558
+ end
559
+
560
+ -- Bump first, then bind, so the initial listener owns gen=1 (any
561
+ -- pre-existing stale callbacks from a Reload Plug-in cycle were
562
+ -- bound against gen=0 and stay ignored).
563
+ pluginState.responseGen = pluginState.responseGen + 1
564
+ pluginState.requestSocket = bindRequest()
565
+ addLog("REQUEST bound on " .. requestPort)
566
+ pluginState.responseSocket = bindResponse(pluginState.responseGen)
567
+ addLog("RESPONSE bound on " .. responsePort .. " gen=" .. pluginState.responseGen)
568
+
569
+ while pluginState.running and not shutdownRequested() do
570
+ if pluginState.requestNeedsReconnect and pluginState.requestSocket then
571
+ pluginState.requestNeedsReconnect = false
572
+ pluginState.requestSocket:reconnect()
573
+ end
574
+ -- Response socket has two recovery paths:
575
+ -- - rebind: full close+rebind for true client disconnect
576
+ -- - reconnect: cheap reconnect for listen-side timeouts
577
+ if pluginState.responseNeedsRebind then
578
+ -- Pre-bump gen BEFORE close so any synchronous onClosed
579
+ -- callback fired during close() sees isLive()==false and
580
+ -- ignores itself. Without this, the OLD listener's close
581
+ -- callback re-sets responseNeedsRebind after we clear it
582
+ -- below, looping the rebind on the next tick. Issue #110
583
+ -- suspected Windows trigger.
584
+ pluginState.responseGen = pluginState.responseGen + 1
585
+ local newGen = pluginState.responseGen
586
+ if pluginState.responseSocket then
587
+ pcall(function() pluginState.responseSocket:close() end)
588
+ end
589
+ pluginState.sendConnected = false
590
+ -- Brief yield so any kernel cleanup of the just-closed
591
+ -- listener completes before we try to bind the same port
592
+ -- again. The actual server-side reconnect takes ~1s, so
593
+ -- 100ms here doesn't meaningfully delay recovery.
594
+ LrTasks.sleep(0.1)
595
+ if shutdownRequested() then break end
596
+ pluginState.responseSocket = bindResponse(newGen)
597
+ pluginState.responseNeedsRebind = false
598
+ pluginState.responseNeedsReconnect = false
599
+ addLog("RESPONSE rebound on " .. responsePort .. " gen=" .. newGen)
600
+ elseif pluginState.responseNeedsReconnect and pluginState.responseSocket then
601
+ pluginState.responseNeedsReconnect = false
602
+ pluginState.responseSocket:reconnect()
603
+ end
604
+ -- Full restart requested by stale detection
605
+ if pluginState.needsFullRestart then
606
+ pluginState.needsFullRestart = false
607
+ LrTasks.startAsyncTask(function()
608
+ addLog("Restarting server (stale connection recovery)")
609
+ -- stopServer() is declared after startServer() in this file
610
+ -- and is not an upvalue here; inline its effect directly.
611
+ pluginState.running = false
612
+ LrTasks.sleep(0.5)
613
+ startServer()
614
+ end)
615
+ end
616
+ -- Stale connection detection: Windows LrSocket does not reliably
617
+ -- fire onClosed when the remote MCP server process exits (issue #134).
618
+ -- The MCP server pings every HEARTBEAT_INTERVAL_SECONDS, so as long as
619
+ -- the connection is genuinely alive, lastRequestTime keeps advancing
620
+ -- even with no real tool calls in flight. If receiveConnected is true
621
+ -- but nothing (including a heartbeat) has arrived for
622
+ -- STALE_RECONNECT_SECONDS, the peer is actually gone, not just idle.
623
+ -- Defer past that soft threshold while a real request is in flight,
624
+ -- up to STALE_RESTART_HARD_CAP_SECONDS, so we don't yank the socket
625
+ -- out from under a response that's about to be sent.
626
+ if pluginState.receiveConnected then
627
+ local ref = pluginState.lastRequestTime or pluginState.lastConnectedTime
628
+ if ref then
629
+ local idle = os.time() - ref
630
+ local restart, suffix = shouldRestartForStaleConnection(
631
+ idle, pluginState.inFlightRequests, STALE_RECONNECT_SECONDS, STALE_RESTART_HARD_CAP_SECONDS)
632
+ if restart then
633
+ addLog("Stale connection: " .. idle .. "s since last heartbeat, scheduling restart" .. suffix)
634
+ pluginState.lastRequestTime = nil
635
+ pluginState.lastConnectedTime = nil
636
+ pluginState.needsFullRestart = true
637
+ pluginState.freshRestart = true
638
+ end
639
+ end
640
+ end
641
+ LrTasks.sleep(0.2)
642
+ end
643
+
644
+ addLog("Server loop exiting")
645
+ -- Socket cleanup runs in context:addCleanupHandler above.
646
+ end)
647
+ end
648
+
649
+ local function startServerFromPanel()
650
+ pluginState.shuttingDown = false
651
+ startServer()
652
+ end
653
+
654
+ local function stopServer()
655
+ if not pluginState.running then
656
+ addLog("Not running")
657
+ return
658
+ end
659
+ addLog("Stopping LrSocket servers")
660
+ pluginState.running = false
661
+ end
662
+
663
+ -- Called by PluginInit on plugin load/reload (never on a Plug-in Manager
664
+ -- render). Reload re-runs PluginInit while a prior instance's state may
665
+ -- still live on _G in the same Lua state, with `running` stale-true and
666
+ -- its task context already cancelled by Lightroom. Clear the flag so the
667
+ -- subsequent startServer() isn't blocked by its "Already running" guard,
668
+ -- and signal any surviving monitor loop to exit. Reset IN PLACE (not a new
669
+ -- table) so this module's pluginState and the old loop's closure keep
670
+ -- pointing at the same table — flipping running here is what stops it.
671
+ local function resetForReload()
672
+ pluginState.shuttingDown = false
673
+ if not pluginState.running then return end
674
+ addLog("Reload detected - resetting previous server instance")
675
+ pluginState.running = false
676
+ if pluginState.requestSocket then
677
+ pcall(function() pluginState.requestSocket:close() end)
678
+ end
679
+ if pluginState.responseSocket then
680
+ pcall(function() pluginState.responseSocket:close() end)
681
+ end
682
+ pluginState.requestSocket = nil
683
+ pluginState.responseSocket = nil
684
+ pluginState.sendConnected = false
685
+ pluginState.receiveConnected = false
686
+ pluginState.token = nil
687
+ -- Return the rest of the transient runtime state to fresh-state defaults
688
+ -- so the Plug-in Manager reports honest status after a reload (no
689
+ -- carried-over lastEvent / counters / ports) and the next startServer
690
+ -- can't inherit a stale reconnect/rebind request. instanceId is
691
+ -- deliberately NOT reset -- it must keep advancing so a superseded
692
+ -- instance's cleanup handler stays a no-op (see startServer).
693
+ pluginState.requestNeedsReconnect = false
694
+ pluginState.responseNeedsRebind = false
695
+ pluginState.responseNeedsReconnect = false
696
+ pluginState.lastEvent = nil
697
+ pluginState.requestsProcessed = 0
698
+ pluginState.requestPort = nil
699
+ pluginState.responsePort = nil
700
+ pluginState.lastRequestTime = nil
701
+ pluginState.lastConnectedTime = nil
702
+ pluginState.needsFullRestart = false
703
+ pluginState.freshRestart = false
704
+ pluginState.inFlightRequests = 0
705
+ end
706
+
707
+ local function shutdown()
708
+ pluginState.shuttingDown = true
709
+ pluginState.running = false
710
+ pluginState.requestNeedsReconnect = false
711
+ pluginState.responseNeedsRebind = false
712
+ pluginState.responseNeedsReconnect = false
713
+ pluginState.needsFullRestart = false
714
+ pluginState.freshRestart = false
715
+ pluginState.sendConnected = false
716
+ pluginState.receiveConnected = false
717
+ pluginState.token = nil
718
+ end
719
+
720
+ addLog("PluginInfoProvider loaded")
721
+
722
+ local PluginInfoProvider = {
723
+ startServer = startServer,
724
+ startServerFromPanel = startServerFromPanel,
725
+ stopServer = stopServer,
726
+ shutdown = shutdown,
727
+ resetForReload = resetForReload,
728
+ -- Exposed for PluginInfoProvider_spec.lua only; not used elsewhere in the plugin.
729
+ shouldRestartForStaleConnection = shouldRestartForStaleConnection,
730
+ handlePing = DISPATCH.ping,
731
+ HEARTBEAT_INTERVAL_SECONDS = HEARTBEAT_INTERVAL_SECONDS,
732
+ STALE_RECONNECT_SECONDS = STALE_RECONNECT_SECONDS,
733
+ STALE_RESTART_HARD_CAP_SECONDS = STALE_RESTART_HARD_CAP_SECONDS,
734
+ }
735
+
736
+ function PluginInfoProvider.sectionsForTopOfDialog(f, propertyTable)
737
+ local prefs = LrPrefs.prefsForPlugin()
738
+ if prefs.autoStartServer == nil then
739
+ prefs.autoStartServer = true
740
+ end
741
+ propertyTable.autoStartServer = prefs.autoStartServer
742
+ propertyTable:addObserver('autoStartServer', function(_, _, value)
743
+ prefs.autoStartServer = value
744
+ end)
745
+
746
+ local cfgRequestPort, cfgResponsePort = readPortPrefs()
747
+ propertyTable.requestPort = cfgRequestPort
748
+ propertyTable.responsePort = cfgResponsePort
749
+ propertyTable:addObserver('requestPort', function(_, _, value)
750
+ local n = tonumber(value)
751
+ if validPort(n) then prefs.requestPort = n end
752
+ end)
753
+ propertyTable:addObserver('responsePort', function(_, _, value)
754
+ local n = tonumber(value)
755
+ if validPort(n) then prefs.responsePort = n end
756
+ end)
757
+
758
+ local activeRequest = pluginState.requestPort or cfgRequestPort
759
+ local activeResponse = pluginState.responsePort or cfgResponsePort
760
+
761
+ local statusText = "=== Lightroom MCP Status ===\n\n"
762
+ statusText = statusText .. "Running: " .. tostring(pluginState.running) .. "\n"
763
+ statusText = statusText .. "Request socket connected: " .. tostring(pluginState.receiveConnected) .. "\n"
764
+ statusText = statusText .. "Response socket connected: " .. tostring(pluginState.sendConnected) .. "\n"
765
+ statusText = statusText .. "Last event: " .. (pluginState.lastEvent or "Never") .. "\n"
766
+ statusText = statusText .. "Requests processed: " .. pluginState.requestsProcessed .. "\n"
767
+ statusText = statusText .. "Request port: " .. activeRequest .. " (mode=receive)\n"
768
+ statusText = statusText .. "Response port: " .. activeResponse .. " (mode=send)\n"
769
+ statusText = statusText .. "Log file: " .. (Log.filePath() or "(unavailable)") .. "\n"
770
+ statusText = statusText .. "\nRecent logs:\n"
771
+ local startIdx = math.max(1, #pluginState.log - 15)
772
+ for i = startIdx, #pluginState.log do
773
+ statusText = statusText .. " " .. pluginState.log[i] .. "\n"
774
+ end
775
+
776
+ return {
777
+ {
778
+ title = "Lightroom MCP Server Status",
779
+ f:static_text {
780
+ title = statusText,
781
+ fill_horizontal = 1,
782
+ width_in_chars = 70,
783
+ height_in_lines = 25,
784
+ },
785
+ f:checkbox {
786
+ title = "Auto-start server on Lightroom launch",
787
+ value = LrView.bind('autoStartServer'),
788
+ },
789
+ f:row {
790
+ f:static_text { title = "Request port:", width = 110 },
791
+ f:edit_field {
792
+ value = LrView.bind('requestPort'),
793
+ width_in_chars = 7,
794
+ precision = 0,
795
+ min = 1,
796
+ max = 65535,
797
+ },
798
+ f:static_text { title = "(default 58763)" },
799
+ },
800
+ f:row {
801
+ f:static_text { title = "Response port:", width = 110 },
802
+ f:edit_field {
803
+ value = LrView.bind('responsePort'),
804
+ width_in_chars = 7,
805
+ precision = 0,
806
+ min = 1,
807
+ max = 65535,
808
+ },
809
+ f:static_text { title = "(default 58764)" },
810
+ },
811
+ f:static_text {
812
+ title = "Port changes apply on next Start. Server env vars must match: LIGHTROOM_MCP_REQUEST_PORT / LIGHTROOM_MCP_RESPONSE_PORT.",
813
+ fill_horizontal = 1,
814
+ width_in_chars = 70,
815
+ height_in_lines = 2,
816
+ },
817
+ f:row {
818
+ -- Two buttons rather than one toggle. The toggle's title was
819
+ -- computed once at render, so after Stop it still read "Stop
820
+ -- Server" and clicking it only logged "Not running" -- there was
821
+ -- no way to start again without closing and reopening the
822
+ -- dialog. A bound title does not work here: Lightroom ignores a
823
+ -- binding on a push_button title.
824
+ f:push_button {
825
+ title = "Start Server",
826
+ action = function()
827
+ if pluginState.running then
828
+ addLog("Start ignored (already running)")
829
+ return
830
+ end
831
+ startServerFromPanel()
832
+ end,
833
+ },
834
+ f:push_button {
835
+ title = "Stop Server",
836
+ action = function()
837
+ stopServer()
838
+ end,
839
+ },
840
+ f:push_button {
841
+ title = "Show Status",
842
+ action = function()
843
+ local lines = {
844
+ "Running: " .. tostring(pluginState.running),
845
+ "Request socket connected: " .. tostring(pluginState.receiveConnected),
846
+ "Response socket connected: " .. tostring(pluginState.sendConnected),
847
+ "Last event: " .. (pluginState.lastEvent or "Never"),
848
+ "Requests processed: " .. pluginState.requestsProcessed,
849
+ "Log file: " .. (Log.filePath() or "(unavailable)"),
850
+ "",
851
+ "Recent logs:",
852
+ }
853
+ local logStart = math.max(1, #pluginState.log - 30)
854
+ for i = logStart, #pluginState.log do
855
+ table.insert(lines, " " .. pluginState.log[i])
856
+ end
857
+ LrDialogs.message("Lightroom MCP Status", table.concat(lines, "\n"), "info")
858
+ end,
859
+ },
860
+ },
861
+ },
862
+ }
863
+ end
864
+
865
+ return PluginInfoProvider