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 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
- [![Version](https://img.shields.io/badge/version-4.1.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.1.3-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.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.1.3-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.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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 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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "4.1.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
@@ -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.1"
14
+ VERSION = "4.1.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -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]: