davinci-resolve-mcp 2.37.1 → 2.37.3
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 +48 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +3 -6
- package/src/granular/common.py +1 -1
- package/src/granular/resolve_control.py +1 -1
- package/src/server.py +34 -19
- package/src/utils/destructive_hook.py +1 -1
- package/src/utils/timeline_kernel_live_probe.py +1 -0
- package/src/utils/update_check.py +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
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.37.3
|
|
6
|
+
|
|
7
|
+
Deep-audit fixes, rounds two and three: agent-facing action discovery,
|
|
8
|
+
crash-safe user-state persistence, and resource hygiene.
|
|
9
|
+
|
|
10
|
+
- **Fixed** three tools' unknown-action error lists drifting from their real
|
|
11
|
+
dispatch: `timeline` omitted `clip_where` and `action_help`;
|
|
12
|
+
`timeline_item_color` and `graph` omitted `action_help`. Agents recovering
|
|
13
|
+
from a typo now see the full action set. A static AST test keeps every
|
|
14
|
+
tool's advertised list in both-way sync with its dispatch (and verifies
|
|
15
|
+
docstring-advertised actions are real).
|
|
16
|
+
- **Fixed** user-state files being vulnerable to truncation by a crash
|
|
17
|
+
mid-write. Affected files all reset to `{}` on corruption, so the next
|
|
18
|
+
save would silently wipe remaining user data. Writers now use atomic
|
|
19
|
+
temp-file + `os.replace`: `media-analysis-preferences.json` (all
|
|
20
|
+
analysis/caps/governance/update defaults — including the
|
|
21
|
+
`set_resolution_tier` and `set_caps_preset` paths, now routed through the
|
|
22
|
+
shared writer), `update-check.json`, the dashboard's `analysis.json`
|
|
23
|
+
transcription patch, and `transcript-corrections.json`.
|
|
24
|
+
- **Fixed** `_safe_project_delete` discarding `SaveProject()`'s result when
|
|
25
|
+
closing the current project before deletion; a failed save now surfaces
|
|
26
|
+
as a warning in the response.
|
|
27
|
+
- **Fixed** a file-descriptor leak: the parent kept its copy of the control
|
|
28
|
+
panel's log handle open after spawning the detached child.
|
|
29
|
+
- Audit classes verified clean: bare excepts, mutable default arguments,
|
|
30
|
+
`asyncio.run` inside running loops, subprocess timeouts, docstring phantom
|
|
31
|
+
actions, silent-success swallows in metadata/marker/archive write paths.
|
|
32
|
+
|
|
33
|
+
## What's New in v2.37.2
|
|
34
|
+
|
|
35
|
+
Static-audit fixes — four undefined-name references that silently fell back
|
|
36
|
+
to defaults instead of erroring (the same bug class as the v2.37.0
|
|
37
|
+
confirm-token fix), plus a regression guard.
|
|
38
|
+
|
|
39
|
+
- **Fixed** the `status://mcp_version` resource reporting update channel
|
|
40
|
+
`"stable"` unconditionally; it now calls the real `get_update_channel()`,
|
|
41
|
+
so beta/dev channel installs report correctly.
|
|
42
|
+
- **Fixed** the `versioning_auto_run_idle_timeout_seconds` preference being
|
|
43
|
+
silently ignored (auto-run idle timeout was always 90s) due to a
|
|
44
|
+
misspelled preference-reader name in the destructive hook.
|
|
45
|
+
- **Fixed** resolve-state snapshot tokens always falling back to a timestamp
|
|
46
|
+
because `short_hash` was never imported; tokens are now content-stable.
|
|
47
|
+
- **Fixed** the timeline-kernel live probe's MCP-stub fallback crashing with
|
|
48
|
+
a `NameError` (missing `types` import) exactly when the mcp library is
|
|
49
|
+
absent — the only case the fallback exists for.
|
|
50
|
+
- **Added** a static test that runs pyflakes over `src/` and fails on any
|
|
51
|
+
undefined name, so this bug class cannot reappear unnoticed.
|
|
52
|
+
|
|
5
53
|
## What's New in v2.37.1
|
|
6
54
|
|
|
7
55
|
Test-suite hygiene — no server behavior changed.
|
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.37.
|
|
38
|
+
VERSION = "2.37.3"
|
|
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
|
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
21
21
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
22
22
|
|
|
23
23
|
from src.utils.media_analysis import (
|
|
24
|
+
_write_json as _atomic_write_json,
|
|
24
25
|
analysis_index_status,
|
|
25
26
|
analysis_root_coverage,
|
|
26
27
|
build_analysis_index,
|
|
@@ -12605,9 +12606,7 @@ def regenerate_clip_transcript(
|
|
|
12605
12606
|
if payload.get("words"):
|
|
12606
12607
|
updated_report["transcription"]["words"] = payload["words"]
|
|
12607
12608
|
try:
|
|
12608
|
-
|
|
12609
|
-
json.dump(updated_report, handle, indent=2)
|
|
12610
|
-
handle.write("\n")
|
|
12609
|
+
_atomic_write_json(artifacts["analysis_json"], updated_report)
|
|
12611
12610
|
except Exception as exc:
|
|
12612
12611
|
return {"success": False, "error": f"transcript written but analysis.json patch failed: {exc}"}
|
|
12613
12612
|
word_segment_count = sum(1 for seg in (payload.get("segments") or []) if isinstance(seg, dict) and seg.get("words"))
|
|
@@ -12779,9 +12778,7 @@ def save_clip_transcript_corrections(project_root: str, clip_id: str, body: Dict
|
|
|
12779
12778
|
},
|
|
12780
12779
|
}
|
|
12781
12780
|
try:
|
|
12782
|
-
|
|
12783
|
-
json.dump(payload, handle, indent=2)
|
|
12784
|
-
handle.write("\n")
|
|
12781
|
+
_atomic_write_json(path, payload)
|
|
12785
12782
|
except Exception as exc:
|
|
12786
12783
|
return {"success": False, "error": str(exc)}
|
|
12787
12784
|
return {
|
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.37.
|
|
83
|
+
VERSION = "2.37.3"
|
|
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()}")
|
|
@@ -534,5 +534,5 @@ def quit_resolve() -> Dict[str, Any]:
|
|
|
534
534
|
resolve = get_resolve()
|
|
535
535
|
if resolve is None:
|
|
536
536
|
return {"error": "Not connected to DaVinci Resolve"}
|
|
537
|
-
|
|
537
|
+
resolve.Quit()
|
|
538
538
|
return {"success": True, "message": "DaVinci Resolve is quitting"}
|
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.37.
|
|
14
|
+
VERSION = "2.37.3"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -53,6 +53,7 @@ from src.utils.update_check import (
|
|
|
53
53
|
check_for_updates,
|
|
54
54
|
clear_update_prompt_preferences,
|
|
55
55
|
get_cached_update_status,
|
|
56
|
+
get_update_channel,
|
|
56
57
|
get_update_mode,
|
|
57
58
|
ignore_update_version,
|
|
58
59
|
set_update_mode,
|
|
@@ -78,6 +79,7 @@ from src.utils.media_analysis import (
|
|
|
78
79
|
load_report as load_media_analysis_report,
|
|
79
80
|
query_analysis_index,
|
|
80
81
|
resolve_output_root as resolve_media_analysis_output_root,
|
|
82
|
+
short_hash,
|
|
81
83
|
slugify,
|
|
82
84
|
summarize_reports as summarize_media_analysis_reports,
|
|
83
85
|
)
|
|
@@ -6942,11 +6944,22 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
|
6942
6944
|
|
|
6943
6945
|
|
|
6944
6946
|
def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
|
|
6947
|
+
# Atomic replace: a crash mid-write must not truncate the file, because the
|
|
6948
|
+
# reader resets to {} on corruption and the next save would then wipe every
|
|
6949
|
+
# remaining user preference.
|
|
6945
6950
|
path = _media_analysis_preferences_path()
|
|
6946
6951
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6952
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
6953
|
+
try:
|
|
6954
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
6955
|
+
json.dump(preferences, handle, indent=2, sort_keys=True)
|
|
6956
|
+
handle.write("\n")
|
|
6957
|
+
os.replace(tmp_path, path)
|
|
6958
|
+
finally:
|
|
6959
|
+
try:
|
|
6960
|
+
os.remove(tmp_path)
|
|
6961
|
+
except OSError:
|
|
6962
|
+
pass
|
|
6950
6963
|
|
|
6951
6964
|
|
|
6952
6965
|
def _setup_text_key(value: Any) -> str:
|
|
@@ -11551,6 +11564,11 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
11551
11564
|
)
|
|
11552
11565
|
except (OSError, FileNotFoundError) as exc:
|
|
11553
11566
|
return _err(f"Failed to launch control panel: {type(exc).__name__}: {exc}")
|
|
11567
|
+
finally:
|
|
11568
|
+
# The child holds its own copy of the fd; keeping ours open would leak
|
|
11569
|
+
# one descriptor per panel launch.
|
|
11570
|
+
if log_handle is not subprocess.DEVNULL:
|
|
11571
|
+
log_handle.close()
|
|
11554
11572
|
|
|
11555
11573
|
# Verify the child actually came up. Bind errors (port in use), import
|
|
11556
11574
|
# failures, etc. would otherwise leave us reporting "launched" while the
|
|
@@ -11671,7 +11689,7 @@ def _resolve_save_state() -> Dict[str, Any]:
|
|
|
11671
11689
|
state["current_folder_name"] = current_folder.GetName()
|
|
11672
11690
|
except Exception:
|
|
11673
11691
|
pass
|
|
11674
|
-
token = short_hash(json.dumps(state, sort_keys=True, default=str), length=12)
|
|
11692
|
+
token = short_hash(json.dumps(state, sort_keys=True, default=str), length=12)
|
|
11675
11693
|
_RESOLVE_STATE_SNAPSHOTS[token] = state
|
|
11676
11694
|
# Prune old snapshots (keep last 20)
|
|
11677
11695
|
if len(_RESOLVE_STATE_SNAPSHOTS) > 20:
|
|
@@ -12268,11 +12286,14 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
12268
12286
|
if current_name == name:
|
|
12269
12287
|
if not p.get("close_current", False):
|
|
12270
12288
|
return _err("Refusing to delete the currently open project; pass close_current=True")
|
|
12271
|
-
if p.get("save_current", True)
|
|
12272
|
-
pm.SaveProject()
|
|
12289
|
+
saved = bool(pm.SaveProject()) if p.get("save_current", True) else None
|
|
12273
12290
|
closed = bool(pm.CloseProject(current))
|
|
12274
12291
|
if not closed:
|
|
12275
12292
|
return _err(f"Failed to close current project '{name}' before delete")
|
|
12293
|
+
result = {"success": bool(pm.DeleteProject(name))}
|
|
12294
|
+
if saved is False:
|
|
12295
|
+
result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
|
|
12296
|
+
return result
|
|
12276
12297
|
return {"success": bool(pm.DeleteProject(name))}
|
|
12277
12298
|
|
|
12278
12299
|
|
|
@@ -14757,10 +14778,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
14757
14778
|
prefs["resolve_ai_governance_preset"] = preset
|
|
14758
14779
|
if isinstance(p.get("overrides"), dict):
|
|
14759
14780
|
prefs["resolve_ai_governance_overrides"] = p["overrides"]
|
|
14760
|
-
|
|
14761
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
14762
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
14763
|
-
json.dump(prefs, fh, indent=2)
|
|
14781
|
+
_write_media_analysis_preferences(prefs)
|
|
14764
14782
|
return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
|
|
14765
14783
|
if action == "set_caps_preset":
|
|
14766
14784
|
preset = (p.get("preset") or "").strip().lower()
|
|
@@ -14772,10 +14790,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
14772
14790
|
prefs["analysis_caps_preset"] = preset
|
|
14773
14791
|
if isinstance(p.get("overrides"), dict):
|
|
14774
14792
|
prefs["analysis_caps_overrides"] = p["overrides"]
|
|
14775
|
-
|
|
14776
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
14777
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
14778
|
-
json.dump(prefs, fh, indent=2)
|
|
14793
|
+
_write_media_analysis_preferences(prefs)
|
|
14779
14794
|
return {"success": True, "preset": preset, "overrides": prefs.get("analysis_caps_overrides") or {}}
|
|
14780
14795
|
if action == "get_usage":
|
|
14781
14796
|
from src.utils import analysis_caps as _ac
|
|
@@ -16097,7 +16112,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
16097
16112
|
if mp_err:
|
|
16098
16113
|
return mp_err
|
|
16099
16114
|
return _fairlight_boundary_report(proj, mp, tl, p)
|
|
16100
|
-
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
16115
|
+
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report","clip_where","action_help",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
16101
16116
|
|
|
16102
16117
|
|
|
16103
16118
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -18016,7 +18031,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
18016
18031
|
return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
|
|
18017
18032
|
elif action == "regenerate_magic_mask":
|
|
18018
18033
|
return {"success": bool(item.RegenerateMagicMask())}
|
|
18019
|
-
return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask",*_COLOR_GRADE_KERNEL_ACTIONS])
|
|
18034
|
+
return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask","action_help",*_COLOR_GRADE_KERNEL_ACTIONS])
|
|
18020
18035
|
|
|
18021
18036
|
|
|
18022
18037
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -18400,7 +18415,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
18400
18415
|
if blocked:
|
|
18401
18416
|
return blocked
|
|
18402
18417
|
return {"success": bool(g.ResetAllGrades())}
|
|
18403
|
-
return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades"])
|
|
18418
|
+
return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades","action_help"])
|
|
18404
18419
|
|
|
18405
18420
|
|
|
18406
18421
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -21155,7 +21170,7 @@ def _resource_mcp_version() -> Dict[str, Any]:
|
|
|
21155
21170
|
"""Server version, build, and update channel. Pure read — no Resolve required."""
|
|
21156
21171
|
return {
|
|
21157
21172
|
"version": VERSION,
|
|
21158
|
-
"channel":
|
|
21173
|
+
"channel": get_update_channel(),
|
|
21159
21174
|
}
|
|
21160
21175
|
|
|
21161
21176
|
|
|
@@ -369,7 +369,7 @@ def _extract_analysis_run_id(params: Optional[Dict[str, Any]], project_root: Opt
|
|
|
369
369
|
return str(rid)
|
|
370
370
|
if project_root:
|
|
371
371
|
try:
|
|
372
|
-
timeout = float(
|
|
372
|
+
timeout = float(_read_preference("versioning_auto_run_idle_timeout_seconds", default=90))
|
|
373
373
|
except Exception:
|
|
374
374
|
timeout = 90.0
|
|
375
375
|
try:
|
|
@@ -631,13 +631,22 @@ def _read_state(path: Path) -> Dict[str, Any]:
|
|
|
631
631
|
|
|
632
632
|
|
|
633
633
|
def _write_state(path: Path, result: Mapping[str, Any]) -> None:
|
|
634
|
+
# Atomic replace: _read_state resets to {} on a corrupt file, so a crash
|
|
635
|
+
# mid-write would silently drop snooze/ignore preferences.
|
|
636
|
+
tmp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}")
|
|
634
637
|
try:
|
|
635
638
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
636
|
-
with
|
|
639
|
+
with tmp_path.open("w", encoding="utf-8") as handle:
|
|
637
640
|
json.dump(result, handle, indent=2, sort_keys=True)
|
|
638
641
|
handle.write("\n")
|
|
642
|
+
os.replace(tmp_path, path)
|
|
639
643
|
except OSError:
|
|
640
644
|
return
|
|
645
|
+
finally:
|
|
646
|
+
try:
|
|
647
|
+
os.remove(tmp_path)
|
|
648
|
+
except OSError:
|
|
649
|
+
pass
|
|
641
650
|
|
|
642
651
|
|
|
643
652
|
def _set_cached_status(result: Mapping[str, Any]) -> None:
|