davinci-resolve-mcp 2.55.0 → 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,26 @@
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
+
5
25
  ## What's New in v2.55.0
6
26
 
7
27
  Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
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.55.0-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.55.0"
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.55.0",
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.55.0"
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.55.0"
14
+ VERSION = "2.55.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -7508,6 +7508,36 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
7508
7508
  return parsed
7509
7509
 
7510
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
+
7511
7541
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7512
7542
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7513
7543
  return 0
@@ -13405,6 +13435,9 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13405
13435
  if p.get("dry_run"):
13406
13436
  return _ok(would_delete=True, name=name)
13407
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
13408
13441
  current_name = current.GetName() if current and _has_method(current, "GetName") else None
13409
13442
  if current_name == name:
13410
13443
  if not p.get("close_current", False):
@@ -13413,11 +13446,13 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13413
13446
  closed = bool(pm.CloseProject(current))
13414
13447
  if not closed:
13415
13448
  return _err(f"Failed to close current project '{name}' before delete")
13416
- result = {"success": bool(pm.DeleteProject(name))}
13449
+ deleted = delete_project_safely(pm, name)
13450
+ result = {"success": bool(deleted.get("success")), "delete_detail": deleted}
13417
13451
  if saved is False:
13418
13452
  result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
13419
13453
  return result
13420
- return {"success": bool(pm.DeleteProject(name))}
13454
+ deleted = delete_project_safely(pm, name)
13455
+ return {"success": bool(deleted.get("success")), "delete_detail": deleted}
13421
13456
 
13422
13457
 
13423
13458
  def _database_capabilities(pm) -> Dict[str, Any]:
@@ -13963,7 +13998,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13963
13998
  proj = pm.GetCurrentProject()
13964
13999
  return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open")
13965
14000
  elif action == "delete":
13966
- 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}
13967
14006
  elif action == "import_project":
13968
14007
  return {"success": bool(pm.ImportProject(p["path"], p.get("name")))}
13969
14008
  elif action == "export_project":
@@ -14718,7 +14757,15 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14718
14757
  return missing
14719
14758
  return {"settings": _ser(proj.GetRenderSettings())}
14720
14759
  elif action == "set_settings":
14721
- 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
14722
14769
  elif action == "list_presets":
14723
14770
  return {"presets": proj.GetRenderPresetList()}
14724
14771
  elif action == "load_preset":
@@ -14730,7 +14777,17 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14730
14777
  elif action == "quick_export_presets":
14731
14778
  return {"presets": proj.GetQuickExportRenderPresets()}
14732
14779
  elif action == "quick_export":
14733
- 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
14734
14791
  elif action == "render_capabilities":
14735
14792
  return _render_capabilities(proj)
14736
14793
  elif action == "probe_render_matrix":
@@ -15036,9 +15093,17 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15036
15093
  elif action == "setup_multicam_timeline":
15037
15094
  return _setup_multicam_timeline(proj, mp, p)
15038
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)
15039
15099
  with long_resolve_op("media_pool.import_timeline"):
15040
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
15041
- 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
15042
15107
  elif action == "delete_timelines":
15043
15108
  count = proj.GetTimelineCount()
15044
15109
  timelines = []
@@ -18057,7 +18122,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18057
18122
  result = tl.CreateFusionClip(found)
18058
18123
  return _ok() if result else _err("Failed to create Fusion clip")
18059
18124
  elif action == "import_into_timeline":
18060
- 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
18061
18130
  elif action == "export":
18062
18131
  # Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
18063
18132
  # JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
@@ -18193,7 +18262,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18193
18262
  missing = _requires_method(tl, "SetVoiceIsolationState", "20.1")
18194
18263
  if missing:
18195
18264
  return missing
18196
- 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
18197
18270
  elif action == "extract_source_frame_ranges":
18198
18271
  p = params or {}
18199
18272
  handles = int(p.get("handles", 24))
@@ -18670,7 +18743,11 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
18670
18743
  missing = _requires_method(item, "SetVoiceIsolationState", "20.1")
18671
18744
  if missing:
18672
18745
  return missing
18673
- 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
18674
18751
 
18675
18752
  # ── Retime ──
18676
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",