davinci-resolve-mcp 3.4.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,137 @@
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
+
52
+ ## What's New in v4.0.0 — verified API facts reach the caller, and one of them refuses
53
+
54
+ Contributed by @Grimthereapper (#217). **Major**, because a call that previously
55
+ returned `{"success": true}` from `copy_grades` or `apply_look_to_items` can now
56
+ refuse.
57
+
58
+ ### Breaking
59
+
60
+ - **Actions that call `TimelineItem.CopyGrades` refuse until the caller passes
61
+ `acknowledge_trap: true`.** That symbol replaces the target's grade wholesale
62
+ rather than merging, returns `True` while doing it, and creates no grade
63
+ version — so applied to a clip carrying hand-work it is unrecoverable loss
64
+ reported as success. `copy_grades` and `apply_look_to_items` refuse;
65
+ `safe_copy_grade` and `bulk_match_to_hero` do not, because each already owns a
66
+ confirmation path (see *Why two actions are exempt* below). The refusal names
67
+ the behaviour, carries the recommendation, and tells the caller what to
68
+ re-send. Dry runs are exempt — a preview destroys nothing.
69
+ - **`RESOLVE_MCP_DISABLE_TRAP_GUARD=1` turns the whole mechanism off**, refusal
70
+ and advisory push alike.
71
+
72
+ ### Added
73
+
74
+ - **`api_truth` became a push, not only a pull.** The ledger answered
75
+ `resolve_control(action="api_truth")` and was otherwise a file nobody greps
76
+ mid-job; exactly one callsite pushed proactively. An action mapped to a symbol
77
+ with a recorded fact now carries a compact `known_limitation` on its result —
78
+ symbol, reality, recommendation, nothing else, because response weight is a
79
+ real cost on a long session and the full entry is one lookup away.
80
+ - **`ACTION_SYMBOLS`** declares which Resolve symbols each compound `(tool,
81
+ action)` actually calls, matched on exact symbol equality only. Nothing is
82
+ inferred from a similar name: an unrelated explanation stapled to a failure
83
+ reads as a diagnosis, and a wrong diagnosis is worse than none.
84
+ - **Four new `api_truth` entries**, in the places the ledger was thinnest —
85
+ `TimelineItem.CopyGrades` (replaces wholesale, no version),
86
+ `TimelineItem.ApplyGradeFromStill` (does not exist on `TimelineItem` or
87
+ `Graph`; `Graph.ApplyGradeFromDRX` is the real symbol),
88
+ `TimelineItem.ExportLUT` (Color-page gated, bare `False` elsewhere, no stale
89
+ file written) and `Timeline.DuplicateTimeline` (silently moves the
90
+ current-timeline pointer). The existing `ProjectManager.DeleteProject` entry
91
+ was reconfirmed rather than duplicated. Each carries a per-entry
92
+ `verified_on`; the stale module-level `VERIFIED_ON` constant was deliberately
93
+ left alone rather than globally bumped to assert 113 re-measurements that did
94
+ not happen.
95
+ - **`color_grade_live_probe`** re-derives three of these against a live build
96
+ and records `drifted` when Resolve stops agreeing. A fact nobody can
97
+ re-measure decays into folklore the moment Blackmagic ships a build — and one
98
+ of these now refuses calls, so a stale entry would block legitimate work
99
+ rather than merely mislead.
100
+
101
+ ### Why two actions are exempt
102
+
103
+ `destroys_prior_work` is a property of the symbol, but four actions call
104
+ `CopyGrades` and two already make the caller confirm. Refusing those too would
105
+ cost a caller two acknowledgements discovered serially — add `acknowledge_trap`,
106
+ retry, then find a `confirm_token` is also needed — and it would land hardest on
107
+ `safe_copy_grade`, whose name promises it is the careful route. Making the
108
+ careful route the most irritating to call pushes people toward the raw
109
+ `copy_grades` the guard exists to protect them from. The confirm-token flow is
110
+ older and more specific, so it wins and the guard stands down; those actions
111
+ still get the advisory `known_limitation`.
112
+
113
+ ### Guards
114
+
115
+ - Every mapped symbol is a real `API_TRUTH` entry, and every mapped action a
116
+ real handler.
117
+ - Every `destroys_prior_work` entry is reachable from some action, or the
118
+ refusal it powers can never fire.
119
+ - Every `destroys_prior_work` entry is named by a live probe, or it becomes a
120
+ superstition.
121
+ - An exempt action's handler still mentions `confirm_token`, so deleting that
122
+ gate fails loudly instead of silently becoming no confirmation at all.
123
+ - No still-refusing action defaults `dry_run` to `True`.
124
+
125
+ ### Measurement provenance
126
+
127
+ The four new entries were measured by the contributor on **DaVinci Resolve
128
+ Studio 21.1.0.14** and are recorded with that `verified_on`. They were **not**
129
+ re-measured for this release — no 21.1 machine was available — so they are this
130
+ server's record of a contributor's measurement, not a maintainer reconfirmation.
131
+ The `ExportLUT` page-gating finding is independently consistent with this
132
+ repository's own recorded behaviour of grade calls off the Color page on Studio
133
+ 19.1.3.7. `color_grade_live_probe` exists precisely so anyone on 21.1 can
134
+ re-derive them and see `drifted` if Resolve has changed.
135
+
5
136
  ## What's New in v3.4.0 — review a bin one frame at a time in the control panel
6
137
 
7
138
  Contributed by @tpellet (#230), their first contribution here.
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.4.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)
@@ -312,6 +312,51 @@ file paths. The one free-text field is the `request` you pass to
312
312
  `begin_execution`, so treat it the way you would a commit message on a client
313
313
  project.
314
314
 
315
+ ## Verified-Trap Guard
316
+
317
+ `src/utils/api_truth.py` records behaviours of the Resolve API that were measured
318
+ against a live build rather than read off a signature — calls that return `True`
319
+ having done nothing, settings keys silently rejected, methods that are not there
320
+ at all. That ledger used to be **pull-only**: it answered
321
+ `resolve_control(action="api_truth")` and was otherwise a file nobody greps in
322
+ the middle of a job.
323
+
324
+ It now reaches the caller at the callsite. An action mapped to a symbol with a
325
+ recorded fact carries a compact `known_limitation` on its result — symbol,
326
+ reality, recommendation, and nothing else, because response weight is a real cost
327
+ on a long grading session and the full entry is one lookup away.
328
+
329
+ A fact is only attached when the mapping names that exact symbol. Nothing is
330
+ inferred from a similar name: an unrelated explanation stapled to a failure reads
331
+ as a diagnosis, and a wrong diagnosis is worse than none.
332
+
333
+ **One behaviour refuses rather than warns.** `TimelineItem.CopyGrades` replaces
334
+ the target's grade wholesale — measured by baking each state to a 33-point LUT
335
+ and comparing bytes — returns `True` while doing it, and creates no version to go
336
+ back to. Applied to clips carrying hand-work, that is unrecoverable loss reported
337
+ as success. So actions that call it refuse until the caller passes
338
+ `acknowledge_trap: true`:
339
+
340
+ ```json
341
+ {
342
+ "success": false,
343
+ "error": "'timeline_item_color.copy_grades' is refused: its verified behaviour destroys existing work that cannot be recovered afterwards.",
344
+ "known_limitation": [{"symbol": "TimelineItem.CopyGrades", "reality": "...", "recommended": "..."}],
345
+ "retry_with": {"acknowledge_trap": true}
346
+ }
347
+ ```
348
+
349
+ The intent is not to forbid the operation — it is to make the caller say out loud
350
+ that they know what it does. Dry runs are exempt: a preview destroys nothing.
351
+
352
+ Set `RESOLVE_MCP_DISABLE_TRAP_GUARD=1` to turn both the refusal and the advisory
353
+ push off. This is a behaviour change for callers that previously received a bare
354
+ `{"success": true}` from a destructive copy.
355
+
356
+ Facts that power a refusal must stay re-measurable, so a live probe re-derives
357
+ each one and records `drifted` when Resolve stops agreeing; a test fails if a
358
+ `destroys_prior_work` entry has no probe.
359
+
315
360
  ## Optional Extras
316
361
 
317
362
  The core install is deliberately small: Python, ffmpeg, and the Resolve scripting
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.4.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
- > 本翻译对应 v3.4.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/docs/SKILL.md CHANGED
@@ -674,6 +674,7 @@ specific pages. Always confirm or switch pages before calling page-sensitive too
674
674
  | Operation category | Required page | How to switch |
675
675
  |---|---|---|
676
676
  | Color grading, node graphs, CDL | Color | `resolve_control(action="open_page", params={"page": "color"})` |
677
+ | LUT export (`export_lut`, `safe_export_lut`) | Color — measured `False` from media, edit, fusion, fairlight and deliver | `resolve_control(action="open_page", params={"page": "color"})` |
677
678
  | Gallery stills export, `grab_and_export` | Color, Gallery panel open | `resolve_control` + open Gallery panel in Workspace menu |
678
679
  | Fusion compositions (page comp) | Fusion | `resolve_control(action="open_page", params={"page": "fusion"})` |
679
680
  | Timeline editing, track operations | Edit or Cut | `resolve_control(action="open_page", params={"page": "edit"})` |
@@ -2332,6 +2333,24 @@ Resolve API returned `False`. This usually means a precondition was not met
2332
2333
 
2333
2334
  ## Known Gotchas
2334
2335
 
2336
+ ### `copy_grades` refuses until you acknowledge it
2337
+
2338
+ `TimelineItem.CopyGrades` **replaces** the target's grade — it does not merge —
2339
+ returns `True` while doing it, and creates no version to roll back to. Measured on
2340
+ Studio 21.1.0.14 by baking each state to a 33-point LUT: after the copy the
2341
+ target's LUT is byte-identical to the source's. Pointed at clips someone graded by
2342
+ hand, that is unrecoverable loss reported as success.
2343
+
2344
+ So `copy_grades`, `safe_copy_grade`, `bulk_match_to_hero` and
2345
+ `timeline.apply_look_to_items` refuse until you pass `acknowledge_trap: true`. The
2346
+ refusal carries the measured fact in `known_limitation`. Before acknowledging,
2347
+ confirm the targets are actually uniform — export each one's LUT on the Color page
2348
+ and compare — rather than assuming a group shares a grade.
2349
+
2350
+ Other recorded traps ride along on results as `known_limitation` without blocking.
2351
+ `RESOLVE_MCP_DISABLE_TRAP_GUARD=1` disables both behaviours.
2352
+
2353
+
2335
2354
  **Resolve API object lifetimes** — Objects like timelines, clips, and color groups
2336
2355
  returned by the API are live references that can become stale if the project state
2337
2356
  changes (e.g., the user deletes a timeline). Always re-fetch IDs after any
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 41 missing capabilities, 52 bugs / unreliable behaviors.
15
+ **Totals:** 41 missing capabilities, 53 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -768,3 +768,11 @@ values, or automation-hostile modal prompts.
768
768
  - **Behavior:** Reported by @legionsound (issue #207) from Studio 21.1.0.14 on macOS; NOT reproduced here (no 21.1 install). The documented success result is None. A minimal identity transform written across several lines — `__DEVICE__ float3 transform(...)` with the body on its own lines — validates (None). The SAME function collapsed onto one line consistently returns 'DCTL Error: main DCTL function does not have return value.', which is false: the return statement is there. A genuinely invalid source returns 'cannot find main DCTL function.', so the validator does distinguish; it is the single-line layout it misreads. An earlier multi-line timeout did not reproduce after a Resolve restart with a 30-second limit. Nothing establishes a GPU compiler or render defect — this is the validator's parse, not the DCTL's execution. EncryptDCTL untested.
769
769
  - **Workaround / current handling:** Any wrapper around ValidateDCTL must pass the native diagnostic through verbatim and must not reflow or rewrite the user's source to dodge it; ship the multi-line identity fixture as the known-good control. A 'no return value' error on a one-line function is this quirk, not a missing return — re-run the validation with the function laid out across lines before believing it. This server's own `dctl validate` is a static, offline check (entry point, brace balance, float suffixes) and does not call ValidateDCTL at all. The separate `dctl validate_native` and granular `validate_dctl_native` now expose the native validator without changing source or diagnostics. Both interfaces were contributor-validated against the three fixtures on Studio 21.1.0.14; see resolve211-dctl-validation.md.
770
770
  - **Tags:** dctl, validation, unreliable-return, version-gated, reported
771
+
772
+ ### TimelineItem.ExportLUT
773
+
774
+ - **Object:** `TimelineItem`
775
+ - **Signature:** `(exportType, path) -> bool`
776
+ - **Behavior:** Gated on the Color page. Measured on all six pages: returns False from media, edit, fusion, fairlight and deliver, and True only from color. The refusal is a bare False with no reason. It does at least fail cleanly - no file is written on the failing pages, so there is no stale-file trap here.
777
+ - **Workaround / current handling:** resolve.OpenPage('color') before the call and restore the prior page afterwards. Treat a False as 'you were on the wrong page' before suspecting the path.
778
+ - **Tags:** page-gated, silent-failure, lut
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.4.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": "3.4.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 = "3.4.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 = "3.4.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())}
@@ -24,7 +24,7 @@ When you add or change a ``submit``-tagged entry, regenerate the report
24
24
  (``venv/bin/python scripts/gen_api_limitations.py``) or the
25
25
  ``tests.test_api_limitations_doc`` drift guard fails.
26
26
  """
27
- from typing import Any, Dict, List, Optional
27
+ from typing import Any, Dict, List, Optional, Tuple
28
28
 
29
29
  VERIFIED_ON = "DaVinci Resolve Studio 21.0.2"
30
30
 
@@ -116,6 +116,10 @@ API_TRUTH: List[Dict[str, Any]] = [
116
116
  "which does exactly that.",
117
117
  "tags": ["unreliable-return", "project", "flaky", "session-lock"],
118
118
  "submit": "bug",
119
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
120
+ "reconfirmed": "2026-09-09: still true on 21.1.0.14. Delete with another "
121
+ "project current returned False; CloseProject on the target "
122
+ "then delete returned True, first attempt.",
119
123
  },
120
124
  {
121
125
  "symbol": "Project.SetSetting('timelinePlaybackFrameRate')",
@@ -3272,6 +3276,76 @@ API_TRUTH: List[Dict[str, Any]] = [
3272
3276
  "tags": ["dctl", "validation", "unreliable-return", "version-gated", "reported"],
3273
3277
  "submit": "bug",
3274
3278
  },
3279
+ # ── Measured on Studio 21.1.0.14, 2026-09-09 (trap-aware execution work) ──
3280
+ {
3281
+ "symbol": "TimelineItem.CopyGrades",
3282
+ "object": "TimelineItem",
3283
+ "signature": "(tgtTimelineItems) -> bool",
3284
+ "reality": "REPLACES the target's grade wholesale; it does not merge. "
3285
+ "Measured by exporting a 33-point LUT from the target before "
3286
+ "and after: after the copy the target's LUT is byte-identical "
3287
+ "to the source's and differs from the grade the target had. "
3288
+ "Returns True while doing it. It creates NO grade version - "
3289
+ "GetVersionNameList is unchanged across the call - so the "
3290
+ "overwritten grade cannot be recovered.",
3291
+ "recommended": "Never call this on clips carrying hand-work. Prove the "
3292
+ "targets are uniform first by exporting each one's LUT "
3293
+ "(Color page) and comparing bytes. If the prior grade has "
3294
+ "any value, call TimelineItem.AddVersion() first - the "
3295
+ "copy will not make a restore point for you.",
3296
+ "tags": ["destructive", "unrecoverable", "grade", "no-version"],
3297
+ "destroys_prior_work": True,
3298
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3299
+ },
3300
+ {
3301
+ "symbol": "TimelineItem.ApplyGradeFromStill",
3302
+ "object": "TimelineItem",
3303
+ "reality": "Does not exist. There is no ApplyGradeFromStill on "
3304
+ "TimelineItem or on Graph in 21.1, and it is absent from the "
3305
+ "typed stubs. Code calling it raises AttributeError, and any "
3306
+ "wrapper that swallows that reports success for a grade it "
3307
+ "never applied.",
3308
+ "recommended": "Use Graph.ApplyGradeFromDRX(path, gradeMode) against a "
3309
+ "'.drx' (gradeMode 0=no keyframes, 1=source-timecode "
3310
+ "aligned, 2=start-frame aligned).",
3311
+ "tags": ["missing-method", "grade"],
3312
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3313
+ },
3314
+ {
3315
+ "symbol": "TimelineItem.ExportLUT",
3316
+ "object": "TimelineItem",
3317
+ "signature": "(exportType, path) -> bool",
3318
+ "reality": "Gated on the Color page. Measured on all six pages: returns "
3319
+ "False from media, edit, fusion, fairlight and deliver, and "
3320
+ "True only from color. The refusal is a bare False with no "
3321
+ "reason. It does at least fail cleanly - no file is written "
3322
+ "on the failing pages, so there is no stale-file trap here.",
3323
+ "recommended": "resolve.OpenPage('color') before the call and restore "
3324
+ "the prior page afterwards. Treat a False as 'you were on "
3325
+ "the wrong page' before suspecting the path.",
3326
+ "tags": ["page-gated", "silent-failure", "lut"],
3327
+ "submit": "bug",
3328
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3329
+ },
3330
+ {
3331
+ "symbol": "Timeline.DuplicateTimeline",
3332
+ "object": "Timeline",
3333
+ "signature": "(timelineName) -> Timeline",
3334
+ "reality": "Silently moves the project's current-timeline pointer to the "
3335
+ "new duplicate. The return value is the duplicate and nothing "
3336
+ "signals that 'current' changed, so every subsequent mutation "
3337
+ "lands in the copy while the caller believes it is still "
3338
+ "editing the original.",
3339
+ "recommended": "Capture GetCurrentTimeline() before the call and "
3340
+ "SetCurrentTimeline() back after it, checking the return "
3341
+ "- SetCurrentTimeline restores it and returns True. "
3342
+ "Never discard that boolean. "
3343
+ "src/utils/timeline_versioning.py:archive_current_timeline "
3344
+ "already does this and fails loudly if the restore fails.",
3345
+ "tags": ["side-effect", "silent-failure", "timeline"],
3346
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3347
+ },
3348
+
3275
3349
  ]
3276
3350
 
3277
3351
 
@@ -3308,3 +3382,64 @@ def submittable_limitations() -> Dict[str, List[Dict[str, Any]]]:
3308
3382
  if kind in groups:
3309
3383
  groups[kind].append(e)
3310
3384
  return groups
3385
+
3386
+
3387
+ # ── Action → Resolve symbol registry ─────────────────────────────────────────
3388
+ #
3389
+ # Which Resolve symbols a compound (tool, action) actually calls. Used to push
3390
+ # the relevant fact to the caller at the moment of the call instead of waiting
3391
+ # for someone to think to query this file.
3392
+ #
3393
+ # Every mapping is declared explicitly and matched by exact symbol equality.
3394
+ # Substring or fuzzy matching is forbidden here for the reason given in
3395
+ # `server._setting_limitation`: attaching an unrelated explanation to a call
3396
+ # reads as a diagnosis, and a wrong diagnosis is worse than none. A guard
3397
+ # (`tests/test_action_symbol_registry.py`) asserts every symbol named below is a
3398
+ # real API_TRUTH entry and every action is a real handler.
3399
+ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
3400
+ ("timeline_item_color", "copy_grades"): ["TimelineItem.CopyGrades"],
3401
+ ("timeline_item_color", "safe_copy_grade"): ["TimelineItem.CopyGrades"],
3402
+ ("timeline_item_color", "bulk_match_to_hero"): ["TimelineItem.CopyGrades"],
3403
+ ("timeline", "apply_look_to_items"): ["TimelineItem.CopyGrades"],
3404
+ ("timeline_item_color", "export_lut"): ["TimelineItem.ExportLUT"],
3405
+ ("timeline_item_color", "safe_export_lut"): ["TimelineItem.ExportLUT"],
3406
+ ("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
3407
+ }
3408
+
3409
+
3410
+ def _entry_for_symbol(symbol: str) -> Optional[Dict[str, Any]]:
3411
+ """The single entry whose `symbol` is exactly `symbol`."""
3412
+ for entry in API_TRUTH:
3413
+ if entry.get("symbol") == symbol:
3414
+ return entry
3415
+ return None
3416
+
3417
+
3418
+ def traps_for(tool: str, action: str) -> List[Dict[str, Any]]:
3419
+ """Verified facts for the symbols this (tool, action) actually calls.
3420
+
3421
+ Exact matches only — an action with no declared mapping returns nothing
3422
+ rather than guessing.
3423
+ """
3424
+ out: List[Dict[str, Any]] = []
3425
+ for symbol in ACTION_SYMBOLS.get((tool, action), ()):
3426
+ entry = _entry_for_symbol(symbol)
3427
+ if entry is not None:
3428
+ out.append(entry)
3429
+ return out
3430
+
3431
+
3432
+ def trap_notice(entry: Dict[str, Any]) -> Dict[str, str]:
3433
+ """The compact push form: what it does, what to do instead.
3434
+
3435
+ Deliberately three fields. A full entry carries signature, tags, submit
3436
+ status and mitigation lists that cost tokens on every single call and tell
3437
+ the caller nothing they can act on at the callsite. Response weight is a
3438
+ real cost on long grading sessions, so the push stays small and the full
3439
+ entry stays one `resolve_control(action="api_truth")` away.
3440
+ """
3441
+ return {
3442
+ "symbol": entry.get("symbol", ""),
3443
+ "reality": entry.get("reality", ""),
3444
+ "recommended": entry.get("recommended", ""),
3445
+ }
@@ -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
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import hashlib
6
7
  import json
7
8
  import os
8
9
  import platform
@@ -141,6 +142,192 @@ def _redact_file_payloads(result: Dict[str, Any]) -> Dict[str, Any]:
141
142
  return redacted
142
143
 
143
144
 
145
+
146
+ # ── api_truth re-verification ────────────────────────────────────────────────
147
+ #
148
+ # The entries these confirm were measured on Studio 21.1.0.14. A fact nobody can
149
+ # re-measure decays into folklore the moment Blackmagic ships a build, so each
150
+ # check below re-derives the behaviour and records `drifted` when what it sees
151
+ # stops matching what api_truth claims. The point is not to pass; it is to
152
+ # notice when the answer changes.
153
+
154
+ _LUT_PAGES = ("media", "edit", "fusion", "fairlight", "deliver", "color")
155
+
156
+
157
+ def _lut_digest(path: Path) -> Optional[str]:
158
+ try:
159
+ return hashlib.md5(path.read_bytes()).hexdigest()
160
+ except OSError:
161
+ return None
162
+
163
+
164
+ def _verify_copygrades_replaces_wholesale(recorder, resolve, items, work_dir: Path) -> None:
165
+ """TimelineItem.CopyGrades: does it still replace, and still leave no version?
166
+
167
+ Measured by baking each state to a 33-point LUT and comparing bytes. There is
168
+ no GetCDL to read back, and GetToolsInNode reports only which tools exist —
169
+ it returns the same list either way — so the exported LUT is the only handle
170
+ on what the grade actually became.
171
+ """
172
+ if len(items) < 2:
173
+ recorder.record("api_truth", "CopyGrades_replaces_wholesale", "not_applicable",
174
+ details={"reason": "probe timeline has fewer than two items"})
175
+ return
176
+
177
+ src, tgt = items[0], items[1]
178
+ lut_type = resolve.EXPORT_LUT_33PTCUBE
179
+ hand = work_dir / "trap_target_handwork.cube"
180
+ source = work_dir / "trap_source_grade.cube"
181
+ after = work_dir / "trap_target_after_copy.cube"
182
+
183
+ # Every return here is checked. SetCDL and ExportLUT both report refusal as a
184
+ # bare False, and a probe that grades nothing, exports nothing, and then
185
+ # compares two identical empty LUTs would conclude "no replacement" with
186
+ # total confidence. A measurement built on unchecked setup is worse than no
187
+ # measurement, because it gets written down as a fact.
188
+ setup: Dict[str, bool] = {}
189
+ try:
190
+ setup["target_reset"] = bool(tgt.GetNodeGraph().ResetAllGrades())
191
+ setup["target_setcdl"] = bool(tgt.SetCDL(
192
+ {"NodeIndex": "1", "Slope": "0.5 0.5 1.5", "Offset": "0.1 0.1 0.1",
193
+ "Power": "1.0 1.0 1.0", "Saturation": "0.3"}))
194
+ versions_before = list(tgt.GetVersionNameList(0) or [])
195
+ setup["export_handwork"] = bool(tgt.ExportLUT(lut_type, str(hand)))
196
+
197
+ setup["source_reset"] = bool(src.GetNodeGraph().ResetAllGrades())
198
+ setup["source_setcdl"] = bool(src.SetCDL(
199
+ {"NodeIndex": "1", "Slope": "2.0 1.0 1.0", "Offset": "0.0 0.0 0.0",
200
+ "Power": "1.0 1.0 1.0", "Saturation": "1.0"}))
201
+ setup["export_source"] = bool(src.ExportLUT(lut_type, str(source)))
202
+
203
+ returned = bool(src.CopyGrades([tgt]))
204
+ setup["export_after"] = bool(tgt.ExportLUT(lut_type, str(after)))
205
+ versions_after = list(tgt.GetVersionNameList(0) or [])
206
+ except Exception as exc: # noqa: BLE001 - a probe reports, it does not raise
207
+ recorder.record_exception("api_truth", "CopyGrades_replaces_wholesale", exc)
208
+ return
209
+
210
+ # Only the calls the measurement actually depends on can abort it. The two
211
+ # resets are best-effort tidying: the stubs type ResetAllGrades as -> bool,
212
+ # but "nothing to reset" plausibly returns False on an already-clean graph,
213
+ # and gating on that would abort every run. Their returns are still recorded
214
+ # rather than dropped.
215
+ required = ("target_setcdl", "export_handwork",
216
+ "source_setcdl", "export_source", "export_after")
217
+ failed_setup = sorted(k for k in required if not setup.get(k))
218
+ if failed_setup:
219
+ recorder.record(
220
+ "api_truth", "CopyGrades_replaces_wholesale", "error",
221
+ details={"setup": setup, "failed_setup": failed_setup,
222
+ "reason": "probe setup did not take, so nothing below would be "
223
+ "a measurement of CopyGrades"},
224
+ )
225
+ _cleanup_exported_files([{"path": str(f)} for f in (hand, source, after)])
226
+ return
227
+
228
+ d_hand, d_source, d_after = _lut_digest(hand), _lut_digest(source), _lut_digest(after)
229
+ replaced = d_after is not None and d_after == d_source and d_after != d_hand
230
+ made_version = versions_after != versions_before
231
+
232
+ details = {
233
+ "setup": setup,
234
+ "copygrades_returned": returned,
235
+ "handwork_digest": d_hand,
236
+ "source_digest": d_source,
237
+ "target_after_copy_digest": d_after,
238
+ "target_became_byte_identical_to_source": replaced,
239
+ "versions_before": versions_before,
240
+ "versions_after": versions_after,
241
+ "created_a_recovery_version": made_version,
242
+ "api_truth_claims": "replaces wholesale, returns True, creates no version",
243
+ }
244
+ # api_truth says: replaced and unrecoverable. Anything else is news.
245
+ if replaced and not made_version:
246
+ recorder.record("api_truth", "CopyGrades_replaces_wholesale", "supported", details=details)
247
+ else:
248
+ details["drifted"] = (
249
+ "CopyGrades no longer matches its api_truth entry — re-read the entry "
250
+ "and the destroys_prior_work flag before trusting either."
251
+ )
252
+ recorder.record("api_truth", "CopyGrades_replaces_wholesale", "error", details=details)
253
+
254
+ _cleanup_exported_files([{"path": str(f)} for f in (hand, source, after)])
255
+
256
+
257
+ def _verify_exportlut_page_gate(recorder, server, resolve, items, work_dir: Path) -> None:
258
+ """TimelineItem.ExportLUT: still Color-page only, still no stale file?"""
259
+ if not items:
260
+ recorder.record("api_truth", "ExportLUT_page_gated", "not_applicable",
261
+ details={"reason": "no timeline items"})
262
+ return
263
+
264
+ item = items[0]
265
+ lut_type = resolve.EXPORT_LUT_33PTCUBE
266
+ by_page = {}
267
+ written = []
268
+ try:
269
+ for page in _LUT_PAGES:
270
+ server.resolve_control("open_page", {"page": page})
271
+ out = work_dir / f"trap_lut_{page}.cube"
272
+ returned = bool(item.ExportLUT(lut_type, str(out)))
273
+ exists = out.exists()
274
+ by_page[page] = {"returned": returned, "wrote_file": exists}
275
+ if exists:
276
+ written.append({"path": str(out)})
277
+ server.resolve_control("open_page", {"page": "color"})
278
+ except Exception as exc: # noqa: BLE001
279
+ recorder.record_exception("api_truth", "ExportLUT_page_gated", exc)
280
+ return
281
+
282
+ off_page = [p for p in _LUT_PAGES if p != "color"]
283
+ gated = (
284
+ by_page.get("color", {}).get("returned") is True
285
+ and all(by_page[p]["returned"] is False for p in off_page)
286
+ )
287
+ stale = [p for p in off_page if by_page[p]["wrote_file"]]
288
+
289
+ details = {"by_page": by_page, "stale_files_written_on_failure": stale,
290
+ "api_truth_claims": "True only on color; no file written elsewhere"}
291
+ if gated and not stale:
292
+ recorder.record("api_truth", "ExportLUT_page_gated", "version_or_page_dependent", details=details)
293
+ else:
294
+ details["drifted"] = "ExportLUT page behaviour no longer matches its api_truth entry."
295
+ recorder.record("api_truth", "ExportLUT_page_gated", "error", details=details)
296
+
297
+ _cleanup_exported_files(written)
298
+
299
+
300
+ def _verify_duplicatetimeline_moves_pointer(recorder, project, timeline) -> None:
301
+ """Timeline.DuplicateTimeline: does it still silently steal `current`?"""
302
+ try:
303
+ before_id = timeline.GetUniqueId()
304
+ dup = timeline.DuplicateTimeline("Trap Probe Duplicate")
305
+ if dup is None:
306
+ recorder.record("api_truth", "DuplicateTimeline_moves_current", "error",
307
+ details={"reason": "DuplicateTimeline returned None"})
308
+ return
309
+ current = project.GetCurrentTimeline()
310
+ moved = bool(current) and current.GetUniqueId() != before_id
311
+ restored, _ = set_current_timeline(project, timeline)
312
+ except Exception as exc: # noqa: BLE001
313
+ recorder.record_exception("api_truth", "DuplicateTimeline_moves_current", exc)
314
+ return
315
+
316
+ details = {
317
+ "current_moved_to_duplicate": moved,
318
+ "setcurrenttimeline_restored_it": bool(restored),
319
+ "api_truth_claims": "the pointer moves to the duplicate; SetCurrentTimeline restores it",
320
+ }
321
+ if moved and restored:
322
+ recorder.record("api_truth", "DuplicateTimeline_moves_current", "supported", details=details)
323
+ else:
324
+ details["drifted"] = (
325
+ "DuplicateTimeline pointer behaviour changed — timeline_versioning "
326
+ "compensates for it, so check that code too."
327
+ )
328
+ recorder.record("api_truth", "DuplicateTimeline_moves_current", "error", details=details)
329
+
330
+
144
331
  def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, Any]:
145
332
  output_dir.mkdir(parents=True, exist_ok=True)
146
333
  work_dir = Path(tempfile.mkdtemp(prefix="mcp_color_grade_probe_"))
@@ -415,6 +602,12 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
415
602
  server.timeline_item_color("grade_boundary_report", {**scope, "include_timeline_graph": True}),
416
603
  )
417
604
 
605
+ # Re-measure the api_truth entries this probe is the home for. Runs last:
606
+ # it deliberately overwrites grades on the probe clips.
607
+ _verify_copygrades_replaces_wholesale(recorder, resolve, items, work_dir)
608
+ _verify_exportlut_page_gate(recorder, server, resolve, items, work_dir)
609
+ _verify_duplicatetimeline_moves_pointer(recorder, project, timeline)
610
+
418
611
  if keep_open:
419
612
  server.project_manager("save")
420
613
  print(f"LEFT PROJECT OPEN FOR INSPECTION: {project_name}")
@@ -33,6 +33,8 @@ import uuid
33
33
  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
+ from src.utils.api_truth import traps_for, trap_notice
37
+ from src.utils.bool_params import coerce_bool, explicit_bool_param
36
38
  from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
37
39
 
38
40
  logger = logging.getLogger("resolve-mcp.destructive-hook")
@@ -349,6 +351,7 @@ DRY_RUN_DEFAULT_TRUE_ACTIONS: frozenset = frozenset({
349
351
 
350
352
  NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
351
353
  ("media_pool", "clear_clip_marks"),
354
+ ("timeline_markers", "add"),
352
355
  ("timeline_item_color", "apply_trace_plan"),
353
356
  ("media_pool", "set_clip_marks"),
354
357
  ("media_pool", "setup_multicam_timeline"),
@@ -365,13 +368,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
365
368
 
366
369
 
367
370
  def _explicit_dry_run_requested(params: Optional[Dict[str, Any]]) -> bool:
368
- if not isinstance(params, dict):
369
- return False
370
- if "dry_run" in params:
371
- return bool(params["dry_run"])
372
- if "dryRun" in params:
373
- return bool(params["dryRun"])
374
- return False
371
+ return explicit_bool_param(params, "dry_run", "dryRun") is True
375
372
 
376
373
 
377
374
  def lacks_native_dry_run(
@@ -433,11 +430,14 @@ def _payload_is_plan_only(
433
430
  tool_name: str, action: str, params: Optional[Dict[str, Any]],
434
431
  ) -> bool:
435
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
436
436
  if (tool_name, action) not in DRY_RUN_DEFAULT_TRUE_ACTIONS:
437
437
  return False
438
- if not isinstance(params, dict):
438
+ if dry_run is None:
439
439
  return True # dry_run defaults to True for these actions
440
- return bool(params.get("dry_run", params.get("dryRun", True)))
440
+ return dry_run
441
441
 
442
442
 
443
443
  def _payload_only_touches_no_archive_keys(
@@ -570,16 +570,7 @@ def _read_preference(key: str, default: Any = None) -> Any:
570
570
 
571
571
 
572
572
  def _coerce_bool(value: Any, default: bool = False) -> bool:
573
- if value is None:
574
- return default
575
- if isinstance(value, str):
576
- lowered = value.strip().lower()
577
- if lowered in {"1", "true", "yes", "on"}:
578
- return True
579
- if lowered in {"0", "false", "no", "off"}:
580
- return False
581
- return default
582
- return bool(value)
573
+ return coerce_bool(value, default)
583
574
 
584
575
 
585
576
  def _safe_mode_enabled() -> bool:
@@ -793,6 +784,79 @@ def _extract_metric(params: Optional[Dict[str, Any]]) -> Tuple[Optional[str], Op
793
784
  # ── Decorator ────────────────────────────────────────────────────────────────
794
785
 
795
786
 
787
+ #: Set truthy to disable the trap guard entirely (both the refusal and the
788
+ #: advisory push). Exists because the refusal is a behaviour change for callers
789
+ #: that previously got a bare `{"success": true}` from a destructive copy.
790
+ TRAP_GUARD_ENV = "RESOLVE_MCP_DISABLE_TRAP_GUARD"
791
+
792
+
793
+ def _trap_guard_disabled() -> bool:
794
+ return os.environ.get(TRAP_GUARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
795
+
796
+
797
+ #: Actions that already make the caller confirm, so the trap guard must NOT also
798
+ #: refuse them.
799
+ #:
800
+ #: `destroys_prior_work` is a property of the SYMBOL (TimelineItem.CopyGrades),
801
+ #: but four actions call that symbol and two of them already own a confirmation
802
+ #: path. Refusing those means the caller is told to add `acknowledge_trap`, and
803
+ #: only after retrying discovers they also need a confirm token — two
804
+ #: acknowledgements for one operation, found serially. Worse, it lands hardest on
805
+ #: `safe_copy_grade`, whose whole name promises it is the careful route; making
806
+ #: it the most irritating to call pushes people toward the raw `copy_grades` the
807
+ #: guard exists to protect them from.
808
+ #:
809
+ #: Two mechanisms enforcing one rule drift apart. The confirm-token flow is older
810
+ #: and more specific, so it wins and this guard stands down. These actions still
811
+ #: get the advisory `known_limitation` — the fact is worth having, the second
812
+ #: refusal is not.
813
+ TRAP_REFUSAL_EXEMPT_ACTIONS: FrozenSet[Tuple[str, str]] = frozenset({
814
+ # Issues a confirm_token whose preview names the exact risk: "Replaces the
815
+ # entire node graph on every successfully resolved target item."
816
+ ("timeline_item_color", "safe_copy_grade"),
817
+ # Dry-run-by-default in the handler (`p.get("dry_run", True)`), then
818
+ # confirm_token to execute. A first call with no params mutates nothing.
819
+ ("timeline_item_color", "bulk_match_to_hero"),
820
+ })
821
+
822
+
823
+ def _trap_acknowledged(params: Optional[Dict[str, Any]]) -> bool:
824
+ """Did the caller explicitly accept a known-destructive behaviour?"""
825
+ return bool(isinstance(params, dict) and params.get("acknowledge_trap"))
826
+
827
+
828
+ def _trap_block_response(
829
+ tool_name: str, action: str, blocking: list,
830
+ ) -> Dict[str, Any]:
831
+ """Refuse a call whose verified behaviour destroys unrecoverable work.
832
+
833
+ The point is not to forbid the operation — it is to make the caller say out
834
+ loud that they know what it does. `CopyGrades` returns True while replacing
835
+ a hand-built grade wholesale and leaving no version to go back to, so a
836
+ caller who did not know that cannot tell success from loss.
837
+ """
838
+ return {
839
+ "success": False,
840
+ "error": (
841
+ f"'{tool_name}.{action}' is refused: its verified behaviour destroys "
842
+ "existing work that cannot be recovered afterwards. Read "
843
+ "`known_limitation`, then re-send with acknowledge_trap=true if that "
844
+ "is genuinely what you want."
845
+ ),
846
+ "known_limitation": [trap_notice(e) for e in blocking],
847
+ "retry_with": {"acknowledge_trap": True},
848
+ "override_env": TRAP_GUARD_ENV,
849
+ }
850
+
851
+
852
+ def _attach_trap_notices(result: Any, traps: list) -> Any:
853
+ """Ride the verified fact along on the result, without overwriting one."""
854
+ if not traps or not isinstance(result, dict):
855
+ return result
856
+ result.setdefault("known_limitation", [trap_notice(e) for e in traps])
857
+ return result
858
+
859
+
796
860
  def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
797
861
  """Wrap a top-level tool function with the version-on-mutate hook.
