davinci-resolve-mcp 4.1.2 → 4.2.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,147 @@
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.2.0 — the raw grade-copy asks before it overwrites, and an injected grade shows as graded
6
+
7
+ Contributed by [@Rohitkanithi](https://github.com/Rohitkanithi) in
8
+ [#231](https://github.com/samuelgursky/davinci-resolve-mcp/pull/231), plus a
9
+ fix to the offline `.drp` grade-injection tier.
10
+
11
+ ### Added
12
+
13
+ - **`timeline_item_color copy_grades` now takes a `confirm_token` and requires
14
+ one before it calls `TimelineItem.CopyGrades`.** The raw action reaches an API
15
+ that replaces the target's entire node graph with the source's, with no
16
+ recovery version — reconfirmed on Studio 21.1.0.14 in
17
+ [#207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207), where
18
+ the target's exported grade became byte-identical to the source and the
19
+ version list stayed `['Version 1']` throughout. Until now the trap
20
+ acknowledgement was the only barrier, and acknowledging a trap is a statement
21
+ about understanding the API, not about the clips in front of you.
22
+
23
+ The first call now returns `confirmation_required` with a preview built from
24
+ the targets it actually resolved — how many, which IDs, and which IDs were not
25
+ found on any video track — and a one-time token bound to the action and a
26
+ fingerprint of the params. Change `target_ids` after receiving the token and
27
+ the token no longer matches. The trap gate still runs first, so the sequence is
28
+ acknowledge, inspect the resolved targets, then commit. The safe siblings
29
+ (`safe_copy_grade`, `bulk_match_to_hero`) already gated their own writes; this
30
+ closes the raw path that bypassed them.
31
+
32
+ A side effect of routing target resolution through the existing
33
+ `_timeline_items_for_grade_copy` helper: IDs that resolve to nothing are now
34
+ **reported** rather than silently dropped, which is the "No target existence
35
+ check" the action's own docstring had been warning about.
36
+
37
+ ### Fixed
38
+
39
+ - **An injected grade rendered correctly but the Color page listed the clip as
40
+ ungraded.** Resolve decides "graded" from the per-version `<HasCorrection>`
41
+ element beside the `Body`, not from the body bytes. `injectGrades` replaced the
42
+ `Body` and left the flag as it found it, so on a 352-clip balance pass the
43
+ grades were live while the page showed them missing. The version element lists
44
+ `HasCorrection` before `Body`, so the last `HasCorrection` preceding the
45
+ replaced `Body` is the owner's; it is now flipped to true and untouched clips
46
+ keep their flag. The test builds an ungraded two-clip DRP, injects one, and
47
+ asserts the target reads true while the sibling still reads false, with an
48
+ already-corrected fixture as the null control.
49
+
50
+ ### Validation
51
+
52
+ - Full offline suite on the merged result: **3,620 passed, 1 skipped,
53
+ 1,257 subtests passed, zero failures.** The `drp-format` Node tests pass
54
+ (8 passed, 1 skipped), including the new `HasCorrection` case.
55
+ - All release drift guards green, including `test_write_enforcement_ratchet`,
56
+ `test_doc_tool_counts`, `test_action_list_drift` and
57
+ `test_release_surface_drift`.
58
+ - No Resolve live run: the confirm-token gate is server-side control flow, and
59
+ the `.drp` change is offline file authoring covered by its own round-trip test.
60
+ Neither alters what Resolve is asked to do once a call is allowed through.
61
+
62
+ ## What's New in v4.1.3 — every live harness could no longer start, and a probe that could never pass
63
+
64
+ Reported and measured by [@legionsound](https://github.com/legionsound) in
65
+ [#207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207) while
66
+ running `color_grade_live_probe` on Studio 21.1.0.14. No server behaviour
67
+ changes; the harnesses that verify Resolve's behaviour do.
68
+
69
+ ### Fixed
70
+
71
+ - **Every hand-run live harness failed at import, on every machine**, with
72
+ `ImportError: cannot import name 'Context' from 'mcp.server.fastmcp'` at
73
+ `src/server.py:240`. Seventeen harnesses each carried a private copy of a stub
74
+ installer, and the copies were wrong in two independent ways:
75
+
76
+ - They called `sys.modules.setdefault("mcp", stub)` *before* anything had
77
+ imported `mcp`, so the stand-ins displaced the **real, working SDK** on
78
+ machines that had it. The stub set was never a fallback in practice; it was
79
+ always what ran.
80
+ - `src/server.py` grew `Context`, `Image` and `mcp.types`; fifteen of the
81
+ seventeen copies still offered only `FastMCP`. Each harness died at whichever
82
+ import its own copy had never been taught about.
83
+
84
+ There is now one installer, `src/utils/mcp_import_stubs.py`, which **imports
85
+ the real package first and leaves it alone**, and only stands in when the SDK
86
+ is genuinely absent. All seventeen call it; 648 lines of divergent copies are
87
+ gone. `tests/test_mcp_import_stubs.py` reads the SDK imports back out of
88
+ `src/server.py` and fails when the stub set falls behind, or when a harness
89
+ hand-rolls its own again — both regressions were re-introduced deliberately to
90
+ confirm the guard catches them.
91
+
92
+ - **`safe_copy_grade` and `safe_apply_drx` could never pass in the probe.** Both
93
+ are rated destructive, so the first call returns `CONFIRMATION_REQUIRED` and a
94
+ one-time token *instead of acting*. The probe predates confirm tokens, called
95
+ once, and recorded the prompt as the action's outcome — two permanent errors in
96
+ a report whose purpose is to notice change. It now answers the gate and records
97
+ what the action actually did, repeating the params the token's fingerprint is
98
+ bound to.
99
+
100
+ ### Changed
101
+
102
+ - **`TimelineItem.ApplyGradeFromStill` is now re-measured rather than trusted.**
103
+ It was the one #217 entry the probe never exercised — a claim that a method
104
+ does *not* exist, which nothing would notice Blackmagic reversing. The check
105
+ uses `dir()` membership and sanity-checks the enumeration against a method
106
+ known to exist before treating an absence as evidence.
107
+
108
+ - **Corrected an api_truth entry that implied `hasattr` is safe on Resolve's own
109
+ objects.** It is not. Measured here on Studio 19.1.3.7:
110
+ `hasattr(timeline_item, 'TotallyMadeUpName')` returns `True`, as does `hasattr`
111
+ for `ApplyGradeFromStill`, while `dir()` on the same object lists 84 real names
112
+ and neither of those. Resolve fabricates a callable for **any** attribute name
113
+ on **every** object, not only Fusion Tools; what is special about Fusion Tools
114
+ is that `dir()` is unreliable there too, leaving no usable probe at all. A
115
+ capability check written on `hasattr` reports every method as present.
116
+
117
+ ### Reconfirmed
118
+
119
+ Three of the four trap entries from
120
+ [#217](https://github.com/samuelgursky/davinci-resolve-mcp/pull/217) were
121
+ independently re-measured on Studio 21.1.0.14 by a second contributor, and now
122
+ carry it. This matters most for `TimelineItem.CopyGrades`, which is the entry
123
+ that makes a mapped action refuse without `acknowledge_trap`:
124
+
125
+ - **`TimelineItem.CopyGrades`** — returned `True`; the target's exported grade
126
+ became byte-identical to the source's; `GetVersionNameList` read
127
+ `['Version 1']` before and after, so there is still no recovery version.
128
+ - **`TimelineItem.ExportLUT`** — wrote a file only from `color`; `deliver`,
129
+ `edit`, `fairlight`, `fusion` and `media` all returned `False` and left no
130
+ stale files.
131
+ - **`Timeline.DuplicateTimeline`** — the current-timeline pointer moved to the
132
+ duplicate, and `SetCurrentTimeline` put it back.
133
+
134
+ `TimelineItem.ApplyGradeFromStill` stays **reported**, not reconfirmed — that
135
+ probe run did not exercise it. The check added above closes that gap for the
136
+ next run.
137
+
138
+ ### Validation
139
+
140
+ Full suite green: 3,616 passed, 1 skipped, 1,257 subtests. The count rises by
141
+ exactly the three new guard tests. No live Resolve run beyond the read-only
142
+ attribute measurement quoted above, taken on Studio 19.1.3.7 — the harness
143
+ changes are import-path and gate-protocol fixes, verified against the real
144
+ token machinery offline.
145
+
5
146
  ## What's New in v4.1.2 — the installer's healthy-branch test stops depending on a live Resolve
6
147
 
7
148
  Test-only. No behaviour change to the server or the installer.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.1.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.2.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.1.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.2.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.1.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.2.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -455,8 +455,8 @@ values, or automation-hostile modal prompts.
455
455
 
456
456
  - **Object:** `Fusion Tool / Composition`
457
457
  - **Signature:** `dir(tool) -> incomplete list`
458
- - **Behavior:** `dir()` on a live Fusion Tool returns 38 names — with 'Composition' listed TWICE — and omits GetAttrs and SetAttrs, which are documented Fusion Tool methods that work perfectly when called. Measured on free 21.0.3.7 over the in-app bridge: invoking GetAttrs directly returned {TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs renamed the tool. This matters because Resolve fabricates a callable for ANY attribute name, so `dir()` is the only evidence of absence that exists — which makes an omitted name unrecoverable by probing. Any capability detection built on dir()/hasattr will therefore report a real Fusion method as missing. Resolve's own API objects do not have this problem: Timeline (60), TimelineItem (88) and Composition (92) all enumerate correctly.
459
- - **Workaround / current handling:** Do not treat dir()/hasattr as authoritative for Fusion Tool objects. Keep a curated set of documented Fusion methods that the enumeration omits, and identify a Fusion object positively (ConnectInput / FindMainInput / GetControlPageNames on a Tool, AddTool / FindTool / GetToolList on a Composition) rather than relaxing the check globally, which would silently re-open capability detection on Resolve API objects.
458
+ - **Behavior:** `dir()` on a live Fusion Tool returns 38 names — with 'Composition' listed TWICE — and omits GetAttrs and SetAttrs, which are documented Fusion Tool methods that work perfectly when called. Measured on free 21.0.3.7 over the in-app bridge: invoking GetAttrs directly returned {TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs renamed the tool. This matters because Resolve fabricates a callable for ANY attribute name, so `dir()` is the only evidence of absence that exists — which makes an omitted name unrecoverable by probing. Any capability detection built on dir()/hasattr will therefore report a real Fusion method as missing. Resolve's own API objects enumerate correctly Timeline (60), TimelineItem (88) and Composition (92) so the INCOMPLETE ENUMERATION is Fusion's alone. The fabrication is not: measured on Studio 19.1.3.7, `hasattr(timeline_item, 'TotallyMadeUpName')` returns True, and so does hasattr for a method that genuinely does not exist (ApplyGradeFromStill), while dir() on the same object lists 84 real names and neither of those. So hasattr/getattr is worthless for absence on EVERY Resolve object, Fusion or not; what is special about Fusion Tools is that dir() is wrong there too, leaving no reliable probe at all.
459
+ - **Workaround / current handling:** Never use hasattr/getattr to test whether ANY Resolve object has a method — it always says yes. Use dir() membership, and sanity-check the enumeration with a method you know exists before trusting an absence. For Fusion Tool objects not even dir() is authoritative: keep a curated set of documented Fusion methods that the enumeration omits, and identify a Fusion object positively (ConnectInput / FindMainInput / GetControlPageNames on a Tool, AddTool / FindTool / GetToolList on a Composition) rather than relaxing the check globally, which would silently re-open capability detection on Resolve API objects.
460
460
  - **Tags:** fusion, introspection, bridge, free-edition
461
461
 
462
462
  ### Composition.Lock (suppresses render invalidation for value writes)
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.1.2"
40
+ VERSION = "4.2.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -244,3 +244,39 @@ test('injectGrades: internals — extractDrxBodyHex throws on missing Body', ()
244
244
  test.skip('injectGrades: rendered frame matches direct DRX apply '
245
245
  + '(covered by tests/live_drp_roundtrip_verification.py — grade-render compare still TODO)',
246
246
  () => {});
247
+
248
+ test('injectGrades: flips the owning version\'s HasCorrection to true (Resolve reads the flag, not the body)', async () => {
249
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'inject-hc-'));
250
+ const src = path.join(dir, 'src.drp');
251
+ const out = path.join(dir, 'out.drp');
252
+ const buf = await drpFormat.buildDRP({
253
+ projectName: 'inject-hc-test',
254
+ timelines: [{
255
+ name: 'T1', frameRate: 24, startTimecode: '01:00:00:00', resolution: '1920x1080',
256
+ videoTracks: [{ clips: [
257
+ { start: 0, duration: 24, in: 0, mediaFilePath: '/synthetic/a.mov', grade: { body: BASELINE_BODY, hasCorrection: false, versionName: 'V1' } },
258
+ { start: 24, duration: 24, in: 0, mediaFilePath: '/synthetic/b.mov', grade: { body: ORIGINAL_BODY_CLIP_2, hasCorrection: false, versionName: 'V1' } },
259
+ ] }],
260
+ audioTracks: [],
261
+ }],
262
+ });
263
+ await fs.writeFile(src, buf);
264
+ const before = await readSeqContainer(src);
265
+ assert.equal((before.match(/<HasCorrection>true<\/HasCorrection>/g) || []).length, 0, 'fixture starts ungraded');
266
+ const ids = [...before.matchAll(/<(?:Sm2TiVideoClip|Sm2VideoClip)[^>]*?DbId="([^"]+)"/g)].map((m) => m[1]);
267
+ assert.equal(ids.length, 2);
268
+ await drpFormat.injectGrades(src, [{ clipId: ids[0], drxContent: makeSyntheticDrx(INJECTED_BODY) }], { outputPath: out });
269
+ const after = await readSeqContainer(out);
270
+ const clipBlock = (xml, id) => {
271
+ const i = xml.indexOf(`DbId="${id}"`);
272
+ const j = xml.indexOf('</Sm2TiVideoClip>', i);
273
+ return xml.slice(i, j);
274
+ };
275
+ assert.match(clipBlock(after, ids[0]), /<HasCorrection>true<\/HasCorrection>/, 'targeted clip is now marked corrected');
276
+ assert.match(clipBlock(after, ids[0]), new RegExp(`<Body>${INJECTED_BODY}</Body>`));
277
+ assert.match(clipBlock(after, ids[1]), /<HasCorrection>false<\/HasCorrection>/, 'untouched clip keeps its flag');
278
+ assert.doesNotMatch(clipBlock(after, ids[1]), /<HasCorrection>true/);
279
+ // Null control: injecting into an already-corrected version changes nothing but the body.
280
+ const already = await readSeqContainer(src);
281
+ assert.equal((already.match(/<HasCorrection>/g) || []).length, 2);
282
+ });
@@ -56,7 +56,8 @@ function extractDrxBodyHex(drxContent) {
56
56
  *
57
57
  * Scoping: the regex anchors on `<Sm2TiVideoClip ... DbId="<id>"` and
58
58
  * runs forward to the matching `</Sm2TiVideoClip>`. Inside that range we
59
- * replace exactly one <Body>HEX</Body>. If a clip has no Body yet (a
59
+ * replace exactly one <Body>HEX</Body> and flip the owning version's
60
+ * <HasCorrection> to true. If a clip has no Body yet (a
60
61
  * brand-new clip with no grade) we don't synthesize the surrounding
61
62
  * LmVersionTable scaffolding — that's a builder responsibility, not an
62
63
  * injector one. Callers wanting to add grades to clean clips should
@@ -100,12 +101,32 @@ function replaceBodyForClip(seqXml, targetDbId, newBodyHex) {
100
101
  // 3. Within the clip's range, replace exactly one <Body>...</Body>.
101
102
  const clipRange = seqXml.slice(openStart, clipEnd);
102
103
  const bodyRe = /<Body>([\s\S]*?)<\/Body>/;
103
- if (!bodyRe.test(clipRange)) return null;
104
- const newClipRange = clipRange.replace(
104
+ const bodyAt = clipRange.search(bodyRe);
105
+ if (bodyAt < 0) return null;
106
+ let newClipRange = clipRange.replace(
105
107
  bodyRe,
106
108
  `<Body>${newBodyHex}</Body>`,
107
109
  );
108
110
 
111
+ // 4. Mark the owning version as corrected. Resolve keeps a per-version
112
+ // <HasCorrection> flag beside the Body and its UI reads THAT (clip strip
113
+ // "graded"/"ungraded", clip filters), not the body bytes — a body
114
+ // injected into a version left at false renders the grade but shows the
115
+ // clip as ungraded (JREG2, 2026-09-13). The version element lists
116
+ // HasCorrection before Body, so the last HasCorrection preceding the
117
+ // replaced Body is the owner's.
118
+ const head = newClipRange.slice(0, bodyAt);
119
+ const hcAt = head.lastIndexOf('<HasCorrection>');
120
+ if (hcAt >= 0) {
121
+ const hcEnd = head.indexOf('</HasCorrection>', hcAt);
122
+ if (hcEnd > hcAt) {
123
+ newClipRange =
124
+ head.slice(0, hcAt) +
125
+ '<HasCorrection>true</HasCorrection>' +
126
+ newClipRange.slice(hcEnd + '</HasCorrection>'.length);
127
+ }
128
+ }
129
+
109
130
  return seqXml.slice(0, openStart) + newClipRange + seqXml.slice(clipEnd);
110
131
  }
111
132
 
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "4.1.2"
90
+ VERSION = "4.2.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.1.2"
14
+ VERSION = "4.2.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -28657,8 +28657,8 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
28657
28657
  Raw mutators (UNSAFE direct mutation — prefer the safe_* sibling):
28658
28658
  set_cdl(cdl, ...) -> {success}
28659
28659
  UNSAFE. No validation; no dry_run. Prefer safe_set_cdl.
28660
- copy_grades(target_ids, ...) -> {success}
28661
- UNSAFE. No target existence check. Prefer safe_copy_grade.
28660
+ copy_grades(target_ids, confirm_token?, ...) -> {success}
28661
+ UNSAFE. Replaces target grades and is confirm-token gated. Prefer safe_copy_grade.
28662
28662
  export_lut(type, path, ...) -> {success}
28663
28663
  UNSAFE. No path sandboxing. Prefer safe_export_lut.
28664
28664
  reset_all_node_colors(...) -> {success}
@@ -28738,14 +28738,23 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
28738
28738
  elif action == "copy_grades":
28739
28739
  # Find target items by IDs
28740
28740
  _, tl, _ = _get_tl()
28741
- targets = []
28742
- target_ids = set(p["target_ids"])
28741
+ target_ids = p["target_ids"]
28743
28742
  if tl:
28744
- for tt in ["video"]:
28745
- for ti in range(1, tl.GetTrackCount(tt) + 1):
28746
- for it in (tl.GetItemListInTrack(tt, ti) or []):
28747
- if it.GetUniqueId() in target_ids:
28748
- targets.append(it)
28743
+ targets, missing = _timeline_items_for_grade_copy(tl, target_ids)
28744
+ else:
28745
+ targets, missing = [], sorted(set(target_ids or []))
28746
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
28747
+ preview = {
28748
+ "operation": "timeline_item_color.copy_grades",
28749
+ "warning": "Replaces the entire node graph on every successfully resolved target item.",
28750
+ "target_count": len(targets),
28751
+ "target_ids": [target.GetUniqueId() for target in targets],
28752
+ "missing": missing,
28753
+ }
28754
+ return _issue_confirm_token(action="timeline_item_color.copy_grades", params=p, preview=preview)
28755
+ blocked = _consume_confirm_token(action="timeline_item_color.copy_grades", params=p)
28756
+ if blocked:
28757
+ return blocked
28749
28758
  return {"success": bool(item.CopyGrades(targets))}
28750
28759
  elif action == "add_version":
28751
28760
  return {"success": bool(item.AddVersion(p["name"], p.get("type", 0)))}
@@ -422,11 +422,23 @@ API_TRUTH: List[Dict[str, Any]] = [
422
422
  "evidence of absence that exists — which makes an omitted "
423
423
  "name unrecoverable by probing. Any capability detection "
424
424
  "built on dir()/hasattr will therefore report a real Fusion "
425
- "method as missing. Resolve's own API objects do not have "
426
- "this problem: Timeline (60), TimelineItem (88) and "
427
- "Composition (92) all enumerate correctly.",
428
- "recommended": "Do not treat dir()/hasattr as authoritative for Fusion "
429
- "Tool objects. Keep a curated set of documented Fusion "
425
+ "method as missing. Resolve's own API objects enumerate "
426
+ "correctly Timeline (60), TimelineItem (88) and "
427
+ "Composition (92) so the INCOMPLETE ENUMERATION is Fusion's "
428
+ "alone. The fabrication is not: measured on Studio 19.1.3.7, "
429
+ "`hasattr(timeline_item, \'TotallyMadeUpName\')` returns True, "
430
+ "and so does hasattr for a method that genuinely does not "
431
+ "exist (ApplyGradeFromStill), while dir() on the same object "
432
+ "lists 84 real names and neither of those. So hasattr/getattr "
433
+ "is worthless for absence on EVERY Resolve object, Fusion or "
434
+ "not; what is special about Fusion Tools is that dir() is "
435
+ "wrong there too, leaving no reliable probe at all.",
436
+ "recommended": "Never use hasattr/getattr to test whether ANY Resolve "
437
+ "object has a method — it always says yes. Use dir() "
438
+ "membership, and sanity-check the enumeration with a "
439
+ "method you know exists before trusting an absence. For "
440
+ "Fusion Tool objects not even dir() is authoritative: "
441
+ "keep a curated set of documented Fusion "
430
442
  "methods that the enumeration omits, and identify a "
431
443
  "Fusion object positively (ConnectInput / FindMainInput "
432
444
  "/ GetControlPageNames on a Tool, AddTool / FindTool / "
@@ -3296,6 +3308,14 @@ API_TRUTH: List[Dict[str, Any]] = [
3296
3308
  "tags": ["destructive", "unrecoverable", "grade", "no-version"],
3297
3309
  "destroys_prior_work": True,
3298
3310
  "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3311
+ "reconfirmed": "2026-09-13: independently re-measured on 21.1.0.14 by a "
3312
+ "second contributor running color_grade_live_probe. "
3313
+ "CopyGrades returned True; the target's exported grade "
3314
+ "became byte-identical to the source's (same digest on "
3315
+ "both); GetVersionNameList read ['Version 1'] before and "
3316
+ "after, so there is still no recovery version. This is the "
3317
+ "entry that makes acknowledge_trap refuse, so it is the one "
3318
+ "that most needed a second pair of hands.",
3299
3319
  },
3300
3320
  {
3301
3321
  "symbol": "TimelineItem.ApplyGradeFromStill",
@@ -3326,6 +3346,11 @@ API_TRUTH: List[Dict[str, Any]] = [
3326
3346
  "tags": ["page-gated", "silent-failure", "lut"],
3327
3347
  "submit": "bug",
3328
3348
  "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3349
+ "reconfirmed": "2026-09-13: independently re-measured on 21.1.0.14 by a "
3350
+ "second contributor. Returned True and wrote a file only "
3351
+ "from color; deliver, edit, fairlight, fusion and media all "
3352
+ "returned False and wrote nothing, with no stale files left "
3353
+ "behind on the failing pages.",
3329
3354
  },
3330
3355
  {
3331
3356
  "symbol": "Timeline.DuplicateTimeline",
@@ -3344,6 +3369,9 @@ API_TRUTH: List[Dict[str, Any]] = [
3344
3369
  "already does this and fails loudly if the restore fails.",
3345
3370
  "tags": ["side-effect", "silent-failure", "timeline"],
3346
3371
  "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3372
+ "reconfirmed": "2026-09-13: independently re-measured on 21.1.0.14 by a "
3373
+ "second contributor. The current-timeline pointer moved to "
3374
+ "the duplicate, and SetCurrentTimeline put it back.",
3347
3375
  },
3348
3376
 
3349
3377
  ]
@@ -61,6 +61,35 @@ def _record_tool_result(
61
61
  recorder.record(category, name, expected_status or "supported", evidence=result)
62
62
 
63
63
 
64
+ def _call_confirmed(tool, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Call a destructive action, answering the confirmation gate if it fires.
66
+
67
+ Actions rated destructive answer the first call with
68
+ `status="confirmation_required"` and a one-time token instead of acting. This
69
+ probe is an operator-run harness whose whole purpose is to perform these
70
+ mutations on a disposable project, so it answers the prompt rather than
71
+ recording the prompt as the action's outcome — which is what it used to do,
72
+ leaving `safe_copy_grade` and `safe_apply_drx` permanently unpassable.
73
+
74
+ The token is minted against a fingerprint of (action, params) with
75
+ `confirm_token` stripped, so the second call must repeat the same params.
76
+ """
77
+ result = tool(action, params)
78
+ if not isinstance(result, dict):
79
+ return result
80
+ token = result.get("confirm_token")
81
+ if result.get("status") != "confirmation_required" or not token:
82
+ return result
83
+
84
+ confirmed = tool(action, {**params, "confirm_token": token})
85
+ if isinstance(confirmed, dict):
86
+ confirmed["confirmation_gate"] = {
87
+ "fired": True,
88
+ "preview": result.get("preview"),
89
+ }
90
+ return confirmed
91
+
92
+
64
93
  def _run_ffmpeg(args: list[str]) -> None:
65
94
  subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
66
95
 
@@ -328,6 +357,62 @@ def _verify_duplicatetimeline_moves_pointer(recorder, project, timeline) -> None
328
357
  recorder.record("api_truth", "DuplicateTimeline_moves_current", "error", details=details)
329
358
 
330
359
 
360
+ def _verify_applygradefromstill_absent(recorder, items) -> None:
361
+ """TimelineItem.ApplyGradeFromStill: is it still absent?
362
+
363
+ The entry for this one is a claim that a method does NOT exist, and a probe
364
+ that only re-measures behaviours leaves it as folklore — nothing notices the
365
+ day Blackmagic ships it. An absence is cheap to re-measure, so it is checked
366
+ here rather than trusted.
367
+
368
+ Measured with `dir()`, never `hasattr`. Resolve fabricates a callable for any
369
+ attribute name you ask for, on its own API objects and not just Fusion ones:
370
+ on Studio 19.1.3.7 `hasattr(item, "TotallyMadeUpName")` is True. A hasattr
371
+ check here would report this method "restored" on every run, forever. `dir()`
372
+ on a TimelineItem enumerates honestly (84 real names on that build), so it is
373
+ the only usable evidence of absence.
374
+ """
375
+ if not items:
376
+ recorder.record("api_truth", "ApplyGradeFromStill_absent", "not_applicable",
377
+ details={"reason": "probe timeline has no items"})
378
+ return
379
+
380
+ item = items[0]
381
+ try:
382
+ names = dir(item)
383
+ present = "ApplyGradeFromStill" in names
384
+ control = "CopyGrades" in names
385
+ except Exception as exc: # noqa: BLE001
386
+ recorder.record_exception("api_truth", "ApplyGradeFromStill_absent", exc)
387
+ return
388
+
389
+ details = {
390
+ "present_on_timelineitem": present,
391
+ "enumerated_names": len(names),
392
+ "control_copygrades_enumerated": control,
393
+ "measured_with": "dir() — hasattr fabricates callables on Resolve objects",
394
+ "api_truth_claims": "TimelineItem.ApplyGradeFromStill does not exist",
395
+ }
396
+ if not control:
397
+ # dir() stopped enumerating usefully; absence proves nothing here.
398
+ details["reason"] = (
399
+ "CopyGrades is missing from dir() too, so this object is not "
400
+ "enumerating — the absence of ApplyGradeFromStill is not evidence."
401
+ )
402
+ recorder.record("api_truth", "ApplyGradeFromStill_absent", "not_applicable", details=details)
403
+ return
404
+ if not present:
405
+ recorder.record("api_truth", "ApplyGradeFromStill_absent", "supported", details=details)
406
+ return
407
+
408
+ details["drifted"] = (
409
+ "ApplyGradeFromStill now exists on TimelineItem. The api_truth entry "
410
+ "calling it missing is stale — measure what it does before anyone "
411
+ "relies on it."
412
+ )
413
+ recorder.record("api_truth", "ApplyGradeFromStill_absent", "error", details=details)
414
+
415
+
331
416
  def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, Any]:
332
417
  output_dir.mkdir(parents=True, exist_ok=True)
333
418
  work_dir = Path(tempfile.mkdtemp(prefix="mcp_color_grade_probe_"))
@@ -478,7 +563,11 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
478
563
  recorder,
479
564
  "copy",
480
565
  "safe_copy_grade",
481
- server.timeline_item_color("safe_copy_grade", {**scope, "target_ids": [target_id]}),
566
+ _call_confirmed(
567
+ server.timeline_item_color,
568
+ "safe_copy_grade",
569
+ {**scope, "target_ids": [target_id]},
570
+ ),
482
571
  )
483
572
  else:
484
573
  recorder.record("copy", "safe_copy_grade", "not_applicable", details={"reason": "No second video item"})
@@ -590,7 +679,7 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
590
679
  recorder,
591
680
  "drx",
592
681
  "safe_apply_drx",
593
- server.timeline_item_color("safe_apply_drx", apply_params),
682
+ _call_confirmed(server.timeline_item_color, "safe_apply_drx", apply_params),
594
683
  )
595
684
  else:
596
685
  recorder.record("drx", "safe_apply_drx", "not_applicable", details={"reason": "No DRX was exported by gallery probe"})
@@ -607,6 +696,7 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
607
696
  _verify_copygrades_replaces_wholesale(recorder, resolve, items, work_dir)
608
697
  _verify_exportlut_page_gate(recorder, server, resolve, items, work_dir)
609
698
  _verify_duplicatetimeline_moves_pointer(recorder, project, timeline)
699
+ _verify_applygradefromstill_absent(recorder, items)
610
700
 
611
701
  if keep_open:
612
702
  server.project_manager("save")
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Stand-ins for the `mcp` package, for live harnesses that import `src.server`.
3
+
4
+ `src.server` imports the MCP SDK at module scope, so a live harness cannot reach
5
+ the tool functions without it. Harnesses used to each carry a private copy of a
6
+ stub installer, and every copy drifted: `src.server` grew `Context`, `Image` and
7
+ `mcp.types`, the copies kept offering only `FastMCP`, and each one broke at the
8
+ import it had never been taught about.
9
+
10
+ Two rules keep that from recurring:
11
+
12
+ 1. **Never stub over a real package.** The old copies called
13
+ `sys.modules.setdefault(...)` before anything had imported `mcp`, so the
14
+ stand-ins won on machines where the genuine SDK was installed and working.
15
+ `install_mcp_stubs()` imports the real package first and leaves it alone.
16
+ 2. **One stub set, checked against its consumer.** `MCP_STUB_NAMES` records what
17
+ the stubs provide; `tests/test_mcp_import_stubs.py` reads the `mcp` imports
18
+ out of `src/server.py` and fails when the server starts needing a name the
19
+ stubs do not define.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ import types
26
+ from typing import Dict, Tuple
27
+
28
+ # What the stub set provides, per module. The drift guard compares this against
29
+ # the names `src/server.py` actually imports, so adding an import there without
30
+ # teaching the stubs about it fails a test instead of a live run.
31
+ MCP_STUB_NAMES: Dict[str, Tuple[str, ...]] = {
32
+ "mcp": ("types",),
33
+ "mcp.server": (),
34
+ "mcp.server.fastmcp": ("Context", "FastMCP", "Image"),
35
+ "mcp.server.stdio": ("stdio_server",),
36
+ "mcp.types": ("ToolAnnotations", "ImageContent", "TextContent"),
37
+ }
38
+
39
+
40
+ def _real_mcp_is_importable() -> bool:
41
+ """True when the genuine SDK is installed and exposes what the server needs."""
42
+ try:
43
+ import mcp # noqa: F401
44
+ from mcp import types as _real_types # noqa: F401
45
+ from mcp.server.fastmcp import Context, FastMCP, Image # noqa: F401
46
+ except Exception:
47
+ return False
48
+ return True
49
+
50
+
51
+ def _build_stub_modules(*, stdio_note: str) -> Dict[str, types.ModuleType]:
52
+ class FastMCP:
53
+ def __init__(self, *args, **kwargs):
54
+ pass
55
+
56
+ def _decorator(self, *args, **kwargs):
57
+ def decorate(func):
58
+ return func
59
+
60
+ return decorate
61
+
62
+ tool = _decorator
63
+ resource = _decorator
64
+ prompt = _decorator
65
+
66
+ class Context:
67
+ pass
68
+
69
+ class Image:
70
+ def __init__(self, *args, **kwargs):
71
+ pass
72
+
73
+ class ToolAnnotations:
74
+ def __init__(self, *args, **kwargs):
75
+ pass
76
+
77
+ def stdio_server(*args, **kwargs):
78
+ raise RuntimeError(stdio_note)
79
+
80
+ anyio = types.ModuleType("anyio")
81
+ anyio.run = lambda func: func()
82
+
83
+ mcp = types.ModuleType("mcp")
84
+ server = types.ModuleType("mcp.server")
85
+ fastmcp = types.ModuleType("mcp.server.fastmcp")
86
+ stdio = types.ModuleType("mcp.server.stdio")
87
+ mcp_types = types.ModuleType("mcp.types")
88
+
89
+ fastmcp.FastMCP = FastMCP
90
+ fastmcp.Context = Context
91
+ fastmcp.Image = Image
92
+ stdio.stdio_server = stdio_server
93
+ mcp_types.ToolAnnotations = ToolAnnotations
94
+ mcp_types.ImageContent = object
95
+ mcp_types.TextContent = object
96
+ mcp.types = mcp_types
97
+
98
+ return {
99
+ "anyio": anyio,
100
+ "mcp": mcp,
101
+ "mcp.server": server,
102
+ "mcp.server.fastmcp": fastmcp,
103
+ "mcp.server.stdio": stdio,
104
+ "mcp.types": mcp_types,
105
+ }
106
+
107
+
108
+ def install_mcp_stubs(*, stdio_note: str = "stdio_server is not used by this live harness") -> bool:
109
+ """Make `import src.server` work, without displacing a working MCP SDK.
110
+
111
+ Returns True when stand-ins were installed, False when the real package was
112
+ found and left in place — so a harness can say which one it ran against.
113
+ """
114
+ if _real_mcp_is_importable():
115
+ return False
116
+
117
+ for name, module in _build_stub_modules(stdio_note=stdio_note).items():
118
+ sys.modules.setdefault(name, module)
119
+ return True
@@ -20,7 +20,6 @@ import tempfile
20
20
 
21
21
  from src.utils.resolve_probe import has_method
22
22
  import time
23
- import types
24
23
  import traceback
25
24
  from pathlib import Path
26
25
  from typing import Any, Dict, Iterable, List, Optional, Tuple
@@ -101,43 +100,17 @@ EXTRA_TIMELINE_ITEM_METHODS = [
101
100
 
102
101
 
103
102
  def _install_mcp_stubs() -> None:
104
- """Allow importing src.server when MCP deps are absent from Python 3.11."""
103
+ """Stand in for the MCP SDK only when it is genuinely absent.
105
104
 
106
- class FastMCP:
107
- def __init__(self, *args, **kwargs):
108
- pass
109
-
110
- def tool(self, *args, **kwargs):
111
- def decorate(func):
112
- return func
113
-
114
- return decorate
115
-
116
- def resource(self, *args, **kwargs):
117
- def decorate(func):
118
- return func
119
-
120
- return decorate
121
-
122
- def stdio_server(*args, **kwargs):
123
- raise RuntimeError("stdio_server is not used by the live timeline kernel probe")
124
-
125
- anyio = types.ModuleType("anyio")
126
- anyio.run = lambda func: func()
127
-
128
- mcp = types.ModuleType("mcp")
129
- server = types.ModuleType("mcp.server")
130
- fastmcp = types.ModuleType("mcp.server.fastmcp")
131
- stdio = types.ModuleType("mcp.server.stdio")
132
-
133
- fastmcp.FastMCP = FastMCP
134
- stdio.stdio_server = stdio_server
105
+ Delegates to the shared installer so this harness cannot drift behind the
106
+ imports `src.server` actually makes; see `src/utils/mcp_import_stubs.py`.
107
+ """
108
+ repo_root = str(Path(__file__).resolve().parents[2])
109
+ if repo_root not in sys.path:
110
+ sys.path.insert(0, repo_root)
111
+ from src.utils.mcp_import_stubs import install_mcp_stubs
135
112
 
136
- sys.modules.setdefault("anyio", anyio)
137
- sys.modules.setdefault("mcp", mcp)
138
- sys.modules.setdefault("mcp.server", server)
139
- sys.modules.setdefault("mcp.server.fastmcp", fastmcp)
140
- sys.modules.setdefault("mcp.server.stdio", stdio)
113
+ install_mcp_stubs(stdio_note="stdio_server is not used by this live harness")
141
114
 
142
115
 
143
116
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]: