davinci-resolve-mcp 2.70.2 → 2.70.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,77 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.70.3
6
+
7
+ The free-edition bridge could never notice Resolve exiting on Windows, so it
8
+ orphaned itself and blocked the next session. Reported in issue #112 by
9
+ @ZontarLives, with a self-contained repro.
10
+
11
+ ### The bug
12
+
13
+ `serve()` detected host exit with a single test:
14
+
15
+ ```python
16
+ if os.getppid() != expected_parent:
17
+ break
18
+ ```
19
+
20
+ That is a POSIX signal. When a parent dies, POSIX reparents the orphan to init
21
+ and the value changes. **Windows does not reparent** — the parent pid is a
22
+ static field in the process record, so `os.getppid()` returns the dead parent's
23
+ pid forever and the check can never fire.
24
+
25
+ The consequence is not a cosmetic leak. The orphaned `fuscript.exe` keeps port
26
+ 49632 and keeps accepting connections while holding a dead `resolve` handle, so
27
+ the next Resolve session's bridge cannot bind, and the client sees:
28
+
29
+ ```
30
+ bridge_timeout: Resolve did not answer in time - check for an open modal dialog,
31
+ which blocks its scripting API entirely
32
+ ```
33
+
34
+ against a socket that `Get-NetTCPConnection` reports as healthily `LISTENING`.
35
+ Every surface-level check passes and the suggested cause is a red herring, which
36
+ is what made it expensive to diagnose.
37
+
38
+ ### The fix
39
+
40
+ Liveness is now asked directly instead of inferred from a pid changing:
41
+
42
+ - `parent_has_exited()` keeps the reparent test as the fast path where it works,
43
+ then checks whether the parent pid still resolves to a live process, then
44
+ whether that process still name-matches `PARENT_MARKERS`. The last step also
45
+ catches pid reuse, which the old check would have misread as "Resolve exited".
46
+ - On Windows liveness comes from `OpenProcess` + `WaitForSingleObject` via
47
+ `ctypes`. Only "no such process" counts as death: access-denied and every
48
+ other error are *unknown*, and unknown never ends the session — the module's
49
+ standing rule is that a bridge which exits early is worse than one that
50
+ lingers.
51
+ - `_process_name()` gained a Windows branch (`QueryFullProcessImageNameW`). It
52
+ previously tried `/proc` then `ps`, so it returned an empty string on every
53
+ Windows machine. `scripts/resolve_bridge_probe.py` gets the same treatment.
54
+
55
+ Binding failures now name the likely cause and the way out, rather than
56
+ surfacing a bare "address already in use" that sends people to check firewalls.
57
+
58
+ The Windows branch is injectable so it is tested off Windows — a constant
59
+ `getppid` plus a simulated liveness answer. The platform difference is precisely
60
+ what kept this invisible to everyone developing on macOS or Linux.
61
+
62
+ ### Also confirmed
63
+
64
+ `%APPDATA%` now joins `%PROGRAMDATA%` as a verified Windows bridge location; the
65
+ #112 report served reads from it against free 21.0.3.7. README and `docs/SKILL.md`
66
+ updated, along with guidance that a bridge which stops answering while
67
+ `LISTENING` is a stale process rather than a modal dialog.
68
+
69
+ ### Also in this release
70
+
71
+ Two offline guard tests built their "not a temp path" target from `os.getcwd()`,
72
+ which failed — and wrote a real `look.cube` into the working directory — whenever
73
+ the suite ran from a directory under `/tmp`. `tests/_paths.py` makes them
74
+ independent of where the suite runs.
75
+
5
76
  ## What's New in v2.70.2
6
77
 
7
78
  The control panel could never reach the free edition, even with a perfectly
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.70.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.70.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
@@ -58,10 +58,15 @@ menu. A Lua canary is installed alongside so you can tell that apart from a
58
58
  wrong folder.
59
59
 
60
60
  Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. The Windows paths
61
- added in v2.70.1 (issue #106) shipped unverified; a report on free 21.0.1.11
62
- (issue #109) has since shown the bridge installing, listing and serving from
63
- `%PROGRAMDATA%` on Windows, so that path is now confirmed rather than assumed.
64
- `%APPDATA%` remains untested. Reports welcome.
61
+ added in v2.70.1 (issue #106) shipped unverified; reports on free 21.0.1.11
62
+ (issue #109) and free 21.0.3.7 (issue #112) have since shown the bridge
63
+ installing, listing and serving from **both** `%PROGRAMDATA%` and `%APPDATA%` on
64
+ Windows 11, so those paths are now confirmed rather than assumed.
65
+
66
+ Note that the bridge holds its port for as long as it serves. Before v2.70.3 a
67
+ Windows bridge could outlive Resolve and block the next session's listener; if
68
+ you are on an older build and a bridge stops answering, check for a stale
69
+ `fuscript.exe` still holding the port.
65
70
 
66
71
  This is the documented in-app path, not a licence circumvention, but Blackmagic
67
72
  could close it — treat it as a supported-until-it-is-not tier. Loopback only,
package/docs/SKILL.md CHANGED
@@ -33,11 +33,17 @@ work unchanged. Two things to know when diagnosing it:
33
33
  Lua canary, which always lists, so "Python not detected" is distinguishable
34
34
  from "wrong folder". The preflight is macOS-only — off macOS Resolve finds
35
35
  Python by other means, and running the check there was a false alarm (#106).
36
- - **Windows: `%PROGRAMDATA%` confirmed, `%APPDATA%` still not.** Both script
37
- folders have been targeted since v2.70.1; a free 21.0.1.11 report (#109) has
38
- since shown Resolve listing and serving the bridge from `%PROGRAMDATA%`. If a
39
- user reports the menu entry missing on Windows, ask whether the Lua canary
40
- lists that separates "wrong folder" from "Python not detected" there too.
36
+ - **Windows: both script folders confirmed.** `%PROGRAMDATA%` (#109) and
37
+ `%APPDATA%` (#112) have each been shown serving the bridge on Windows 11 free
38
+ builds. If a user reports the menu entry missing on Windows, ask whether the
39
+ Lua canary lists that separates "wrong folder" from "Python not detected".
40
+ - **A bridge that stops answering while its socket is `LISTENING` is a stale
41
+ process, not a modal dialog.** Before v2.70.3 the Windows bridge could never
42
+ detect Resolve exiting (`os.getppid()` does not change there), so it outlived
43
+ Resolve holding the port and answering with a dead handle — and the
44
+ `bridge_timeout` message blamed a modal dialog. On any build, the way out is
45
+ the `shutdown` operation (`BridgeClient.bridge_shutdown()`); killing the
46
+ process is the fallback, not the first move.
41
47
  - The **control panel connects over the bridge too** (fixed in v2.70.2). It runs
42
48
  as a separate process with its own connector, so a panel that reports "Resolve
43
49
  unavailable" while tool calls work is a panel-side bug, not a broken bridge.
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.70.2"
39
+ VERSION = "2.70.3"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.70.2",
3
+ "version": "2.70.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -35,6 +35,26 @@ PARENT_MARKERS = ("resolve", "fuscript", "fusion")
35
35
 
36
36
  def process_name(pid):
37
37
  """Best-effort process name; empty string when it cannot be read."""
38
+ if os.name == "nt":
39
+ # Windows has neither /proc nor ps, so the probe reported an empty
40
+ # parent name on every Windows machine (issue #112).
41
+ try:
42
+ import ctypes
43
+
44
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
45
+ handle = kernel32.OpenProcess(0x1000, False, int(pid))
46
+ if not handle:
47
+ return ""
48
+ try:
49
+ size = ctypes.c_ulong(260)
50
+ buf = ctypes.create_unicode_buffer(size.value)
51
+ if not kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
52
+ return ""
53
+ return os.path.basename(buf.value)
54
+ finally:
55
+ kernel32.CloseHandle(handle)
56
+ except Exception:
57
+ return ""
38
58
  try: # Linux
39
59
  with open("/proc/%d/comm" % pid) as handle:
40
60
  return handle.read().strip()
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.70.2"
88
+ VERSION = "2.70.3"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.70.2"
14
+ VERSION = "2.70.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -204,6 +204,8 @@ def _host_model(
204
204
 
205
205
  def _process_name(pid: int) -> str:
206
206
  """Best-effort process name; empty string when it cannot be read."""
207
+ if os.name == "nt":
208
+ return _windows_process_name(pid)
207
209
  try: # Linux
208
210
  with open(f"/proc/{pid}/comm", "r", encoding="utf-8") as handle:
209
211
  return handle.read().strip()
@@ -221,6 +223,126 @@ def _process_name(pid: int) -> str:
221
223
  return ""
222
224
 
223
225
 
226
+ def _windows_process_name(pid: int) -> str: # pragma: no cover - exercised on Windows
227
+ """Image name for a pid via Win32, or "" when it cannot be read.
228
+
229
+ ctypes rather than `tasklist`: the bridge polls this, and spawning a console
230
+ process every second inside Resolve is both slow and visible.
231
+ """
232
+ try:
233
+ import ctypes
234
+ from ctypes import wintypes
235
+
236
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
237
+ handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
238
+ if not handle:
239
+ return ""
240
+ try:
241
+ size = wintypes.DWORD(260)
242
+ buf = ctypes.create_unicode_buffer(size.value)
243
+ if not kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
244
+ return ""
245
+ return os.path.basename(buf.value)
246
+ finally:
247
+ kernel32.CloseHandle(handle)
248
+ except Exception:
249
+ return ""
250
+
251
+
252
+ # Win32 constants used for parent-liveness detection.
253
+ _WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
254
+ _WIN_SYNCHRONIZE = 0x00100000
255
+ _WIN_WAIT_OBJECT_0 = 0x0
256
+ _WIN_WAIT_TIMEOUT = 0x102
257
+ _WIN_ERROR_INVALID_PARAMETER = 87
258
+
259
+
260
+ def _process_is_alive(pid: int) -> Optional[bool]:
261
+ """True / False / None when it genuinely cannot be determined.
262
+
263
+ `None` matters: this module's standing rule is that a bridge which exits
264
+ early is worse than one that lingers, so an undeterminable answer must not
265
+ be allowed to read as "the parent died".
266
+ """
267
+ if not pid or pid <= 0:
268
+ return None
269
+ if os.name == "nt": # pragma: no cover - exercised on Windows
270
+ try:
271
+ import ctypes
272
+
273
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
274
+ access = _WIN_SYNCHRONIZE | _WIN_PROCESS_QUERY_LIMITED_INFORMATION
275
+ handle = kernel32.OpenProcess(access, False, int(pid))
276
+ if not handle:
277
+ # Only "no such process" is proof of death. Access-denied and
278
+ # everything else are unknown — a bridge must not quit because
279
+ # it could not open a handle.
280
+ return False if ctypes.get_last_error() == _WIN_ERROR_INVALID_PARAMETER else None
281
+ try:
282
+ status = kernel32.WaitForSingleObject(handle, 0)
283
+ if status == _WIN_WAIT_OBJECT_0:
284
+ return False # signalled == exited
285
+ if status == _WIN_WAIT_TIMEOUT:
286
+ return True
287
+ return None
288
+ finally:
289
+ kernel32.CloseHandle(handle)
290
+ except Exception:
291
+ return None
292
+ try:
293
+ os.kill(pid, 0)
294
+ return True
295
+ except ProcessLookupError:
296
+ return False
297
+ except PermissionError:
298
+ return True # exists, owned by someone else
299
+ except OSError:
300
+ return None
301
+
302
+
303
+ def parent_has_exited(expected_pid: int, expected_name: str = "",
304
+ *, getppid: Callable[[], int] = os.getppid,
305
+ is_alive: Callable[[int], Optional[bool]] = _process_is_alive,
306
+ name_of: Callable[[int], str] = _process_name) -> bool:
307
+ """Has the Resolve that launched this script gone away?
308
+
309
+ `os.getppid() != expected_pid` is the POSIX signal — an orphan is reparented
310
+ to init, so the value changes. **Windows does not reparent.** The parent pid
311
+ is a static field in the process record, so `getppid()` returns the dead
312
+ parent's pid forever and a check written that way can never fire: the bridge
313
+ outlives Resolve, keeps its port, and answers with a dead handle. The next
314
+ session's bridge then cannot bind, and the client sees a timeout against a
315
+ socket that is `LISTENING` — every surface-level check passes (issue #112).
316
+
317
+ So liveness is asked directly, and reparenting is kept as the fast path
318
+ where it works. The name check additionally catches pid reuse, which the
319
+ reparent test would misread as "Resolve exited".
320
+
321
+ Unknown liveness is never treated as death, per the module's standing rule.
322
+ """
323
+ try:
324
+ if getppid() != expected_pid:
325
+ return True
326
+ except Exception: # pragma: no cover - defensive
327
+ pass
328
+ alive = is_alive(expected_pid)
329
+ if alive is False:
330
+ return True
331
+ if alive is None:
332
+ return False
333
+ # Pid reuse: the number is alive but now belongs to something else. Only
334
+ # usable when the ORIGINAL name looked like Resolve — otherwise there is no
335
+ # baseline to have drifted from. `_host_model` documents that the parent
336
+ # name is routinely empty or non-matching under the App Store sandbox, where
337
+ # `ps` on another process is blocked; treating that as death would kill the
338
+ # bridge on its first poll, on the exact edition it exists for.
339
+ if expected_name and any(m in expected_name.lower() for m in PARENT_MARKERS):
340
+ current = (name_of(expected_pid) or "").lower()
341
+ if current and not any(marker in current for marker in PARENT_MARKERS):
342
+ return True
343
+ return False
344
+
345
+
224
346
  def probe_host_model() -> Dict[str, Any]:
225
347
  """Report the host model without starting a listener.
226
348
 
@@ -502,7 +624,23 @@ class Bridge:
502
624
  return held == MAX_CONCURRENT_CONNECTIONS
503
625
 
504
626
  def start(self) -> "Bridge":
505
- self._server = _Server((self.config["host"], self.config["port"]), self)
627
+ host, port = self.config["host"], self.config["port"]
628
+ try:
629
+ self._server = _Server((host, port), self)
630
+ except OSError as exc:
631
+ # An orphaned bridge from a previous session is the likely cause,
632
+ # and a bare "address already in use" sends people looking at
633
+ # firewalls. Say what is actually holding the port and how to clear
634
+ # it — the alternative is a LISTENING socket answering with a dead
635
+ # Resolve handle, which reads as a broken install (issue #112).
636
+ raise BridgeConfigError(
637
+ f"cannot listen on {host}:{port} — {exc}. Another bridge is probably still "
638
+ "running from an earlier Resolve session. Ask it to stop (send the `shutdown` "
639
+ "operation, or `resolve_bridge_client.BridgeClient(...).bridge_shutdown()`), or "
640
+ "end the stale process: Windows `Get-NetTCPConnection -LocalPort "
641
+ f"{port} | Select-Object OwningProcess` then `Stop-Process -Id <pid>`; "
642
+ f"macOS/Linux `lsof -ti tcp:{port} | xargs kill`."
643
+ ) from exc
506
644
  self._thread = threading.Thread(target=self._server.serve_forever, name="ResolveBridge", daemon=True)
507
645
  self._thread.start()
508
646
  return self
@@ -518,7 +656,8 @@ class Bridge:
518
656
  def port(self) -> int:
519
657
  return self._server.server_address[1] if self._server else self.config["port"]
520
658
 
521
- def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
659
+ def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None,
660
+ parent_exited: Optional[Callable[[int, str], bool]] = None) -> Dict[str, Any]:
522
661
  """Start, then block or return according to the detected host model.
523
662
 
524
663
  Returns immediately in-process (the caller keeps a reference alive);
@@ -535,6 +674,10 @@ class Bridge:
535
674
  model["stop_reason"] = None
536
675
  return model
537
676
  expected_parent = model["parent_pid"]
677
+ expected_name = model.get("parent_name") or ""
678
+ # Injectable so the Windows branch is testable off Windows — the bug
679
+ # this replaced was invisible on the maintainer's platform.
680
+ host_exited = parent_exited or parent_has_exited
538
681
  reason = "resolve_exited"
539
682
  try:
540
683
  while True:
@@ -544,7 +687,7 @@ class Bridge:
544
687
  if self._thread is None or not self._thread.is_alive():
545
688
  reason = "listener_died"
546
689
  break
547
- if os.getppid() != expected_parent:
690
+ if host_exited(expected_parent, expected_name):
548
691
  break
549
692
  # Waiting on the event rather than joining the thread makes a
550
693
  # requested stop immediate instead of up to `poll_seconds` late.