798
862
 
@@ -802,8 +866,7 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
802
866
  """
803
867
 
804
868
  def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
805
- @functools.wraps(fn)
806
- def wrapper(action: str, params: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Any:
869
+ def _inner(action: str, params: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Any:
807
870
  if lacks_native_dry_run(tool_name, action, params):
808
871
  # An explicit dry-run request this handler would silently
809
872
  # execute for real. Refuse before archive, state lookup, or the
@@ -1151,6 +1214,29 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
1151
1214
  recognised=risk_recognised,
1152
1215
  )
1153
1216
 
1217
+ @functools.wraps(fn)
1218
+ def wrapper(action: str, params: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Any:
1219
+ # Trap push. `_inner` has a dozen return paths (dry-run refusal,
1220
+ # safe-mode block, pending-confirm, strict/no-context, normal); one
1221
+ # outer attach point covers all of them and cannot drift as those
1222
+ # paths change.
1223
+ traps = traps_for(tool_name, action)
1224
+ if traps and not _trap_guard_disabled():
1225
+ blocking = [t for t in traps if t.get("destroys_prior_work")]
1226
+ # A dry run destroys nothing, so there is nothing to acknowledge
1227
+ # — and preempting `_inner` here would swallow its
1228
+ # DRY_RUN_UNAVAILABLE refusal, which is the more important
1229
+ # answer: it tells the caller this action cannot be previewed
1230
+ # at all. The advisory push still rides along on that refusal.
1231
+ if (
1232
+ blocking
1233
+ and (tool_name, action) not in TRAP_REFUSAL_EXEMPT_ACTIONS
1234
+ and not _trap_acknowledged(params)
1235
+ and not _explicit_dry_run_requested(params)
1236
+ ):
1237
+ return _trap_block_response(tool_name, action, blocking)
1238
+ return _attach_trap_notices(_inner(action, params, *args, **kwargs), traps)
1239
+
1154
1240
  wrapper.__wrapped_tool_name__ = tool_name # type: ignore[attr-defined]
1155
1241
  wrapper.__is_destructive_wrapped__ = True # type: ignore[attr-defined]
1156
1242
  return wrapper
@@ -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"]: