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 +20 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/project.py +6 -2
- package/src/server.py +88 -11
- package/src/utils/api_truth.py +12 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.55.
|
|
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()}")
|
package/src/granular/project.py
CHANGED
|
@@ -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
|
-
|
|
790
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"],
|
|
15041
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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",
|