davinci-resolve-mcp 2.54.5 → 2.55.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/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
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.55.1
6
+
7
+ Deep-QC P1 — settings/options whitelisting + DeleteProject reliability.
8
+
9
+ - **Fixed** options/settings dicts passed to several Resolve APIs are now
10
+ whitelisted to their documented keys, so a typo'd key is reported instead of
11
+ silently dropped (`_filter_to_keys` → `ignored_options`/`ignored_settings`):
12
+ `media_pool.import_timeline` (ImportTimelineFromFile), `timeline.import_into_timeline`
13
+ (ImportIntoTimeline), raw `render.set_settings` (SetRenderSettings) and
14
+ `render.quick_export`, and `set_voice_isolation_state` (track + item).
15
+ - **Fixed** `ProjectManager.DeleteProject` is flaky — it silently returns False
16
+ when the target is/was the current project. All callers (the safe and raw
17
+ `project_manager.delete` actions and the granular `delete_project`) now route
18
+ through `delete_project_safely` (switch-away + retry) and report `delete_detail`.
19
+ Added an `api_truth` entry.
20
+ - **Added** a destructive-registry drift guard (`tests/test_destructive_registry_drift.py`)
21
+ that asserts token-gated actions map to real handlers and locks in the media-pool
22
+ delete governance from v2.55.0. (It also surfaced a pre-existing registry
23
+ mis-keying issue now tracked for a dedicated audit.)
24
+
25
+ ## What's New in v2.55.0
26
+
27
+ Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
28
+
29
+ **Behavior change:** `media_pool` `delete_clips`, `delete_folders`, and
30
+ `delete_timelines` now require a confirm token, the same two-step handshake
31
+ `timeline.delete_track` already uses — the first call returns
32
+ `status: "confirmation_required"` with a `confirm_token` and a preview (count +
33
+ names); re-call with `params.confirm_token` to execute. Callers that disable
34
+ gating via the `destructive.require_confirm_token=false` preference are
35
+ unaffected.
36
+
37
+ - **Fixed** the destructive-action registry listed granular function names
38
+ (`delete_media_pool_clips`, `move_media_pool_folders`, …) that the compound
39
+ `media_pool` tool never dispatches, so `is_destructive()` returned False and
40
+ these catastrophic deletes silently skipped version-on-mutate archiving and
41
+ change logging. The registry now uses the real compound action strings, so the
42
+ deletes are archived/logged as intended.
43
+ - **Added** confirm-token gating + previews to the three deletes (EX3). The
44
+ registry fix also re-enables the existing media-pool change log for them.
45
+ - Live-validated against DaVinci Resolve Studio 21.0.0 (non-destructively): the
46
+ no-token call returns `confirmation_required` and deletes nothing.
47
+
5
48
  ## What's New in v2.54.5
6
49
 
7
50
  Reliability + security hardening from the exhaustive reliability audit (Wave A).
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.54.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.55.1-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
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.54.5"
38
+ VERSION = "2.55.1"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (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.54.5",
3
+ "version": "2.55.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.54.5"
83
+ VERSION = "2.55.1"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
@@ -786,8 +786,12 @@ def delete_project(project_name: str) -> Dict[str, Any]:
786
786
  if resolve is None:
787
787
  return {"error": "Not connected to DaVinci Resolve"}
788
788
  pm = resolve.GetProjectManager()
789
- result = pm.DeleteProject(project_name)
790
- return {"success": bool(result), "project_name": project_name}
789
+ # DeleteProject is flaky (silently returns False when the target is/was
790
+ # current, transient first-attempt failures); route through the retry+switch
791
+ # guard (#19).
792
+ from src.utils.project_cleanup import delete_project_safely
793
+ deleted = delete_project_safely(pm, project_name)
794
+ return {"success": bool(deleted.get("success")), "project_name": project_name, "delete_detail": deleted}
791
795
 
792
796
 
793
797
  @mcp.tool()
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.54.5"
14
+ VERSION = "2.55.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -784,6 +784,11 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
784
784
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
785
785
  ("timeline", "delete_track"),
786
786
  ("timeline", "apply_cuts"),
787
+ # Catastrophic media-pool deletes (EX3): irreversibly destroy clips/folders/
788
+ # timelines. Gated like delete_track; also archive-on-mutate via the registry.
789
+ ("media_pool", "delete_clips"),
790
+ ("media_pool", "delete_folders"),
791
+ ("media_pool", "delete_timelines"),
787
792
  # Phase E edit-engine loops: plan → confirm → execute.
788
793
  ("edit_engine", "execute_selects"),
789
794
  ("edit_engine", "execute_tighten"),
@@ -7503,6 +7508,36 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
7503
7508
  return parsed
7504
7509
 
7505
7510
 
7511
+ def _filter_to_keys(settings: Any, allowed) -> Tuple[Dict[str, Any], list]:
7512
+ """Whitelist a settings/options dict to the keys a Resolve API documents.
7513
+
7514
+ Resolve silently ignores unrecognized keys in several options dicts (import
7515
+ options, render settings, voice-isolation state), so a typo'd key is dropped
7516
+ with no signal. This returns ``(filtered, ignored)`` — only known keys are
7517
+ forwarded; ``ignored`` lists the dropped keys so the caller can surface them
7518
+ in ``ignored_settings`` (P1 / deep-QC #4-6,23-25)."""
7519
+ if not isinstance(settings, dict):
7520
+ return {}, []
7521
+ allowed_set = set(allowed)
7522
+ filtered = {k: v for k, v in settings.items() if k in allowed_set}
7523
+ ignored = sorted(str(k) for k in settings if k not in allowed_set)
7524
+ return filtered, ignored
7525
+
7526
+
7527
+ # Documented option/state key sets (docs/reference/resolve_scripting_api.txt).
7528
+ _IMPORT_TIMELINE_OPTION_KEYS = frozenset({
7529
+ "timelineName", "importSourceClips", "sourceClipsPath",
7530
+ "sourceClipsFolders", "interlaceProcessing",
7531
+ })
7532
+ _IMPORT_INTO_TIMELINE_OPTION_KEYS = frozenset({
7533
+ "autoImportSourceClipsIntoMediaPool", "ignoreFileExtensionsWhenMatching",
7534
+ "linkToSourceCameraFiles", "useSizingInfo",
7535
+ "importMultiChannelAudioTracksAsLinkedGroups", "insertAdditionalTracks",
7536
+ "insertWithOffset", "sourceClipsPath", "sourceClipsFolders",
7537
+ })
7538
+ _VOICE_ISOLATION_STATE_KEYS = frozenset({"isEnabled", "amount"})
7539
+
7540
+
7506
7541
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7507
7542
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7508
7543
  return 0
@@ -13400,6 +13435,9 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13400
13435
  if p.get("dry_run"):
13401
13436
  return _ok(would_delete=True, name=name)
13402
13437
  current = pm.GetCurrentProject()
13438
+ # Route through delete_project_safely: DeleteProject silently returns False
13439
+ # when the target is/was current and is flaky on the first attempt (#19).
13440
+ from src.utils.project_cleanup import delete_project_safely
13403
13441
  current_name = current.GetName() if current and _has_method(current, "GetName") else None
13404
13442
  if current_name == name:
13405
13443
  if not p.get("close_current", False):
@@ -13408,11 +13446,13 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13408
13446
  closed = bool(pm.CloseProject(current))
13409
13447
  if not closed:
13410
13448
  return _err(f"Failed to close current project '{name}' before delete")
13411
- result = {"success": bool(pm.DeleteProject(name))}
13449
+ deleted = delete_project_safely(pm, name)
13450
+ result = {"success": bool(deleted.get("success")), "delete_detail": deleted}
13412
13451
  if saved is False:
13413
13452
  result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
13414
13453
  return result
13415
- return {"success": bool(pm.DeleteProject(name))}
13454
+ deleted = delete_project_safely(pm, name)
13455
+ return {"success": bool(deleted.get("success")), "delete_detail": deleted}
13416
13456
 
13417
13457
 
13418
13458
  def _database_capabilities(pm) -> Dict[str, Any]:
@@ -13958,7 +13998,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13958
13998
  proj = pm.GetCurrentProject()
13959
13999
  return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open")
13960
14000
  elif action == "delete":
13961
- return {"success": bool(pm.DeleteProject(p["name"]))}
14001
+ if not p.get("name"):
14002
+ return _err("delete requires name")
14003
+ from src.utils.project_cleanup import delete_project_safely
14004
+ deleted = delete_project_safely(pm, p["name"])
14005
+ return {"success": bool(deleted.get("success")), "delete_detail": deleted}
13962
14006
  elif action == "import_project":
13963
14007
  return {"success": bool(pm.ImportProject(p["path"], p.get("name")))}
13964
14008
  elif action == "export_project":
@@ -14713,7 +14757,15 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14713
14757
  return missing
14714
14758
  return {"settings": _ser(proj.GetRenderSettings())}
14715
14759
  elif action == "set_settings":
14716
- return {"success": bool(proj.SetRenderSettings(p["settings"]))}
14760
+ if "settings" not in p or not isinstance(p["settings"], dict):
14761
+ return _err("set_settings requires a settings object")
14762
+ # Whitelist to documented keys; SetRenderSettings silently ignores unknown
14763
+ # keys, so a typo'd setting would be dropped with no signal (P1 #6).
14764
+ settings, ignored_settings = _filter_to_keys(p["settings"], _RENDER_SETTING_KEYS)
14765
+ result = {"success": bool(proj.SetRenderSettings(settings))}
14766
+ if ignored_settings:
14767
+ result["ignored_settings"] = ignored_settings
14768
+ return result
14717
14769
  elif action == "list_presets":
14718
14770
  return {"presets": proj.GetRenderPresetList()}
14719
14771
  elif action == "load_preset":
@@ -14725,7 +14777,17 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14725
14777
  elif action == "quick_export_presets":
14726
14778
  return {"presets": proj.GetQuickExportRenderPresets()}
14727
14779
  elif action == "quick_export":
14728
- return _ser(proj.RenderWithQuickExport(p["preset"], p.get("params", {})))
14780
+ if "preset" not in p:
14781
+ return _err("quick_export requires a preset")
14782
+ # Whitelist the documented quick-export params (unknown keys silently
14783
+ # ignored by Resolve) (P1 #23).
14784
+ qe_params, ignored_params = _filter_to_keys(
14785
+ p.get("params", {}), {"TargetDir", "CustomName", "VideoQuality", "EnableUpload"}
14786
+ )
14787
+ out = _ser(proj.RenderWithQuickExport(p["preset"], qe_params))
14788
+ if ignored_params and isinstance(out, dict):
14789
+ out["ignored_params"] = ignored_params
14790
+ return out
14729
14791
  elif action == "render_capabilities":
14730
14792
  return _render_capabilities(proj)
14731
14793
  elif action == "probe_render_matrix":
@@ -14935,7 +14997,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
14935
14997
  for sub in (root.GetSubFolderList() or []):
14936
14998
  if sub.GetUniqueId() == fid:
14937
14999
  folders.append(sub)
14938
- return {"success": bool(mp.DeleteFolders(folders))} if folders else _err("No folders found")
15000
+ if not folders:
15001
+ return _err("No folders found")
15002
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15003
+ return _issue_confirm_token(
15004
+ action="media_pool.delete_folders", params=p,
15005
+ preview={"operation": "media_pool.delete_folders",
15006
+ "warning": "Permanently deletes folders AND every clip they contain.",
15007
+ "folders_lost": len(folders),
15008
+ "names": [_clip_name(f) for f in folders][:25]},
15009
+ )
15010
+ blocked = _consume_confirm_token(action="media_pool.delete_folders", params=p)
15011
+ if blocked:
15012
+ return blocked
15013
+ return {"success": bool(mp.DeleteFolders(folders))}
14939
15014
  elif action == "move_folders":
14940
15015
  target = _navigate_folder(mp, p["target_path"])
14941
15016
  if not target:
@@ -15018,9 +15093,17 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15018
15093
  elif action == "setup_multicam_timeline":
15019
15094
  return _setup_multicam_timeline(proj, mp, p)
15020
15095
  elif action == "import_timeline":
15096
+ # Whitelist options to the documented keys; Resolve silently ignores
15097
+ # unknown keys, so a typo would be dropped with no signal (P1 #4).
15098
+ options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_TIMELINE_OPTION_KEYS)
15021
15099
  with long_resolve_op("media_pool.import_timeline"):
15022
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
15023
- return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
15100
+ tl = mp.ImportTimelineFromFile(p["path"], options)
15101
+ if not tl:
15102
+ return _err("Failed to import timeline")
15103
+ result = _ok(name=tl.GetName())
15104
+ if ignored_opts:
15105
+ result["ignored_options"] = ignored_opts
15106
+ return result
15024
15107
  elif action == "delete_timelines":
15025
15108
  count = proj.GetTimelineCount()
15026
15109
  timelines = []
@@ -15028,7 +15111,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15028
15111
  tl = proj.GetTimelineByIndex(i)
15029
15112
  if tl and tl.GetUniqueId() in p["timeline_ids"]:
15030
15113
  timelines.append(tl)
15031
- return {"success": bool(mp.DeleteTimelines(timelines))} if timelines else _err("No timelines found")
15114
+ if not timelines:
15115
+ return _err("No timelines found")
15116
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15117
+ return _issue_confirm_token(
15118
+ action="media_pool.delete_timelines", params=p,
15119
+ preview={"operation": "media_pool.delete_timelines",
15120
+ "warning": "Permanently deletes timelines.",
15121
+ "timelines_lost": len(timelines),
15122
+ "names": [t.GetName() for t in timelines][:25]},
15123
+ )
15124
+ blocked = _consume_confirm_token(action="media_pool.delete_timelines", params=p)
15125
+ if blocked:
15126
+ return blocked
15127
+ return {"success": bool(mp.DeleteTimelines(timelines))}
15032
15128
  elif action == "append_to_timeline":
15033
15129
  if p.get("clip_infos") is not None:
15034
15130
  raw = p["clip_infos"]
@@ -15115,7 +15211,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15115
15211
  elif action == "delete_clips":
15116
15212
  clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
15117
15213
  clips = [c for c in clips if c]
15118
- return {"success": bool(mp.DeleteClips(clips))} if clips else _err("No clips found")
15214
+ if not clips:
15215
+ return _err("No clips found")
15216
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15217
+ return _issue_confirm_token(
15218
+ action="media_pool.delete_clips", params=p,
15219
+ preview={"operation": "media_pool.delete_clips",
15220
+ "warning": "Permanently deletes Media Pool clips.",
15221
+ "clips_lost": len(clips),
15222
+ "names": [_clip_name(c) for c in clips][:25]},
15223
+ )
15224
+ blocked = _consume_confirm_token(action="media_pool.delete_clips", params=p)
15225
+ if blocked:
15226
+ return blocked
15227
+ return {"success": bool(mp.DeleteClips(clips))}
15119
15228
  elif action == "move_clips":
15120
15229
  target = _navigate_folder(mp, p["target_path"])
15121
15230
  if not target:
@@ -18013,7 +18122,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18013
18122
  result = tl.CreateFusionClip(found)
18014
18123
  return _ok() if result else _err("Failed to create Fusion clip")
18015
18124
  elif action == "import_into_timeline":
18016
- return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
18125
+ options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_INTO_TIMELINE_OPTION_KEYS)
18126
+ result = {"success": bool(tl.ImportIntoTimeline(p["path"], options))}
18127
+ if ignored_opts:
18128
+ result["ignored_options"] = ignored_opts
18129
+ return result
18017
18130
  elif action == "export":
18018
18131
  # Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
18019
18132
  # JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
@@ -18149,7 +18262,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18149
18262
  missing = _requires_method(tl, "SetVoiceIsolationState", "20.1")
18150
18263
  if missing:
18151
18264
  return missing
18152
- return {"success": bool(tl.SetVoiceIsolationState(p["track_index"], p["state"]))}
18265
+ state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
18266
+ result = {"success": bool(tl.SetVoiceIsolationState(p["track_index"], state))}
18267
+ if ignored_state:
18268
+ result["ignored_state_keys"] = ignored_state
18269
+ return result
18153
18270
  elif action == "extract_source_frame_ranges":
18154
18271
  p = params or {}
18155
18272
  handles = int(p.get("handles", 24))
@@ -18626,7 +18743,11 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
18626
18743
  missing = _requires_method(item, "SetVoiceIsolationState", "20.1")
18627
18744
  if missing:
18628
18745
  return missing
18629
- return {"success": bool(item.SetVoiceIsolationState(p["state"]))}
18746
+ state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
18747
+ result = {"success": bool(item.SetVoiceIsolationState(state))}
18748
+ if ignored_state:
18749
+ result["ignored_state_keys"] = ignored_state
18750
+ return result
18630
18751
 
18631
18752
  # ── Retime ──
18632
18753
  elif action == "get_retime":
@@ -77,6 +77,18 @@ API_TRUTH: List[Dict[str, Any]] = [
77
77
  "tags": ["silent-failure", "timeline", "export", "enum"],
78
78
  "mitigation": ["_timeline_export_spec", "_timeline_export_value"],
79
79
  },
80
+ {
81
+ "symbol": "ProjectManager.DeleteProject",
82
+ "object": "ProjectManager",
83
+ "signature": "(projectName) -> bool",
84
+ "reality": "Returns False (no deletion) when the target project is, or "
85
+ "recently was, the current project, and is flaky on the first "
86
+ "attempt — so a single bool() call leaves the project undeleted "
87
+ "with no useful error.",
88
+ "recommended": "Load/close away from the target first, then retry; use "
89
+ "src/utils/project_cleanup.py:delete_project_safely.",
90
+ "tags": ["unreliable-return", "project", "flaky"],
91
+ },
80
92
  {
81
93
  "symbol": "Composition.Paste",
82
94
  "object": "Fusion Composition",
@@ -41,18 +41,17 @@ logger = logging.getLogger("resolve-mcp.destructive-hook")
41
41
  # default metric.
42
42
 
43
43
  DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
44
+ # Real COMPOUND media_pool action strings (the @destructive_op("media_pool")
45
+ # wrapper queries is_destructive("media_pool", <compound action>)). The prior
46
+ # entries used granular function names (delete_media_pool_clips, …) and
47
+ # media_pool_item names that the compound tool never dispatches, so catastrophic
48
+ # deletes silently bypassed version-on-mutate archiving (EX2). media_pool_item
49
+ # link/replace actions are governed separately (its own tool is not yet wrapped).
44
50
  "media_pool": frozenset({
45
- "delete_media_pool_clips",
46
- "delete_media_pool_folders",
51
+ "delete_clips",
52
+ "delete_folders",
47
53
  "move_clips",
48
- "move_media_pool_folders",
49
- "replace_clip",
50
- "replace_clip_preserve_sub_clip",
51
- "link_clip_proxy_media",
52
- "unlink_clip_proxy_media",
53
- "link_proxy_media",
54
- "unlink_proxy_media",
55
- "link_clip_full_resolution_media",
54
+ "move_folders",
56
55
  "delete_clip_mattes",
57
56
  "delete_timelines",
58
57
  }),