davinci-resolve-mcp 2.68.0 → 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,126 @@
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
+
77
+ ## What's New in v2.68.1
78
+
79
+ Bug fix. A missing tool argument now returns the structured error envelope the
80
+ rest of the surface uses, instead of escaping as a raw `KeyError`.
81
+
82
+ ### Fixed
83
+
84
+ - **A missing argument reached the client as an unroutable crash.** Actions read
85
+ `p["track_type"]` directly; when the key was absent the resulting `KeyError`
86
+ escaped the action, escaped the tool, and arrived as
87
+ `ToolError: Error executing tool timeline: 'track_type'` — no code, no
88
+ category, no remediation. Walking the whole surface found **99 of 512 declared
89
+ actions** failing this way across 16 tools (`timeline`, `graph`, `render`,
90
+ `media_pool`, `media_storage`, `fusion_comp`, `folder`, `gallery_stills`,
91
+ `layout_presets`, `project_settings`, `project_manager_*`, `render_presets`,
92
+ `resolve_control`, `timeline_markers`). They now return, for example:
93
+
94
+ {"error": {"message": "'track_type' is required",
95
+ "code": "MISSING_TRACK_TYPE", "category": "invalid_input",
96
+ "retryable": false, "remediation": "...", "state": {...}}}
97
+
98
+ `retryable: false` matters as much as the code: an input error the caller must
99
+ fix was previously reported through a path that defaulted to retryable, which
100
+ sends an agent into a loop it cannot win.
101
+
102
+ The mechanism is a params dict whose missing keys raise a dedicated
103
+ `KeyError` subclass, caught at the tool boundary. Catching plain `KeyError`
104
+ there would have been simpler and wrong — it cannot tell a missing argument
105
+ from an internal lookup failure on some other dict, so our own bugs would have
106
+ been relabelled as the caller's mistake. `contracts.validate` remains the
107
+ preferred tool where a type, range or enum also needs checking, and the actions
108
+ already using it are unchanged.
109
+
110
+ ### Added
111
+
112
+ - `tests/test_tool_argument_validation.py` walks **every declared action** with an
113
+ empty params dict and asserts none leaks a `KeyError`. Reading the source cannot
114
+ find this class — `p[...]` after a guard is correct and `p[...]` without one is a
115
+ bug — so the walk executes each branch against a stub Resolve. It fails on the
116
+ pre-fix tree and runs in 2 s.
117
+
118
+ ### Changed
119
+
120
+ - `test_doc_tool_counts` counts `@mcp.tool()` decorators from the parsed syntax
121
+ tree rather than by matching the text. A docstring explaining where the
122
+ decorator has to sit was counted as a 35th tool; prose that mentions a
123
+ decorator is not a tool.
124
+
5
125
  ## What's New in v2.68.0
6
126
 
7
127
  Six engines that let an agent judge its own output before shipping it — audio
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.0-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.0"
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.0",
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.0"
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,11 +11,13 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.68.0"
14
+ VERSION = "2.68.2"
15
15
 
16
16
  import base64
17
17
  import os
18
18
  import sys
19
+ import functools
20
+ import inspect
19
21
  import json
20
22
  import logging
21
23
  import math
@@ -294,7 +296,7 @@ def davinci_resolve_workflow() -> str:
294
296
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
295
297
 
296
298
  Core pattern:
297
- - 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.
298
300
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
299
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.
300
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.
@@ -897,6 +899,37 @@ def _launch_resolve():
897
899
  logger.warning("Resolve did not respond within 60s after launch")
898
900
  return False
899
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
+
900
933
  def get_resolve():
901
934
  """Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
902
935
  global resolve
@@ -907,12 +940,84 @@ def get_resolve():
907
940
  # Try to connect to an already-running Resolve.
908
941
  if _try_connect():
909
942
  return resolve
910
- # 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
911
962
  logger.info("Resolve not running, attempting to launch automatically...")
912
963
  _launch_resolve()
913
964
  return resolve
914
965
 
915
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
+
916
1021
  def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
917
1022
  """Provider used by the C6 version-on-mutate hook.
918
1023
 
@@ -1225,6 +1330,120 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
1225
1330
  _RETRYABLE_UNSET = object()
1226
1331
 
1227
1332
 
1333
+ class _MissingParam(KeyError):
1334
+ """A tool action subscripted a parameter the caller did not supply.
1335
+
1336
+ Subclasses `KeyError` so any existing `except KeyError` in the codebase keeps
1337
+ behaving as it did; what it adds is the ability to tell a *missing argument*
1338
+ apart from a KeyError on some other dict — a Resolve settings map, a parsed
1339
+ JSON body — which would otherwise be reported to the caller as "you forgot an
1340
+ argument" when the real fault is ours.
1341
+ """
1342
+
1343
+
1344
+ class _Params(dict):
1345
+ """The params dict, wired so a missing key is a diagnosable refusal.
1346
+
1347
+ Actions have historically reached into `p["track_type"]` directly. When the
1348
+ key is absent that raises a bare `KeyError`, which escapes the action, escapes
1349
+ the tool, and reaches the client as
1350
+ `ToolError: Error executing tool timeline: 'track_type'` — no code, no
1351
+ category, no remediation, and nothing an agent can route on. Measured across
1352
+ the whole surface: **99 of 512 declared actions** failed that way.
1353
+
1354
+ Two ways to fix that were available. Rewriting all 99 sites to use
1355
+ `contracts.validate` gives the best individual errors but is 99 chances to get
1356
+ a spec wrong, and leaves the 100th site to be written next month. Catching
1357
+ `KeyError` at the tool boundary closes the class in one place but cannot tell
1358
+ which dict raised, so an unrelated internal KeyError would be mislabelled as
1359
+ the caller's mistake.
1360
+
1361
+ This is the third option: keep the boundary catch, but make the *parameters*
1362
+ raise something only they can raise. False positives become impossible by
1363
+ construction.
1364
+
1365
+ `contracts.validate` remains the better tool wherever a type, a range or an
1366
+ enum also needs checking, and the actions that already use it keep doing so.
1367
+ This is deliberately **not** a migration of the 99 — rewriting them would add
1368
+ 99 opportunities to write a spec wrong without improving the defect being
1369
+ fixed here, and it would still leave the next new action unguarded. What is
1370
+ lost by not migrating is bounded: a *wrong* value still reaches Resolve, which
1371
+ answers None or False, so the result is a truthful failure rather than a
1372
+ misleading success.
1373
+ """
1374
+
1375
+ def __missing__(self, key):
1376
+ raise _MissingParam(key)
1377
+
1378
+
1379
+ def _params(params: Optional[Dict[str, Any]]) -> "_Params":
1380
+ """Every tool body starts here, so every action gets the diagnosable dict.
1381
+
1382
+ Note `_Params(params or {})` rather than a truthiness dance: an empty
1383
+ `_Params` is falsy like any empty dict, so `p = params or {}` would have
1384
+ quietly handed back a *plain* dict in exactly the no-arguments case this
1385
+ exists to serve.
1386
+ """
1387
+ return _Params(params or {})
1388
+
1389
+
1390
+ def _missing_param_error(exc: _MissingParam, action: str) -> Dict[str, Any]:
1391
+ """Turn a missing parameter into the envelope the rest of the surface uses."""
1392
+ name = exc.args[0] if exc.args else "parameter"
1393
+ slug = re.sub(r"[^A-Z0-9]+", "_", str(name).upper()).strip("_") or "PARAMETER"
1394
+ return _err(
1395
+ f"'{name}' is required",
1396
+ code=f"MISSING_{slug}",
1397
+ category="invalid_input",
1398
+ remediation=f"pass {name} in params, e.g. params={{\"{name}\": ...}}",
1399
+ state={"action": action, "missing": str(name)},
1400
+ )
1401
+
1402
+
1403
+ def _guarded_action_name(args, kwargs) -> str:
1404
+ """The `action` this call was for, wherever it was passed."""
1405
+ if "action" in kwargs:
1406
+ return str(kwargs["action"])
1407
+ return str(args[0]) if args else "?"
1408
+
1409
+
1410
+ def _guard_missing_params(fn):
1411
+ """Tool decorator: report a missing parameter instead of leaking a KeyError.
1412
+
1413
+ Sits *under* `@mcp.tool()` so FastMCP registers the guarded callable.
1414
+ `functools.wraps` keeps the name, docstring and signature FastMCP builds the
1415
+ tool schema from.
1416
+
1417
+ Two things this has to preserve, both learned by breaking them:
1418
+
1419
+ - **Async tools must stay coroutine functions.** `media_analysis` is `async`,
1420
+ and a synchronous wrapper around it returned an un-awaited coroutine that
1421
+ FastMCP could not use — the tool stopped answering entirely, taking its 71
1422
+ actions with it. `inspect.iscoroutinefunction` decides which wrapper to
1423
+ build, and a test now pins that async tools are still async afterwards.
1424
+ - **The signature is not (action, params).** `media_analysis` also takes
1425
+ `ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
1426
+ than naming parameters it does not know about.
1427
+ """
1428
+ if inspect.iscoroutinefunction(fn):
1429
+ @functools.wraps(fn)
1430
+ async def wrapper(*args, **kwargs):
1431
+ try:
1432
+ return await fn(*args, **kwargs)
1433
+ except _MissingParam as exc:
1434
+ return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1435
+ else:
1436
+ @functools.wraps(fn)
1437
+ def wrapper(*args, **kwargs):
1438
+ try:
1439
+ return fn(*args, **kwargs)
1440
+ except _MissingParam as exc:
1441
+ return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1442
+
1443
+ wrapper.__wrapped_by_missing_param_guard__ = True
1444
+ return wrapper
1445
+
1446
+
1228
1447
  def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
1229
1448
  remediation=None, reason=None, state=None):
1230
1449
  """Return a structured error envelope.
@@ -12687,6 +12906,7 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
12687
12906
 
12688
12907
 
12689
12908
  @mcp.tool()
12909
+ @_guard_missing_params
12690
12910
  def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
12691
12911
  """Configure MCP conversational defaults and setup preferences.
12692
12912
 
@@ -12700,7 +12920,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
12700
12920
  media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
12701
12921
  updates.*: MCP update policy, interval, and snooze defaults
12702
12922
  """
12703
- p = params or {}
12923
+ p = _params(params)
12704
12924
  if action in {"schema", "capabilities", "options"}:
12705
12925
  return {
12706
12926
  "actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"],
@@ -12840,6 +13060,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
12840
13060
 
12841
13061
 
12842
13062
  @mcp.tool()
13063
+ @_guard_missing_params
12843
13064
  def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
12844
13065
  """App-level DaVinci Resolve operations.
12845
13066
 
@@ -12882,7 +13103,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12882
13103
  restore_state(state_token) -> {success, restored: {...}}
12883
13104
  — Returns Resolve to a previously-saved state.
12884
13105
  """
12885
- p = params or {}
13106
+ p = _params(params)
12886
13107
 
12887
13108
  # api_truth is a static knowledge lookup — no Resolve connection needed.
12888
13109
  if action == "api_truth":
@@ -12947,11 +13168,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12947
13168
  r = get_resolve() # auto-launches if not running
12948
13169
  if r is not None:
12949
13170
  return _ok(message="DaVinci Resolve is running and connected.")
12950
- 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()
12951
13172
 
12952
13173
  r = get_resolve() # auto-launches if not running
12953
13174
  if r is None:
12954
- return _err("Could not connect to DaVinci Resolve after auto-launch attempt. Check that Resolve Studio is installed.")
13175
+ return _not_connected_error()
12955
13176
 
12956
13177
  if action == "get_version":
12957
13178
  update_env = _setup_update_env()
@@ -13859,6 +14080,7 @@ def _close_control_panel() -> Dict[str, Any]:
13859
14080
  # ═══════════════════════════════════════════════════════════════════════════════
13860
14081
 
13861
14082
  @mcp.tool()
14083
+ @_guard_missing_params
13862
14084
  def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
13863
14085
  """Manage DaVinci Resolve UI layout presets.
13864
14086
 
@@ -13870,10 +14092,10 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13870
14092
  import_preset(path, name?) -> {success}
13871
14093
  delete(name) -> {success}
13872
14094
  """
13873
- p = params or {}
14095
+ p = _params(params)
13874
14096
  r = get_resolve()
13875
14097
  if r is None:
13876
- 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()
13877
14099
 
13878
14100
  if action == "save":
13879
14101
  if not p.get("name"):
@@ -13905,6 +14127,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13905
14127
  # ═══════════════════════════════════════════════════════════════════════════════
13906
14128
 
13907
14129
  @mcp.tool()
14130
+ @_guard_missing_params
13908
14131
  def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
13909
14132
  """Import/export render and burn-in presets.
13910
14133
 
@@ -13914,10 +14137,10 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13914
14137
  import_burnin(path) -> {success}
13915
14138
  export_burnin(name, path) -> {success}
13916
14139
  """
13917
- p = params or {}
14140
+ p = _params(params)
13918
14141
  r = get_resolve()
13919
14142
  if r is None:
13920
- 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()
13921
14144
 
13922
14145
  if action == "import_render":
13923
14146
  return {"success": bool(r.ImportRenderPreset(p["path"]))}
@@ -14815,6 +15038,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
14815
15038
 
14816
15039
 
14817
15040
  @mcp.tool()
15041
+ @_guard_missing_params
14818
15042
  def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
14819
15043
  """Manage DaVinci Resolve projects.
14820
15044
 
@@ -14856,10 +15080,10 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14856
15080
  settings are applied in dependency order; markers only added if absent. Hooks run only
14857
15081
  when run_hooks=true (executes shell from the spec — opt-in).
14858
15082
  """
14859
- p = params or {}
15083
+ p = _params(params)
14860
15084
  r = get_resolve()
14861
15085
  if r is None:
14862
- 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()
14863
15087
  pm = r.GetProjectManager()
14864
15088
 
14865
15089
  if action == "lint":
@@ -14963,6 +15187,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14963
15187
  # ═══════════════════════════════════════════════════════════════════════════════
14964
15188
 
14965
15189
  @mcp.tool()
15190
+ @_guard_missing_params
14966
15191
  def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
14967
15192
  """Navigate and manage project folders in the Project Manager.
14968
15193
 
@@ -14975,10 +15200,10 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
14975
15200
  goto_root() -> {success}
14976
15201
  goto_parent() -> {success}
14977
15202
  """
14978
- p = params or {}
15203
+ p = _params(params)
14979
15204
  r = get_resolve()
14980
15205
  if r is None:
14981
- 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()
14982
15207
  pm = r.GetProjectManager()
14983
15208
 
14984
15209
  if action == "list":
@@ -15007,6 +15232,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
15007
15232
  # ═══════════════════════════════════════════════════════════════════════════════
15008
15233
 
15009
15234
  @mcp.tool()
15235
+ @_guard_missing_params
15010
15236
  def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15011
15237
  """Cloud project operations (requires DaVinci Resolve cloud infrastructure).
15012
15238
 
@@ -15016,10 +15242,10 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
15016
15242
  import_project(path, settings) -> {success}
15017
15243
  restore(folder_path, settings) -> {success}
15018
15244
  """
15019
- p = params or {}
15245
+ p = _params(params)
15020
15246
  r = get_resolve()
15021
15247
  if r is None:
15022
- 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()
15023
15249
  pm = r.GetProjectManager()
15024
15250
 
15025
15251
  # cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
@@ -15045,6 +15271,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
15045
15271
  # ═══════════════════════════════════════════════════════════════════════════════
15046
15272
 
15047
15273
  @mcp.tool()
15274
+ @_guard_missing_params
15048
15275
  def project_manager_database(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15049
15276
  """Manage DaVinci Resolve project databases.
15050
15277
 
@@ -15053,10 +15280,10 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15053
15280
  list() -> {databases}
15054
15281
  set_current(db_info) -> {success} — db_info: {DbType, DbName}
15055
15282
  """
15056
- p = params or {}
15283
+ p = _params(params)
15057
15284
  r = get_resolve()
15058
15285
  if r is None:
15059
- 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()
15060
15287
  pm = r.GetProjectManager()
15061
15288
 
15062
15289
  if action == "get_current":
@@ -15076,6 +15303,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15076
15303
  # ═══════════════════════════════════════════════════════════════════════════════
15077
15304
 
15078
15305
  @mcp.tool()
15306
+ @_guard_missing_params
15079
15307
  def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15080
15308
  """Project metadata, settings, and color groups.
15081
15309
 
@@ -15100,7 +15328,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
15100
15328
  apply_fairlight_preset(preset_name) -> {success}
15101
15329
  generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated)
15102
15330
  """
15103
- p = params or {}
15331
+ p = _params(params)
15104
15332
  _, proj, err = _check()
15105
15333
  if err:
15106
15334
  return err
@@ -15853,6 +16081,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
15853
16081
 
15854
16082
 
15855
16083
  @mcp.tool()
16084
+ @_guard_missing_params
15856
16085
  def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15857
16086
  """Render pipeline: jobs, presets, formats, codecs, and rendering.
15858
16087
 
@@ -15901,7 +16130,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15901
16130
  resolved against the live matrix, so an intent unavailable on this
15902
16131
  machine/license fails with the actual available lists.
15903
16132
  """
15904
- p = params or {}
16133
+ p = _params(params)
15905
16134
  _, proj, err = _check()
15906
16135
  if err:
15907
16136
  return err
@@ -16044,6 +16273,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16044
16273
  # ═══════════════════════════════════════════════════════════════════════════════
16045
16274
 
16046
16275
  @mcp.tool()
16276
+ @_guard_missing_params
16047
16277
  def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16048
16278
  """Browse storage volumes and import media into the Media Pool.
16049
16279
 
@@ -16060,10 +16290,10 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
16060
16290
  add_clip_mattes(clip_id, paths, stereo_eye?) -> {success}
16061
16291
  add_timeline_mattes(paths) -> {items}
16062
16292
  """
16063
- p = params or {}
16293
+ p = _params(params)
16064
16294
  r = get_resolve()
16065
16295
  if r is None:
16066
- 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()
16067
16297
  ms = r.GetMediaStorage()
16068
16298
 
16069
16299
  if action == "get_volumes":
@@ -16111,6 +16341,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
16111
16341
  # ═══════════════════════════════════════════════════════════════════════════════
16112
16342
 
16113
16343
  @mcp.tool()
16344
+ @_guard_missing_params
16114
16345
  @_destructive_op("media_pool")
16115
16346
  def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16116
16347
  """Manage the Media Pool: folders, clips, timelines, import/export.
@@ -16198,7 +16429,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16198
16429
  copy_clip_annotations(source_clip_id, target_clip_ids, include_markers?, include_flags?, include_clip_color?, dry_run?) -> {success, results}
16199
16430
  media_pool_boundary_report(depth?, clip_ids?, selected?) -> {capabilities, media_pool, items?}
16200
16431
  """
16201
- p = params or {}
16432
+ p = _params(params)
16202
16433
  _, proj, mp, err = _get_mp()
16203
16434
  if err:
16204
16435
  return err
@@ -16575,6 +16806,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16575
16806
  # ═══════════════════════════════════════════════════════════════════════════════
16576
16807
 
16577
16808
  @mcp.tool()
16809
+ @_guard_missing_params
16578
16810
  def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16579
16811
  """Operations on Media Pool folders.
16580
16812
 
@@ -16593,7 +16825,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16593
16825
  analyze_for_slate(path?, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra
16594
16826
  remove_motion_blur(path?, deblur_option?) -> {success, created} — Resolve 21+; renders NEW media (confirm-gated)
16595
16827
  """
16596
- p = params or {}
16828
+ p = _params(params)
16597
16829
  _, _, mp, err = _get_mp()
16598
16830
  if err:
16599
16831
  return err
@@ -16716,6 +16948,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16716
16948
  # ═══════════════════════════════════════════════════════════════════════════════
16717
16949
 
16718
16950
  @mcp.tool()
16951
+ @_guard_missing_params
16719
16952
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16720
16953
  """Operations on a media pool clip. Identify clip by clip_id.
16721
16954
 
@@ -16767,7 +17000,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16767
17000
  verified empirically on Resolve Studio 20.3.2.9. If BMD changes the
16768
17001
  behavior the clip will still be selected — editor double-clicks to load.
16769
17002
  """
16770
- p = params or {}
17003
+ p = _params(params)
16771
17004
  _, _, mp, err = _get_mp()
16772
17005
  if err:
16773
17006
  return err
@@ -17080,6 +17313,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17080
17313
  # ═══════════════════════════════════════════════════════════════════════════════
17081
17314
 
17082
17315
  @mcp.tool()
17316
+ @_guard_missing_params
17083
17317
  def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
17084
17318
  """Markers and flags on media pool clips. Identify clip by clip_id.
17085
17319
 
@@ -17100,7 +17334,7 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
17100
17334
  monitor_growing_file(clip_id) -> {success}
17101
17335
  replace_clip_preserve_sub_clip(clip_id, path) -> {success}
17102
17336
  """
17103
- p = params or {}
17337
+ p = _params(params)
17104
17338
  _, _, mp, err = _get_mp()
17105
17339
  if err:
17106
17340
  return err
@@ -17212,6 +17446,7 @@ def _opt_number(value: Any) -> Optional[float]:
17212
17446
 
17213
17447
 
17214
17448
  @mcp.tool()
17449
+ @_guard_missing_params
17215
17450
  async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]:
17216
17451
  """Project-scoped media analysis and guarded metadata publishing.
17217
17452
 
@@ -17308,7 +17543,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17308
17543
  vision-dependent metadata to Resolve. Works with any MCP client whose chat
17309
17544
  model is vision-capable; no sampling/createMessage support required.
17310
17545
  """
17311
- p = _media_analysis_apply_setup_defaults(action, dict(params or {}))
17546
+ p = _params(_media_analysis_apply_setup_defaults(action, dict(params or {})))
17312
17547
 
17313
17548
  # First-run frame-sampling prompt: if the user has never chosen a sampling
17314
17549
  # mode (and didn't pass one this call), ask before spending any vision
@@ -18384,6 +18619,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18384
18619
  # ═══════════════════════════════════════════════════════════════════════════════
18385
18620
 
18386
18621
  @mcp.tool()
18622
+ @_guard_missing_params
18387
18623
  def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18388
18624
  """Timeline version-on-mutate, archive, rollback, and brain-edit history (C6).
18389
18625
 
@@ -18431,7 +18667,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
18431
18667
  if ctx is None:
18432
18668
  return _err("No current project / can't resolve project root")
18433
18669
  resolve_h, project_h, project_root, project_name = ctx
18434
- p = params or {}
18670
+ p = _params(params)
18435
18671
 
18436
18672
  if action == "begin_run":
18437
18673
  return _analysis_runs.begin_run(
@@ -18756,6 +18992,7 @@ def _edit_engine_linked_audio_tracks(
18756
18992
 
18757
18993
 
18758
18994
  @mcp.tool()
18995
+ @_guard_missing_params
18759
18996
  @_destructive_op("edit_engine")
18760
18997
  def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18761
18998
  """Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
@@ -18797,7 +19034,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18797
19034
  timeline (version-archived first).
18798
19035
  - list_plans(limit?) / get_plan(plan_id)
18799
19036
  """
18800
- p = params or {}
19037
+ p = _params(params)
18801
19038
 
18802
19039
  def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
18803
19040
  # An explicit analysis_root always wins — the evidence DB the caller
@@ -19562,6 +19799,7 @@ _TIMELINE_ACTIONS = [
19562
19799
 
19563
19800
 
19564
19801
  @mcp.tool()
19802
+ @_guard_missing_params
19565
19803
  @_destructive_op("timeline")
19566
19804
  def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19567
19805
  """Timeline operations: tracks, clips, import/export, generators, titles.
@@ -19747,7 +19985,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19747
19985
  For long-form per-action guidance and a worked example, call:
19748
19986
  timeline(action="action_help", params={"name": "<action>"})
19749
19987
  """
19750
- p = params or {}
19988
+ p = _params(params)
19751
19989
  # action_help is pull-on-demand metadata; no Resolve connection needed.
19752
19990
  if action == "action_help":
19753
19991
  return _action_help("timeline", p)
@@ -20148,7 +20386,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20148
20386
  result["ignored_state_keys"] = ignored_state
20149
20387
  return result
20150
20388
  elif action == "extract_source_frame_ranges":
20151
- p = params or {}
20389
+ p = _params(params)
20152
20390
  handles = int(p.get("handles", 24))
20153
20391
  gap_max = int(p.get("gap_max", 30))
20154
20392
  audio_ext = tuple(
@@ -20335,6 +20573,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20335
20573
  # ═══════════════════════════════════════════════════════════════════════════════
20336
20574
 
20337
20575
  @mcp.tool()
20576
+ @_guard_missing_params
20338
20577
  @_destructive_op("timeline_markers")
20339
20578
  def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
20340
20579
  """Markers and playhead operations on the current timeline.
@@ -20370,7 +20609,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
20370
20609
  export_review_report(scope?, include_capabilities?) -> {title, annotations, capabilities?}
20371
20610
  annotation_boundary_report(scope?) -> {capabilities, annotations}
20372
20611
  """
20373
- p = params or {}
20612
+ p = _params(params)
20374
20613
  _, tl, err = _get_tl()
20375
20614
  if err:
20376
20615
  return err
@@ -20455,6 +20694,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
20455
20694
  # ═══════════════════════════════════════════════════════════════════════════════
20456
20695
 
20457
20696
  @mcp.tool()
20697
+ @_guard_missing_params
20458
20698
  @_destructive_op("timeline_ai")
20459
20699
  def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20460
20700
  """AI and analysis operations on the current timeline.
@@ -20468,7 +20708,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
20468
20708
  grab_still() -> {success}
20469
20709
  grab_all_stills(source?) -> {count}
20470
20710
  """
20471
- p = params or {}
20711
+ p = _params(params)
20472
20712
  _, tl, err = _get_tl()
20473
20713
  if err:
20474
20714
  return err
@@ -20509,6 +20749,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
20509
20749
  # ═══════════════════════════════════════════════════════════════════════════════
20510
20750
 
20511
20751
  @mcp.tool()
20752
+ @_guard_missing_params
20512
20753
  @_destructive_op("timeline_item")
20513
20754
  def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20514
20755
  """Properties, transforms, speed, keyframes, and metadata for a timeline item.
@@ -20560,7 +20801,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
20560
20801
 
20561
20802
  Default: track_type="video", track_index=1, item_index=0
20562
20803
  """
20563
- p = params or {}
20804
+ p = _params(params)
20564
20805
  tl, item, err = _get_item(p)
20565
20806
  if err:
20566
20807
  return err
@@ -20748,6 +20989,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
20748
20989
  # ═══════════════════════════════════════════════════════════════════════════════
20749
20990
 
20750
20991
  @mcp.tool()
20992
+ @_guard_missing_params
20751
20993
  @_destructive_op("timeline_item_markers")
20752
20994
  def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20753
20995
  """Markers, flags, and clip color on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
@@ -20770,7 +21012,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
20770
21012
 
20771
21013
  Default: track_type="video", track_index=1, item_index=0
20772
21014
  """
20773
- p = params or {}
21015
+ p = _params(params)
20774
21016
  _, item, err = _get_item(p)
20775
21017
  if err:
20776
21018
  return err
@@ -20825,6 +21067,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
20825
21067
  # ═══════════════════════════════════════════════════════════════════════════════
20826
21068
 
20827
21069
  @mcp.tool()
21070
+ @_guard_missing_params
20828
21071
  @_destructive_op("timeline_item_fusion")
20829
21072
  def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20830
21073
  """Fusion composition operations on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
@@ -20845,7 +21088,7 @@ def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -
20845
21088
 
20846
21089
  Default: track_type="video", track_index=1, item_index=0
20847
21090
  """
20848
- p = params or {}
21091
+ p = _params(params)
20849
21092
  _, item, err = _get_item(p)
20850
21093
  if err:
20851
21094
  return err
@@ -22126,6 +22369,7 @@ def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]:
22126
22369
  }
22127
22370
 
22128
22371
  @mcp.tool()
22372
+ @_guard_missing_params
22129
22373
  @_destructive_op("timeline_item_color")
22130
22374
  def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22131
22375
  """Color grading, versions, LUTs, cache, and AI tools on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
@@ -22216,7 +22460,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
22216
22460
  For long-form per-action guidance and a worked example, call:
22217
22461
  timeline_item_color(action="action_help", params={"name": "<action>"})
22218
22462
  """
22219
- p = params or {}
22463
+ p = _params(params)
22220
22464
  if action == "action_help":
22221
22465
  return _action_help("timeline_item_color", p)
22222
22466
  _, item, err = _get_item(p)
@@ -22331,6 +22575,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
22331
22575
  # ═══════════════════════════════════════════════════════════════════════════════
22332
22576
 
22333
22577
  @mcp.tool()
22578
+ @_guard_missing_params
22334
22579
  @_destructive_op("timeline_item_takes")
22335
22580
  def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22336
22581
  """Take management on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
@@ -22346,7 +22591,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
22346
22591
 
22347
22592
  Default: track_type="video", track_index=1, item_index=0
22348
22593
  """
22349
- p = params or {}
22594
+ p = _params(params)
22350
22595
  _, item, err = _get_item(p)
22351
22596
  if err:
22352
22597
  return err
@@ -22379,6 +22624,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
22379
22624
  # ═══════════════════════════════════════════════════════════════════════════════
22380
22625
 
22381
22626
  @mcp.tool()
22627
+ @_guard_missing_params
22382
22628
  def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22383
22629
  """Gallery album management.
22384
22630
 
@@ -22394,7 +22640,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
22394
22640
 
22395
22641
  album_index is 0-based into the still albums list.
22396
22642
  """
22397
- p = params or {}
22643
+ p = _params(params)
22398
22644
  _, proj, err = _check()
22399
22645
  if err:
22400
22646
  return err
@@ -22444,6 +22690,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
22444
22690
  # ═══════════════════════════════════════════════════════════════════════════════
22445
22691
 
22446
22692
  @mcp.tool()
22693
+ @_guard_missing_params
22447
22694
  def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22448
22695
  """Manage stills in gallery albums (best results on Color page).
22449
22696
 
@@ -22464,7 +22711,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
22464
22711
  File data is inlined in the response (DRX as text, images as base64).
22465
22712
  cleanup (default true) deletes exported files from disk after inlining.
22466
22713
  """
22467
- p = params or {}
22714
+ p = _params(params)
22468
22715
  _, proj, err = _check()
22469
22716
  if err:
22470
22717
  return err
@@ -22597,6 +22844,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
22597
22844
  # ═══════════════════════════════════════════════════════════════════════════════
22598
22845
 
22599
22846
  @mcp.tool()
22847
+ @_guard_missing_params
22600
22848
  @_destructive_op("graph")
22601
22849
  def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22602
22850
  """Node graph operations (color grading nodes). Source can be timeline, timeline item, or color group.
@@ -22636,7 +22884,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
22636
22884
  For long-form per-action guidance and a worked example, call:
22637
22885
  graph(action="action_help", params={"name": "<action>"})
22638
22886
  """
22639
- p = params or {}
22887
+ p = _params(params)
22640
22888
  if action == "action_help":
22641
22889
  return _action_help("graph", p)
22642
22890
  source = p.get("source", "timeline")
@@ -22739,6 +22987,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
22739
22987
  # ═══════════════════════════════════════════════════════════════════════════════
22740
22988
 
22741
22989
  @mcp.tool()
22990
+ @_guard_missing_params
22742
22991
  def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22743
22992
  """Manage color groups and their node graphs.
22744
22993
 
@@ -22750,7 +22999,7 @@ def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22750
22999
  get_pre_clip_graph(group_name) -> {available, num_nodes}
22751
23000
  get_post_clip_graph(group_name) -> {available, num_nodes}
22752
23001
  """
22753
- p = params or {}
23002
+ p = _params(params)
22754
23003
  _, proj, err = _check()
22755
23004
  if err:
22756
23005
  return err
@@ -23661,6 +23910,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
23661
23910
 
23662
23911
 
23663
23912
  @mcp.tool()
23913
+ @_guard_missing_params
23664
23914
  def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
23665
23915
  """Fusion composition node graph operations.
23666
23916
 
@@ -23731,7 +23981,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
23731
23981
  ColorCorrector, RectangleMask, EllipseMask, Tracker, MediaIn, MediaOut,
23732
23982
  Loader, Saver, Glow, FilmGrain, CornerPositioner, DeltaKeyer, UltraKeyer
23733
23983
  """
23734
- p = params or {}
23984
+ p = _params(params)
23735
23985
 
23736
23986
  if action == "bulk_set_inputs":
23737
23987
  return _fusion_comp_bulk_set_inputs(p)
@@ -24231,6 +24481,7 @@ def _validate_glsl_minimal(source: str) -> Dict[str, Any]:
24231
24481
 
24232
24482
 
24233
24483
  @mcp.tool()
24484
+ @_guard_missing_params
24234
24485
  def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
24235
24486
  """Author and install Fusion Fuse plugins (.fuse files).
24236
24487
 
@@ -24257,7 +24508,7 @@ def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24257
24508
  docs/authoring/fuse-dctl-authoring.md for the per-kind option spec.
24258
24509
  list_templates() -> {kinds}
24259
24510
  """
24260
- p = params or {}
24511
+ p = _params(params)
24261
24512
 
24262
24513
  if action == "path":
24263
24514
  return {"fuses_dir": _fuses_dir()}
@@ -24474,6 +24725,7 @@ _DCTL_VALID_CATEGORIES = ("lut", "aces_idt", "aces_odt")
24474
24725
 
24475
24726
 
24476
24727
  @mcp.tool()
24728
+ @_guard_missing_params
24477
24729
  def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
24478
24730
  """Author and install DCTL files (Color page custom shaders + ACES transforms).
24479
24731
 
@@ -24510,7 +24762,7 @@ def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]
24510
24762
  pass that as the `category` argument to install().
24511
24763
  list_templates() -> {kinds, kind_categories}
24512
24764
  """
24513
- p = params or {}
24765
+ p = _params(params)
24514
24766
 
24515
24767
  def _category(default: str = "lut") -> Tuple[Optional[Dict[str, Any]], str]:
24516
24768
  cat = p.get("category", default)
@@ -24787,8 +25039,7 @@ def _execute_python_script(path: str, args: List[str],
24787
25039
  def _execute_lua_script(path: str) -> Dict[str, Any]:
24788
25040
  r = get_resolve()
24789
25041
  if r is None:
24790
- return _err("Cannot run Lua script — Resolve isn't running and "
24791
- "auto-launch failed.")
25042
+ return _not_connected_error()
24792
25043
  fusion = r.Fusion()
24793
25044
  if fusion is None:
24794
25045
  return _err("handle.Fusion() returned None — cannot run Lua scripts.")
@@ -24853,7 +25104,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
24853
25104
  """
24854
25105
  r = get_resolve()
24855
25106
  if r is None:
24856
- return _err("Cannot run Lua — Resolve isn't running and auto-launch failed.")
25107
+ return _not_connected_error()
24857
25108
  fusion = r.Fusion()
24858
25109
  if fusion is None:
24859
25110
  return _err("handle.Fusion() returned None — cannot run inline Lua.")
@@ -25403,6 +25654,7 @@ def _extension_boundary_report(p: Dict[str, Any]) -> Dict[str, Any]:
25403
25654
 
25404
25655
 
25405
25656
  @mcp.tool()
25657
+ @_guard_missing_params
25406
25658
  def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
25407
25659
  """Author and install Resolve-page Lua/Python scripts (Workspace → Scripts menu).
25408
25660
 
@@ -25458,7 +25710,7 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
25458
25710
  refresh_or_restart_required(extension_type, category?) -> {refresh_luts, restart_required}
25459
25711
  extension_boundary_report(include_template_matrix?) -> {capabilities, template_matrix, dry_run_probes}
25460
25712
  """
25461
- p = params or {}
25713
+ p = _params(params)
25462
25714
 
25463
25715
  if action == "extension_capabilities":
25464
25716
  return _extension_capabilities()
@@ -25962,5 +26214,5 @@ if __name__ == "__main__":
25962
26214
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
25963
26215
  sys.exit(2)
25964
26216
 
25965
- logger.info(f"Starting DaVinci Resolve MCP Server (32 compound tools)")
26217
+ logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
25966
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