davinci-resolve-mcp 3.2.0 → 3.2.2

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,86 @@
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 v3.2.2 — an analysis root that is deleted is actually let go of
6
+
7
+ Contributed by @Dev-next-gen (#228), generalised to the second site and to the
8
+ connection cache underneath both.
9
+
10
+ ### Fixed
11
+
12
+ - **`cleanup_artifacts(frames_only=false)` reported success whether or not it
13
+ removed anything.** The analysis root contains
14
+ `_soul/timeline_brain.sqlite`, which `timeline_brain_db` keeps open in a
15
+ process-wide cache for the life of the server. Nothing let go of it before
16
+ the `shutil.rmtree`, and the rmtree runs with `ignore_errors=True`, so both
17
+ failure modes were swallowed. On Windows the open handle makes the DB
18
+ undeletable: the root survives with `_soul/` and the brain-edit history still
19
+ in it while the tool returns `{"success": true}`. On POSIX the root is
20
+ removed but the stale connection stays cached, so the next write for that
21
+ project goes to a file with no directory entry and is lost — the dashboard,
22
+ which opens the path fresh, sees nothing. `timeline_brain_db.close()` now
23
+ releases one project's connection, and the cleanup returns `success: false`
24
+ if the root is still on disk afterwards. (#228)
25
+ - **The same bug at a second site.** A `session_only` run without
26
+ `keep_artifacts` ingests every report into the brain DB under its output root
27
+ and then deletes that root, with the connection still cached. Because each
28
+ such run gets a fresh temp root, the cache accumulated one dead connection
29
+ per run. Both sites now go through one helper, and
30
+ `artifacts_cleaned_up` reports whether the removal happened rather than that
31
+ it was attempted.
32
+ - **`close()` released nothing when the root was spelled differently.** The
33
+ connection cache keyed on the caller's spelling of the path, and callers
34
+ disagree by construction: `media_analysis` realpaths a root before using it,
35
+ while its own callers pass what the user typed. On macOS that alone was
36
+ enough — every temp root under `/var/folders` is a symlink to
37
+ `/private/var/folders` — so `close()` computed a key that was never in the
38
+ cache, popped nothing, and the fix above silently did not apply. Two
39
+ spellings of one root also opened two connections to one SQLite file. The
40
+ cache now keys on the resolved DB path.
41
+
42
+ ### Release process
43
+
44
+ - **`tests.test_release_surface_drift` is now in the documented gate list.** It
45
+ asserts the README badge, the `README.zh-CN.md` badge and translation line,
46
+ and a `CHANGELOG.md` entry all match `package.json` — and it was the one
47
+ version-surface check missing from `docs/process/release-process.md`. v3.2.1
48
+ shipped with a zh-CN badge still reading v3.2.0 because every documented gate
49
+ passed while none of them looks at a version surface. That badge is corrected
50
+ here.
51
+
52
+ ## What's New in v3.2.1 — three correctness fixes to the LUT tool
53
+
54
+ Contributed by @Dev-next-gen (#225, #226, #227), each found by reading the v3.2.0
55
+ `lut` tool rather than by hitting it in use.
56
+
57
+ ### Fixed
58
+
59
+ - **`install` reported `overwritten: true` on a fresh write.** The flag was
60
+ `bool(overwrite and payload is not None)`, and `payload` can never be `None`
61
+ where that line runs, so the field handed the caller's own `overwrite`
62
+ argument back instead of an observation. An install passing `overwrite=true`
63
+ for idempotence, landing on an empty `MCP/`, was reported as having replaced
64
+ existing work. `execution_lifecycle` rates `lut install` MEDIUM precisely
65
+ because it "can replace with overwrite=true", so this flag is what a caller
66
+ and the execution trace read to learn whether an install destroyed anything.
67
+ It is now the `os.path.exists` observation already taken one line above — the
68
+ measured pre-state, not the permission. (#225)
69
+ - **`install(source_path=...)` could not copy a binary LUT.** The copy went
70
+ through a UTF-8 text round-trip, so the two binary extensions this server
71
+ advertises — `.dat` and `.olut` — raised `UnicodeDecodeError` before anything
72
+ was written, surfacing as `LUT_ERROR`. So did an ordinary `.cube` whose vendor
73
+ wrote its `TITLE` line in latin-1. The file-copy branch is now byte-exact; the
74
+ text branch (`source=`) is unchanged. (#226)
75
+ - **`list` marked siblings of the writable subdir as writable.** The `writable`
76
+ flag used `realpath(current).startswith(writable_root)`, a string prefix with
77
+ no separator, so `MCP_old/` left by a hand backup or a vendor pack unpacking
78
+ as `MCPresets/` cleared it. The listing then contradicted the only tool that
79
+ consumes the flag: `remove` resolves names inside `MCP/` and refused the very
80
+ `set_lut_path` the listing had just handed out. It now compares by path
81
+ segment with `commonpath`, which is what `_is_relative_to` and `resolve_writable`
82
+ already use elsewhere in this repo. Nothing that worked before stops working —
83
+ the flag only flips for paths `remove` was already refusing. (#227)
84
+
5
85
  ## What's New in v3.2.0 — LUT files: find them, install them, remove them, gated
6
86
 
7
87
  Contributed by @legionsound (#223), live-validated on Studio 21.1.0.14.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v3.2.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.2.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -75,7 +75,7 @@ venv/bin/python scripts/audit_api_parity.py
75
75
  venv/bin/python scripts/gen_api_limitations.py --check
76
76
  venv/bin/python scripts/audit_readwrite_symmetry.py --check
77
77
  node scripts/agent-rules/generate.mjs --check
78
- venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
78
+ venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift tests.test_release_surface_drift
79
79
  node bin/davinci-resolve-mcp.mjs --help
80
80
  node bin/davinci-resolve-mcp.mjs --version
81
81
  npm pack --dry-run
@@ -89,6 +89,13 @@ regeneration is in the working tree when the test reads it — that ordering is
89
89
  the check is a regeneration followed by a test, not a `git diff --exit-code`,
90
90
  which would fire on the release bump's own legitimate change.
91
91
 
92
+ `test_release_surface_drift` is the gate on the version bump itself: it asserts
93
+ the README badge, the `README.zh-CN.md` badge and its `本翻译对应 vX.Y.Z` line, and
94
+ a `CHANGELOG.md` entry all match `package.json`. It was not in this list until
95
+ v3.2.2, and v3.2.1 shipped with a zh-CN badge still reading v3.2.0 as a direct
96
+ result — every other gate passed, because none of them looks at a version
97
+ surface. Run it before tagging, not after.
98
+
92
99
  `test_duplicate_definitions` asserts no module-level name is defined twice under
93
100
  `src/`. A second `def foo` silently replaces the first, and in a module the size
94
101
  of `src/server.py` the two can be thousands of lines apart with different
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.2.0"
40
+ VERSION = "3.2.2"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.2.0"
90
+ VERSION = "3.2.2"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "3.2.0"
14
+ VERSION = "3.2.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -50,6 +50,20 @@ def writable_dir() -> str:
50
50
  return os.path.join(master_lut_dir(), WRITABLE_SUBDIR)
51
51
 
52
52
 
53
+ def _is_within(path: str, parent: str) -> bool:
54
+ """Whether ``path`` is ``parent`` itself or sits under it, compared by segment.
55
+
56
+ ``commonpath`` is the comparison the rest of the repo uses for confinement
57
+ checks (`media_analysis._is_relative_to`), and it is the only one that is
58
+ correct here: a string prefix test also accepts a sibling whose name merely
59
+ starts with the same characters.
60
+ """
61
+ try:
62
+ return os.path.commonpath([path, parent]) == parent
63
+ except (ValueError, OSError):
64
+ return False
65
+
66
+
53
67
  def normalize_relative(name: str, *, default_ext: Optional[str] = None) -> str:
54
68
  """Validate a caller-supplied LUT name and return it as a POSIX relative path.
55
69
 
@@ -123,7 +137,13 @@ def list_luts(subdir: Optional[str] = None) -> Dict[str, Any]:
123
137
  except OSError:
124
138
  size = None
125
139
  try:
126
- is_writable = os.path.realpath(current).startswith(writable_root)
140
+ # Segment-wise, not a string prefix: a sibling of the writable
141
+ # subdir whose name starts with it -- MCP_old/ after a manual
142
+ # backup, MCPresets/ from a vendor pack -- cleared
143
+ # `startswith(writable_root)` and was listed as writable, while
144
+ # `remove` resolves names under MCP/ and refuses the very path
145
+ # the listing handed back.
146
+ is_writable = _is_within(os.path.realpath(current), writable_root)
127
147
  except OSError:
128
148
  is_writable = False
129
149
  found.append({
@@ -154,28 +174,45 @@ def install_lut(name: str, *, source: Optional[str] = None,
154
174
  if (source is None) == (source_path is None):
155
175
  raise LutPathError("provide exactly one of source (text) or source_path (a file to copy)")
156
176
  absolute, set_lut_path = resolve_writable(name)
157
- if os.path.exists(absolute) and not overwrite:
177
+ existed = os.path.exists(absolute)
178
+ if existed and not overwrite:
158
179
  raise LutPathError(
159
180
  f"{set_lut_path} already exists. Pass overwrite=true to replace it."
160
181
  )
161
182
  if source_path is not None:
162
183
  if not os.path.isfile(source_path):
163
184
  raise LutPathError(f"source_path not found: {source_path}")
164
- with open(source_path, "r", encoding="utf-8", errors="strict") as handle:
185
+ # A copy has to be byte-exact. Two of the extensions this server
186
+ # advertises -- .dat and .olut -- are binary, and a .cube carries its
187
+ # TITLE in whatever encoding the vendor wrote it in, so decoding the
188
+ # source as UTF-8 turned "install the LUT I already have on disk" into
189
+ # a UnicodeDecodeError surfacing as LUT_ERROR.
190
+ with open(source_path, "rb") as handle:
165
191
  payload = handle.read()
166
192
  else:
167
193
  payload = source
168
194
  if not payload.strip():
169
195
  raise LutPathError("refusing to install an empty LUT")
170
196
  os.makedirs(os.path.dirname(absolute), exist_ok=True)
171
- with open(absolute, "w", encoding="utf-8") as handle:
172
- handle.write(payload)
197
+ if isinstance(payload, bytes):
198
+ with open(absolute, "wb") as handle:
199
+ handle.write(payload)
200
+ else:
201
+ with open(absolute, "w", encoding="utf-8") as handle:
202
+ handle.write(payload)
173
203
  return {
174
204
  "success": True,
175
205
  "path": absolute,
176
206
  "set_lut_path": set_lut_path,
177
207
  "bytes": os.path.getsize(absolute),
178
- "overwritten": bool(overwrite and payload is not None),
208
+ # Whether a file was actually replaced, not whether the caller allowed
209
+ # it: `payload is not None` is always true here, so this reported a
210
+ # replacement for every overwrite=true install, including the ones that
211
+ # landed on an empty MCP/. This flag is the record of what an install
212
+ # destroyed -- execution_lifecycle rates `lut install` on the fact that
213
+ # it "can replace with overwrite=true" -- so it has to be the observed
214
+ # pre-state, not the permission.
215
+ "overwritten": existed,
179
216
  "note": ("Call project_settings(action='refresh_luts') so Resolve picks up "
180
217
  "the new file before applying it."),
181
218
  }
@@ -2590,6 +2590,48 @@ def _read_json(path: str) -> Dict[str, Any]:
2590
2590
  return json.load(f)
2591
2591
 
2592
2592
 
2593
+ def _drop_brain_db_and_rmtree(project_root: str, cleanup_root: str) -> bool:
2594
+ """Delete `cleanup_root`, releasing `project_root`'s brain DB first.
2595
+
2596
+ `<project_root>/_soul/timeline_brain.sqlite` is held open in a
2597
+ process-wide cache for the life of the server, and nothing else lets go of
2598
+ it. Deleting the root underneath that handle fails differently on each
2599
+ platform and silently on both, because rmtree runs with
2600
+ ``ignore_errors=True``:
2601
+
2602
+ - Windows refuses to delete the open file, so the root survives with
2603
+ `_soul/` and the brain-edit history still in it while the caller is told
2604
+ it is gone.
2605
+ - POSIX unlinks it, but the cache then hands the next writer a connection
2606
+ to a file with no directory entry, so the write lands nowhere.
2607
+
2608
+ Returns whether `cleanup_root` is actually gone afterwards, so callers can
2609
+ report a removal that happened rather than one they attempted.
2610
+ """
2611
+ from src.utils import timeline_brain_db as _brain_db
2612
+
2613
+ _brain_db.close(project_root)
2614
+ shutil.rmtree(cleanup_root, ignore_errors=True)
2615
+ return not os.path.isdir(cleanup_root)
2616
+
2617
+
2618
+ def _release_session_root(manifest: Dict[str, Any], project_root: str,
2619
+ cleanup_root: str) -> bool:
2620
+ """Session-only artifact cleanup: same rule as `cleanup_artifacts`.
2621
+
2622
+ A session-only run ingests every report into the brain DB under
2623
+ `project_root` and then throws the root away, and each run gets a fresh
2624
+ temp root -- so without the release the cache accumulates one dead
2625
+ connection per run.
2626
+ """
2627
+ removed = _drop_brain_db_and_rmtree(project_root, cleanup_root)
2628
+ if not removed:
2629
+ manifest.setdefault("memory_layer_warnings", []).append(
2630
+ f"Could not fully remove the session analysis root: {cleanup_root}"
2631
+ )
2632
+ return removed
2633
+
2634
+
2593
2635
  def _ingest_report_into_db(project_root: str, report: Dict[str, Any], clip_dir: Optional[str]) -> Dict[str, Any]:
2594
2636
  """C1 — write a report into the DB-canonical store (rows in a transaction).
2595
2637
 
@@ -5971,8 +6013,9 @@ async def execute_plan_async(
5971
6013
  and _is_relative_to(output_root, candidate)
5972
6014
  ):
5973
6015
  cleanup_root = candidate
5974
- shutil.rmtree(cleanup_root, ignore_errors=True)
5975
- manifest["artifacts_cleaned_up"] = True
6016
+ manifest["artifacts_cleaned_up"] = _release_session_root(
6017
+ manifest, output_root, cleanup_root
6018
+ )
5976
6019
  manifest["artifact_cleanup_root"] = cleanup_root
5977
6020
 
5978
6021
  return manifest
@@ -7057,7 +7100,14 @@ def cleanup_artifacts(project_root: str, *, frames_only: bool = True) -> Dict[st
7057
7100
  shutil.rmtree(full, ignore_errors=True)
7058
7101
  removed.append(full)
7059
7102
  else:
7060
- shutil.rmtree(root, ignore_errors=True)
7103
+ # The whole root goes, including `_soul/timeline_brain.sqlite`.
7104
+ if not _drop_brain_db_and_rmtree(root, root):
7105
+ return {
7106
+ "success": False,
7107
+ "error": f"Could not fully remove the project analysis root: {root}",
7108
+ "removed": removed,
7109
+ "frames_only": frames_only,
7110
+ }
7061
7111
  removed.append(root)
7062
7112
  return {"success": True, "removed": removed, "frames_only": frames_only}
7063
7113
 
@@ -50,6 +50,26 @@ def db_path_for_project(project_root: str) -> str:
50
50
  return os.path.join(project_root, SOUL_DIRNAME, DB_FILENAME)
51
51
 
52
52
 
53
+ def _cache_key(project_root: str) -> str:
54
+ """The connection-cache key for a project root: its DB path, realpath'd.
55
+
56
+ Two spellings of one root must not become two cached connections to one
57
+ file. They do without this, because callers disagree about spelling by
58
+ construction -- `media_analysis` normalizes a root through `realpath`
59
+ before using it, while its own callers pass whatever the user typed. On
60
+ macOS that alone is enough: every temp root under `/var/folders/...` is a
61
+ symlink to `/private/var/folders/...`.
62
+
63
+ The damage is not just a duplicate. `close()` pops by key, so a mismatch
64
+ makes it silently no-op and leave the connection it was called to release
65
+ -- which is the whole point of calling it before deleting the root.
66
+
67
+ `realpath` on a path that does not exist yet resolves the ancestors that do
68
+ and leaves the rest literal, which is what a not-yet-created DB needs.
69
+ """
70
+ return os.path.realpath(db_path_for_project(project_root))
71
+
72
+
53
73
  def _ensure_parent_dir(path: str) -> None:
54
74
  os.makedirs(os.path.dirname(path), exist_ok=True)
55
75
 
@@ -224,7 +244,7 @@ def connect(project_root: str) -> sqlite3.Connection:
224
244
  """
225
245
  if not project_root:
226
246
  raise ValueError("project_root is required")
227
- path = db_path_for_project(project_root)
247
+ path = _cache_key(project_root)
228
248
  with _CONNECTION_LOCK:
229
249
  existing = _CONNECTIONS.get(path)
230
250
  if existing is not None:
@@ -250,6 +270,25 @@ def close_all() -> None:
250
270
  _CONNECTIONS.clear()
251
271
 
252
272
 
273
+ def close(project_root: str) -> None:
274
+ """Drop and close the cached connection for `project_root`, if any.
275
+
276
+ Callers that are about to delete or move a project's analysis root need
277
+ this. On Windows the open handle makes `_soul/timeline_brain.sqlite`
278
+ undeletable; on POSIX the cache would otherwise hand the next writer a
279
+ connection to a file that no longer has a directory entry, so the write
280
+ lands nowhere.
281
+ """
282
+ path = _cache_key(project_root)
283
+ with _CONNECTION_LOCK:
284
+ conn = _CONNECTIONS.pop(path, None)
285
+ if conn is not None:
286
+ try:
287
+ conn.close()
288
+ except sqlite3.Error:
289
+ pass
290
+
291
+
253
292
  @contextmanager
254
293
  def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
255
294
  """Context manager wrapping a write transaction.
@@ -291,14 +330,8 @@ def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
291
330
 
292
331
  def reset_for_test(project_root: str) -> None:
293
332
  """Drop + recreate every table. Tests only."""
294
- path = db_path_for_project(project_root)
295
- with _CONNECTION_LOCK:
296
- conn = _CONNECTIONS.pop(path, None)
297
- if conn is not None:
298
- try:
299
- conn.close()
300
- except sqlite3.Error:
301
- pass
333
+ path = _cache_key(project_root)
334
+ close(project_root)
302
335
  for suffix in ("", "-wal", "-shm"):
303
336
  try:
304
337
  os.remove(path + suffix)