davinci-resolve-mcp 2.68.1 → 2.68.2

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,78 @@
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.68.2
6
+
7
+ Bug fixes. The server no longer opens a second DaVinci Resolve, and a failed
8
+ connection now tells the caller what is actually wrong.
9
+
10
+ ### Fixed
11
+
12
+ - **The server launched Resolve when one was already running — usually the wrong
13
+ one.** The free edition refuses external scripting by design, so on a machine
14
+ where only it is running `scriptapp("Resolve")` always returns None.
15
+ `get_resolve()` read that as *"Resolve is not running"* and ran `open` on the
16
+ application; with both editions installed that started **Studio**, a second and
17
+ different application, on every tool call.
18
+
19
+ `resolve_is_running()` now answers the question that was being assumed. It
20
+ returns None when it cannot tell, deliberately — "cannot tell" must never decay
21
+ into "nothing is running", because that is the answer that leads to launching
22
+ something. A genuinely absent Resolve is still launched, as documented, and the
23
+ macOS candidate list now contains **both** editions rather than only the
24
+ installer path.
25
+ - **The error a caller received described something that had not happened.**
26
+ Eleven sites asserted that Resolve was not running, that starting it had been
27
+ tried and failed, and that the reader should check their Studio install. After
28
+ the fix above none of that was true, and the accurate guidance was only reaching
29
+ the log. `_not_connected_error()` derives the message from the situation and
30
+ names the applicable remedy — external scripting on Studio, or the in-app bridge
31
+ on the free edition, including the framework-Python prerequisite. Three codes:
32
+ `SCRIPTING_UNAVAILABLE`, `BRIDGE_UNAVAILABLE`, `RESOLVE_NOT_RUNNING`.
33
+ - **Those two are marked `retryable: false`.** The `not_connected` category
34
+ defaults to retryable because auto-launch may succeed next time; neither of
35
+ these can — one needs a preference changed, the other a script started inside
36
+ Resolve — and reporting them as retryable sends an agent into a loop it cannot
37
+ win.
38
+ - **The offline test suite opened Resolve, and connected to whatever was running.**
39
+ A stub-based audio test reached for `AUDIO_SYNC_*`, which are attributes on the
40
+ *live* object, so it called `get_resolve()`, found nothing, and started Resolve;
41
+ when Resolve *was* open it connected instead, making the test's result depend on
42
+ the state of the machine. `tests/conftest.py` closes both suite-wide and names
43
+ any launch attempt in the terminal summary.
44
+ - **`src/server.py` reported the wrong tool count to every agent.** Its module
45
+ docstring said "34 compound tools" while the agent-facing workflow prompt and
46
+ the startup log line said 32. The drift guard only required the correct number
47
+ to appear *somewhere* in the file, so the wrong one shipped. It now rejects any
48
+ other count in front of that phrase, and counts decorators from the parsed
49
+ syntax tree rather than by matching text — a docstring mentioning
50
+ `@mcp.tool()` had been counted as a 35th tool.
51
+ - **The bridge harness graded the wrong clip and reported a known trap as a
52
+ finding.** It took the first video item, which is often a generator or title
53
+ that has no MediaPoolItem and answers None to every node query, so the grading
54
+ surface read as unreachable; it now takes the first gradeable item. And it
55
+ passed the codec *description* `"H.264"` to `SetCurrentRenderFormatAndCodec`,
56
+ which only accepts the id `"H264"`, making that observation permanently false
57
+ for a documented reason.
58
+
59
+ ### Changed
60
+
61
+ - `scripts/install_resolve_bridge.py` no longer claims Resolve ignores Fusion's
62
+ standalone script tree. Measured on free 21.0.3.7: markers left in two
63
+ undocumented container paths both appeared under Workspace ▸ Scripts, so Lite
64
+ scans more locations than the README documents. Install behaviour is unchanged —
65
+ the documented paths work, and each extra target is another chance for the macOS
66
+ App Management prompt to stall the copy — but the stated reason is now the true
67
+ one.
68
+
69
+ ### Validated
70
+
71
+ Free edition alone (21.0.3.7, App Store), Studio never launched at any point,
72
+ confirmed by process check after every stage: 165/165 read-shaped MCP actions
73
+ clean with Blackmagic's module blocked; 109/112 API read methods exercised; render
74
+ to a non-empty file with `set_format` true; AAF/DRT/EDL/FCPXML all written;
75
+ SetLUT clears and reads back. Suite 1913 passing.
76
+
5
77
  ## What's New in v2.68.1
6
78
 
7
79
  Bug fix. A missing tool argument now returns the structured error envelope the
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.68.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.68.2-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)
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.68.1"
39
+ VERSION = "2.68.2"
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.68.1",
3
+ "version": "2.68.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -75,11 +75,21 @@ def scenario_render(resolve: Any, workspace: Path) -> Dict[str, Any]:
75
75
  observations: Dict[str, Any] = {}
76
76
  formats = project.GetRenderFormats() or {}
77
77
  observations["format_count"] = len(formats)
78
- observations["codec_count_for_mov"] = len(project.GetRenderCodecs("mov") or {})
78
+ codecs = project.GetRenderCodecs("mov") or {}
79
+ observations["codec_count_for_mov"] = len(codecs)
79
80
 
80
81
  output = workspace / "bridge_render_probe"
81
82
  output.mkdir(parents=True, exist_ok=True)
82
- observations["set_format"] = project.SetCurrentRenderFormatAndCodec("mov", "H.264")
83
+ # `GetRenderCodecs` returns {description: id} and only the **id** is accepted.
84
+ # Passing the description shown in the Deliver page returns False — verified
85
+ # again here on free 21.0.3.7: 'H.264' -> False, 'H264' -> True. Feeding it the
86
+ # description made this observation permanently False for a documented reason,
87
+ # which is a harness reporting a known trap as if it were a finding.
88
+ codec_id = codecs.get("H.264") or next(iter(codecs.values()), None)
89
+ observations["codec_id"] = codec_id
90
+ observations["set_format"] = (
91
+ project.SetCurrentRenderFormatAndCodec("mov", codec_id) if codec_id else False
92
+ )
83
93
  observations["set_settings"] = project.SetRenderSettings({
84
94
  "TargetDir": str(output),
85
95
  "CustomName": "bridge_render_probe",
@@ -147,8 +157,16 @@ def scenario_set_lut(resolve: Any, _workspace: Path) -> Dict[str, Any]:
147
157
  items = timeline.GetItemListInTrack("video", 1) or []
148
158
  if not items:
149
159
  return {"skipped": "no clip on video track 1"}
150
- item = items[0]
151
- observations: Dict[str, Any] = {"node_count": item.GetNumNodes()}
160
+ # Not items[0]: a generator or title has no MediaPoolItem and answers None to
161
+ # every node question, so grading it silently produces a row of nulls that
162
+ # reads like a bridge failure. Measured on a real timeline — SMPTE Color Bar
163
+ # returns None for GetNumNodes and GetNodeGraph, the clip beside it returns 1
164
+ # and a live graph.
165
+ item = next((i for i in items if i.GetMediaPoolItem()), None)
166
+ if item is None:
167
+ return {"skipped": "video track 1 holds only generators/titles, which cannot be graded",
168
+ "items_seen": len(items)}
169
+ observations: Dict[str, Any] = {"clip": item.GetName(), "node_count": item.GetNumNodes()}
152
170
  original = item.GetLUT(1)
153
171
  observations["lut_before"] = original
154
172
  # An empty path is the documented way to clear a node's LUT, so it is a
@@ -37,9 +37,9 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
37
37
  #: Two traps, both hit on a real install:
38
38
  #:
39
39
  #: 1. `<container>/Data/Library/Application Support/Fusion/Scripts/Utility`
40
- #: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility
41
- #: but that is **Fusion's standalone tree**, not Resolve's, and Resolve does
42
- #: not scan it. Keying on "which directory already exists" picks the decoy.
40
+ #: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility, but
41
+ #: it is **Fusion's standalone tree**, not Resolve's. Keying on "which
42
+ #: directory already exists" picks it over the one the README documents.
43
43
  #: 2. Resolve does not create its own `Fusion/Scripts` tree until a script is
44
44
  #: installed, so requiring the target to pre-exist skips the free edition
45
45
  #: entirely — the one build the bridge exists for.
@@ -47,6 +47,16 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
47
47
  #: So: a container that exists means the edition is installed, and the tree is
48
48
  #: *created*. Blackmagic's README is the authority on the suffix, not the
49
49
  #: filesystem.
50
+ #:
51
+ #: **Correction, measured on free 21.0.3.7:** an earlier version of trap 1 said
52
+ #: Resolve "does not scan" the Fusion standalone tree. It does — a marker left in
53
+ #: `<container>/Data/Library/Application Support/Fusion/Scripts/Utility` appeared
54
+ #: under Workspace ▸ Scripts, as did one in
55
+ #: `<container>/Data/Documents/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility`.
56
+ #: So Lite scans more locations than the README documents. That changes nothing
57
+ #: about *where to install* — the documented paths work, and each extra target is
58
+ #: another chance for the App Management prompt to stall the copy — but the reason
59
+ #: to prefer them is that they are documented, not that the others are dead.
50
60
  _SANDBOX_MARKER = "com.blackmagic-design."
51
61
  #: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
52
62
  _NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.68.1"
88
+ VERSION = "2.68.2"
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.68.1"
14
+ VERSION = "2.68.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -296,7 +296,7 @@ def davinci_resolve_workflow() -> str:
296
296
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
297
297
 
298
298
  Core pattern:
299
- - Prefer the 32 compound tools and their action names over raw scripting.
299
+ - Prefer the 34 compound tools and their action names over raw scripting.
300
300
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
301
301
  - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
302
302
  - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
@@ -899,6 +899,37 @@ def _launch_resolve():
899
899
  logger.warning("Resolve did not respond within 60s after launch")
900
900
  return False
901
901
 
902
+ #: Process names that mean a DaVinci Resolve application is already running.
903
+ #: Deliberately not the full path: the App Store build lives at
904
+ #: `/Applications/DaVinci Resolve.app` and the installer build inside
905
+ #: `/Applications/DaVinci Resolve/`, and either one counts.
906
+ _RESOLVE_PROCESS_PATTERNS = (
907
+ "DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
908
+ "Resolve.exe", # Windows
909
+ "/opt/resolve/bin/resolve", # Linux
910
+ )
911
+
912
+
913
+ def resolve_is_running() -> Optional[bool]:
914
+ """Is a Resolve application already up? None when it cannot be determined.
915
+
916
+ Best-effort and dependency-free. `None` rather than `False` on failure, so an
917
+ unanswerable question never silently becomes "nothing is running" — which is
918
+ the answer that leads to launching something.
919
+ """
920
+ try:
921
+ if platform.system().lower() == "windows":
922
+ out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
923
+ else:
924
+ out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
925
+ if out.returncode != 0 and not out.stdout:
926
+ return None
927
+ listing = out.stdout or ""
928
+ except Exception: # pragma: no cover - defensive; an unknown answer is None
929
+ return None
930
+ return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
931
+
932
+
902
933
  def get_resolve():
903
934
  """Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
904
935
  global resolve
@@ -909,12 +940,84 @@ def get_resolve():
909
940
  # Try to connect to an already-running Resolve.
910
941
  if _try_connect():
911
942
  return resolve
912
- # Not running launch it automatically.
943
+ # Failing to connect is NOT the same as nothing running, and conflating
944
+ # them opened an application nobody asked for. The free edition refuses
945
+ # external scripting by design, so on a machine where only it is running
946
+ # `_try_connect` always fails — and this then launched *Studio*, a second,
947
+ # different application, on every tool call. Reported three times before
948
+ # it was traced.
949
+ already_running = resolve_is_running()
950
+ if already_running:
951
+ logger.error(
952
+ "DaVinci Resolve is already running but is not answering the scripting API, "
953
+ "so it will NOT be launched again. Either enable Preferences > General > "
954
+ "'External scripting using' = Local (Studio only), or, on the free edition, "
955
+ "use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
956
+ "and set DAVINCI_RESOLVE_BRIDGE=1."
957
+ )
958
+ return None
959
+ if already_running is None:
960
+ logger.warning("Could not determine whether Resolve is running; not launching it.")
961
+ return None
913
962
  logger.info("Resolve not running, attempting to launch automatically...")
914
963
  _launch_resolve()
915
964
  return resolve
916
965
 
917
966
 
967
+ def _not_connected_error():
968
+ """The caller-facing "no Resolve" error, describing what is actually the case.
969
+
970
+ Eleven call sites used to assert that Resolve was not running, that starting it
971
+ had been tried and failed, and that the reader should check their Studio
972
+ install. Once `get_resolve` stopped launching a second application, all three
973
+ claims were wrong: nothing was launched, Resolve *is* running, and a
974
+ free-edition user was being sent to check an install they may not have. The
975
+ accurate guidance was only reaching the log.
976
+
977
+ So the message is derived from the situation instead of asserted, and it names
978
+ the fix that applies: enable external scripting on Studio, or use the in-app
979
+ bridge on the free edition.
980
+ """
981
+ running = resolve_is_running()
982
+ bridge_on = _bridge_requested()
983
+ if bridge_on:
984
+ return _err(
985
+ "The in-app bridge is enabled but not answering.",
986
+ code="BRIDGE_UNAVAILABLE", category="not_connected",
987
+ # `not_connected` defaults to retryable because auto-launch may
988
+ # succeed next time. Nothing here will: the listener only appears once
989
+ # someone runs the script inside Resolve, so an agent retrying on a
990
+ # loop it cannot win is strictly worse than being told to stop.
991
+ retryable=False,
992
+ reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
993
+ remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
994
+ "that menu, run `python scripts/install_resolve_bridge.py` and restart "
995
+ "Resolve; a framework Python from python.org is required for Resolve to "
996
+ "list .py scripts at all.",
997
+ state={"resolve_running": running, "bridge_enabled": True},
998
+ )
999
+ if running:
1000
+ return _err(
1001
+ "DaVinci Resolve is running but is not answering the scripting API.",
1002
+ code="SCRIPTING_UNAVAILABLE", category="not_connected",
1003
+ # Not retryable: a preference has to change, or the bridge has to be
1004
+ # started. Retrying the same call cannot make either happen.
1005
+ retryable=False,
1006
+ reason="External scripting is a Studio feature; the free edition refuses it "
1007
+ "regardless of the preference. Resolve was NOT launched again.",
1008
+ remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
1009
+ "On the free edition: install the in-app bridge, run "
1010
+ "Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
1011
+ state={"resolve_running": True, "bridge_enabled": False},
1012
+ )
1013
+ return _err(
1014
+ "DaVinci Resolve is not running and could not be started.",
1015
+ code="RESOLVE_NOT_RUNNING", category="not_connected",
1016
+ remediation="Start DaVinci Resolve and open a project, then retry.",
1017
+ state={"resolve_running": bool(running), "bridge_enabled": False},
1018
+ )
1019
+
1020
+
918
1021
  def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
919
1022
  """Provider used by the C6 version-on-mutate hook.
920
1023
 
@@ -13065,11 +13168,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13065
13168
  r = get_resolve() # auto-launches if not running
13066
13169
  if r is not None:
13067
13170
  return _ok(message="DaVinci Resolve is running and connected.")
13068
- return _err("Could not connect to DaVinci Resolve. Check that Resolve Studio is installed and 'External scripting using' is set to Local in Preferences.")
13171
+ return _not_connected_error()
13069
13172
 
13070
13173
  r = get_resolve() # auto-launches if not running
13071
13174
  if r is None:
13072
- return _err("Could not connect to DaVinci Resolve after auto-launch attempt. Check that Resolve Studio is installed.")
13175
+ return _not_connected_error()
13073
13176
 
13074
13177
  if action == "get_version":
13075
13178
  update_env = _setup_update_env()
@@ -13992,7 +14095,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13992
14095
  p = _params(params)
13993
14096
  r = get_resolve()
13994
14097
  if r is None:
13995
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
14098
+ return _not_connected_error()
13996
14099
 
13997
14100
  if action == "save":
13998
14101
  if not p.get("name"):
@@ -14037,7 +14140,7 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
14037
14140
  p = _params(params)
14038
14141
  r = get_resolve()
14039
14142
  if r is None:
14040
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
14143
+ return _not_connected_error()
14041
14144
 
14042
14145
  if action == "import_render":
14043
14146
  return {"success": bool(r.ImportRenderPreset(p["path"]))}
@@ -14980,7 +15083,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14980
15083
  p = _params(params)
14981
15084
  r = get_resolve()
14982
15085
  if r is None:
14983
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15086
+ return _not_connected_error()
14984
15087
  pm = r.GetProjectManager()
14985
15088
 
14986
15089
  if action == "lint":
@@ -15100,7 +15203,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
15100
15203
  p = _params(params)
15101
15204
  r = get_resolve()
15102
15205
  if r is None:
15103
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15206
+ return _not_connected_error()
15104
15207
  pm = r.GetProjectManager()
15105
15208
 
15106
15209
  if action == "list":
@@ -15142,7 +15245,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
15142
15245
  p = _params(params)
15143
15246
  r = get_resolve()
15144
15247
  if r is None:
15145
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15248
+ return _not_connected_error()
15146
15249
  pm = r.GetProjectManager()
15147
15250
 
15148
15251
  # cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
@@ -15180,7 +15283,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15180
15283
  p = _params(params)
15181
15284
  r = get_resolve()
15182
15285
  if r is None:
15183
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15286
+ return _not_connected_error()
15184
15287
  pm = r.GetProjectManager()
15185
15288
 
15186
15289
  if action == "get_current":
@@ -16190,7 +16293,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
16190
16293
  p = _params(params)
16191
16294
  r = get_resolve()
16192
16295
  if r is None:
16193
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
16296
+ return _not_connected_error()
16194
16297
  ms = r.GetMediaStorage()
16195
16298
 
16196
16299
  if action == "get_volumes":
@@ -24936,8 +25039,7 @@ def _execute_python_script(path: str, args: List[str],
24936
25039
  def _execute_lua_script(path: str) -> Dict[str, Any]:
24937
25040
  r = get_resolve()
24938
25041
  if r is None:
24939
- return _err("Cannot run Lua script — Resolve isn't running and "
24940
- "auto-launch failed.")
25042
+ return _not_connected_error()
24941
25043
  fusion = r.Fusion()
24942
25044
  if fusion is None:
24943
25045
  return _err("handle.Fusion() returned None — cannot run Lua scripts.")
@@ -25002,7 +25104,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
25002
25104
  """
25003
25105
  r = get_resolve()
25004
25106
  if r is None:
25005
- return _err("Cannot run Lua — Resolve isn't running and auto-launch failed.")
25107
+ return _not_connected_error()
25006
25108
  fusion = r.Fusion()
25007
25109
  if fusion is None:
25008
25110
  return _err("handle.Fusion() returned None — cannot run inline Lua.")
@@ -26112,5 +26214,5 @@ if __name__ == "__main__":
26112
26214
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
26113
26215
  sys.exit(2)
26114
26216
 
26115
- logger.info(f"Starting DaVinci Resolve MCP Server (32 compound tools)")
26217
+ logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
26116
26218
  run_fastmcp_stdio(mcp)
@@ -182,12 +182,22 @@ def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
182
182
  #: MediaPoolItem or Graph — which between them carry most of the read surface
183
183
  #: worth qualifying (LUTs, node graphs, source timings, metadata).
184
184
  _INDEX = "[]"
185
+ #: Pick the first element of a returned list for which `method()` is truthy.
186
+ #: Indexing blindly is not good enough: a timeline's first video item is often a
187
+ #: generator or a title, which has no MediaPoolItem and answers None to every
188
+ #: node-graph question. Measured on a real timeline — `GetNumNodes` and
189
+ #: `GetNodeGraph` both None on an SMPTE Color Bar, and 1 and a live graph on the
190
+ #: clip sitting next to it. Taking index 0 therefore reported the whole grading
191
+ #: surface as unreachable, which is the silent coverage gap this module exists to
192
+ #: refuse.
193
+ _FIRST_WITH = "[first-with]"
185
194
 
186
195
  _PM = ("GetProjectManager", ())
187
196
  _PROJECT = (_PM, ("GetCurrentProject", ()))
188
197
  _POOL = _PROJECT + (("GetMediaPool", ()),)
189
198
  _TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
190
- _TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)), (_INDEX, (0,)))
199
+ _TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
200
+ (_FIRST_WITH, ("GetMediaPoolItem",)))
191
201
  _GALLERY = _PROJECT + (("GetGallery", ()),)
192
202
 
193
203
  OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
@@ -229,6 +239,22 @@ def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
229
239
  return None
230
240
  current = current[index]
231
241
  continue
242
+ if method == _FIRST_WITH:
243
+ if not isinstance(current, (list, tuple)):
244
+ return None
245
+ probe = args[0]
246
+ chosen = None
247
+ for candidate in current:
248
+ try:
249
+ if getattr(candidate, probe, lambda: None)():
250
+ chosen = candidate
251
+ break
252
+ except Exception: # noqa: BLE001 - an unusable candidate, not a verdict
253
+ continue
254
+ if chosen is None:
255
+ return None
256
+ current = chosen
257
+ continue
232
258
  function = getattr(current, method, None)
233
259
  if function is None:
234
260
  return None