davinci-resolve-mcp 2.37.3 → 2.38.0

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,23 @@
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.38.0
6
+
7
+ Busy gate for long DaVinci Resolve operations — the first piece of the
8
+ concurrency design for the stdio + networked two-instance setup.
9
+
10
+ - **Added** cross-process registration for long synchronous Resolve calls
11
+ (timeline export/import, scene-cut detection, subtitle generation,
12
+ Dolby Vision analysis, folder/clip audio transcription). A tool call that
13
+ arrives while one is running now waits up to 5 seconds and then returns a
14
+ structured `RESOLVE_BUSY` error (new `busy` category, retryable, with
15
+ `state.busy_with` and `state.age_seconds`) instead of hanging silently
16
+ inside the scripting bridge. Stale registrations from crashed operations
17
+ are ignored automatically; an operation never gates its own thread.
18
+ - Design decisions recorded: single-editor/multi-client is the supported
19
+ concurrency target; confirm tokens stay per-instance; actor identity is
20
+ deferred to the governance phase.
21
+
5
22
  ## What's New in v2.37.3
6
23
 
7
24
  Deep-audit fixes, rounds two and three: agent-facing action discovery,
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.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.38.0-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.3"
38
+ VERSION = "2.38.0"
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.3",
3
+ "version": "2.38.0",
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.37.3"
83
+ VERSION = "2.38.0"
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/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.3"
14
+ VERSION = "2.38.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -84,6 +84,8 @@ from src.utils.media_analysis import (
84
84
  summarize_reports as summarize_media_analysis_reports,
85
85
  )
86
86
  from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events
87
+ from src.utils import resolve_busy
88
+ from src.utils.resolve_busy import long_resolve_op
87
89
  from src.utils.media_analysis_jobs import (
88
90
  MEDIA_EXTENSIONS,
89
91
  batch_job_status as media_analysis_batch_job_status,
@@ -830,6 +832,7 @@ ERROR_CATEGORIES = (
830
832
  "wrong_page", # action requires a specific page (Color, Edit, Fairlight…)
831
833
  "invalid_input", # caller-side fixable (bad param shape, unknown enum)
832
834
  "resolve_api_failed", # Resolve returned None/False for unclear reasons
835
+ "busy", # another long Resolve op holds the bridge; retry later
833
836
  "destructive_blocked", # strict-mode/confirm-token refusal
834
837
  "pending_user_decision", # confirm_token required
835
838
  "unsupported", # feature/version/method not available
@@ -846,6 +849,7 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
846
849
  "wrong_page": True, # trivial caller fix; retry after page switch
847
850
  "invalid_input": False, # caller fix required
848
851
  "resolve_api_failed": True, # often transient; retry once
852
+ "busy": True, # another long Resolve op holds the bridge
849
853
  "destructive_blocked": False, # user decision required
850
854
  "pending_user_decision": False, # confirm_token required
851
855
  "unsupported": False, # API/version mismatch
@@ -1671,6 +1675,19 @@ def _annotation_boundary_report(tl, p: Dict[str, Any]):
1671
1675
  }
1672
1676
 
1673
1677
  def _check():
1678
+ busy = resolve_busy.wait_until_free()
1679
+ if busy:
1680
+ return None, None, _err(
1681
+ f"Resolve is busy with a long operation: {busy['label']} "
1682
+ f"(running for {busy['age_seconds']}s). Retry after it completes.",
1683
+ code="RESOLVE_BUSY", category="busy",
1684
+ remediation="Wait for the named operation to finish, then retry this call.",
1685
+ state={
1686
+ "busy_with": busy["label"],
1687
+ "age_seconds": busy["age_seconds"],
1688
+ "same_process": busy["same_process"],
1689
+ },
1690
+ )
1674
1691
  resolve = get_resolve()
1675
1692
  if resolve is None:
1676
1693
  return None, None, _err(
@@ -4916,7 +4933,8 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
4916
4933
  spec = _timeline_export_spec(p, resolve)
4917
4934
  if p.get("dry_run"):
4918
4935
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
4919
- success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4936
+ with long_resolve_op("timeline.export_timeline_checked"):
4937
+ success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4920
4938
  files = []
4921
4939
  primary_file = path
4922
4940
  if success and os.path.isdir(path):
@@ -4969,7 +4987,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
4969
4987
  tl_existing = proj.GetTimelineByIndex(index)
4970
4988
  if tl_existing:
4971
4989
  before_ids.add(str(tl_existing.GetUniqueId()))
4972
- imported = mp.ImportTimelineFromFile(path, options)
4990
+ with long_resolve_op("timeline.import_timeline_checked"):
4991
+ imported = mp.ImportTimelineFromFile(path, options)
4973
4992
  if not imported:
4974
4993
  return _err("Failed to import timeline")
4975
4994
  imported_id = str(imported.GetUniqueId())
@@ -5937,7 +5956,8 @@ def _subtitle_generation_probe(tl, p: Dict[str, Any]):
5937
5956
  return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
5938
5957
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
5939
5958
  return _err("CreateSubtitlesFromAudio unavailable")
5940
- return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
5959
+ with long_resolve_op("timeline.create_subtitles_from_audio"):
5960
+ return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
5941
5961
 
5942
5962
 
5943
5963
  def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
@@ -13811,7 +13831,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13811
13831
  elif action == "setup_multicam_timeline":
13812
13832
  return _setup_multicam_timeline(proj, mp, p)
13813
13833
  elif action == "import_timeline":
13814
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13834
+ with long_resolve_op("media_pool.import_timeline"):
13835
+ tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13815
13836
  return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
13816
13837
  elif action == "delete_timelines":
13817
13838
  count = proj.GetTimelineCount()
@@ -14021,8 +14042,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14021
14042
  elif action == "transcribe_audio":
14022
14043
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14023
14044
  if usd is None:
14024
- return {"success": bool(f.TranscribeAudio())}
14025
- return {"success": bool(f.TranscribeAudio(bool(usd)))}
14045
+ with long_resolve_op("folder.transcribe_audio"):
14046
+ return {"success": bool(f.TranscribeAudio())}
14047
+ with long_resolve_op("folder.transcribe_audio"):
14048
+ return {"success": bool(f.TranscribeAudio(bool(usd)))}
14026
14049
  elif action == "clear_transcription":
14027
14050
  return {"success": bool(f.ClearTranscription())}
14028
14051
  elif action == "perform_audio_classification":
@@ -14338,8 +14361,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14338
14361
  elif action == "transcribe_audio":
14339
14362
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14340
14363
  if usd is None:
14341
- return {"success": bool(clip.TranscribeAudio())}
14342
- return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14364
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14365
+ return {"success": bool(clip.TranscribeAudio())}
14366
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14367
+ return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14343
14368
  elif action == "clear_transcription":
14344
14369
  return {"success": bool(clip.ClearTranscription())}
14345
14370
  elif action == "get_transcription":
@@ -15808,7 +15833,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15808
15833
  elif action == "import_into_timeline":
15809
15834
  return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
15810
15835
  elif action == "export":
15811
- return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15836
+ with long_resolve_op("timeline.export"):
15837
+ return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15812
15838
  elif action == "get_setting":
15813
15839
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
15814
15840
  elif action == "set_setting":
@@ -16249,9 +16275,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16249
16275
  return err
16250
16276
 
16251
16277
  if action == "create_subtitles":
16252
- return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16278
+ with long_resolve_op("timeline_ai.create_subtitles"):
16279
+ return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16253
16280
  elif action == "detect_scene_cuts":
16254
- return {"success": bool(tl.DetectSceneCuts())}
16281
+ with long_resolve_op("timeline_ai.detect_scene_cuts"):
16282
+ return {"success": bool(tl.DetectSceneCuts())}
16255
16283
  elif action == "analyze_dolby_vision":
16256
16284
  clip_ids = p.get("clip_ids", [])
16257
16285
  items = []
@@ -16262,7 +16290,8 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16262
16290
  if it.GetUniqueId() in clip_ids:
16263
16291
  items.append(it)
16264
16292
  analysis_type = p.get("analysis_type")
16265
- return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16293
+ with long_resolve_op("timeline_ai.analyze_dolby_vision"):
16294
+ return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16266
16295
  elif action == "grab_still":
16267
16296
  still = tl.GrabStill()
16268
16297
  return _ok() if still else _err("Failed to grab still")
@@ -0,0 +1,171 @@
1
+ """Cross-process visibility for long synchronous DaVinci Resolve operations.
2
+
3
+ The Resolve scripting bridge executes one call at a time. Most calls return in
4
+ milliseconds, but a handful block for seconds-to-minutes (timeline export and
5
+ import, scene-cut detection, subtitle generation, audio transcription). A
6
+ second tool call issued during one of those — from another thread of this
7
+ server, or from the other instance when stdio and networked servers share one
8
+ Resolve — simply hangs inside fusionscript with no feedback.
9
+
10
+ This module gives those long operations a name and a presence that every
11
+ instance can see, so the Resolve preflight can wait briefly and then return a
12
+ structured "busy" answer instead of hanging:
13
+
14
+ with long_resolve_op("timeline.export"):
15
+ tl.Export(...)
16
+
17
+ op = wait_until_free(timeout_seconds=5.0) # None when free
18
+ if op:
19
+ ... return a RESOLVE_BUSY error naming op["label"] ...
20
+
21
+ Registration is advisory and best-effort: a sidecar JSON file in the temp dir
22
+ (same approach as page_lock) carries {label, pid, thread, started_at}. Records
23
+ are ignored when their pid is dead or they exceed MAX_OP_AGE_SECONDS, so a
24
+ crashed operation can never wedge the server. The registering thread is exempt
25
+ from its own gate — long operations call Resolve helpers internally.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import tempfile
32
+ import threading
33
+ import time
34
+ from contextlib import contextmanager
35
+ from typing import Any, Dict, Optional
36
+
37
+ _SIDECAR = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_busy.json")
38
+ # A long op that has run this long is presumed crashed/leaked and ignored.
39
+ MAX_OP_AGE_SECONDS = 2 * 60 * 60
40
+ # Default time a preflight will wait for the bridge to free up before
41
+ # returning a busy error.
42
+ DEFAULT_WAIT_SECONDS = 5.0
43
+ _POLL_SECONDS = 0.25
44
+
45
+ _lock = threading.Lock()
46
+ _local_owner: Optional[int] = None # thread ident of the in-process registrant
47
+
48
+
49
+ def _pid_alive(pid: int) -> bool:
50
+ try:
51
+ os.kill(pid, 0)
52
+ return True
53
+ except ProcessLookupError:
54
+ return False
55
+ except PermissionError:
56
+ return True
57
+ except OSError:
58
+ return False
59
+
60
+
61
+ def _read_record() -> Optional[Dict[str, Any]]:
62
+ try:
63
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
64
+ record = json.load(handle)
65
+ except (OSError, json.JSONDecodeError):
66
+ return None
67
+ if not isinstance(record, dict) or not record.get("label"):
68
+ return None
69
+ started_at = record.get("started_at")
70
+ if not isinstance(started_at, (int, float)):
71
+ return None
72
+ if time.time() - started_at > MAX_OP_AGE_SECONDS:
73
+ return None
74
+ pid = record.get("pid")
75
+ if not isinstance(pid, int) or not _pid_alive(pid):
76
+ return None
77
+ return record
78
+
79
+
80
+ def _write_record(record: Dict[str, Any]) -> None:
81
+ tmp_path = f"{_SIDECAR}.tmp-{os.getpid()}-{threading.get_ident()}"
82
+ try:
83
+ with open(tmp_path, "w", encoding="utf-8") as handle:
84
+ json.dump(record, handle)
85
+ os.replace(tmp_path, _SIDECAR)
86
+ except OSError:
87
+ pass
88
+ finally:
89
+ try:
90
+ os.remove(tmp_path)
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ def _clear_record() -> None:
96
+ record = None
97
+ try:
98
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
99
+ record = json.load(handle)
100
+ except (OSError, json.JSONDecodeError):
101
+ pass
102
+ # Only the owning process removes the sidecar; never clobber another
103
+ # instance's registration.
104
+ if isinstance(record, dict) and record.get("pid") == os.getpid():
105
+ try:
106
+ os.remove(_SIDECAR)
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ def current_long_op() -> Optional[Dict[str, Any]]:
112
+ """The active long operation visible across instances, or None."""
113
+ record = _read_record()
114
+ if record is None:
115
+ return None
116
+ return {
117
+ "label": record.get("label"),
118
+ "pid": record.get("pid"),
119
+ "same_process": record.get("pid") == os.getpid(),
120
+ "started_at": record.get("started_at"),
121
+ "age_seconds": round(max(0.0, time.time() - float(record.get("started_at", 0.0))), 1),
122
+ }
123
+
124
+
125
+ @contextmanager
126
+ def long_resolve_op(label: str):
127
+ """Register a long synchronous Resolve call for its duration."""
128
+ global _local_owner
129
+ ident = threading.get_ident()
130
+ with _lock:
131
+ nested = _local_owner is not None
132
+ if not nested:
133
+ _local_owner = ident
134
+ if not nested:
135
+ _write_record({
136
+ "label": str(label),
137
+ "pid": os.getpid(),
138
+ "thread": ident,
139
+ "started_at": time.time(),
140
+ })
141
+ try:
142
+ yield
143
+ finally:
144
+ if not nested:
145
+ with _lock:
146
+ _local_owner = None
147
+ _clear_record()
148
+
149
+
150
+ def wait_until_free(timeout_seconds: Optional[float] = None) -> Optional[Dict[str, Any]]:
151
+ """Wait briefly for any long op to finish.
152
+
153
+ Returns None when the bridge is free (or becomes free within the timeout),
154
+ otherwise the still-running operation's info. The thread that registered
155
+ the current in-process op is never gated by it. timeout_seconds defaults
156
+ to DEFAULT_WAIT_SECONDS, read at call time so callers/tests can tune it.
157
+ """
158
+ if timeout_seconds is None:
159
+ timeout_seconds = DEFAULT_WAIT_SECONDS
160
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
161
+ while True:
162
+ op = current_long_op()
163
+ if op is None:
164
+ return None
165
+ if op["same_process"]:
166
+ record = _read_record() or {}
167
+ if record.get("thread") == threading.get_ident():
168
+ return None
169
+ if time.monotonic() >= deadline:
170
+ return op
171
+ time.sleep(_POLL_SECONDS)