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 +71 -0
- package/README.md +10 -5
- package/docs/SKILL.md +11 -5
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/resolve_bridge_probe.py +20 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/resolve_bridge.py +146 -3
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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;
|
|
62
|
-
(issue #109)
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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
|
@@ -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()
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.70.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|