davinci-resolve-mcp 2.37.2 → 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,34 @@
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
+
5
33
  ## What's New in v2.37.2
6
34
 
7
35
  Static-audit fixes — four undefined-name references that silently fell back
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.2-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.2"
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.2",
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.2"
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.2"
14
+ VERSION = "2.37.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -6944,11 +6944,22 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
6944
6944
 
6945
6945
 
6946
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.
6947
6950
  path = _media_analysis_preferences_path()
6948
6951
  os.makedirs(os.path.dirname(path), exist_ok=True)
6949
- with open(path, "w", encoding="utf-8") as handle:
6950
- json.dump(preferences, handle, indent=2, sort_keys=True)
6951
- 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
6952
6963
 
6953
6964
 
6954
6965
  def _setup_text_key(value: Any) -> str:
@@ -11553,6 +11564,11 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
11553
11564
  )
11554
11565
  except (OSError, FileNotFoundError) as exc:
11555
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()
11556
11572
 
11557
11573
  # Verify the child actually came up. Bind errors (port in use), import
11558
11574
  # failures, etc. would otherwise leave us reporting "launched" while the
@@ -12270,11 +12286,14 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
12270
12286
  if current_name == name:
12271
12287
  if not p.get("close_current", False):
12272
12288
  return _err("Refusing to delete the currently open project; pass close_current=True")
12273
- if p.get("save_current", True):
12274
- pm.SaveProject()
12289
+ saved = bool(pm.SaveProject()) if p.get("save_current", True) else None
12275
12290
  closed = bool(pm.CloseProject(current))
12276
12291
  if not closed:
12277
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
12278
12297
  return {"success": bool(pm.DeleteProject(name))}
12279
12298
 
12280
12299
 
@@ -14759,10 +14778,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14759
14778
  prefs["resolve_ai_governance_preset"] = preset
14760
14779
  if isinstance(p.get("overrides"), dict):
14761
14780
  prefs["resolve_ai_governance_overrides"] = p["overrides"]
14762
- path = _media_analysis_preferences_path()
14763
- os.makedirs(os.path.dirname(path), exist_ok=True)
14764
- with open(path, "w", encoding="utf-8") as fh:
14765
- json.dump(prefs, fh, indent=2)
14781
+ _write_media_analysis_preferences(prefs)
14766
14782
  return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
14767
14783
  if action == "set_caps_preset":
14768
14784
  preset = (p.get("preset") or "").strip().lower()
@@ -14774,10 +14790,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14774
14790
  prefs["analysis_caps_preset"] = preset
14775
14791
  if isinstance(p.get("overrides"), dict):
14776
14792
  prefs["analysis_caps_overrides"] = p["overrides"]
14777
- path = _media_analysis_preferences_path()
14778
- os.makedirs(os.path.dirname(path), exist_ok=True)
14779
- with open(path, "w", encoding="utf-8") as fh:
14780
- json.dump(prefs, fh, indent=2)
14793
+ _write_media_analysis_preferences(prefs)
14781
14794
  return {"success": True, "preset": preset, "overrides": prefs.get("analysis_caps_overrides") or {}}
14782
14795
  if action == "get_usage":
14783
14796
  from src.utils import analysis_caps as _ac
@@ -16099,7 +16112,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
16099
16112
  if mp_err:
16100
16113
  return mp_err
16101
16114
  return _fairlight_boundary_report(proj, mp, tl, p)
16102
- 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])
16103
16116
 
16104
16117
 
16105
16118
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -18018,7 +18031,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
18018
18031
  return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
18019
18032
  elif action == "regenerate_magic_mask":
18020
18033
  return {"success": bool(item.RegenerateMagicMask())}
18021
- 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])
18022
18035
 
18023
18036
 
18024
18037
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -18402,7 +18415,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
18402
18415
  if blocked:
18403
18416
  return blocked
18404
18417
  return {"success": bool(g.ResetAllGrades())}
18405
- 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"])
18406
18419
 
18407
18420
 
18408
18421
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -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: