davinci-resolve-mcp 3.4.0 → 4.0.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,90 @@
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.0.0 — verified API facts reach the caller, and one of them refuses
6
+
7
+ Contributed by @Grimthereapper (#217). **Major**, because a call that previously
8
+ returned `{"success": true}` from `copy_grades` or `apply_look_to_items` can now
9
+ refuse.
10
+
11
+ ### Breaking
12
+
13
+ - **Actions that call `TimelineItem.CopyGrades` refuse until the caller passes
14
+ `acknowledge_trap: true`.** That symbol replaces the target's grade wholesale
15
+ rather than merging, returns `True` while doing it, and creates no grade
16
+ version — so applied to a clip carrying hand-work it is unrecoverable loss
17
+ reported as success. `copy_grades` and `apply_look_to_items` refuse;
18
+ `safe_copy_grade` and `bulk_match_to_hero` do not, because each already owns a
19
+ confirmation path (see *Why two actions are exempt* below). The refusal names
20
+ the behaviour, carries the recommendation, and tells the caller what to
21
+ re-send. Dry runs are exempt — a preview destroys nothing.
22
+ - **`RESOLVE_MCP_DISABLE_TRAP_GUARD=1` turns the whole mechanism off**, refusal
23
+ and advisory push alike.
24
+
25
+ ### Added
26
+
27
+ - **`api_truth` became a push, not only a pull.** The ledger answered
28
+ `resolve_control(action="api_truth")` and was otherwise a file nobody greps
29
+ mid-job; exactly one callsite pushed proactively. An action mapped to a symbol
30
+ with a recorded fact now carries a compact `known_limitation` on its result —
31
+ symbol, reality, recommendation, nothing else, because response weight is a
32
+ real cost on a long session and the full entry is one lookup away.
33
+ - **`ACTION_SYMBOLS`** declares which Resolve symbols each compound `(tool,
34
+ action)` actually calls, matched on exact symbol equality only. Nothing is
35
+ inferred from a similar name: an unrelated explanation stapled to a failure
36
+ reads as a diagnosis, and a wrong diagnosis is worse than none.
37
+ - **Four new `api_truth` entries**, in the places the ledger was thinnest —
38
+ `TimelineItem.CopyGrades` (replaces wholesale, no version),
39
+ `TimelineItem.ApplyGradeFromStill` (does not exist on `TimelineItem` or
40
+ `Graph`; `Graph.ApplyGradeFromDRX` is the real symbol),
41
+ `TimelineItem.ExportLUT` (Color-page gated, bare `False` elsewhere, no stale
42
+ file written) and `Timeline.DuplicateTimeline` (silently moves the
43
+ current-timeline pointer). The existing `ProjectManager.DeleteProject` entry
44
+ was reconfirmed rather than duplicated. Each carries a per-entry
45
+ `verified_on`; the stale module-level `VERIFIED_ON` constant was deliberately
46
+ left alone rather than globally bumped to assert 113 re-measurements that did
47
+ not happen.
48
+ - **`color_grade_live_probe`** re-derives three of these against a live build
49
+ and records `drifted` when Resolve stops agreeing. A fact nobody can
50
+ re-measure decays into folklore the moment Blackmagic ships a build — and one
51
+ of these now refuses calls, so a stale entry would block legitimate work
52
+ rather than merely mislead.
53
+
54
+ ### Why two actions are exempt
55
+
56
+ `destroys_prior_work` is a property of the symbol, but four actions call
57
+ `CopyGrades` and two already make the caller confirm. Refusing those too would
58
+ cost a caller two acknowledgements discovered serially — add `acknowledge_trap`,
59
+ retry, then find a `confirm_token` is also needed — and it would land hardest on
60
+ `safe_copy_grade`, whose name promises it is the careful route. Making the
61
+ careful route the most irritating to call pushes people toward the raw
62
+ `copy_grades` the guard exists to protect them from. The confirm-token flow is
63
+ older and more specific, so it wins and the guard stands down; those actions
64
+ still get the advisory `known_limitation`.
65
+
66
+ ### Guards
67
+
68
+ - Every mapped symbol is a real `API_TRUTH` entry, and every mapped action a
69
+ real handler.
70
+ - Every `destroys_prior_work` entry is reachable from some action, or the
71
+ refusal it powers can never fire.
72
+ - Every `destroys_prior_work` entry is named by a live probe, or it becomes a
73
+ superstition.
74
+ - An exempt action's handler still mentions `confirm_token`, so deleting that
75
+ gate fails loudly instead of silently becoming no confirmation at all.
76
+ - No still-refusing action defaults `dry_run` to `True`.
77
+
78
+ ### Measurement provenance
79
+
80
+ The four new entries were measured by the contributor on **DaVinci Resolve
81
+ Studio 21.1.0.14** and are recorded with that `verified_on`. They were **not**
82
+ re-measured for this release — no 21.1 machine was available — so they are this
83
+ server's record of a contributor's measurement, not a maintainer reconfirmation.
84
+ The `ExportLUT` page-gating finding is independently consistent with this
85
+ repository's own recorded behaviour of grade calls off the Color page on Studio
86
+ 19.1.3.7. `color_grade_live_probe` exists precisely so anyone on 21.1 can
87
+ re-derive them and see `drifted` if Resolve has changed.
88
+
5
89
  ## What's New in v3.4.0 — review a bin one frame at a time in the control panel
6
90
 
7
91
  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.0.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.0.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.0.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.0.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.0.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.0.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.0.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -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
+ }
@@ -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,7 @@ 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
36
37
  from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
37
38
 
38
39
  logger = logging.getLogger("resolve-mcp.destructive-hook")
@@ -793,6 +794,79 @@ def _extract_metric(params: Optional[Dict[str, Any]]) -> Tuple[Optional[str], Op
793
794
  # ── Decorator ────────────────────────────────────────────────────────────────
794
795
 
795
796
 
797
+ #: Set truthy to disable the trap guard entirely (both the refusal and the
798
+ #: advisory push). Exists because the refusal is a behaviour change for callers
799
+ #: that previously got a bare `{"success": true}` from a destructive copy.
800
+ TRAP_GUARD_ENV = "RESOLVE_MCP_DISABLE_TRAP_GUARD"
801
+
802
+
803
+ def _trap_guard_disabled() -> bool:
804
+ return os.environ.get(TRAP_GUARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
805
+
806
+
807
+ #: Actions that already make the caller confirm, so the trap guard must NOT also
808
+ #: refuse them.
809
+ #:
810
+ #: `destroys_prior_work` is a property of the SYMBOL (TimelineItem.CopyGrades),
811
+ #: but four actions call that symbol and two of them already own a confirmation
812
+ #: path. Refusing those means the caller is told to add `acknowledge_trap`, and
813
+ #: only after retrying discovers they also need a confirm token — two
814
+ #: acknowledgements for one operation, found serially. Worse, it lands hardest on
815
+ #: `safe_copy_grade`, whose whole name promises it is the careful route; making
816
+ #: it the most irritating to call pushes people toward the raw `copy_grades` the
817
+ #: guard exists to protect them from.
818
+ #:
819
+ #: Two mechanisms enforcing one rule drift apart. The confirm-token flow is older
820
+ #: and more specific, so it wins and this guard stands down. These actions still
821
+ #: get the advisory `known_limitation` — the fact is worth having, the second
822
+ #: refusal is not.
823
+ TRAP_REFUSAL_EXEMPT_ACTIONS: FrozenSet[Tuple[str, str]] = frozenset({
824
+ # Issues a confirm_token whose preview names the exact risk: "Replaces the
825
+ # entire node graph on every successfully resolved target item."
826
+ ("timeline_item_color", "safe_copy_grade"),
827
+ # Dry-run-by-default in the handler (`p.get("dry_run", True)`), then
828
+ # confirm_token to execute. A first call with no params mutates nothing.
829
+ ("timeline_item_color", "bulk_match_to_hero"),
830
+ })
831
+
832
+
833
+ def _trap_acknowledged(params: Optional[Dict[str, Any]]) -> bool:
834
+ """Did the caller explicitly accept a known-destructive behaviour?"""
835
+ return bool(isinstance(params, dict) and params.get("acknowledge_trap"))
836
+
837
+
838
+ def _trap_block_response(
839
+ tool_name: str, action: str, blocking: list,
840
+ ) -> Dict[str, Any]:
841
+ """Refuse a call whose verified behaviour destroys unrecoverable work.
842
+
843
+ The point is not to forbid the operation — it is to make the caller say out
844
+ loud that they know what it does. `CopyGrades` returns True while replacing
845
+ a hand-built grade wholesale and leaving no version to go back to, so a
846
+ caller who did not know that cannot tell success from loss.
847
+ """
848
+ return {
849
+ "success": False,
850
+ "error": (
851
+ f"'{tool_name}.{action}' is refused: its verified behaviour destroys "
852
+ "existing work that cannot be recovered afterwards. Read "
853
+ "`known_limitation`, then re-send with acknowledge_trap=true if that "
854
+ "is genuinely what you want."
855
+ ),
856
+ "known_limitation": [trap_notice(e) for e in blocking],
857
+ "retry_with": {"acknowledge_trap": True},
858
+ "override_env": TRAP_GUARD_ENV,
859
+ }
860
+
861
+
862
+ def _attach_trap_notices(result: Any, traps: list) -> Any:
863
+ """Ride the verified fact along on the result, without overwriting one."""
864
+ if not traps or not isinstance(result, dict):
865
+ return result
866
+ result.setdefault("known_limitation", [trap_notice(e) for e in traps])
867
+ return result
868
+
869
+
796
870
  def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
797
871
  """Wrap a top-level tool function with the version-on-mutate hook.
798
872
 
@@ -802,8 +876,7 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
802
876
  """
803
877
 
804
878
  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:
879
+ def _inner(action: str, params: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Any:
807
880
  if lacks_native_dry_run(tool_name, action, params):
808
881
  # An explicit dry-run request this handler would silently
809
882
  # execute for real. Refuse before archive, state lookup, or the
@@ -1151,6 +1224,29 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
1151
1224
  recognised=risk_recognised,
1152
1225
  )
1153
1226
 
1227
+ @functools.wraps(fn)
1228
+ def wrapper(action: str, params: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Any:
1229
+ # Trap push. `_inner` has a dozen return paths (dry-run refusal,
1230
+ # safe-mode block, pending-confirm, strict/no-context, normal); one
1231
+ # outer attach point covers all of them and cannot drift as those
1232
+ # paths change.
1233
+ traps = traps_for(tool_name, action)
1234
+ if traps and not _trap_guard_disabled():
1235
+ blocking = [t for t in traps if t.get("destroys_prior_work")]
1236
+ # A dry run destroys nothing, so there is nothing to acknowledge
1237
+ # — and preempting `_inner` here would swallow its
1238
+ # DRY_RUN_UNAVAILABLE refusal, which is the more important
1239
+ # answer: it tells the caller this action cannot be previewed
1240
+ # at all. The advisory push still rides along on that refusal.
1241
+ if (
1242
+ blocking
1243
+ and (tool_name, action) not in TRAP_REFUSAL_EXEMPT_ACTIONS
1244
+ and not _trap_acknowledged(params)
1245
+ and not _explicit_dry_run_requested(params)
1246
+ ):
1247
+ return _trap_block_response(tool_name, action, blocking)
1248
+ return _attach_trap_notices(_inner(action, params, *args, **kwargs), traps)
1249
+
1154
1250
  wrapper.__wrapped_tool_name__ = tool_name # type: ignore[attr-defined]
1155
1251
  wrapper.__is_destructive_wrapped__ = True # type: ignore[attr-defined]
1156
1252
  return wrapper