davinci-resolve-mcp 4.0.0 → 4.1.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,53 @@
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 v4.1.0 — `timeline_markers add` can be previewed, and "false" stops meaning true
6
+
7
+ Contributed by @Rohitkanithi (#218), adapted onto v4.0.0.
8
+
9
+ ### Added
10
+
11
+ - **`timeline_markers add` accepts `dry_run` / `dryRun` natively.** The preview
12
+ resolves the marker frame through the same path as a real add — including the
13
+ current-playhead default when frame and timecode are both omitted —
14
+ normalizes the colour through the existing validator, applies the same
15
+ defaults for name, note, duration and custom data, and returns a
16
+ `would_change` block with `executed: false` without calling Resolve's
17
+ `AddMarker`. It sits *after* payload resolution and *before* the write, so
18
+ the preview reports the values that would actually have been sent rather than
19
+ a synthesized guess, and a payload the real handler would reject is rejected
20
+ here too instead of previewing a success that could not happen.
21
+ - Registered in `NATIVE_DRY_RUN_ACTIONS`, so an explicit dry run is treated as
22
+ plan-only: no timeline archive and no versioning row for a request that
23
+ mutates nothing. A normal add keeps the full safety and versioning path, and
24
+ marker actions *without* a native preview still refuse with
25
+ `DRY_RUN_UNAVAILABLE` rather than pretending to simulate.
26
+
27
+ ### Fixed
28
+
29
+ - **`dry_run="false"` meant true.** Both the destructive hook and the operation
30
+ log tested the flag with a bare `bool(...)`, and every non-empty string is
31
+ truthy — so a caller passing the string `"false"`, which is what several MCP
32
+ clients send for a boolean, got the dry-run path when they had explicitly
33
+ asked not to. The mutation silently did not happen. Both now share
34
+ `src/utils/bool_params.py`, which reads `"true"/"1"/"yes"/"on"` and
35
+ `"false"/"0"/"no"/"off"`, so the safety layer and the log cannot drift on the
36
+ question of whether a dry run was actually requested.
37
+ - **This also closes a bypass in the v4.0.0 trap guard.** That guard exempts an
38
+ explicit dry run from the `CopyGrades` refusal, on the correct grounds that a
39
+ preview destroys nothing — but it decided "explicit dry run" with the same
40
+ truthy test. A call carrying `dry_run="false"` therefore read as a dry run and
41
+ skipped the refusal. It was caught downstream by `lacks_native_dry_run`, which
42
+ shared the same flaw and refused with `DRY_RUN_UNAVAILABLE`, so nothing
43
+ destructive got through — but the guard was being answered by a bug rather
44
+ than by its own logic. Both now go through the shared helper.
45
+
46
+ ### Changed
47
+
48
+ - Successful dry-run entries in the operation log summarize as previews
49
+ (`timeline_markers.add dry-run preview`), so a JSONL scan distinguishes a
50
+ preview from a mutation without parsing the payload.
51
+
5
52
  ## What's New in v4.0.0 — verified API facts reach the caller, and one of them refuses
6
53
 
7
54
  Contributed by @Grimthereapper (#217). **Major**, because a call that previously
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-4.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.1.0-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(387%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-4.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.1.0-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(387%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
- > 本翻译对应 v4.0.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.1.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.0.0"
40
+ VERSION = "4.1.0"
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": "4.0.0",
3
+ "version": "4.1.0",
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 = "4.0.0"
90
+ VERSION = "4.1.0"
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 = "4.0.0"
14
+ VERSION = "4.1.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -72,6 +72,7 @@ from src.utils.proc import safe_run
72
72
  from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
73
73
  from src.utils import operation_result as _operation_result
74
74
  from src.utils import operation_log as _operation_log
75
+ from src.utils.bool_params import explicit_bool_param as _explicit_bool_param
75
76
  from src.utils.operation_result import (
76
77
  build_operation_envelope as _build_operation_envelope,
77
78
  get_envelope_mode as _get_envelope_mode,
@@ -26145,7 +26146,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
26145
26146
  itself refuses sub-start timecodes with a bare False.
26146
26147
 
26147
26148
  Actions:
26148
- add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame}
26149
+ add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?, dry_run?/dryRun?) -> {success, frame} or dry-run preview
26149
26150
  If frame/timecode is omitted, add uses the current playhead timecode.
26150
26151
  get_all() -> {markers}
26151
26152
  get_by_custom_data(custom_data) -> {markers}
@@ -26178,6 +26179,21 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
26178
26179
  marker, marker_err = _marker_add_payload(p, tl=tl, default_to_current=True)
26179
26180
  if marker_err:
26180
26181
  return marker_err
26182
+ if _explicit_bool_param(p, "dry_run", "dryRun") is True:
26183
+ return {
26184
+ "success": True,
26185
+ "dry_run": True,
26186
+ "executed": False,
26187
+ "would_change": {
26188
+ "operation": "timeline_markers.add",
26189
+ "frame": marker["frame"],
26190
+ "color": marker["color"],
26191
+ "name": marker["name"],
26192
+ "note": marker["note"],
26193
+ "duration": marker["duration"],
26194
+ "custom_data": marker["custom_data"],
26195
+ },
26196
+ }
26181
26197
  return _add_marker(tl, marker)
26182
26198
  elif action == "get_all":
26183
26199
  return {"markers": _ser(tl.GetMarkers())}
@@ -0,0 +1,33 @@
1
+ """Boolean coercion helpers for tool parameters and preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ _TRUE_STRINGS = {"1", "true", "yes", "on"}
9
+ _FALSE_STRINGS = {"0", "false", "no", "off"}
10
+
11
+
12
+ def coerce_bool(value: Any, default: bool = False) -> bool:
13
+ """Return a predictable bool for user-facing params and config values."""
14
+ if value is None:
15
+ return default
16
+ if isinstance(value, str):
17
+ lowered = value.strip().lower()
18
+ if lowered in _TRUE_STRINGS:
19
+ return True
20
+ if lowered in _FALSE_STRINGS:
21
+ return False
22
+ return default
23
+ return bool(value)
24
+
25
+
26
+ def explicit_bool_param(params: Optional[Dict[str, Any]], *keys: str) -> Optional[bool]:
27
+ """Coerce the first present key, or None when none of the keys are present."""
28
+ if not isinstance(params, dict):
29
+ return None
30
+ for key in keys:
31
+ if key in params:
32
+ return coerce_bool(params[key])
33
+ return None
@@ -34,6 +34,7 @@ from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
34
34
 
35
35
  from src.utils import analysis_runs, brain_edits, media_pool_changes, timeline_versioning
36
36
  from src.utils.api_truth import traps_for, trap_notice
37
+ from src.utils.bool_params import coerce_bool, explicit_bool_param
37
38
  from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
38
39
 
39
40
  logger = logging.getLogger("resolve-mcp.destructive-hook")
@@ -350,6 +351,7 @@ DRY_RUN_DEFAULT_TRUE_ACTIONS: frozenset = frozenset({
350
351
 
351
352
  NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
352
353
  ("media_pool", "clear_clip_marks"),
354
+ ("timeline_markers", "add"),
353
355
  ("timeline_item_color", "apply_trace_plan"),
354
356
  ("media_pool", "set_clip_marks"),
355
357
  ("media_pool", "setup_multicam_timeline"),
@@ -366,13 +368,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
366
368
 
367
369
 
368
370
  def _explicit_dry_run_requested(params: Optional[Dict[str, Any]]) -> bool:
369
- if not isinstance(params, dict):
370
- return False
371
- if "dry_run" in params:
372
- return bool(params["dry_run"])
373
- if "dryRun" in params:
374
- return bool(params["dryRun"])
375
- return False
371
+ return explicit_bool_param(params, "dry_run", "dryRun") is True
376
372
 
377
373
 
378
374
  def lacks_native_dry_run(
@@ -434,11 +430,14 @@ def _payload_is_plan_only(
434
430
  tool_name: str, action: str, params: Optional[Dict[str, Any]],
435
431
  ) -> bool:
436
432
  """True iff this call only produces a plan and mutates nothing."""
433
+ dry_run = explicit_bool_param(params, "dry_run", "dryRun")
434
+ if (tool_name, action) in NATIVE_DRY_RUN_ACTIONS and dry_run is True:
435
+ return True
437
436
  if (tool_name, action) not in DRY_RUN_DEFAULT_TRUE_ACTIONS:
438
437
  return False
439
- if not isinstance(params, dict):
438
+ if dry_run is None:
440
439
  return True # dry_run defaults to True for these actions
441
- return bool(params.get("dry_run", params.get("dryRun", True)))
440
+ return dry_run
442
441
 
443
442
 
444
443
  def _payload_only_touches_no_archive_keys(
@@ -571,16 +570,7 @@ def _read_preference(key: str, default: Any = None) -> Any:
571
570
 
572
571
 
573
572
  def _coerce_bool(value: Any, default: bool = False) -> bool:
574
- if value is None:
575
- return default
576
- if isinstance(value, str):
577
- lowered = value.strip().lower()
578
- if lowered in {"1", "true", "yes", "on"}:
579
- return True
580
- if lowered in {"0", "false", "no", "off"}:
581
- return False
582
- return default
583
- return bool(value)
573
+ return coerce_bool(value, default)
584
574
 
585
575
 
586
576
  def _safe_mode_enabled() -> bool:
@@ -16,6 +16,7 @@ import uuid
16
16
  from pathlib import Path
17
17
  from typing import Any, Callable, Dict, Optional
18
18
 
19
+ from src.utils.bool_params import coerce_bool, explicit_bool_param
19
20
  from src.utils import operation_result
20
21
 
21
22
  logger = logging.getLogger("resolve-mcp.operation-log")
@@ -41,16 +42,7 @@ def _read_preference(key: str, default: Any = None) -> Any:
41
42
 
42
43
 
43
44
  def _coerce_bool(value: Any, default: bool = False) -> bool:
44
- if value is None:
45
- return default
46
- if isinstance(value, str):
47
- lowered = value.strip().lower()
48
- if lowered in {"1", "true", "yes", "on"}:
49
- return True
50
- if lowered in {"0", "false", "no", "off"}:
51
- return False
52
- return default
53
- return bool(value)
45
+ return coerce_bool(value, default)
54
46
 
55
47
 
56
48
  def operation_log_enabled() -> bool:
@@ -72,12 +64,12 @@ def _now_iso() -> str:
72
64
 
73
65
 
74
66
  def _dry_run_requested(params: Optional[Dict[str, Any]], result: Any) -> bool:
75
- if isinstance(params, dict):
76
- if "dry_run" in params:
77
- return bool(params["dry_run"])
78
- if "dryRun" in params:
79
- return bool(params["dryRun"])
80
- return bool(isinstance(result, dict) and result.get("dry_run") is True)
67
+ requested = explicit_bool_param(params, "dry_run", "dryRun")
68
+ if requested is not None:
69
+ return requested
70
+ if isinstance(result, dict) and "dry_run" in result:
71
+ return coerce_bool(result.get("dry_run"))
72
+ return False
81
73
 
82
74
 
83
75
  def _envelope(result: Any) -> Dict[str, Any]:
@@ -119,9 +111,18 @@ def _status(result: Any, envelope: Dict[str, Any]) -> str:
119
111
  return str(envelope.get("status") or operation_result.normalize_status(result))
120
112
 
121
113
 
122
- def _summary(tool_name: str, action: str, status: str, envelope: Dict[str, Any]) -> str:
114
+ def _summary(
115
+ tool_name: str,
116
+ action: str,
117
+ status: str,
118
+ envelope: Dict[str, Any],
119
+ *,
120
+ dry_run: bool = False,
121
+ ) -> str:
123
122
  operation = f"{tool_name}.{action}"
124
123
  changes = envelope.get("changes")
124
+ if dry_run and status == "success":
125
+ return f"{operation} dry-run preview"
125
126
  if isinstance(changes, dict) and changes:
126
127
  parts = [f"{key}={value}" for key, value in sorted(changes.items())[:4]]
127
128
  return f"{operation} {status}; " + ", ".join(parts)
@@ -140,6 +141,7 @@ def build_record(
140
141
  ) -> Dict[str, Any]:
141
142
  env = _envelope(result)
142
143
  status = _status(result, env)
144
+ dry_run = _dry_run_requested(params, result)
143
145
  record = {
144
146
  "operation_id": _operation_id(result, env),
145
147
  "tool": tool_name,
@@ -147,9 +149,9 @@ def build_record(
147
149
  "operation": f"{tool_name}.{action}",
148
150
  "risk_level": risk.get("level", "unknown"),
149
151
  "risk_established": risk.get("recognised"),
150
- "dry_run": _dry_run_requested(params, result),
152
+ "dry_run": dry_run,
151
153
  "timestamp": _now_iso(),
152
- "summary": _summary(tool_name, action, status, env),
154
+ "summary": _summary(tool_name, action, status, env, dry_run=dry_run),
153
155
  "status": status,
154
156
  }
155
157
  if env.get("execution_id") and env.get("execution_id") != record["operation_id"]: