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 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
- [![Version](https://img.shields.io/badge/version-2.37.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.37.3-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-33%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.37.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.37.1",
3
+ "version": "2.37.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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
- with open(artifacts["analysis_json"], "w", encoding="utf-8") as handle:
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
- with open(path, "w", encoding="utf-8") as handle:
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 {
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.37.1"
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
- result = resolve.Quit()
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.1"
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
- with open(path, "w", encoding="utf-8") as handle:
6948
- json.dump(preferences, handle, indent=2, sort_keys=True)
6949
- handle.write("\n")
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) if "short_hash" in globals() else str(int(time.time() * 1000))
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
- path = _media_analysis_preferences_path()
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
- path = _media_analysis_preferences_path()
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": _update_channel_current() if "_update_channel_current" in globals() else "stable",
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(_preference("versioning_auto_run_idle_timeout_seconds", default=90))
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:
@@ -18,6 +18,7 @@ import subprocess
18
18
  import sys
19
19
  import tempfile
20
20
  import time
21
+ import types
21
22
  import traceback
22
23
  from pathlib import Path
23
24
  from typing import Any, Dict, Iterable, List, Optional, Tuple
@@ -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 path.open("w", encoding="utf-8") as handle:
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: