davinci-resolve-mcp 4.0.0 → 4.1.1

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,93 @@
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.1 — drift detection stops comparing two different timelines
6
+
7
+ Reported by @V2arK (#224), with the root cause correctly diagnosed in the report.
8
+
9
+ ### Fixed
10
+
11
+ - **`project_manager.load` emitted a drift warning for an edit that never
12
+ happened.** `DriftDetectionHook` compared `pre_state["duration_frames"]`
13
+ against the post-state's with no check that the two described the same
14
+ timeline — and `load` is not in `_DURATION_ALTERING_ACTIONS`, so a project
15
+ switch took the "unexpected drift" branch by construction. Switching from a
16
+ 120-frame timeline in one project to a 17854-frame timeline in another
17
+ reported a drift of 17734 frames during an action that edited nothing.
18
+
19
+ This is the failure the verification layer exists to prevent, occurring
20
+ inside the verification layer: an agent reading the envelope was told an edit
21
+ had corrupted a timeline when no edit had occurred, and the README is
22
+ explicit that a confident wrong answer is worse than no answer.
23
+
24
+ - **The check is now on identity, not on an action allow-list.** The hook skips
25
+ the comparison when `project_name` or `timeline_name` moved between pre- and
26
+ post-state — both of which the state provider already reported and the hook
27
+ simply ignored. Identity was chosen over adding `load` to a list because the
28
+ set of actions that can replace the current timeline is open-ended (`load`,
29
+ `create`, `set_current`, anything that closes a project) while the question —
30
+ does the baseline still refer to what we measured? — is the same for all of
31
+ them. The reporter suggested both directions; this is the more general one.
32
+
33
+ - **The reset is reported, not silently omitted.** The hook returns
34
+ `drift_detected: false` with `baseline_reset: true`, the key that moved, and
35
+ a notice, rather than returning nothing. No drift record is indistinguishable
36
+ from "not checked"; this says the check ran and the baseline stopped
37
+ applying.
38
+
39
+ The case the hook exists for is unaffected: same project, same timeline,
40
+ duration moved under a non-duration-altering action still reports drift, and
41
+ a state with no identity keys at all still compares durations rather than
42
+ silently disabling itself. All three are covered by tests, and the two new
43
+ ones fail without the change.
44
+
45
+ ## What's New in v4.1.0 — `timeline_markers add` can be previewed, and "false" stops meaning true
46
+
47
+ Contributed by @Rohitkanithi (#218), adapted onto v4.0.0.
48
+
49
+ ### Added
50
+
51
+ - **`timeline_markers add` accepts `dry_run` / `dryRun` natively.** The preview
52
+ resolves the marker frame through the same path as a real add — including the
53
+ current-playhead default when frame and timecode are both omitted —
54
+ normalizes the colour through the existing validator, applies the same
55
+ defaults for name, note, duration and custom data, and returns a
56
+ `would_change` block with `executed: false` without calling Resolve's
57
+ `AddMarker`. It sits *after* payload resolution and *before* the write, so
58
+ the preview reports the values that would actually have been sent rather than
59
+ a synthesized guess, and a payload the real handler would reject is rejected
60
+ here too instead of previewing a success that could not happen.
61
+ - Registered in `NATIVE_DRY_RUN_ACTIONS`, so an explicit dry run is treated as
62
+ plan-only: no timeline archive and no versioning row for a request that
63
+ mutates nothing. A normal add keeps the full safety and versioning path, and
64
+ marker actions *without* a native preview still refuse with
65
+ `DRY_RUN_UNAVAILABLE` rather than pretending to simulate.
66
+
67
+ ### Fixed
68
+
69
+ - **`dry_run="false"` meant true.** Both the destructive hook and the operation
70
+ log tested the flag with a bare `bool(...)`, and every non-empty string is
71
+ truthy — so a caller passing the string `"false"`, which is what several MCP
72
+ clients send for a boolean, got the dry-run path when they had explicitly
73
+ asked not to. The mutation silently did not happen. Both now share
74
+ `src/utils/bool_params.py`, which reads `"true"/"1"/"yes"/"on"` and
75
+ `"false"/"0"/"no"/"off"`, so the safety layer and the log cannot drift on the
76
+ question of whether a dry run was actually requested.
77
+ - **This also closes a bypass in the v4.0.0 trap guard.** That guard exempts an
78
+ explicit dry run from the `CopyGrades` refusal, on the correct grounds that a
79
+ preview destroys nothing — but it decided "explicit dry run" with the same
80
+ truthy test. A call carrying `dry_run="false"` therefore read as a dry run and
81
+ skipped the refusal. It was caught downstream by `lacks_native_dry_run`, which
82
+ shared the same flaw and refused with `DRY_RUN_UNAVAILABLE`, so nothing
83
+ destructive got through — but the guard was being answered by a bug rather
84
+ than by its own logic. Both now go through the shared helper.
85
+
86
+ ### Changed
87
+
88
+ - Successful dry-run entries in the operation log summarize as previews
89
+ (`timeline_markers.add dry-run preview`), so a JSONL scan distinguishes a
90
+ preview from a mutation without parsing the payload.
91
+
5
92
  ## What's New in v4.0.0 — verified API facts reach the caller, and one of them refuses
6
93
 
7
94
  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.1-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.1-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.1 版 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.1"
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.1",
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.1"
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.1"
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:
@@ -541,9 +541,44 @@ class DriftDetectionHook(LifecycleHook):
541
541
  "delete_clips", "cut_clip", "delete_item", "ripple_trim"
542
542
  }
543
543
 
544
+ #: Identity keys that make a duration comparable. If either of these moved,
545
+ #: the two durations describe different timelines and their difference is
546
+ #: not drift.
547
+ _IDENTITY_KEYS = ("project_name", "timeline_name")
548
+
544
549
  def __init__(self, state_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None):
545
550
  self._state_provider = state_provider
546
551
 
552
+ @classmethod
553
+ def _baseline_identity_changed(
554
+ cls, pre_state: Dict[str, Any], post_state: Dict[str, Any]
555
+ ) -> Optional[str]:
556
+ """Name the identity key that moved, or None if the baseline still holds.
557
+
558
+ A duration delta only means drift when both numbers describe the same
559
+ timeline. Actions that *replace* the current timeline rather than modify
560
+ it -- `project_manager.load` most obviously -- leave a pre-state
561
+ measuring one project's timeline and a post-state measuring another's.
562
+ Comparing them reports a large unexpected drift for a call during which
563
+ nothing was edited at all.
564
+
565
+ That is the failure this layer exists to prevent, occurring inside the
566
+ layer itself: the README is explicit that a confident wrong answer is
567
+ worse than no answer, and an agent reading the envelope is told an edit
568
+ corrupted a timeline when no edit happened.
569
+
570
+ Identity is checked rather than the action being allow-listed, because
571
+ the set of actions that can swap the current timeline is open-ended
572
+ (`load`, `create`, `set_current`, anything that closes a project) while
573
+ the question -- does the baseline still refer to the thing we measured?
574
+ -- is the same for all of them.
575
+ """
576
+ for key in cls._IDENTITY_KEYS:
577
+ before, after = pre_state.get(key), post_state.get(key)
578
+ if before is not None and after is not None and before != after:
579
+ return key
580
+ return None
581
+
547
582
  def after_tool_call(
548
583
  self, ctx: ToolCallContext, result: Any, duration_ms: int
549
584
  ) -> Optional[Dict[str, Any]]:
@@ -556,6 +591,23 @@ class DriftDetectionHook(LifecycleHook):
556
591
  return None
557
592
  ctx.post_state = post_state
558
593
 
594
+ moved = self._baseline_identity_changed(ctx.pre_state, post_state)
595
+ if moved:
596
+ # Say so explicitly rather than returning None. "No drift
597
+ # record" is indistinguishable from "not checked"; this reports
598
+ # that the check ran and the baseline stopped applying.
599
+ return {
600
+ "drift_detected": False,
601
+ "baseline_reset": True,
602
+ "reset_on": moved,
603
+ "notice": (
604
+ f"Drift not evaluated: {moved} changed from "
605
+ f"{ctx.pre_state.get(moved)!r} to {post_state.get(moved)!r} "
606
+ f"during '{ctx.action}', so the pre-state duration is no "
607
+ "longer a baseline for the post-state duration."
608
+ ),
609
+ }
610
+
559
611
  pre_dur = ctx.pre_state.get("duration_frames")
560
612
  post_dur = post_state.get("duration_frames")
561
613
 
@@ -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"]: