davinci-resolve-mcp 2.67.1 → 2.68.1

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/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.67.1"
14
+ VERSION = "2.68.1"
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
@@ -122,6 +124,7 @@ from src.utils.fusion_group_settings import (
122
124
  from src.utils import analysis_runs as _analysis_runs
123
125
  from src.utils import brain_edits as _brain_edits
124
126
  from src.utils import edit_engine as _edit_engine_mod
127
+ from src.utils import transcript_edit as _transcript_edit_defaults
125
128
  from src.utils import media_pool_changes as _media_pool_changes
126
129
  from src.utils import timeline_versioning as _timeline_versioning
127
130
  from src.utils import project_spec as _project_spec
@@ -803,11 +806,32 @@ def _is_resolve_handle_live(candidate) -> bool:
803
806
  return False
804
807
 
805
808
 
809
+ def _bridge_requested() -> bool:
810
+ """Has the operator asked for the in-app bridge?
811
+
812
+ Read at call time rather than at import, so a server started before the
813
+ variable was set still honours it.
814
+ """
815
+ try:
816
+ from src.utils import resolve_bridge_client
817
+
818
+ return resolve_bridge_client.bridge_enabled()
819
+ except Exception: # pragma: no cover - the client is optional
820
+ return False
821
+
822
+
806
823
  def _try_connect():
807
824
  """Attempt to connect to Resolve once. Returns resolve object or None."""
808
825
  global resolve
809
826
  with _resolve_lock:
810
- if dvr_script is None:
827
+ # `dvr_script` is Blackmagic's module, and it ships with the *installer*,
828
+ # not the App Store build — a free-edition-only machine has no
829
+ # Developer/Scripting/Modules tree at all, so the import fails. Bailing
830
+ # here made the bridge unreachable on precisely the configuration it
831
+ # exists for: `connect_resolve` accepts None in bridge mode, and this
832
+ # returned before ever calling it. Verified by blocking the import with a
833
+ # healthy bridge listening — get_resolve() answered None.
834
+ if dvr_script is None and not _bridge_requested():
811
835
  return None
812
836
  try:
813
837
  candidate = connect_resolve(dvr_script)
@@ -822,13 +846,34 @@ def _try_connect():
822
846
  resolve = None
823
847
  return None
824
848
 
849
+ #: macOS puts the two editions in different places, and only the first was ever
850
+ #: checked — so an auto-launch on a machine with both would start Studio even
851
+ #: when the free edition was the one in use. Ordered installer-first to keep the
852
+ #: previous behaviour where Studio is present.
853
+ _MACOS_RESOLVE_APPS = (
854
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app", # installer / Studio
855
+ "/Applications/DaVinci Resolve.app", # App Store, free
856
+ )
857
+
858
+
825
859
  def _launch_resolve():
826
860
  """Launch DaVinci Resolve and wait for it to become available."""
861
+ if _bridge_requested():
862
+ # Launching cannot produce a bridge. The listener only exists once
863
+ # someone runs Workspace > Scripts > resolve_bridge inside an already
864
+ # running Resolve, so starting the application here would open a window
865
+ # nobody asked for and still fail to connect.
866
+ logger.error(
867
+ "The in-app bridge is enabled but not answering. Start it from "
868
+ "Workspace > Scripts > resolve_bridge inside the running Resolve; "
869
+ "launching the application cannot start it."
870
+ )
871
+ return False
827
872
  sys_name = platform.system().lower()
828
873
  if sys_name == "darwin":
829
- app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
830
- if not os.path.exists(app_path):
831
- logger.error(f"DaVinci Resolve not found at {app_path}")
874
+ app_path = next((p for p in _MACOS_RESOLVE_APPS if os.path.exists(p)), None)
875
+ if app_path is None:
876
+ logger.error("DaVinci Resolve not found in %s", list(_MACOS_RESOLVE_APPS))
832
877
  return False
833
878
  subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
834
879
  elif sys_name == "windows":
@@ -1182,6 +1227,120 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
1182
1227
  _RETRYABLE_UNSET = object()
1183
1228
 
1184
1229
 
1230
+ class _MissingParam(KeyError):
1231
+ """A tool action subscripted a parameter the caller did not supply.
1232
+
1233
+ Subclasses `KeyError` so any existing `except KeyError` in the codebase keeps
1234
+ behaving as it did; what it adds is the ability to tell a *missing argument*
1235
+ apart from a KeyError on some other dict — a Resolve settings map, a parsed
1236
+ JSON body — which would otherwise be reported to the caller as "you forgot an
1237
+ argument" when the real fault is ours.
1238
+ """
1239
+
1240
+
1241
+ class _Params(dict):
1242
+ """The params dict, wired so a missing key is a diagnosable refusal.
1243
+
1244
+ Actions have historically reached into `p["track_type"]` directly. When the
1245
+ key is absent that raises a bare `KeyError`, which escapes the action, escapes
1246
+ the tool, and reaches the client as
1247
+ `ToolError: Error executing tool timeline: 'track_type'` — no code, no
1248
+ category, no remediation, and nothing an agent can route on. Measured across
1249
+ the whole surface: **99 of 512 declared actions** failed that way.
1250
+
1251
+ Two ways to fix that were available. Rewriting all 99 sites to use
1252
+ `contracts.validate` gives the best individual errors but is 99 chances to get
1253
+ a spec wrong, and leaves the 100th site to be written next month. Catching
1254
+ `KeyError` at the tool boundary closes the class in one place but cannot tell
1255
+ which dict raised, so an unrelated internal KeyError would be mislabelled as
1256
+ the caller's mistake.
1257
+
1258
+ This is the third option: keep the boundary catch, but make the *parameters*
1259
+ raise something only they can raise. False positives become impossible by
1260
+ construction.
1261
+
1262
+ `contracts.validate` remains the better tool wherever a type, a range or an
1263
+ enum also needs checking, and the actions that already use it keep doing so.
1264
+ This is deliberately **not** a migration of the 99 — rewriting them would add
1265
+ 99 opportunities to write a spec wrong without improving the defect being
1266
+ fixed here, and it would still leave the next new action unguarded. What is
1267
+ lost by not migrating is bounded: a *wrong* value still reaches Resolve, which
1268
+ answers None or False, so the result is a truthful failure rather than a
1269
+ misleading success.
1270
+ """
1271
+
1272
+ def __missing__(self, key):
1273
+ raise _MissingParam(key)
1274
+
1275
+
1276
+ def _params(params: Optional[Dict[str, Any]]) -> "_Params":
1277
+ """Every tool body starts here, so every action gets the diagnosable dict.
1278
+
1279
+ Note `_Params(params or {})` rather than a truthiness dance: an empty
1280
+ `_Params` is falsy like any empty dict, so `p = params or {}` would have
1281
+ quietly handed back a *plain* dict in exactly the no-arguments case this
1282
+ exists to serve.
1283
+ """
1284
+ return _Params(params or {})
1285
+
1286
+
1287
+ def _missing_param_error(exc: _MissingParam, action: str) -> Dict[str, Any]:
1288
+ """Turn a missing parameter into the envelope the rest of the surface uses."""
1289
+ name = exc.args[0] if exc.args else "parameter"
1290
+ slug = re.sub(r"[^A-Z0-9]+", "_", str(name).upper()).strip("_") or "PARAMETER"
1291
+ return _err(
1292
+ f"'{name}' is required",
1293
+ code=f"MISSING_{slug}",
1294
+ category="invalid_input",
1295
+ remediation=f"pass {name} in params, e.g. params={{\"{name}\": ...}}",
1296
+ state={"action": action, "missing": str(name)},
1297
+ )
1298
+
1299
+
1300
+ def _guarded_action_name(args, kwargs) -> str:
1301
+ """The `action` this call was for, wherever it was passed."""
1302
+ if "action" in kwargs:
1303
+ return str(kwargs["action"])
1304
+ return str(args[0]) if args else "?"
1305
+
1306
+
1307
+ def _guard_missing_params(fn):
1308
+ """Tool decorator: report a missing parameter instead of leaking a KeyError.
1309
+
1310
+ Sits *under* `@mcp.tool()` so FastMCP registers the guarded callable.
1311
+ `functools.wraps` keeps the name, docstring and signature FastMCP builds the
1312
+ tool schema from.
1313
+
1314
+ Two things this has to preserve, both learned by breaking them:
1315
+
1316
+ - **Async tools must stay coroutine functions.** `media_analysis` is `async`,
1317
+ and a synchronous wrapper around it returned an un-awaited coroutine that
1318
+ FastMCP could not use — the tool stopped answering entirely, taking its 71
1319
+ actions with it. `inspect.iscoroutinefunction` decides which wrapper to
1320
+ build, and a test now pins that async tools are still async afterwards.
1321
+ - **The signature is not (action, params).** `media_analysis` also takes
1322
+ `ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
1323
+ than naming parameters it does not know about.
1324
+ """
1325
+ if inspect.iscoroutinefunction(fn):
1326
+ @functools.wraps(fn)
1327
+ async def wrapper(*args, **kwargs):
1328
+ try:
1329
+ return await fn(*args, **kwargs)
1330
+ except _MissingParam as exc:
1331
+ return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1332
+ else:
1333
+ @functools.wraps(fn)
1334
+ def wrapper(*args, **kwargs):
1335
+ try:
1336
+ return fn(*args, **kwargs)
1337
+ except _MissingParam as exc:
1338
+ return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1339
+
1340
+ wrapper.__wrapped_by_missing_param_guard__ = True
1341
+ return wrapper
1342
+
1343
+
1185
1344
  def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
1186
1345
  remediation=None, reason=None, state=None):
1187
1346
  """Return a structured error envelope.
@@ -12644,6 +12803,7 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
12644
12803
 
12645
12804
 
12646
12805
  @mcp.tool()
12806
+ @_guard_missing_params
12647
12807
  def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
12648
12808
  """Configure MCP conversational defaults and setup preferences.
12649
12809
 
@@ -12657,7 +12817,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
12657
12817
  media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
12658
12818
  updates.*: MCP update policy, interval, and snooze defaults
12659
12819
  """
12660
- p = params or {}
12820
+ p = _params(params)
12661
12821
  if action in {"schema", "capabilities", "options"}:
12662
12822
  return {
12663
12823
  "actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"],
@@ -12797,6 +12957,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
12797
12957
 
12798
12958
 
12799
12959
  @mcp.tool()
12960
+ @_guard_missing_params
12800
12961
  def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
12801
12962
  """App-level DaVinci Resolve operations.
12802
12963
 
@@ -12839,7 +13000,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12839
13000
  restore_state(state_token) -> {success, restored: {...}}
12840
13001
  — Returns Resolve to a previously-saved state.
12841
13002
  """
12842
- p = params or {}
13003
+ p = _params(params)
12843
13004
 
12844
13005
  # api_truth is a static knowledge lookup — no Resolve connection needed.
12845
13006
  if action == "api_truth":
@@ -13816,6 +13977,7 @@ def _close_control_panel() -> Dict[str, Any]:
13816
13977
  # ═══════════════════════════════════════════════════════════════════════════════
13817
13978
 
13818
13979
  @mcp.tool()
13980
+ @_guard_missing_params
13819
13981
  def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
13820
13982
  """Manage DaVinci Resolve UI layout presets.
13821
13983
 
@@ -13827,7 +13989,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13827
13989
  import_preset(path, name?) -> {success}
13828
13990
  delete(name) -> {success}
13829
13991
  """
13830
- p = params or {}
13992
+ p = _params(params)
13831
13993
  r = get_resolve()
13832
13994
  if r is None:
13833
13995
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -13862,6 +14024,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13862
14024
  # ═══════════════════════════════════════════════════════════════════════════════
13863
14025
 
13864
14026
  @mcp.tool()
14027
+ @_guard_missing_params
13865
14028
  def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
13866
14029
  """Import/export render and burn-in presets.
13867
14030
 
@@ -13871,7 +14034,7 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13871
14034
  import_burnin(path) -> {success}
13872
14035
  export_burnin(name, path) -> {success}
13873
14036
  """
13874
- p = params or {}
14037
+ p = _params(params)
13875
14038
  r = get_resolve()
13876
14039
  if r is None:
13877
14040
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -14772,6 +14935,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
14772
14935
 
14773
14936
 
14774
14937
  @mcp.tool()
14938
+ @_guard_missing_params
14775
14939
  def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
14776
14940
  """Manage DaVinci Resolve projects.
14777
14941
 
@@ -14813,7 +14977,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14813
14977
  settings are applied in dependency order; markers only added if absent. Hooks run only
14814
14978
  when run_hooks=true (executes shell from the spec — opt-in).
14815
14979
  """
14816
- p = params or {}
14980
+ p = _params(params)
14817
14981
  r = get_resolve()
14818
14982
  if r is None:
14819
14983
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -14920,6 +15084,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14920
15084
  # ═══════════════════════════════════════════════════════════════════════════════
14921
15085
 
14922
15086
  @mcp.tool()
15087
+ @_guard_missing_params
14923
15088
  def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
14924
15089
  """Navigate and manage project folders in the Project Manager.
14925
15090
 
@@ -14932,7 +15097,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
14932
15097
  goto_root() -> {success}
14933
15098
  goto_parent() -> {success}
14934
15099
  """
14935
- p = params or {}
15100
+ p = _params(params)
14936
15101
  r = get_resolve()
14937
15102
  if r is None:
14938
15103
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -14964,6 +15129,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
14964
15129
  # ═══════════════════════════════════════════════════════════════════════════════
14965
15130
 
14966
15131
  @mcp.tool()
15132
+ @_guard_missing_params
14967
15133
  def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
14968
15134
  """Cloud project operations (requires DaVinci Resolve cloud infrastructure).
14969
15135
 
@@ -14973,7 +15139,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
14973
15139
  import_project(path, settings) -> {success}
14974
15140
  restore(folder_path, settings) -> {success}
14975
15141
  """
14976
- p = params or {}
15142
+ p = _params(params)
14977
15143
  r = get_resolve()
14978
15144
  if r is None:
14979
15145
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -15002,6 +15168,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
15002
15168
  # ═══════════════════════════════════════════════════════════════════════════════
15003
15169
 
15004
15170
  @mcp.tool()
15171
+ @_guard_missing_params
15005
15172
  def project_manager_database(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15006
15173
  """Manage DaVinci Resolve project databases.
15007
15174
 
@@ -15010,7 +15177,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15010
15177
  list() -> {databases}
15011
15178
  set_current(db_info) -> {success} — db_info: {DbType, DbName}
15012
15179
  """
15013
- p = params or {}
15180
+ p = _params(params)
15014
15181
  r = get_resolve()
15015
15182
  if r is None:
15016
15183
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -15033,6 +15200,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15033
15200
  # ═══════════════════════════════════════════════════════════════════════════════
15034
15201
 
15035
15202
  @mcp.tool()
15203
+ @_guard_missing_params
15036
15204
  def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15037
15205
  """Project metadata, settings, and color groups.
15038
15206
 
@@ -15057,7 +15225,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
15057
15225
  apply_fairlight_preset(preset_name) -> {success}
15058
15226
  generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated)
15059
15227
  """
15060
- p = params or {}
15228
+ p = _params(params)
15061
15229
  _, proj, err = _check()
15062
15230
  if err:
15063
15231
  return err
@@ -15240,6 +15408,7 @@ _RENDER_KERNEL_ACTIONS = [
15240
15408
  "export_render_boundary_report",
15241
15409
  "list_delivery_targets",
15242
15410
  "resolve_delivery_target",
15411
+ "list_loudness_standards",
15243
15412
  "prepare_delivery_job",
15244
15413
  ]
15245
15414
 
@@ -15618,6 +15787,9 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
15618
15787
 
15619
15788
  fps = _delivery_timeline_fps(proj)
15620
15789
  qc_spec = _delivery_targets.to_qc_spec(target, timeline_fps=fps)
15790
+ # Loudness is a separate projection feeding advanced `loudness_qc`, not a
15791
+ # deliverable_qc field — the mix sets programme loudness, not the render.
15792
+ loudness = _delivery_targets.to_loudness_target(target)
15621
15793
  return (
15622
15794
  {
15623
15795
  "target": target.id,
@@ -15633,12 +15805,40 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
15633
15805
  if qc_spec
15634
15806
  else "This target renders an image sequence; deliverable_qc probes a single file, so it has no QC spec."
15635
15807
  ),
15808
+ "loudness_target": loudness,
15809
+ "loudness_note": (
15810
+ None
15811
+ if loudness
15812
+ else "This target pins no programme loudness. Name one with "
15813
+ "overrides={'loudness_standard': ...}; see render(action='list_loudness_standards')."
15814
+ ),
15636
15815
  "notes": list(target.notes),
15637
15816
  },
15638
15817
  None,
15639
15818
  )
15640
15819
 
15641
15820
 
15821
+ def _list_loudness_standards(proj, p: Dict[str, Any]):
15822
+ """Named programme-loudness contracts, so an agent cites one rather than inventing numbers."""
15823
+ return (
15824
+ {
15825
+ "standards": _delivery_targets.list_loudness_standards(),
15826
+ "lra_advisory_lu": _delivery_targets.LRA_ADVISORY_LU,
15827
+ "usage": (
15828
+ "Attach with render(action='resolve_delivery_target', params={'target': ..., "
15829
+ "'overrides': {'loudness_standard': '<id>'}}), then hand the returned "
15830
+ "loudness_target.target to the advanced server's loudness_qc."
15831
+ ),
15832
+ "note": (
15833
+ "Dialogue-gated standards emit no gradeable integrated value: loudness_qc "
15834
+ "measures full-program loudness, so that figure travels in meta for a "
15835
+ "dialogue-gated meter and only true peak is asserted."
15836
+ ),
15837
+ },
15838
+ None,
15839
+ )
15840
+
15841
+
15642
15842
  def _list_delivery_targets(proj, p: Dict[str, Any]):
15643
15843
  tier = p.get("tier")
15644
15844
  if tier is not None and tier not in _delivery_targets.TIERS:
@@ -15778,6 +15978,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
15778
15978
 
15779
15979
 
15780
15980
  @mcp.tool()
15981
+ @_guard_missing_params
15781
15982
  def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15782
15983
  """Render pipeline: jobs, presets, formats, codecs, and rendering.
15783
15984
 
@@ -15826,7 +16027,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15826
16027
  resolved against the live matrix, so an intent unavailable on this
15827
16028
  machine/license fails with the actual available lists.
15828
16029
  """
15829
- p = params or {}
16030
+ p = _params(params)
15830
16031
  _, proj, err = _check()
15831
16032
  if err:
15832
16033
  return err
@@ -15864,6 +16065,9 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15864
16065
  elif action == "resolve_delivery_target":
15865
16066
  _resolved, _target_err = _resolve_delivery_target_live(proj, p)
15866
16067
  return _target_err if _target_err else _ok(**_resolved)
16068
+ elif action == "list_loudness_standards":
16069
+ _standards, _standards_err = _list_loudness_standards(proj, p)
16070
+ return _standards_err if _standards_err else _ok(**_standards)
15867
16071
  elif action == "prepare_delivery_job":
15868
16072
  return _prepare_delivery_job(proj, p)
15869
16073
  elif action == "set_format_and_codec":
@@ -15966,6 +16170,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15966
16170
  # ═══════════════════════════════════════════════════════════════════════════════
15967
16171
 
15968
16172
  @mcp.tool()
16173
+ @_guard_missing_params
15969
16174
  def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15970
16175
  """Browse storage volumes and import media into the Media Pool.
15971
16176
 
@@ -15982,7 +16187,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
15982
16187
  add_clip_mattes(clip_id, paths, stereo_eye?) -> {success}
15983
16188
  add_timeline_mattes(paths) -> {items}
15984
16189
  """
15985
- p = params or {}
16190
+ p = _params(params)
15986
16191
  r = get_resolve()
15987
16192
  if r is None:
15988
16193
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
@@ -16033,6 +16238,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
16033
16238
  # ═══════════════════════════════════════════════════════════════════════════════
16034
16239
 
16035
16240
  @mcp.tool()
16241
+ @_guard_missing_params
16036
16242
  @_destructive_op("media_pool")
16037
16243
  def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16038
16244
  """Manage the Media Pool: folders, clips, timelines, import/export.
@@ -16120,7 +16326,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16120
16326
  copy_clip_annotations(source_clip_id, target_clip_ids, include_markers?, include_flags?, include_clip_color?, dry_run?) -> {success, results}
16121
16327
  media_pool_boundary_report(depth?, clip_ids?, selected?) -> {capabilities, media_pool, items?}
16122
16328
  """
16123
- p = params or {}
16329
+ p = _params(params)
16124
16330
  _, proj, mp, err = _get_mp()
16125
16331
  if err:
16126
16332
  return err
@@ -16497,6 +16703,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16497
16703
  # ═══════════════════════════════════════════════════════════════════════════════
16498
16704
 
16499
16705
  @mcp.tool()
16706
+ @_guard_missing_params
16500
16707
  def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16501
16708
  """Operations on Media Pool folders.
16502
16709
 
@@ -16515,7 +16722,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16515
16722
  analyze_for_slate(path?, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra
16516
16723
  remove_motion_blur(path?, deblur_option?) -> {success, created} — Resolve 21+; renders NEW media (confirm-gated)
16517
16724
  """
16518
- p = params or {}
16725
+ p = _params(params)
16519
16726
  _, _, mp, err = _get_mp()
16520
16727
  if err:
16521
16728
  return err
@@ -16638,6 +16845,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16638
16845
  # ═══════════════════════════════════════════════════════════════════════════════
16639
16846
 
16640
16847
  @mcp.tool()
16848
+ @_guard_missing_params
16641
16849
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16642
16850
  """Operations on a media pool clip. Identify clip by clip_id.
16643
16851
 
@@ -16689,7 +16897,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16689
16897
  verified empirically on Resolve Studio 20.3.2.9. If BMD changes the
16690
16898
  behavior the clip will still be selected — editor double-clicks to load.
16691
16899
  """
16692
- p = params or {}
16900
+ p = _params(params)
16693
16901
  _, _, mp, err = _get_mp()
16694
16902
  if err:
16695
16903
  return err
@@ -17002,6 +17210,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17002
17210
  # ═══════════════════════════════════════════════════════════════════════════════
17003
17211
 
17004
17212
  @mcp.tool()
17213
+ @_guard_missing_params
17005
17214
  def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
17006
17215
  """Markers and flags on media pool clips. Identify clip by clip_id.
17007
17216
 
@@ -17022,7 +17231,7 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
17022
17231
  monitor_growing_file(clip_id) -> {success}
17023
17232
  replace_clip_preserve_sub_clip(clip_id, path) -> {success}
17024
17233
  """
17025
- p = params or {}
17234
+ p = _params(params)
17026
17235
  _, _, mp, err = _get_mp()
17027
17236
  if err:
17028
17237
  return err
@@ -17134,6 +17343,7 @@ def _opt_number(value: Any) -> Optional[float]:
17134
17343
 
17135
17344
 
17136
17345
  @mcp.tool()
17346
+ @_guard_missing_params
17137
17347
  async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]:
17138
17348
  """Project-scoped media analysis and guarded metadata publishing.
17139
17349
 
@@ -17230,7 +17440,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17230
17440
  vision-dependent metadata to Resolve. Works with any MCP client whose chat
17231
17441
  model is vision-capable; no sampling/createMessage support required.
17232
17442
  """
17233
- p = _media_analysis_apply_setup_defaults(action, dict(params or {}))
17443
+ p = _params(_media_analysis_apply_setup_defaults(action, dict(params or {})))
17234
17444
 
17235
17445
  # First-run frame-sampling prompt: if the user has never chosen a sampling
17236
17446
  # mode (and didn't pass one this call), ask before spending any vision
@@ -17288,6 +17498,38 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17288
17498
 
17289
17499
  if action == "capabilities":
17290
17500
  return _media_analysis_capabilities_for_request(ctx)
17501
+ if action == "assess_grade":
17502
+ # Numeric grade-damage QC. Distinct from advanced `verify_grade`, which
17503
+ # asks whether Resolve applied what was asked; this asks whether the
17504
+ # resulting image is damaged. Free and deterministic — the vision block
17505
+ # in the result says whether a paid second opinion is warranted.
17506
+ from src.utils import image_qc as _image_qc_mod
17507
+
17508
+ try:
17509
+ return _ok(**_image_qc_mod.assess_grade(
17510
+ str(p.get("source_path") or p.get("sourcePath") or ""),
17511
+ time_seconds=float(p.get("time_seconds", p.get("timeSeconds", 0.0)) or 0.0),
17512
+ graded_path=(p.get("graded_path") or p.get("gradedPath")) or None,
17513
+ lut_path=(p.get("lut_path") or p.get("lutPath")) or None,
17514
+ working_space=str(p.get("working_space") or p.get("workingSpace") or "rec709"),
17515
+ cost_tier=str(p.get("cost_tier") or p.get("costTier") or _image_qc_mod.DEFAULT_COST_TIER),
17516
+ ))
17517
+ except _image_qc_mod.ImageQcError as exc:
17518
+ return _err(
17519
+ str(exc),
17520
+ code="IMAGE_QC_REFUSED",
17521
+ category="invalid_input",
17522
+ remediation=(
17523
+ "Supply exactly one of graded_path or lut_path, and declare a display-referred "
17524
+ "working_space. Log/scene-referred frames must be converted first — these "
17525
+ "metrics are undefined on them and will not be guessed at."
17526
+ ),
17527
+ )
17528
+ if action == "image_qc_capabilities":
17529
+ from src.utils import image_qc as _image_qc_mod
17530
+
17531
+ return _ok(**_image_qc_mod.capabilities(), cost_tiers=list(_image_qc_mod.COST_TIERS),
17532
+ default_cost_tier=_image_qc_mod.DEFAULT_COST_TIER)
17291
17533
  if action == "recheck_capabilities":
17292
17534
  # Re-runs detection (shutil.which / importlib.util.find_spec) so a tool
17293
17535
  # an agent just installed flips from missing → available without the
@@ -18197,6 +18439,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18197
18439
  return _unknown(action, [
18198
18440
  "capabilities",
18199
18441
  "recheck_capabilities",
18442
+ "assess_grade",
18443
+ "image_qc_capabilities",
18200
18444
  "install_guidance",
18201
18445
  "resolve_output_root",
18202
18446
  "plan",
@@ -18272,6 +18516,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18272
18516
  # ═══════════════════════════════════════════════════════════════════════════════
18273
18517
 
18274
18518
  @mcp.tool()
18519
+ @_guard_missing_params
18275
18520
  def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18276
18521
  """Timeline version-on-mutate, archive, rollback, and brain-edit history (C6).
18277
18522
 
@@ -18319,7 +18564,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
18319
18564
  if ctx is None:
18320
18565
  return _err("No current project / can't resolve project root")
18321
18566
  resolve_h, project_h, project_root, project_name = ctx
18322
- p = params or {}
18567
+ p = _params(params)
18323
18568
 
18324
18569
  if action == "begin_run":
18325
18570
  return _analysis_runs.begin_run(
@@ -18644,6 +18889,7 @@ def _edit_engine_linked_audio_tracks(
18644
18889
 
18645
18890
 
18646
18891
  @mcp.tool()
18892
+ @_guard_missing_params
18647
18893
  @_destructive_op("edit_engine")
18648
18894
  def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18649
18895
  """Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
@@ -18685,7 +18931,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18685
18931
  timeline (version-archived first).
18686
18932
  - list_plans(limit?) / get_plan(plan_id)
18687
18933
  """
18688
- p = params or {}
18934
+ p = _params(params)
18689
18935
 
18690
18936
  def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
18691
18937
  # An explicit analysis_root always wins — the evidence DB the caller
@@ -18752,6 +18998,71 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18752
18998
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
18753
18999
  )
18754
19000
 
19001
+ if action == "generate_captions":
19002
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19003
+ if err:
19004
+ return err
19005
+ from src.utils import captions as _captions_mod
19006
+ from src.utils import strata as _strata_mod
19007
+
19008
+ conn, clip, clip_err = _strata_mod.resolve_clip(
19009
+ project_root, p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"), require_media=False
19010
+ )
19011
+ if clip_err:
19012
+ return clip_err
19013
+ _words = _strata_mod.read_words(conn, clip["clip_uuid"])
19014
+ _opts = {}
19015
+ for _key, _param in (
19016
+ ("max_chars_per_line", "maxCharsPerLine"), ("max_lines", "maxLines"),
19017
+ ("max_block_seconds", "maxBlockSeconds"), ("min_block_seconds", "minBlockSeconds"),
19018
+ ("min_gap_seconds", "minGapSeconds"), ("pause_break_seconds", "pauseBreakSeconds"),
19019
+ ):
19020
+ _value = p.get(_key, p.get(_param))
19021
+ if _value is not None:
19022
+ _opts[_key] = int(_value) if _key in {"max_chars_per_line", "max_lines"} else float(_value)
19023
+ try:
19024
+ _result = _captions_mod.generate(
19025
+ _words,
19026
+ fmt=str(p.get("format") or p.get("fmt") or "srt").lower(),
19027
+ with_chapters=str(p.get("with_chapters", p.get("withChapters", False))).strip().lower() in {"true", "1", "yes", "on"},
19028
+ **_opts,
19029
+ )
19030
+ except _captions_mod.CaptionError as exc:
19031
+ return _err(str(exc), code="CAPTION_PARAMS_INVALID", category="invalid_input")
19032
+ _result["clip"] = {"clip_uuid": clip["clip_uuid"], "clip_name": clip.get("clip_name")}
19033
+ return _result
19034
+
19035
+ if action == "plan_transcript_tighten":
19036
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19037
+ if err:
19038
+ return err
19039
+ return _edit_engine_mod.plan_transcript_tighten(
19040
+ project_root,
19041
+ clip_ref=p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"),
19042
+ remove_fillers=str(p.get("remove_fillers", p.get("removeFillers", True))).strip().lower() not in {"false", "0", "no", "off"},
19043
+ remove_false_starts=str(p.get("remove_false_starts", p.get("removeFalseStarts", True))).strip().lower() not in {"false", "0", "no", "off"},
19044
+ collapse_pauses=str(p.get("collapse_pauses", p.get("collapsePauses", True))).strip().lower() not in {"false", "0", "no", "off"},
19045
+ max_pause=float(p.get("max_pause", p.get("maxPause", _transcript_edit_defaults.DEFAULT_MAX_PAUSE_S))),
19046
+ handle=float(p.get("handle", _transcript_edit_defaults.DEFAULT_HANDLE_S)),
19047
+ min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
19048
+ )
19049
+
19050
+ if action == "search_spoken_content":
19051
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19052
+ if err:
19053
+ return err
19054
+ query = p.get("query") or p.get("q")
19055
+ if not isinstance(query, str) or not query.strip():
19056
+ return _err("query is required", code="MISSING_QUERY", category="invalid_input")
19057
+ return _edit_engine_mod.search_spoken_content(
19058
+ project_root,
19059
+ query=query,
19060
+ mode=str(p.get("mode") or "phrase"),
19061
+ context_seconds=float(p.get("context_seconds", p.get("contextSeconds", 1.5))),
19062
+ handle_seconds=float(p.get("handle_seconds", p.get("handleSeconds", 0.5))),
19063
+ max_hits=int(p.get("max_hits", p.get("maxHits", 200))),
19064
+ )
19065
+
18755
19066
  if action == "plan_silence_ripple":
18756
19067
  _r, proj, project_root, err = _project_context(need_resolve=True)
18757
19068
  if err:
@@ -18765,7 +19076,14 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18765
19076
  items=items,
18766
19077
  timeline_name=tl.GetName(),
18767
19078
  timeline_fps=_edit_engine_timeline_fps(tl),
18768
- threshold_db=float(p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb") if p.get("thresholdDb") is not None else _edit_engine_mod.DEFAULT_SILENCE_THRESHOLD_DB),
19079
+ # Omitted => None => the gate is calibrated per item from that
19080
+ # slice's own dynamics. Passing the old fixed default here would
19081
+ # make auto-calibration unreachable through the MCP surface.
19082
+ threshold_db=(
19083
+ float(_th)
19084
+ if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
19085
+ else None
19086
+ ),
18769
19087
  min_strip_frames=float(p.get("min_strip_frames") if p.get("min_strip_frames") is not None else p.get("minStripFrames") if p.get("minStripFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_MIN_STRIP_FRAMES),
18770
19088
  pre_head_frames=float(p.get("pre_head_frames") if p.get("pre_head_frames") is not None else p.get("preHeadFrames") if p.get("preHeadFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_PRE_HEAD_FRAMES),
18771
19089
  post_tail_frames=float(p.get("post_tail_frames") if p.get("post_tail_frames") is not None else p.get("postTailFrames") if p.get("postTailFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_POST_TAIL_FRAMES),
@@ -19344,6 +19662,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19344
19662
  "plan_tighten",
19345
19663
  "execute_tighten",
19346
19664
  "plan_silence_ripple",
19665
+ "plan_transcript_tighten",
19666
+ "generate_captions",
19667
+ "search_spoken_content",
19347
19668
  "execute_silence_ripple",
19348
19669
  "plan_swap",
19349
19670
  "execute_swap",
@@ -19375,6 +19696,7 @@ _TIMELINE_ACTIONS = [
19375
19696
 
19376
19697
 
19377
19698
  @mcp.tool()
19699
+ @_guard_missing_params
19378
19700
  @_destructive_op("timeline")
19379
19701
  def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19380
19702
  """Timeline operations: tracks, clips, import/export, generators, titles.
@@ -19560,7 +19882,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19560
19882
  For long-form per-action guidance and a worked example, call:
19561
19883
  timeline(action="action_help", params={"name": "<action>"})
19562
19884
  """
19563
- p = params or {}
19885
+ p = _params(params)
19564
19886
  # action_help is pull-on-demand metadata; no Resolve connection needed.
19565
19887
  if action == "action_help":
19566
19888
  return _action_help("timeline", p)
@@ -19961,7 +20283,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19961
20283
  result["ignored_state_keys"] = ignored_state
19962
20284
  return result
19963
20285
  elif action == "extract_source_frame_ranges":
19964
- p = params or {}
20286
+ p = _params(params)
19965
20287
  handles = int(p.get("handles", 24))
19966
20288
  gap_max = int(p.get("gap_max", 30))
19967
20289
  audio_ext = tuple(
@@ -20148,6 +20470,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20148
20470
  # ═══════════════════════════════════════════════════════════════════════════════
20149
20471
 
20150
20472
  @mcp.tool()
20473
+ @_guard_missing_params
20151
20474
  @_destructive_op("timeline_markers")
20152
20475
  def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
20153
20476
  """Markers and playhead operations on the current timeline.
@@ -20183,7 +20506,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
20183
20506
  export_review_report(scope?, include_capabilities?) -> {title, annotations, capabilities?}
20184
20507
  annotation_boundary_report(scope?) -> {capabilities, annotations}
20185
20508
  """
20186
- p = params or {}
20509
+ p = _params(params)
20187
20510
  _, tl, err = _get_tl()
20188
20511
  if err:
20189
20512
  return err
@@ -20268,6 +20591,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
20268
20591
  # ═══════════════════════════════════════════════════════════════════════════════
20269
20592
 
20270
20593
  @mcp.tool()
20594
+ @_guard_missing_params
20271
20595
  @_destructive_op("timeline_ai")
20272
20596
  def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20273
20597
  """AI and analysis operations on the current timeline.
@@ -20281,7 +20605,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
20281
20605
  grab_still() -> {success}
20282
20606
  grab_all_stills(source?) -> {count}
20283
20607
  """
20284
- p = params or {}
20608
+ p = _params(params)
20285
20609
  _, tl, err = _get_tl()
20286
20610
  if err:
20287
20611
  return err
@@ -20322,6 +20646,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
20322
20646
  # ═══════════════════════════════════════════════════════════════════════════════
20323
20647
 
20324
20648
  @mcp.tool()
20649
+ @_guard_missing_params
20325
20650
  @_destructive_op("timeline_item")
20326
20651
  def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20327
20652
  """Properties, transforms, speed, keyframes, and metadata for a timeline item.
@@ -20373,7 +20698,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
20373
20698
 
20374
20699
  Default: track_type="video", track_index=1, item_index=0
20375
20700
  """
20376
- p = params or {}
20701
+ p = _params(params)
20377
20702
  tl, item, err = _get_item(p)
20378
20703
  if err:
20379
20704
  return err
@@ -20561,6 +20886,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
20561
20886
  # ═══════════════════════════════════════════════════════════════════════════════
20562
20887
 
20563
20888
  @mcp.tool()
20889
+ @_guard_missing_params
20564
20890
  @_destructive_op("timeline_item_markers")
20565
20891
  def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20566
20892
  """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).
@@ -20583,7 +20909,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
20583
20909
 
20584
20910
  Default: track_type="video", track_index=1, item_index=0
20585
20911
  """
20586
- p = params or {}
20912
+ p = _params(params)
20587
20913
  _, item, err = _get_item(p)
20588
20914
  if err:
20589
20915
  return err
@@ -20638,6 +20964,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
20638
20964
  # ═══════════════════════════════════════════════════════════════════════════════
20639
20965
 
20640
20966
  @mcp.tool()
20967
+ @_guard_missing_params
20641
20968
  @_destructive_op("timeline_item_fusion")
20642
20969
  def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20643
20970
  """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).
@@ -20658,7 +20985,7 @@ def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -
20658
20985
 
20659
20986
  Default: track_type="video", track_index=1, item_index=0
20660
20987
  """
20661
- p = params or {}
20988
+ p = _params(params)
20662
20989
  _, item, err = _get_item(p)
20663
20990
  if err:
20664
20991
  return err
@@ -21939,6 +22266,7 @@ def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]:
21939
22266
  }
21940
22267
 
21941
22268
  @mcp.tool()
22269
+ @_guard_missing_params
21942
22270
  @_destructive_op("timeline_item_color")
21943
22271
  def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21944
22272
  """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).
@@ -22029,7 +22357,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
22029
22357
  For long-form per-action guidance and a worked example, call:
22030
22358
  timeline_item_color(action="action_help", params={"name": "<action>"})
22031
22359
  """
22032
- p = params or {}
22360
+ p = _params(params)
22033
22361
  if action == "action_help":
22034
22362
  return _action_help("timeline_item_color", p)
22035
22363
  _, item, err = _get_item(p)
@@ -22144,6 +22472,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
22144
22472
  # ═══════════════════════════════════════════════════════════════════════════════
22145
22473
 
22146
22474
  @mcp.tool()
22475
+ @_guard_missing_params
22147
22476
  @_destructive_op("timeline_item_takes")
22148
22477
  def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22149
22478
  """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).
@@ -22159,7 +22488,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
22159
22488
 
22160
22489
  Default: track_type="video", track_index=1, item_index=0
22161
22490
  """
22162
- p = params or {}
22491
+ p = _params(params)
22163
22492
  _, item, err = _get_item(p)
22164
22493
  if err:
22165
22494
  return err
@@ -22192,6 +22521,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
22192
22521
  # ═══════════════════════════════════════════════════════════════════════════════
22193
22522
 
22194
22523
  @mcp.tool()
22524
+ @_guard_missing_params
22195
22525
  def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22196
22526
  """Gallery album management.
22197
22527
 
@@ -22207,7 +22537,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
22207
22537
 
22208
22538
  album_index is 0-based into the still albums list.
22209
22539
  """
22210
- p = params or {}
22540
+ p = _params(params)
22211
22541
  _, proj, err = _check()
22212
22542
  if err:
22213
22543
  return err
@@ -22257,6 +22587,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
22257
22587
  # ═══════════════════════════════════════════════════════════════════════════════
22258
22588
 
22259
22589
  @mcp.tool()
22590
+ @_guard_missing_params
22260
22591
  def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22261
22592
  """Manage stills in gallery albums (best results on Color page).
22262
22593
 
@@ -22277,7 +22608,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
22277
22608
  File data is inlined in the response (DRX as text, images as base64).
22278
22609
  cleanup (default true) deletes exported files from disk after inlining.
22279
22610
  """
22280
- p = params or {}
22611
+ p = _params(params)
22281
22612
  _, proj, err = _check()
22282
22613
  if err:
22283
22614
  return err
@@ -22410,6 +22741,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
22410
22741
  # ═══════════════════════════════════════════════════════════════════════════════
22411
22742
 
22412
22743
  @mcp.tool()
22744
+ @_guard_missing_params
22413
22745
  @_destructive_op("graph")
22414
22746
  def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22415
22747
  """Node graph operations (color grading nodes). Source can be timeline, timeline item, or color group.
@@ -22449,7 +22781,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
22449
22781
  For long-form per-action guidance and a worked example, call:
22450
22782
  graph(action="action_help", params={"name": "<action>"})
22451
22783
  """
22452
- p = params or {}
22784
+ p = _params(params)
22453
22785
  if action == "action_help":
22454
22786
  return _action_help("graph", p)
22455
22787
  source = p.get("source", "timeline")
@@ -22552,6 +22884,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
22552
22884
  # ═══════════════════════════════════════════════════════════════════════════════
22553
22885
 
22554
22886
  @mcp.tool()
22887
+ @_guard_missing_params
22555
22888
  def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22556
22889
  """Manage color groups and their node graphs.
22557
22890
 
@@ -22563,7 +22896,7 @@ def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22563
22896
  get_pre_clip_graph(group_name) -> {available, num_nodes}
22564
22897
  get_post_clip_graph(group_name) -> {available, num_nodes}
22565
22898
  """
22566
- p = params or {}
22899
+ p = _params(params)
22567
22900
  _, proj, err = _check()
22568
22901
  if err:
22569
22902
  return err
@@ -23474,6 +23807,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
23474
23807
 
23475
23808
 
23476
23809
  @mcp.tool()
23810
+ @_guard_missing_params
23477
23811
  def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
23478
23812
  """Fusion composition node graph operations.
23479
23813
 
@@ -23544,7 +23878,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
23544
23878
  ColorCorrector, RectangleMask, EllipseMask, Tracker, MediaIn, MediaOut,
23545
23879
  Loader, Saver, Glow, FilmGrain, CornerPositioner, DeltaKeyer, UltraKeyer
23546
23880
  """
23547
- p = params or {}
23881
+ p = _params(params)
23548
23882
 
23549
23883
  if action == "bulk_set_inputs":
23550
23884
  return _fusion_comp_bulk_set_inputs(p)
@@ -24044,6 +24378,7 @@ def _validate_glsl_minimal(source: str) -> Dict[str, Any]:
24044
24378
 
24045
24379
 
24046
24380
  @mcp.tool()
24381
+ @_guard_missing_params
24047
24382
  def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
24048
24383
  """Author and install Fusion Fuse plugins (.fuse files).
24049
24384
 
@@ -24070,7 +24405,7 @@ def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24070
24405
  docs/authoring/fuse-dctl-authoring.md for the per-kind option spec.
24071
24406
  list_templates() -> {kinds}
24072
24407
  """
24073
- p = params or {}
24408
+ p = _params(params)
24074
24409
 
24075
24410
  if action == "path":
24076
24411
  return {"fuses_dir": _fuses_dir()}
@@ -24287,6 +24622,7 @@ _DCTL_VALID_CATEGORIES = ("lut", "aces_idt", "aces_odt")
24287
24622
 
24288
24623
 
24289
24624
  @mcp.tool()
24625
+ @_guard_missing_params
24290
24626
  def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
24291
24627
  """Author and install DCTL files (Color page custom shaders + ACES transforms).
24292
24628
 
@@ -24323,7 +24659,7 @@ def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]
24323
24659
  pass that as the `category` argument to install().
24324
24660
  list_templates() -> {kinds, kind_categories}
24325
24661
  """
24326
- p = params or {}
24662
+ p = _params(params)
24327
24663
 
24328
24664
  def _category(default: str = "lut") -> Tuple[Optional[Dict[str, Any]], str]:
24329
24665
  cat = p.get("category", default)
@@ -25216,6 +25552,7 @@ def _extension_boundary_report(p: Dict[str, Any]) -> Dict[str, Any]:
25216
25552
 
25217
25553
 
25218
25554
  @mcp.tool()
25555
+ @_guard_missing_params
25219
25556
  def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
25220
25557
  """Author and install Resolve-page Lua/Python scripts (Workspace → Scripts menu).
25221
25558
 
@@ -25271,7 +25608,7 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
25271
25608
  refresh_or_restart_required(extension_type, category?) -> {refresh_luts, restart_required}
25272
25609
  extension_boundary_report(include_template_matrix?) -> {capabilities, template_matrix, dry_run_probes}
25273
25610
  """
25274
- p = params or {}
25611
+ p = _params(params)
25275
25612
 
25276
25613
  if action == "extension_capabilities":
25277
25614
  return _extension_capabilities()