davinci-resolve-mcp 4.1.1 → 4.1.3
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 +141 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/reference/api-limitations.md +2 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +33 -5
- package/src/utils/color_grade_live_probe.py +92 -2
- package/src/utils/mcp_import_stubs.py +119 -0
- package/src/utils/timeline_kernel_live_probe.py +9 -36
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.1.3 — every live harness could no longer start, and a probe that could never pass
|
|
6
|
+
|
|
7
|
+
Reported and measured by [@legionsound](https://github.com/legionsound) in
|
|
8
|
+
[#207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207) while
|
|
9
|
+
running `color_grade_live_probe` on Studio 21.1.0.14. No server behaviour
|
|
10
|
+
changes; the harnesses that verify Resolve's behaviour do.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Every hand-run live harness failed at import, on every machine**, with
|
|
15
|
+
`ImportError: cannot import name 'Context' from 'mcp.server.fastmcp'` at
|
|
16
|
+
`src/server.py:240`. Seventeen harnesses each carried a private copy of a stub
|
|
17
|
+
installer, and the copies were wrong in two independent ways:
|
|
18
|
+
|
|
19
|
+
- They called `sys.modules.setdefault("mcp", stub)` *before* anything had
|
|
20
|
+
imported `mcp`, so the stand-ins displaced the **real, working SDK** on
|
|
21
|
+
machines that had it. The stub set was never a fallback in practice; it was
|
|
22
|
+
always what ran.
|
|
23
|
+
- `src/server.py` grew `Context`, `Image` and `mcp.types`; fifteen of the
|
|
24
|
+
seventeen copies still offered only `FastMCP`. Each harness died at whichever
|
|
25
|
+
import its own copy had never been taught about.
|
|
26
|
+
|
|
27
|
+
There is now one installer, `src/utils/mcp_import_stubs.py`, which **imports
|
|
28
|
+
the real package first and leaves it alone**, and only stands in when the SDK
|
|
29
|
+
is genuinely absent. All seventeen call it; 648 lines of divergent copies are
|
|
30
|
+
gone. `tests/test_mcp_import_stubs.py` reads the SDK imports back out of
|
|
31
|
+
`src/server.py` and fails when the stub set falls behind, or when a harness
|
|
32
|
+
hand-rolls its own again — both regressions were re-introduced deliberately to
|
|
33
|
+
confirm the guard catches them.
|
|
34
|
+
|
|
35
|
+
- **`safe_copy_grade` and `safe_apply_drx` could never pass in the probe.** Both
|
|
36
|
+
are rated destructive, so the first call returns `CONFIRMATION_REQUIRED` and a
|
|
37
|
+
one-time token *instead of acting*. The probe predates confirm tokens, called
|
|
38
|
+
once, and recorded the prompt as the action's outcome — two permanent errors in
|
|
39
|
+
a report whose purpose is to notice change. It now answers the gate and records
|
|
40
|
+
what the action actually did, repeating the params the token's fingerprint is
|
|
41
|
+
bound to.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **`TimelineItem.ApplyGradeFromStill` is now re-measured rather than trusted.**
|
|
46
|
+
It was the one #217 entry the probe never exercised — a claim that a method
|
|
47
|
+
does *not* exist, which nothing would notice Blackmagic reversing. The check
|
|
48
|
+
uses `dir()` membership and sanity-checks the enumeration against a method
|
|
49
|
+
known to exist before treating an absence as evidence.
|
|
50
|
+
|
|
51
|
+
- **Corrected an api_truth entry that implied `hasattr` is safe on Resolve's own
|
|
52
|
+
objects.** It is not. Measured here on Studio 19.1.3.7:
|
|
53
|
+
`hasattr(timeline_item, 'TotallyMadeUpName')` returns `True`, as does `hasattr`
|
|
54
|
+
for `ApplyGradeFromStill`, while `dir()` on the same object lists 84 real names
|
|
55
|
+
and neither of those. Resolve fabricates a callable for **any** attribute name
|
|
56
|
+
on **every** object, not only Fusion Tools; what is special about Fusion Tools
|
|
57
|
+
is that `dir()` is unreliable there too, leaving no usable probe at all. A
|
|
58
|
+
capability check written on `hasattr` reports every method as present.
|
|
59
|
+
|
|
60
|
+
### Reconfirmed
|
|
61
|
+
|
|
62
|
+
Three of the four trap entries from
|
|
63
|
+
[#217](https://github.com/samuelgursky/davinci-resolve-mcp/pull/217) were
|
|
64
|
+
independently re-measured on Studio 21.1.0.14 by a second contributor, and now
|
|
65
|
+
carry it. This matters most for `TimelineItem.CopyGrades`, which is the entry
|
|
66
|
+
that makes a mapped action refuse without `acknowledge_trap`:
|
|
67
|
+
|
|
68
|
+
- **`TimelineItem.CopyGrades`** — returned `True`; the target's exported grade
|
|
69
|
+
became byte-identical to the source's; `GetVersionNameList` read
|
|
70
|
+
`['Version 1']` before and after, so there is still no recovery version.
|
|
71
|
+
- **`TimelineItem.ExportLUT`** — wrote a file only from `color`; `deliver`,
|
|
72
|
+
`edit`, `fairlight`, `fusion` and `media` all returned `False` and left no
|
|
73
|
+
stale files.
|
|
74
|
+
- **`Timeline.DuplicateTimeline`** — the current-timeline pointer moved to the
|
|
75
|
+
duplicate, and `SetCurrentTimeline` put it back.
|
|
76
|
+
|
|
77
|
+
`TimelineItem.ApplyGradeFromStill` stays **reported**, not reconfirmed — that
|
|
78
|
+
probe run did not exercise it. The check added above closes that gap for the
|
|
79
|
+
next run.
|
|
80
|
+
|
|
81
|
+
### Validation
|
|
82
|
+
|
|
83
|
+
Full suite green: 3,616 passed, 1 skipped, 1,257 subtests. The count rises by
|
|
84
|
+
exactly the three new guard tests. No live Resolve run beyond the read-only
|
|
85
|
+
attribute measurement quoted above, taken on Studio 19.1.3.7 — the harness
|
|
86
|
+
changes are import-path and gate-protocol fixes, verified against the real
|
|
87
|
+
token machinery offline.
|
|
88
|
+
|
|
89
|
+
## What's New in v4.1.2 — the installer's healthy-branch test stops depending on a live Resolve
|
|
90
|
+
|
|
91
|
+
Test-only. No behaviour change to the server or the installer.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **`SetupExitStatusTests.test_a_working_install_still_reports_ready_and_exits_zero`
|
|
96
|
+
failed roughly once per full-suite run** while passing in isolation and on an
|
|
97
|
+
immediate re-run. It was not the shared-state bug class this repo has seen
|
|
98
|
+
before: it asserted the healthy branch by running the **real** probe, which
|
|
99
|
+
spawns a subprocess asking a live GUI application to answer over IPC within
|
|
100
|
+
10 seconds and returns `False, "Connection timed out"` if it does not.
|
|
101
|
+
Resolve can be mid-launch, showing a modal, loading a project, or simply slow
|
|
102
|
+
while the rest of the suite saturates the machine — none of which is a defect
|
|
103
|
+
in the installer, which is the only thing the test exists to catch.
|
|
104
|
+
|
|
105
|
+
What it actually guards — *a successful verification must print
|
|
106
|
+
`Environment ready!` and return 0* — is a property of `main()`'s reporting,
|
|
107
|
+
not of the host. It is now asserted against a **pinned** verification result,
|
|
108
|
+
so it is deterministic and runs everywhere, including on CI with no Resolve
|
|
109
|
+
installed. Its mirror (*a stated failure is never reported ready*) is pinned
|
|
110
|
+
the same way.
|
|
111
|
+
|
|
112
|
+
- **The live probe is still exercised, as an integration check**, by
|
|
113
|
+
`test_the_live_probe_agrees_with_the_summary`. It asserts the summary and
|
|
114
|
+
exit status **agree with whatever the probe said** — and skips, naming the
|
|
115
|
+
probe's own message, when the probe did not answer. It is not an assertion
|
|
116
|
+
that the probe succeeds, because that is not something a unit suite can
|
|
117
|
+
guarantee. A probe that answers and a summary that contradicts it still
|
|
118
|
+
fails, which is the regression that matters.
|
|
119
|
+
|
|
120
|
+
- **The skip gate admitted machines the test could not pass.**
|
|
121
|
+
`_resolve_is_installed()` checked only for a `fusionscript` library, but
|
|
122
|
+
`main()` sets `verification_failed` when `api_path` is falsy — printing
|
|
123
|
+
`Skipped — Resolve API path not detected` — *before* the probe runs. On a
|
|
124
|
+
machine with the app installed but no `Developer/Scripting` directory (Studio's
|
|
125
|
+
installer can omit that component; on Linux it may sit outside the defaults),
|
|
126
|
+
the test therefore failed **deterministically**, for a reason unrelated to
|
|
127
|
+
what it pins. The gate now requires both halves, and it expands `{user}` the
|
|
128
|
+
way `find_resolve_paths()` does.
|
|
129
|
+
|
|
130
|
+
### Guards
|
|
131
|
+
|
|
132
|
+
- Every reporting test in `SetupExitStatusTests` is re-run with
|
|
133
|
+
`verify_resolve_connection` booby-trapped to raise, so any test that reaches
|
|
134
|
+
a live Resolve — by dropping its pin, by letting discovery find the host's
|
|
135
|
+
install, or by being added without one — fails at authoring time instead of
|
|
136
|
+
once a fortnight in someone's suite run. Exactly one test is exempt, named in
|
|
137
|
+
`LIVE_TEST`.
|
|
138
|
+
- The ready assertion is re-asserted against a dead `RESOLVE_PATHS`, pinning
|
|
139
|
+
that the summary follows the verification result rather than the machine.
|
|
140
|
+
- A pinned verification is asserted to actually replace the probe rather than
|
|
141
|
+
shadow it, so pinning the wrong symbol cannot quietly reacquire the flake.
|
|
142
|
+
- The gate is asserted to reject a library with no API directory beside it.
|
|
143
|
+
|
|
144
|
+
All four fail when the defect they pin is reintroduced.
|
|
145
|
+
|
|
5
146
|
## What's New in v4.1.1 — drift detection stops comparing two different timelines
|
|
6
147
|
|
|
7
148
|
Reported by @V2arK (#224), with the root cause correctly diagnosed in the report.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.1.
|
|
15
|
+
> 本翻译对应 v4.1.3 版 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
|
|
459
|
-
- **Workaround / current handling:**
|
|
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.
|
|
40
|
+
VERSION = "4.1.3"
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "4.1.
|
|
90
|
+
VERSION = "4.1.3"
|
|
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
package/src/utils/api_truth.py
CHANGED
|
@@ -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
|
|
426
|
-
"
|
|
427
|
-
"Composition (92)
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
103
|
+
"""Stand in for the MCP SDK only when it is genuinely absent.
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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]:
|