davinci-resolve-mcp 2.54.0 → 2.54.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,52 @@
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.54.2
6
+
7
+ A destructive-overwrite bug in the installer (issue #71): the MCP client setup
8
+ step could wipe a user's entire editor settings file instead of merging into it.
9
+
10
+ - **Fixed** `install.py` silently destroyed existing client config files whose
11
+ contents weren't strict JSON. `read_json` swallowed `JSONDecodeError` and
12
+ returned `{}`, so the subsequent "merge" wrote a file containing *only* the
13
+ `davinci-resolve` server entry — wiping themes, terminal env vars, LSP
14
+ settings, keybindings, everything else. Zed was the reported victim because
15
+ its `settings.json` ships with `//` comments (JSONC), but the same latent
16
+ risk existed for **every** supported client — VS Code and Continue also accept
17
+ JSONC. The fix is centralized in the single read/merge path so all clients are
18
+ covered:
19
+ - `read_json` now best-effort strips JSONC `//` and `/* */` comments and
20
+ trailing commas (string-aware, so comment markers inside string values are
21
+ preserved), letting commented configs merge cleanly instead of being lost.
22
+ - When a config file exists but still can't be parsed after that, the
23
+ installer **refuses to overwrite it** and tells the user to add the entry
24
+ manually — rather than silently replacing their settings.
25
+ - **Added** regression tests covering JSONC merge, plain-JSON merge,
26
+ refuse-to-overwrite on unparseable files, fresh-file creation, and
27
+ string-aware comment stripping.
28
+
29
+ ## What's New in v2.54.1
30
+
31
+ One more instance of the enum-keyed silent-failure class (issue #70), plus a
32
+ guard so the next one can't ship unnoticed.
33
+
34
+ - **Fixed** the raw `timeline.export` action passed `type`/`subtype` straight to
35
+ `Timeline.Export`, which needs resolved `resolve.EXPORT_*` enum *values* — a
36
+ JSON/MCP caller can't pass a live enum, so the action silently wrote nothing
37
+ for every caller. It now resolves friendly format names (and `EXPORT_*`
38
+ constant names) via the same `_timeline_export_spec` resolver that
39
+ `export_timeline_checked` uses, and reports the resolved `export_type`/
40
+ `export_subtype`.
41
+ - **Fixed** `export_timeline_checked` resolved enum constants against the module
42
+ global `resolve` (which can be `None` and silently degrade the `EXPORT_*` args
43
+ to strings); it now uses `get_resolve()`, matching the issue-#70 lesson.
44
+ - **Added** an `api_truth` ↔ mitigation guard test: every `enum`-tagged catalog
45
+ entry must declare a `mitigation` (the resolver/wrapper functions), each of
46
+ which must exist in `src.server`. The next raw enum passthrough — a documented
47
+ symbol with no real resolver, or a renamed/removed resolver — now fails CI.
48
+ Added a `Timeline.Export` catalog entry and wired `mitigation` onto the
49
+ `AutoSyncAudio`, `CreateSubtitlesFromAudio`, and CloudProject entries.
50
+
5
51
  ## What's New in v2.54.0
6
52
 
7
53
  Hardens the rest of the enum-keyed settings APIs against the same silent-failure
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.54.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.54.2-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-34%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -17,6 +17,7 @@ import argparse
17
17
  import json
18
18
  import os
19
19
  import platform
20
+ import re
20
21
  import shutil
21
22
  import subprocess
22
23
  import sys
@@ -35,7 +36,7 @@ from src.utils.update_check import (
35
36
 
36
37
  # ─── Version ──────────────────────────────────────────────────────────────────
37
38
 
38
- VERSION = "2.54.0"
39
+ VERSION = "2.54.2"
39
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
@@ -458,14 +459,91 @@ def build_zed_entry(python_path, server_path, api_path, lib_path, system=SYSTEM,
458
459
 
459
460
  # ─── Config File Operations ──────────────────────────────────────────────────
460
461
 
462
+ class ConfigParseError(Exception):
463
+ """Existing config file has content but could not be parsed.
464
+
465
+ Callers must NOT overwrite such a file -- doing so destroys the user's
466
+ settings (issue #71).
467
+ """
468
+
469
+
470
+ def _strip_jsonc(text):
471
+ """Best-effort strip of // and /* */ comments and trailing commas.
472
+
473
+ Zed's ``settings.json`` (and several other clients) accept JSON-with-comments.
474
+ Python's ``json`` module rejects those, so we strip them before parsing.
475
+ The walk is string-aware, so a ``//`` or ``/*`` sequence inside a JSON
476
+ string value is preserved.
477
+ """
478
+ out = []
479
+ i = 0
480
+ n = len(text)
481
+ in_string = False
482
+ escape = False
483
+ while i < n:
484
+ ch = text[i]
485
+ if in_string:
486
+ out.append(ch)
487
+ if escape:
488
+ escape = False
489
+ elif ch == "\\":
490
+ escape = True
491
+ elif ch == '"':
492
+ in_string = False
493
+ i += 1
494
+ continue
495
+ if ch == '"':
496
+ in_string = True
497
+ out.append(ch)
498
+ i += 1
499
+ continue
500
+ if ch == "/" and i + 1 < n and text[i + 1] == "/":
501
+ i += 2
502
+ while i < n and text[i] not in "\r\n":
503
+ i += 1
504
+ continue
505
+ if ch == "/" and i + 1 < n and text[i + 1] == "*":
506
+ i += 2
507
+ while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
508
+ i += 1
509
+ i += 2
510
+ continue
511
+ out.append(ch)
512
+ i += 1
513
+ stripped = "".join(out)
514
+ # Drop trailing commas before a closing brace/bracket.
515
+ stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
516
+ return stripped
517
+
518
+
461
519
  def read_json(path):
462
- """Read a JSON file, return empty dict if missing or invalid."""
520
+ """Read a JSON/JSONC config file.
521
+
522
+ Returns an empty dict when the file is absent or empty (safe to create).
523
+ Raises :class:`ConfigParseError` when the file has content that cannot be
524
+ parsed even after stripping JSONC comments and trailing commas -- callers
525
+ must refuse to overwrite such a file, or they would wipe the user's
526
+ existing settings (issue #71: Zed's settings.json ships with comments).
527
+ """
463
528
  try:
464
529
  with open(path, "r") as f:
465
- return json.load(f)
466
- except (FileNotFoundError, json.JSONDecodeError):
530
+ raw = f.read()
531
+ except FileNotFoundError:
467
532
  return {}
468
533
 
534
+ if not raw.strip():
535
+ return {}
536
+
537
+ try:
538
+ return json.loads(raw)
539
+ except json.JSONDecodeError:
540
+ pass
541
+
542
+ try:
543
+ return json.loads(_strip_jsonc(raw))
544
+ except json.JSONDecodeError as exc:
545
+ raise ConfigParseError(str(exc)) from exc
546
+
469
547
 
470
548
  def write_json(path, data):
471
549
  """Write JSON to file, creating parent directories."""
@@ -501,8 +579,18 @@ def write_client_config(client, python_path, server_path, api_path, lib_path, dr
501
579
  preview = {config_key: {"davinci-resolve": server_entry}}
502
580
  return True, f"Would write to {config_path}:\n{json.dumps(preview, indent=2)}"
503
581
 
504
- # Read existing config and merge
505
- existing = read_json(config_path)
582
+ # Read existing config and merge. If the file exists but cannot be parsed,
583
+ # refuse to overwrite it -- silently replacing it would wipe the user's
584
+ # settings (issue #71, especially Zed's commented settings.json).
585
+ try:
586
+ existing = read_json(config_path)
587
+ except ConfigParseError as exc:
588
+ return False, (
589
+ f"{config_path} exists but could not be parsed ({exc}). "
590
+ f"Refusing to overwrite to avoid data loss. Add the "
591
+ f'"{config_key}" entry manually using the manual config output '
592
+ f"(run with --manual)."
593
+ )
506
594
 
507
595
  if config_key not in existing:
508
596
  existing[config_key] = {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.54.0",
3
+ "version": "2.54.2",
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.54.0"
83
+ VERSION = "2.54.2"
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.54.0"
14
+ VERSION = "2.54.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -5042,7 +5042,10 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
5042
5042
  folder = os.path.dirname(os.path.abspath(path))
5043
5043
  if folder:
5044
5044
  os.makedirs(folder, exist_ok=True)
5045
- spec = _timeline_export_spec(p, resolve)
5045
+ # Resolve enum constants via get_resolve(), not the module global `resolve`
5046
+ # (which can be None and silently degrade the EXPORT_* args to strings — the
5047
+ # same failure class as issue #70).
5048
+ spec = _timeline_export_spec(p, get_resolve())
5046
5049
  if p.get("dry_run"):
5047
5050
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
5048
5051
  with long_resolve_op("timeline.export_timeline_checked"):
@@ -17851,8 +17854,18 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
17851
17854
  elif action == "import_into_timeline":
17852
17855
  return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
17853
17856
  elif action == "export":
17857
+ # Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
17858
+ # JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
17859
+ # friendly type/subtype names (or EXPORT_* constant names) the same way
17860
+ # export_timeline_checked does. This raw action keeps no path sandbox.
17861
+ spec = _timeline_export_spec(p, get_resolve())
17854
17862
  with long_resolve_op("timeline.export"):
17855
- return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
17863
+ success = bool(tl.Export(p["path"], spec["export_type"], spec["export_subtype"]))
17864
+ return {
17865
+ "success": success,
17866
+ "export_type": spec["export_type_name"],
17867
+ "export_subtype": spec["export_subtype_name"],
17868
+ }
17856
17869
  elif action == "get_setting":
17857
17870
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
17858
17871
  elif action == "set_setting":
@@ -28,6 +28,7 @@ API_TRUTH: List[Dict[str, Any]] = [
28
28
  "resolve handle, and verify by reading each clip's "
29
29
  "'Synced Audio' property (see verify_by_readback).",
30
30
  "tags": ["unreliable-return", "silent-failure", "audio", "enum"],
31
+ "mitigation": ["_normalize_auto_sync_settings", "_safe_auto_sync_audio"],
31
32
  },
32
33
  {
33
34
  "symbol": "Timeline.CreateSubtitlesFromAudio",
@@ -43,6 +44,7 @@ API_TRUTH: List[Dict[str, Any]] = [
43
44
  "and verify by reading the timeline's subtitle track count "
44
45
  "before/after (server._safe_create_subtitles).",
45
46
  "tags": ["unreliable-return", "silent-failure", "subtitle", "enum"],
47
+ "mitigation": ["_normalize_auto_caption_settings", "_safe_create_subtitles"],
46
48
  },
47
49
  {
48
50
  "symbol": "ProjectManager CloudProject family (Create/Load/Import/RestoreCloudProject)",
@@ -57,6 +59,23 @@ API_TRUTH: List[Dict[str, Any]] = [
57
59
  "before calling, and treat the bool return from "
58
60
  "Import/RestoreCloudProject as advisory.",
59
61
  "tags": ["silent-failure", "project", "cloud", "enum"],
62
+ "mitigation": ["_normalize_cloud_settings"],
63
+ },
64
+ {
65
+ "symbol": "Timeline.Export",
66
+ "object": "Timeline",
67
+ "signature": "(fileName, exportType, exportSubtype) -> bool",
68
+ "reality": "exportType/exportSubtype must be resolve.EXPORT_* enum *values* "
69
+ "resolved from the live handle. A JSON/MCP caller cannot pass a "
70
+ "live enum, and a plain string ('fcpxml', or even the constant "
71
+ "name 'EXPORT_FCPXML_1_10') is silently rejected with no file "
72
+ "written.",
73
+ "recommended": "Map a friendly format/subtype to the EXPORT_* constant and "
74
+ "resolve it against the live handle "
75
+ "(server._timeline_export_spec) before calling; verify the "
76
+ "output file exists afterward.",
77
+ "tags": ["silent-failure", "timeline", "export", "enum"],
78
+ "mitigation": ["_timeline_export_spec", "_timeline_export_value"],
60
79
  },
61
80
  {
62
81
  "symbol": "Composition.Paste",