davinci-resolve-mcp 4.12.0 → 4.13.1

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,105 @@
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.13.1 — Windows: liveness checks no longer kill the process they check, and the offline suite runs clean
6
+
7
+ Closes #278, reported by @AllastorV with the failures grouped by cause. Nobody
8
+ here runs Windows, so everything below was worked out from the code and
9
+ simulated on macOS: the Windows-only API calls are covered by tests that run
10
+ only on Windows.
11
+
12
+ ### Fixes
13
+
14
+ - **On Windows, three pid liveness checks terminated the process they asked
15
+ about.** Python's `os.kill(pid, 0)` is a harmless existence check on POSIX,
16
+ but on Windows any signal other than `CTRL_C_EVENT`/`CTRL_BREAK_EVENT` goes
17
+ through `TerminateProcess`. `resolve_busy._pid_alive` (the busy sidecar),
18
+ `mcp_transport.read_transport_state` (the networked-transport state file)
19
+ and `server._control_panel_pid_alive` (the control-panel pidfile) used it,
20
+ and all three are routinely asked about the server's own pid. On Windows they
21
+ now use `proc.windows_pid_alive` (`OpenProcess` + `WaitForSingleObject`, the
22
+ approach the bridge already used); POSIX is unchanged. A new AST guard fails
23
+ the suite if a signal-0 probe lands in `src/` without a Windows branch.
24
+ - **`.gitattributes` pins `eol=lf` on the 32 generated agent-rule files**, so
25
+ a default `core.autocrlf=true` checkout no longer fails
26
+ `generate.mjs --check`. Existing Windows clones need
27
+ `git add --renormalize .` or a fresh checkout.
28
+
29
+ ### Tests (Windows portability, no behaviour change)
30
+
31
+ - **SQLite handles closed before temp dirs are removed** (56 tests across
32
+ `test_resolve_ai_ledger`, `test_resolve_ai_governance`,
33
+ `test_resolve21_actions`, `test_provenance`, `MediaAnalysisPlanningTests` and
34
+ others). Each class follows the existing pattern, closing with
35
+ `timeline_brain_db.close_all` registered so it runs before the removal. No
36
+ central hook was added, so a test that leaks a handle still shows. The list
37
+ came from a macOS run that flags any removal under a live connection: 56
38
+ before, 0 after.
39
+ - Path expectations are built the way the code builds them, not as POSIX
40
+ strings (`test_cdl_and_install_config` and others).
41
+ - Symlink-dependent cases skip, with the `OSError` as the reason, when the
42
+ process cannot create symlinks.
43
+ - One-offs: `sys.executable` instead of `python3`/`cat`; `tempfile.gettempdir()`
44
+ instead of a literal `/tmp`; the home directory stays resolvable
45
+ (`USERPROFILE` as well as `HOME`); sandbox-container installer tests pinned
46
+ to macOS; the unwritable-target installer tests reworked so they still test
47
+ Windows; a stale `taskkill` expectation updated; `proc.kill()` instead of
48
+ `SIGKILL`.
49
+
50
+ ## What's New in v4.13.0 — Resolve 21.1 render-setting parity, `SetHighPriority(bool)`, and a project-destroying preset order
51
+
52
+ Lands the three items queued for @legionsound in #207. The 21.1 behaviour
53
+ below is **their measurement on Studio 21.1.0.14 / macOS**, recorded as
54
+ *reported*; this machine runs 19.1.3.7 and did not reproduce it.
55
+
56
+ ### New
57
+
58
+ - **`resolve_control set_high_priority` takes `high`** (alias
59
+ `high_priority`, default `true`), and there is a new granular
60
+ **`set_high_priority(high)`** tool. On 21.1+ it calls
61
+ `SetHighPriority(bool)`, which 21.1 types as script execution priority and
62
+ which is reversible there: `high=false` restores normal. The no-argument call
63
+ this server used to make returns `False` on 21.1 and does nothing. Builds
64
+ before 21.1 keep the no-argument call, and `high=false` is refused on them,
65
+ because they cannot lower priority without a restart. `high` is coerced
66
+ (`"false"` is false); anything that doesn't name a boolean is refused before
67
+ any call. No build has a getter, so results carry `readback: null` and claim
68
+ no readback. Registered as a write in both the destructive registry and the
69
+ lifecycle risk sets (MEDIUM).
70
+ - **Compound render settings accept `AudioFormat` and `UseUniqueFilenames`.**
71
+ The compound whitelist held 31 of the 33 typed `RenderSettings` fields and
72
+ reported these two as ignored; granular already forwarded them. A test now
73
+ checks the whitelist against every field of the bundled 21.1 stub's
74
+ `RenderSettings`. `UseUniqueFilenames` is coerced on every path (compound
75
+ `set_settings`, `safe_set_render_settings`, `prepare_render_job`, granular
76
+ `set_render_settings`), and an unrecognised value is refused with
77
+ `INVALID_RENDER_SETTING`. `AudioFormat` with `ExportVideo: true` warns,
78
+ since the stub documents it for audio-only renders.
79
+
80
+ ### API truth ledger
81
+
82
+ - **`Project.StartRendering` is state-dependent** (reported, 21.1.0.14):
83
+ freshly configured jobs sometimes return `False` and stay Ready, while an
84
+ identical later job completes. Direct official calls reproduced both
85
+ outcomes. Mapped to `render start`, so the fact rides on its results. Verify
86
+ job status and output; nothing retries automatically.
87
+ - **Project-settings presets mutated before a project's first `SaveProject`
88
+ destroy the project** (reported, 21.1.0.14, reproduced twice): saving a
89
+ preset, changing resolution, then updating, applying or deleting the preset,
90
+ then saving and closing. Every call returned `True` and SQLite
91
+ `integrity_check` said `ok`, but `LoadProject` then failed with "DaVinci
92
+ Resolve failed to open project". Save the project once before any preset
93
+ write, and prove it survived only by reloading it. No preset-write wrappers
94
+ ship yet; this entry lands first on purpose.
95
+ - The `Resolve.SetHighPriority` entry is now version-scoped: irreversible with
96
+ no argument before 21.1 (measured on 19.1.3.7), and a reversible boolean on
97
+ 21.1 (reported).
98
+
99
+ ### Counts
100
+
101
+ - Granular tools 389 → 390; compound stays at 37. Docs and generated agent
102
+ rules updated.
103
+
5
104
  ## What's New in v4.12.0 — Live subtitle presets and title text across Fusion title tools
6
105
 
7
106
  Contributed by @sidevconcept (#281). The live behaviour below rests on their
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.12.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.13.1-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
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#server-modes)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#server-modes)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#server-modes)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
@@ -133,7 +133,7 @@ The command starts a loopback-only server and opens the control panel in your br
133
133
  | Mode | Entry point | Tools | Best for |
134
134
  |------|-------------|-------|----------|
135
135
  | Compound | `src/server.py` | 37 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
136
- | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 389 | Power users who want one MCP tool per Resolve API method. |
136
+ | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 390 | Power users who want one MCP tool per Resolve API method. |
137
137
 
138
138
  The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
139
139
 
@@ -410,7 +410,7 @@ The default server is a local stdio process launched by your MCP client; it does
410
410
 
411
411
  | Metric | Value |
412
412
  |--------|-------|
413
- | MCP Tools | **37** compound / **389** granular (live server) |
413
+ | MCP Tools | **37** compound / **390** granular (live server) |
414
414
  | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
415
415
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
416
416
  | API Methods Covered | **361/361** (100%) |
package/README.zh-CN.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.12.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.13.1-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
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#服务器模式)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#服务器模式)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#服务器模式)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
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.12.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.13.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -88,7 +88,7 @@ venv/bin/python -m src.control_panel
88
88
  | 模式 | 入口 | 工具数 | 适合谁 |
89
89
  |------|------|--------|--------|
90
90
  | Compound(复合) | `src/server.py` | 37 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
91
- | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 389 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
91
+ | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 390 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
92
92
 
93
93
  除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
94
94
 
@@ -226,7 +226,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
226
226
 
227
227
  | 指标 | 数值 |
228
228
  |------|------|
229
- | MCP 工具 | **37** 复合 / **389** 细粒度(实时服务器) |
229
+ | MCP 工具 | **37** 复合 / **390** 细粒度(实时服务器) |
230
230
  | Advanced(离线)工具 | **18**——.drp/.drt/.drx + 数据库创作,无需 Resolve 运行 |
231
231
  | 内核 action | 9 个复合工具下 **136** 个带护栏的工作流 action |
232
232
  | API 方法覆盖 | **361/361**(100%) |
package/docs/SKILL.md CHANGED
@@ -353,7 +353,7 @@ to the user as verified.
353
353
  | Mode | Entry point | Tool count | Use when |
354
354
  |---|---|---|---|
355
355
  | Compound (default) | `src/server.py` | 37 tools | Most workflows — keeps context lean |
356
- | Granular (full) | `src/server.py --full` | 389 tools | Power users needing one tool per API method |
356
+ | Granular (full) | `src/server.py --full` | 390 tools | Power users needing one tool per API method |
357
357
 
358
358
  Resolve 21.1 adds [twelve read-only discovery controls](reference/resolve211-read-controls.md)
359
359
  for edition, presets, audio formats/codecs, normalization modes, speed, fades
@@ -796,6 +796,12 @@ Key actions:
796
796
  is fine. No connection needed, and it never launches Resolve
797
797
  - `get_page` / `open_page(page)` — read or switch the active page
798
798
  - `get_keyframe_mode` / `set_keyframe_mode(mode)`
799
+ - `set_high_priority(high?)` — script execution priority (granular:
800
+ `set_high_priority`). `high` defaults to true; on Resolve 21.1+ it calls
801
+ `SetHighPriority(bool)` and `high=false` restores normal. Older builds take no
802
+ argument and cannot lower it again this session, so `high=false` is refused
803
+ there. No build has a getter — report the native return, never a confirmed
804
+ state. Only call when the user asked for it
799
805
  - `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
800
806
  preset names
801
807
  - `list/save/load/delete/import/export_user_preferences_preset` — Resolve
package/docs/install.md CHANGED
@@ -144,7 +144,7 @@ The MCP server comes in two modes:
144
144
  | Mode | File | Tools | Best For |
145
145
  |------|------|-------|----------|
146
146
  | **Compound** (default) | `src/server.py` | 37 | Most users — fast, clean, low context usage |
147
- | **Full** | `src/resolve_mcp_server.py` | 389 | Power users who want one tool per API method |
147
+ | **Full** | `src/resolve_mcp_server.py` | 390 | Power users who want one tool per API method |
148
148
 
149
149
  The compound server's `timeline_item` tool includes dedicated actions for common workflows:
150
150
 
@@ -159,7 +159,7 @@ The compound server's `timeline_item` tool includes dedicated actions for common
159
159
 
160
160
  The installer uses the compound server by default. To use the full server:
161
161
  ```bash
162
- python src/server.py --full # Launch full 389-tool server
162
+ python src/server.py --full # Launch full 390-tool server
163
163
  # Or point your MCP config directly at src/resolve_mcp_server.py
164
164
  ```
165
165
 
@@ -25,7 +25,7 @@ Every non-deprecated method in the bundled legacy README is represented. This
25
25
  does not claim complete coverage of the newer Resolve 21.1 typed API. The
26
26
  default compound server exposes **37 tools** that group related operations by
27
27
  action parameter, keeping LLM context windows lean. The full granular server
28
- provides **389 individual tools** for power users. The legacy coverage spans
28
+ provides **390 individual tools** for power users. The legacy coverage spans
29
29
  13 API object classes. MCP-level kernel actions are tracked separately in
30
30
  [Kernel Action Coverage](../kernels/README.md).
31
31
 
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 42 missing capabilities, 56 bugs / unreliable behaviors.
15
+ **Totals:** 42 missing capabilities, 58 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -288,13 +288,14 @@ equivalent, blocking full automation.
288
288
  - **Workaround / current handling:** Record interpolation choices in the caller; readback is not available at any version.
289
289
  - **Tags:** timeline, missing-method, readback, keyframes
290
290
 
291
- ### Resolve.SetHighPriority (write-only, irreversible per session)
291
+ ### Resolve.SetHighPriority (write-only; irreversible before 21.1)
292
292
 
293
293
  - **Object:** `Resolve`
294
- - **Signature:** `() -> Bool`
295
- - **Behavior:** Raises the Resolve process priority; there is no getter and no way to lower it again through the API (confirmed absent from dir() on Studio 19.1.3.7).
296
- - **Workaround / current handling:** Call it only when the user asked for a long render on a dedicated machine; state cannot be read back or undone without restarting Resolve.
297
- - **Tags:** app-control, missing-method, readback
294
+ - **Signature:** `() -> Bool (19.x); (highPriority: bool) -> bool (21.1 typed stub)`
295
+ - **Behavior:** VERSION-SCOPED. On Studio 19.1.3.7 (measured here) the method takes no argument and raises priority; there is no getter and no way to lower it again through the API (no getter in dir()), so it is irreversible for the session. Resolve 21.1 retyped it SetHighPriority(highPriority: bool) -> bool, documented as 'script execution priority to high or normal'. Reported by @legionsound (issue #207) from Studio 21.1.0.14 on macOS, 2026-09-09; NOT reproduced here (no 21.1 install). One live session, in order: (False) -> True, (True) -> True, () -> False, (1) -> False, ('high') -> False, (False) -> True. So on 21.1 the no-argument call is a NO-OP that reports its own failure, only a real bool is accepted (1 and 'high' are rejected like the missing argument), and the setting is REVERSIBLE: SetHighPriority(False) restores normal. There is still no getter on any build — dir() on the live 21.1 Resolve object shows SetHighPriority as the only priority-related name — so the current state cannot be read back on either generation.
296
+ - **Workaround / current handling:** On 21.1+ pass a real bool; never forward 1 or a string (coerce first — resolve_control set_high_priority and granular set_high_priority do, and refuse values that name no boolean). On older builds call it only when the user asked for a long scripted job on a dedicated machine: it cannot be lowered without restarting Resolve, which is why both tools refuse high=false there. No build offers readback, so report the native return, never a confirmed state.
297
+ - **Reference:** [issue #207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207)
298
+ - **Tags:** app-control, missing-method, readback, version-gated, reported
298
299
 
299
300
  ### TimelineItem.CreateMagicMask (needs operator clicks)
300
301
 
@@ -796,6 +797,24 @@ values, or automation-hostile modal prompts.
796
797
  - **Workaround / current handling:** Any wrapper around ValidateDCTL must pass the native diagnostic through verbatim and must not reflow or rewrite the user's source to dodge it; ship the multi-line identity fixture as the known-good control. A 'no return value' error on a one-line function is this quirk, not a missing return — re-run the validation with the function laid out across lines before believing it. This server's own `dctl validate` is a static, offline check (entry point, brace balance, float suffixes) and does not call ValidateDCTL at all. The separate `dctl validate_native` and granular `validate_dctl_native` now expose the native validator without changing source or diagnostics. Both interfaces were contributor-validated against the three fixtures on Studio 21.1.0.14; see resolve211-dctl-validation.md.
797
798
  - **Tags:** dctl, validation, unreliable-return, version-gated, reported
798
799
 
800
+ ### Project.StartRendering
801
+
802
+ - **Object:** `Project`
803
+ - **Signature:** `(jobIds: list[str], isInteractiveMode=False) -> bool (21.1 typed stub)`
804
+ - **Behavior:** STATE-DEPENDENT RETURN. Reported by @legionsound (issue #207) from Studio 21.1.0.14 on macOS, 2026-09-09; NOT reproduced here (no 21.1 install). In one session, some freshly configured render jobs made StartRendering return False and remain Ready, while an identical job configured later in the same session started and completed. Official direct calls reproduced BOTH outcomes, so a False from one interface and a True from another in that sequence is not an interface difference. The trigger was not isolated; no root cause is established. The same session confirmed the two 21.1 RenderSettings keys the compound whitelist used to drop: UseUniqueFilenames=True gave queued filenames Resolve's '_V1-0001' suffix, and AudioFormat='wav' with LPCM produced complete 14-second stereo 48 kHz 24-bit WAVs whose decoded PCM matched byte for byte across both community interfaces.
805
+ - **Workaround / current handling:** Treat the boolean as a hint in both directions. After start, read render(action='get_job_status') and render(action='verify_output') for the actual output file before reporting a render as running or done; a False with the job still Ready means nothing rendered. Do not auto-retry in a wrapper: the measured later success was a NEW identical job, not a retry of the same one, so a loop would hide the state it depends on. Surface the False and let the caller decide whether to re-queue.
806
+ - **Reference:** [issue #207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207)
807
+ - **Tags:** render, unreliable-return, state-dependent, version-gated, reported
808
+
809
+ ### Project settings presets mutated before the project's first SaveProject
810
+
811
+ - **Object:** `Project`
812
+ - **Signature:** `SaveCurrentProjectSettingsAsNewPreset / UpdateProjectSettingsPreset / SetProjectSettingsPreset / DeleteProjectSettingsPreset (presetName) -> bool (21.1+)`
813
+ - **Behavior:** DESTROYS THE PROJECT ITSELF, NOT A GRADE, AND REPORTS SUCCESS THROUGHOUT. Reported by @legionsound (issue #207) from Studio 21.1.0.14 on macOS, 2026-09-09; NOT reproduced here (no 21.1 install). On a NEW project that had never been saved: save a project-settings preset (SaveCurrentProjectSettingsAsNewPreset), change the timeline resolution, then update (UpdateProjectSettingsPreset), apply (SetProjectSettingsPreset) and delete (DeleteProjectSettingsPreset) the preset, then SaveProject and close. Every native call returned True. SQLite PRAGMA integrity_check on the project database still reported ok, but its Sm2MultiSysData table had ZERO rows, and LoadProject then failed with 'DaVinci Resolve failed to open project.' Reproduced twice, through community wrappers and through direct native calls. The control — the same sequence with one explicit SaveProject BEFORE any preset mutation — kept its Sm2MultiSysData row and reopened. Which single call in the sequence is responsible was not isolated, and preset Export/Import were not part of it, so do not assume any preset write is safe on an unsaved project. ProjectManager.GetProjectLastModifiedTime cannot serve as a 'has this project been saved' guard: it returned None even for saved projects on the same build.
814
+ - **Workaround / current handling:** Before ANY project-settings-preset write on 21.1, call ProjectManager.SaveProject() on the current project and require it to return True; never mutate presets on a project created in this session that has not been saved. Do not read the True returns, a clean integrity_check, or the absence of an error as evidence the project survived — the only proof is closing it and reloading it. A wrapper must not save silently on the caller's behalf (the first save is the caller's decision), so it should require an explicit assertion that the project has been saved, and refuse without one. If the project already holds work, back it up (export a .drp) before the first preset write.
815
+ - **Reference:** [issue #207](https://github.com/samuelgursky/davinci-resolve-mcp/issues/207)
816
+ - **Tags:** destructive, unrecoverable, project, order-dependent, silent-failure, unreliable-return, version-gated, reported
817
+
799
818
  ### TimelineItem.ExportLUT
800
819
 
801
820
  - **Object:** `TimelineItem`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.12.0"
40
+ VERSION = "4.13.1"
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
@@ -1543,7 +1543,7 @@ def verify_resolve_connection(python_path, api_path, lib_path):
1543
1543
 
1544
1544
  def print_banner():
1545
1545
  title = f"DaVinci Resolve MCP Server — Installer v{VERSION}"
1546
- subtitle = "37 compound · 389 full · 3 platforms"
1546
+ subtitle = "37 compound · 390 full · 3 platforms"
1547
1547
  print()
1548
1548
  print(bold(" ╔══════════════════════════════════════════════════════╗"))
1549
1549
  print(bold(f" ║{title:^54}║"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.12.0",
3
+ "version": "4.13.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.12.0"
94
+ VERSION = "4.13.1"
95
95
  logger = logging.getLogger("davinci-resolve-mcp")
96
96
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
97
97
  logger.info(f"Detected platform: {get_platform()}")
@@ -1,6 +1,7 @@
1
1
  """Project, render, cache, cloud, and project-property tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ from src.utils.bool_params import recognised_bool
4
5
  from src.utils.page_lock import restoring_page
5
6
 
6
7
  resolve = ResolveProxy()
@@ -1109,6 +1110,11 @@ def get_render_job_list() -> Dict[str, Any]:
1109
1110
  def start_rendering_jobs(job_ids: Optional[List[str]] = None, is_interactive_mode: bool = False) -> Dict[str, Any]:
1110
1111
  """Start rendering jobs. If no job IDs specified, renders all queued jobs.
1111
1112
 
1113
+ StartRendering is state-dependent on Resolve 21.1 (api_truth
1114
+ 'Project.StartRendering'): a freshly configured job can return False and
1115
+ stay Ready while an identical later job completes. Check job status and the
1116
+ output file afterwards; this tool does not retry.
1117
+
1112
1118
  Args:
1113
1119
  job_ids: Optional list of job IDs to render. If None, renders all.
1114
1120
  is_interactive_mode: If True, enables interactive rendering mode.
@@ -1224,8 +1230,19 @@ def set_render_settings(settings: Dict[str, Any]) -> Dict[str, Any]:
1224
1230
  ExportAlpha (bool), EncodingProfile (str), MultiPassEncode (bool),
1225
1231
  AlphaMode (int), NetworkOptimization (bool), ClipStartFrame (int),
1226
1232
  TimelineStartTimecode (str), ReplaceExistingFilesInPlace (bool),
1227
- ExportSubtitle (bool), SubtitleFormat ("BurnIn", "EmbeddedCaptions", "SeparateFile").
1233
+ ExportSubtitle (bool), SubtitleFormat ("BurnIn", "EmbeddedCaptions", "SeparateFile"),
1234
+ and the Resolve 21.1 typed keys UseUniqueFilenames (bool; "true"/"false"
1235
+ strings are coerced, anything else is refused before the call) and
1236
+ AudioFormat (audio file extension, e.g. "wav"; documented for
1237
+ ExportVideo false). A True return means the settings were taken, not
1238
+ that a render will succeed: verify job status and the output file
1239
+ after starting (see api_truth 'Project.StartRendering').
1228
1240
  """
1241
+ if isinstance(settings, dict) and "UseUniqueFilenames" in settings:
1242
+ coerced = recognised_bool(settings["UseUniqueFilenames"])
1243
+ if coerced is None:
1244
+ return {"error": "UseUniqueFilenames must be a boolean (true/false)"}
1245
+ settings = dict(settings, UseUniqueFilenames=coerced)
1229
1246
  resolve = get_resolve()
1230
1247
  if resolve is None:
1231
1248
  return {"error": "Not connected to DaVinci Resolve"}
@@ -3,6 +3,7 @@
3
3
  from src.granular.common import * # noqa: F401,F403
4
4
  import os
5
5
  from src.utils import typed_api_search
6
+ from src.utils.app_control import set_high_priority as set_resolve_high_priority
6
7
 
7
8
  resolve = ResolveProxy()
8
9
 
@@ -316,6 +317,28 @@ def disable_background_tasks_for_current_session() -> Dict[str, Any]:
316
317
  return {"success": True}
317
318
 
318
319
 
320
+ @mcp.tool()
321
+ @granular_destructive_op()
322
+ def set_high_priority(high: bool = True) -> Dict[str, Any]:
323
+ """Set Resolve's script execution priority to high or normal.
324
+
325
+ Granular counterpart of resolve_control set_high_priority. On Resolve
326
+ 21.1+ this calls SetHighPriority(bool) and is reversible (high=False
327
+ restores normal; reported on Studio 21.1.0.14, where the old no-argument
328
+ call is a no-op that returns False). Older builds take no argument and can
329
+ only raise priority for the session, so high=False is refused there. No
330
+ build has a getter, so nothing is read back.
331
+
332
+ Args:
333
+ high: True for high priority (default), False for normal. "true"/"false"
334
+ strings are coerced; anything else is refused before the call.
335
+ """
336
+ resolve = get_resolve()
337
+ if resolve is None:
338
+ return {"error": "Not connected to DaVinci Resolve"}
339
+ return set_resolve_high_priority(resolve, high)
340
+
341
+
319
342
  @mcp.resource("resolve://app/state")
320
343
  def get_app_state_endpoint() -> Dict[str, Any]:
321
344
  """Get DaVinci Resolve application state information."""
@@ -35,7 +35,7 @@ if __name__ == "__main__":
35
35
  try:
36
36
  connect_at_startup()
37
37
  start_background_update_check(VERSION, project_dir, logger)
38
- logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (389 granular tools)")
38
+ logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (390 granular tools)")
39
39
  run_fastmcp_stdio(mcp)
40
40
  except KeyboardInterrupt:
41
41
  logger.info("Server shutdown requested")
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.12.0"
14
+ VERSION = "4.13.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -75,11 +75,15 @@ from src.utils.page_lock import (
75
75
  restoring_page as _restoring_page,
76
76
  page_lock as _page_lock,
77
77
  )
78
- from src.utils.proc import safe_run
78
+ from src.utils.proc import safe_run, windows_pid_alive
79
79
  from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
80
80
  from src.utils import operation_result as _operation_result
81
81
  from src.utils import operation_log as _operation_log
82
- from src.utils.bool_params import coerce_bool as _coerce_bool, explicit_bool_param as _explicit_bool_param
82
+ from src.utils.bool_params import (
83
+ coerce_bool as _coerce_bool,
84
+ explicit_bool_param as _explicit_bool_param,
85
+ recognised_bool as _recognised_bool,
86
+ )
83
87
  from src.utils.confirm_tokens import (
84
88
  ConfirmTokenStore as _ConfirmTokenStore,
85
89
  gate_required_from as _gate_required_from,
@@ -17092,7 +17096,13 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17092
17096
  — one 'Class.Method' or one TypedDict name. No connection needed.
17093
17097
  api_surface() -> {methods, option_types, option_fields, objects}
17094
17098
  — what the shipped stub contains. No connection needed.
17095
- set_high_priority() -> {success}
17099
+ set_high_priority(high?) -> {success, high_priority, signature, reversible, readback: null}
17100
+ — script execution priority. high: true (default, the old meaning)
17101
+ or false for normal. Resolve 21.1+ calls SetHighPriority(bool) and
17102
+ is reversible (reported on 21.1.0.14: the old no-argument call is a
17103
+ no-op there). Older builds take no argument and can only RAISE it
17104
+ for the session, so high=false is refused there. No getter exists
17105
+ on any build: nothing is read back.
17096
17106
  disable_background_tasks_for_current_session() -> {success} — Resolve 21+
17097
17107
  list_user_preferences_presets() -> {presets} — Resolve 21.0.4+
17098
17108
  save_user_preferences_preset(name) -> {success} — Resolve 21.0.4+
@@ -17537,7 +17547,23 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17537
17547
  return missing
17538
17548
  return {"presets": _ser(r.GetFairlightPresets())}
17539
17549
  elif action == "set_high_priority":
17540
- return {"success": bool(r.SetHighPriority())}
17550
+ from src.utils.app_control import set_high_priority as _set_high_priority
17551
+ # `high` defaults to True so a bare call keeps its old meaning; the
17552
+ # helper picks SetHighPriority(bool) on 21.1+ and the no-arg form below.
17553
+ # The raw value goes to the helper, which coerces it with
17554
+ # recognised_bool and REFUSES a value naming no boolean, rather than
17555
+ # _coerce_bool's fall-back-to-default (that would turn "hgih" into True).
17556
+ _high_key = next((k for k in ("high", "high_priority") if k in p), None)
17557
+ _hp = _set_high_priority(r, p[_high_key] if _high_key else True)
17558
+ if _hp.get("refused"):
17559
+ return _err(
17560
+ _hp["error"],
17561
+ code="HIGH_PRIORITY_REFUSED",
17562
+ category="invalid_input",
17563
+ reason="Nothing was sent to Resolve.SetHighPriority.",
17564
+ state={k: v for k, v in _hp.items() if k not in ("error", "refused", "success")},
17565
+ )
17566
+ return _hp
17541
17567
  elif action == "disable_background_tasks_for_current_session":
17542
17568
  missing = _requires_method(r, "DisableBackgroundTasksForCurrentResolveSession", "21.0")
17543
17569
  if missing:
@@ -17989,6 +18015,9 @@ def _control_panel_pid_alive(pid: int) -> bool:
17989
18015
  """Check whether a PID is alive on this OS without killing it."""
17990
18016
  if not pid or pid <= 0:
17991
18017
  return False
18018
+ if os.name == "nt":
18019
+ # os.kill(pid, 0) is TerminateProcess on Windows, not a probe.
18020
+ return windows_pid_alive(pid) is True
17992
18021
  try:
17993
18022
  os.kill(pid, 0) # signal 0 = existence check
17994
18023
  except (ProcessLookupError, PermissionError):
@@ -20300,6 +20329,7 @@ _RENDER_SETTING_KEYS = [
20300
20329
  "MarkOut",
20301
20330
  "TargetDir",
20302
20331
  "CustomName",
20332
+ "UseUniqueFilenames",
20303
20333
  "UniqueFilenameStyle",
20304
20334
  "ExportVideo",
20305
20335
  "ExportAudio",
@@ -20308,6 +20338,7 @@ _RENDER_SETTING_KEYS = [
20308
20338
  "FrameRate",
20309
20339
  "PixelAspectRatio",
20310
20340
  "VideoQuality",
20341
+ "AudioFormat",
20311
20342
  "AudioCodec",
20312
20343
  "AudioBitDepth",
20313
20344
  "AudioSampleRate",
@@ -20560,6 +20591,12 @@ def _render_settings_warnings(settings: Dict[str, Any], version_major: Optional[
20560
20591
  "AddFrameHandles is ignored while UseFullExtents is true: Resolve renders the "
20561
20592
  "clip's full extents and the handle count silently does nothing. Drop one of the two."
20562
20593
  )
20594
+ if "AudioFormat" in settings and settings.get("ExportVideo") is True:
20595
+ warnings.append(
20596
+ "AudioFormat is documented for audio-only renders (ExportVideo false) in the "
20597
+ "Resolve 21.1 typed stub; with ExportVideo true the container comes from the "
20598
+ "render format instead. Verify the output's container before relying on it."
20599
+ )
20563
20600
  if ("ExportSubtitle" in settings or "SubtitleFormat" in settings) and (
20564
20601
  version_major is not None and version_major < 21
20565
20602
  ):
@@ -20574,11 +20611,40 @@ def _render_settings_warnings(settings: Dict[str, Any], version_major: Optional[
20574
20611
  return warnings
20575
20612
 
20576
20613
 
20614
+ #: RenderSettings keys typed `bool` in the 21.1 stub that are COERCED rather than
20615
+ #: required to be a JSON boolean: an MCP client that sends "false" means false,
20616
+ #: and forwarding the string would hand Resolve a truthy value. Only keys added
20617
+ #: alongside this helper are listed; the older bool keys keep their strict check.
20618
+ _RENDER_COERCED_BOOL_KEYS = ("UseUniqueFilenames",)
20619
+
20620
+
20621
+ def _coerce_render_bool_settings(settings: Any):
20622
+ """Return ``(settings_copy, errors)`` with the coerced bool keys normalised.
20623
+
20624
+ A value that does not unambiguously name a boolean is an error, never a
20625
+ default: ``UseUniqueFilenames: "maybe"`` must not quietly become False.
20626
+ """
20627
+ if not isinstance(settings, dict):
20628
+ return settings, []
20629
+ out = dict(settings)
20630
+ errors = []
20631
+ for key in _RENDER_COERCED_BOOL_KEYS:
20632
+ if key not in out:
20633
+ continue
20634
+ coerced = _recognised_bool(out[key])
20635
+ if coerced is None:
20636
+ errors.append(f"{key} must be a boolean (true/false)")
20637
+ else:
20638
+ out[key] = coerced
20639
+ return out, errors
20640
+
20641
+
20577
20642
  def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_target: bool = False):
20578
20643
  if not isinstance(settings, dict) or not settings:
20579
20644
  return None, _err("settings must be a non-empty object")
20645
+ settings, coerce_errors = _coerce_render_bool_settings(settings)
20580
20646
  unknown = sorted(key for key in settings if key not in _RENDER_SETTING_KEYS)
20581
- errors = []
20647
+ errors = list(coerce_errors)
20582
20648
  target_dir = settings.get("TargetDir")
20583
20649
  if target_dir is not None:
20584
20650
  if not isinstance(target_dir, str) or not target_dir:
@@ -20597,6 +20663,8 @@ def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_
20597
20663
  errors.append(f"{key} must be a boolean")
20598
20664
  if "AddFrameHandles" in settings and isinstance(settings["AddFrameHandles"], int) and settings["AddFrameHandles"] < 0:
20599
20665
  errors.append("AddFrameHandles must be >= 0")
20666
+ if "AudioFormat" in settings and (not isinstance(settings["AudioFormat"], str) or not settings["AudioFormat"].strip()):
20667
+ errors.append("AudioFormat must be a non-empty string (an audio file extension, e.g. 'wav')")
20600
20668
  if "DataBurnIn" in settings and not isinstance(settings["DataBurnIn"], str):
20601
20669
  errors.append("DataBurnIn must be a string (a burn-in preset name, 'Same as project', or 'None')")
20602
20670
  if "MarkIn" in settings and "MarkOut" in settings and settings["MarkOut"] < settings["MarkIn"]:
@@ -20641,6 +20709,7 @@ def _safe_set_render_settings(proj, p: Dict[str, Any]):
20641
20709
  return {"success": False, "validation": validation}
20642
20710
  if _coerce_bool(p.get("dry_run")):
20643
20711
  return _ok(validation=validation)
20712
+ settings = validation["settings"] # coerced bool keys, see _coerce_render_bool_settings
20644
20713
  before = _render_settings_snapshot(proj)
20645
20714
  success = bool(proj.SetRenderSettings(settings))
20646
20715
  after_settings = _ser(proj.GetRenderSettings()) if _has_method(proj, "GetRenderSettings") else {}
@@ -20675,6 +20744,7 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
20675
20744
  return {"success": False, "validation": validation}
20676
20745
  if _coerce_bool(p.get("dry_run")):
20677
20746
  return _ok(validation=validation, format=p.get("format"), codec=p.get("codec"))
20747
+ settings = validation["settings"] # coerced bool keys, see _coerce_render_bool_settings
20678
20748
  before = _render_settings_snapshot(proj)
20679
20749
  # Pin the base render state before layering explicit settings on top. Without
20680
20750
  # this the job inherits the Deliver page's loaded preset for every key the
@@ -21096,6 +21166,10 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
21096
21166
  collapse warning unless the stated expectation matches the mark range.
21097
21167
  Verify BEFORE deleting the job; deleted jobs carry no TargetDir.
21098
21168
  start(job_ids?, interactive?) -> {success}
21169
+ StartRendering is state-dependent on 21.1 (api_truth
21170
+ 'Project.StartRendering'): a freshly configured job can return False
21171
+ and stay Ready while an identical later job completes. Read the job
21172
+ status and the output; this action does not retry.
21099
21173
  stop() -> {success}
21100
21174
  is_rendering() -> {rendering}
21101
21175
  get_formats() -> {formats}
@@ -21118,6 +21192,14 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
21118
21192
  burn-in, sidecar, or caption track — so setting them on a pre-21
21119
21193
  host comes back in warnings; verify the output before claiming
21120
21194
  caption delivery.
21195
+ Resolve 21.1 typed keys: UseUniqueFilenames (bool; "true"/"false"
21196
+ strings are coerced, anything else is refused before the call) and
21197
+ AudioFormat (audio file extension, e.g. "wav" — the stub documents it
21198
+ for ExportVideo false, so pairing it with ExportVideo true warns).
21199
+ Reported on Studio 21.1.0.14: UseUniqueFilenames=true gave queued
21200
+ files a "_V1-0001" suffix, and AudioFormat "wav" + LPCM rendered
21201
+ 48 kHz/24-bit WAVs. A True here only means the settings were taken;
21202
+ verify the job (get_job_status / verify_output) after start.
21121
21203
  list_presets() -> {presets}
21122
21204
  load_preset(name) -> {success}
21123
21205
  save_preset(name) -> {success}
@@ -21427,6 +21509,14 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
21427
21509
  # Whitelist to documented keys; SetRenderSettings silently ignores unknown
21428
21510
  # keys, so a typo'd setting would be dropped with no signal (P1 #6).
21429
21511
  settings, ignored_settings = _filter_to_keys(p["settings"], _RENDER_SETTING_KEYS)
21512
+ settings, bool_errors = _coerce_render_bool_settings(settings)
21513
+ if bool_errors:
21514
+ return _err(
21515
+ "; ".join(bool_errors),
21516
+ code="INVALID_RENDER_SETTING",
21517
+ category="invalid_input",
21518
+ reason="Nothing was sent to SetRenderSettings.",
21519
+ )
21430
21520
  result = {"success": bool(proj.SetRenderSettings(settings))}
21431
21521
  if ignored_settings:
21432
21522
  result["ignored_settings"] = ignored_settings
@@ -2955,17 +2955,40 @@ API_TRUTH: List[Dict[str, Any]] = [
2955
2955
  "submit": "missing",
2956
2956
  },
2957
2957
  {
2958
- "symbol": "Resolve.SetHighPriority (write-only, irreversible per session)",
2958
+ "symbol": "Resolve.SetHighPriority (write-only; irreversible before 21.1)",
2959
2959
  "object": "Resolve",
2960
- "signature": "() -> Bool",
2961
- "reality": "Raises the Resolve process priority; there is no getter "
2962
- "and no way to lower it again through the API (confirmed "
2963
- "absent from dir() on Studio 19.1.3.7).",
2964
- "recommended": "Call it only when the user asked for a long render on "
2965
- "a dedicated machine; state cannot be read back or "
2966
- "undone without restarting Resolve.",
2967
- "tags": ["app-control", "missing-method", "readback"],
2960
+ "signature": "() -> Bool (19.x); (highPriority: bool) -> bool (21.1 typed stub)",
2961
+ "reality": "VERSION-SCOPED. On Studio 19.1.3.7 (measured here) the "
2962
+ "method takes no argument and raises priority; there is no "
2963
+ "getter and no way to lower it again through the API "
2964
+ "(no getter in dir()), so it is irreversible for the session. "
2965
+ "Resolve 21.1 retyped it SetHighPriority(highPriority: bool) "
2966
+ "-> bool, documented as 'script execution priority to high or "
2967
+ "normal'. Reported by @legionsound (issue #207) from Studio "
2968
+ "21.1.0.14 on macOS, 2026-09-09; NOT reproduced here (no 21.1 "
2969
+ "install). One live session, in order: (False) -> True, "
2970
+ "(True) -> True, () -> False, (1) -> False, ('high') -> False, "
2971
+ "(False) -> True. So on 21.1 the no-argument call is a NO-OP "
2972
+ "that reports its own failure, only a real bool is accepted "
2973
+ "(1 and 'high' are rejected like the missing argument), and "
2974
+ "the setting is REVERSIBLE: SetHighPriority(False) restores "
2975
+ "normal. There is still no getter on any build — dir() on the "
2976
+ "live 21.1 Resolve object shows SetHighPriority as the only "
2977
+ "priority-related name — so the current state cannot be read "
2978
+ "back on either generation.",
2979
+ "recommended": "On 21.1+ pass a real bool; never forward 1 or a string "
2980
+ "(coerce first — resolve_control set_high_priority and "
2981
+ "granular set_high_priority do, and refuse values that "
2982
+ "name no boolean). On older builds call it only when the "
2983
+ "user asked for a long scripted job on a dedicated "
2984
+ "machine: it cannot be lowered without restarting "
2985
+ "Resolve, which is why both tools refuse high=false "
2986
+ "there. No build offers readback, so report the native "
2987
+ "return, never a confirmed state.",
2988
+ "tags": ["app-control", "missing-method", "readback", "version-gated", "reported"],
2968
2989
  "submit": "missing",
2990
+ "issue": 207,
2991
+ "mitigation": ["resolve_control.set_high_priority", "set_high_priority"],
2969
2992
  },
2970
2993
  {
2971
2994
  "symbol": "Project.IsRenderingInProgress (stuck True after deleting the rendering project)",
@@ -3471,6 +3494,86 @@ API_TRUTH: List[Dict[str, Any]] = [
3471
3494
  "tags": ["dctl", "validation", "unreliable-return", "version-gated", "reported"],
3472
3495
  "submit": "bug",
3473
3496
  },
3497
+ {
3498
+ "symbol": "Project.StartRendering",
3499
+ "object": "Project",
3500
+ "signature": "(jobIds: list[str], isInteractiveMode=False) -> bool (21.1 typed stub)",
3501
+ "reality": "STATE-DEPENDENT RETURN. Reported by @legionsound (issue #207) "
3502
+ "from Studio 21.1.0.14 on macOS, 2026-09-09; NOT reproduced "
3503
+ "here (no 21.1 install). In one session, some freshly "
3504
+ "configured render jobs made StartRendering return False and "
3505
+ "remain Ready, while an identical job configured later in the "
3506
+ "same session started and completed. Official direct calls "
3507
+ "reproduced BOTH outcomes, so a False from one interface and a "
3508
+ "True from another in that sequence is not an interface "
3509
+ "difference. The trigger was not isolated; no root cause is "
3510
+ "established. The same session confirmed the two 21.1 "
3511
+ "RenderSettings keys the compound whitelist used to drop: "
3512
+ "UseUniqueFilenames=True gave queued filenames Resolve's "
3513
+ "'_V1-0001' suffix, and AudioFormat='wav' with LPCM produced "
3514
+ "complete 14-second stereo 48 kHz 24-bit WAVs whose decoded PCM "
3515
+ "matched byte for byte across both community interfaces.",
3516
+ "recommended": "Treat the boolean as a hint in both directions. After "
3517
+ "start, read render(action='get_job_status') and "
3518
+ "render(action='verify_output') for the actual output file "
3519
+ "before reporting a render as running or done; a False "
3520
+ "with the job still Ready means nothing rendered. Do not "
3521
+ "auto-retry in a wrapper: the measured later success was a "
3522
+ "NEW identical job, not a retry of the same one, so a loop "
3523
+ "would hide the state it depends on. Surface the False and "
3524
+ "let the caller decide whether to re-queue.",
3525
+ "tags": ["render", "unreliable-return", "state-dependent", "version-gated", "reported"],
3526
+ "submit": "bug",
3527
+ "issue": 207,
3528
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3529
+ },
3530
+ {
3531
+ "symbol": "Project settings presets mutated before the project's first SaveProject",
3532
+ "object": "Project",
3533
+ "signature": "SaveCurrentProjectSettingsAsNewPreset / UpdateProjectSettingsPreset / "
3534
+ "SetProjectSettingsPreset / DeleteProjectSettingsPreset (presetName) "
3535
+ "-> bool (21.1+)",
3536
+ "reality": "DESTROYS THE PROJECT ITSELF, NOT A GRADE, AND REPORTS SUCCESS "
3537
+ "THROUGHOUT. Reported by @legionsound (issue #207) from Studio "
3538
+ "21.1.0.14 on macOS, 2026-09-09; NOT reproduced here (no 21.1 "
3539
+ "install). On a NEW project that had never been saved: save a "
3540
+ "project-settings preset (SaveCurrentProjectSettingsAsNewPreset), "
3541
+ "change the timeline resolution, then update "
3542
+ "(UpdateProjectSettingsPreset), apply (SetProjectSettingsPreset) "
3543
+ "and delete (DeleteProjectSettingsPreset) the preset, then "
3544
+ "SaveProject and close. Every native call returned True. "
3545
+ "SQLite PRAGMA integrity_check on the project database still "
3546
+ "reported ok, but its Sm2MultiSysData table had ZERO rows, and "
3547
+ "LoadProject then failed with 'DaVinci Resolve failed to open "
3548
+ "project.' Reproduced twice, through community wrappers and "
3549
+ "through direct native calls. The control — the same sequence "
3550
+ "with one explicit SaveProject BEFORE any preset mutation — kept "
3551
+ "its Sm2MultiSysData row and reopened. Which single call in the "
3552
+ "sequence is responsible was not isolated, and preset "
3553
+ "Export/Import were not part of it, so do not assume any preset "
3554
+ "write is safe on an unsaved project. "
3555
+ "ProjectManager.GetProjectLastModifiedTime cannot serve as a "
3556
+ "'has this project been saved' guard: it returned None even for "
3557
+ "saved projects on the same build.",
3558
+ "recommended": "Before ANY project-settings-preset write on 21.1, call "
3559
+ "ProjectManager.SaveProject() on the current project and "
3560
+ "require it to return True; never mutate presets on a "
3561
+ "project created in this session that has not been saved. "
3562
+ "Do not read the True returns, a clean integrity_check, or "
3563
+ "the absence of an error as evidence the project survived "
3564
+ "— the only proof is closing it and reloading it. A "
3565
+ "wrapper must not save silently on the caller's behalf "
3566
+ "(the first save is the caller's decision), so it should "
3567
+ "require an explicit assertion that the project has been "
3568
+ "saved, and refuse without one. If the project already "
3569
+ "holds work, back it up (export a .drp) before the first "
3570
+ "preset write.",
3571
+ "tags": ["destructive", "unrecoverable", "project", "order-dependent",
3572
+ "silent-failure", "unreliable-return", "version-gated", "reported"],
3573
+ "submit": "bug",
3574
+ "issue": 207,
3575
+ "verified_on": "DaVinci Resolve Studio 21.1.0.14",
3576
+ },
3474
3577
  # ── Measured on Studio 21.1.0.14, 2026-09-09 (trap-aware execution work) ──
3475
3578
  {
3476
3579
  "symbol": "TimelineItem.CopyGrades",
@@ -3714,6 +3817,10 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
3714
3817
  ("fusion_comp", "add_modifier"): ["Tool.AddModifier", "Tool.AddModifier (NestControl inputs)"],
3715
3818
  ("fusion_comp", "add_keyframe"): ["Tool.AddModifier (NestControl inputs)"],
3716
3819
  ("project_manager", "safe_project_archive"): ["ProjectManager.ArchiveProject"],
3820
+ ("render", "start"): ["Project.StartRendering"],
3821
+ ("resolve_control", "set_high_priority"): [
3822
+ "Resolve.SetHighPriority (write-only; irreversible before 21.1)",
3823
+ ],
3717
3824
  }
3718
3825
 
3719
3826
 
@@ -335,3 +335,95 @@ def open_project_settings(resolve_obj) -> Dict[str, Any]:
335
335
  def open_preferences(resolve_obj) -> Dict[str, Any]:
336
336
  """Open the Preferences dialog. See `_open_dialog` for the result shape."""
337
337
  return _open_dialog(resolve_obj, "OpenPreferences", "Preferences")
338
+
339
+
340
+ # ── Script execution priority ───────────────────────────────────────────────
341
+ #
342
+ # Resolve 21.1 retyped `Resolve.SetHighPriority(highPriority: bool) -> bool`
343
+ # ("Sets the script execution priority to high or normal"). Reported by
344
+ # @legionsound on Studio 21.1.0.14 (issue #207): (False) -> True, (True) ->
345
+ # True, and the no-argument call this server used to make -> False, a no-op;
346
+ # (1) and ("high") also -> False. So on 21.1 the argument must be a real bool
347
+ # and the setting is reversible. On 19.1.3.7 the method takes no argument and
348
+ # there is no way to lower it again within the session. No build has a getter.
349
+
350
+ #: First build whose stub types the boolean argument.
351
+ HIGH_PRIORITY_TYPED_FLOOR = "21.1"
352
+
353
+ _NO_READBACK_NOTE = (
354
+ "Resolve exposes no getter for script execution priority on any build, so "
355
+ "the state cannot be read back; success reflects the native return only."
356
+ )
357
+
358
+
359
+ def set_high_priority(resolve_obj, high: Any = True) -> Dict[str, Any]:
360
+ """Set script execution priority, choosing the call form by build.
361
+
362
+ `high` is coerced with `recognised_bool` (so "false" means False) and a
363
+ value that names no boolean is refused before any call: Resolve 21.1
364
+ returns a silent False for 1 or "high", so forwarding a guess would turn
365
+ the caller's mistake into a no-op. The native call always receives a real
366
+ bool. Returns a plain dict; a refusal carries `error` and `refused: True`
367
+ and means nothing was called.
368
+ """
369
+ from src.utils.bool_params import recognised_bool
370
+ from src.utils.resolve_versions import at_least
371
+
372
+ requested = recognised_bool(high)
373
+ if requested is None:
374
+ return {
375
+ "success": False,
376
+ "refused": True,
377
+ "error": "high must be a boolean (true for high priority, false for normal)",
378
+ }
379
+ try:
380
+ version = resolve_obj.GetVersion()
381
+ except Exception: # noqa: BLE001 — an unreadable version routes as unknown
382
+ version = None
383
+ typed = at_least(version, HIGH_PRIORITY_TYPED_FLOOR)
384
+ base: Dict[str, Any] = {
385
+ "high_priority": requested,
386
+ "resolve_version": version,
387
+ "readback": None,
388
+ "note": _NO_READBACK_NOTE,
389
+ }
390
+
391
+ def _typed_call() -> Dict[str, Any]:
392
+ ok = bool(resolve_obj.SetHighPriority(requested))
393
+ out = dict(base, success=ok, signature="SetHighPriority(bool)", reversible=True)
394
+ if not ok:
395
+ out["warning"] = (
396
+ "SetHighPriority returned False. On 21.1 that is the answer to a "
397
+ "rejected call; the priority is most likely unchanged, but it "
398
+ "cannot be read back to confirm."
399
+ )
400
+ return out
401
+
402
+ def _legacy_call() -> Dict[str, Any]:
403
+ if not requested:
404
+ return dict(
405
+ base,
406
+ success=False,
407
+ refused=True,
408
+ signature="SetHighPriority()",
409
+ reversible=False,
410
+ error=(
411
+ "This Resolve build takes SetHighPriority() with no argument and "
412
+ "can only RAISE priority; it cannot be lowered back to normal "
413
+ "without restarting Resolve (Resolve 21.1+ accepts a boolean). "
414
+ "Nothing was called."
415
+ ),
416
+ )
417
+ ok = bool(resolve_obj.SetHighPriority())
418
+ return dict(base, success=ok, signature="SetHighPriority()", reversible=False)
419
+
420
+ if typed is True:
421
+ return _typed_call()
422
+ if typed is False:
423
+ return _legacy_call()
424
+ # Version unreadable: try the typed form and fall back only on the
425
+ # signature mismatch an older build raises, never on a False return.
426
+ try:
427
+ return _typed_call()
428
+ except TypeError:
429
+ return _legacy_call()
@@ -31,3 +31,26 @@ def explicit_bool_param(params: Optional[Dict[str, Any]], *keys: str) -> Optiona
31
31
  if key in params:
32
32
  return coerce_bool(params[key])
33
33
  return None
34
+
35
+
36
+ def recognised_bool(value: Any) -> Optional[bool]:
37
+ """Coerce only values that unambiguously name a boolean, else None.
38
+
39
+ ``coerce_bool`` falls back to a default for anything it does not recognise,
40
+ which is right for an optional flag and wrong for a value that is forwarded
41
+ to Resolve as the setting itself: ``"maybe"`` must be refused, not silently
42
+ read as ``False``. Accepts real bools, the true/false strings
43
+ ``coerce_bool`` knows, and the integers 0 and 1. Everything else is None,
44
+ so the caller can refuse it with a reason.
45
+ """
46
+ if isinstance(value, bool):
47
+ return value
48
+ if isinstance(value, int) and value in (0, 1):
49
+ return bool(value)
50
+ if isinstance(value, str):
51
+ lowered = value.strip().lower()
52
+ if lowered in _TRUE_STRINGS:
53
+ return True
54
+ if lowered in _FALSE_STRINGS:
55
+ return False
56
+ return None
@@ -109,7 +109,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
109
109
  "project_settings": frozenset({"delete_color_group"}),
110
110
  "render": frozenset({"delete_all_jobs", "delete_job", "delete_preset"}),
111
111
  "render_presets": frozenset({"delete_burnin"}),
112
- "resolve_control": frozenset({"delete_user_preferences_preset"}),
112
+ # set_high_priority: an app-wide setting with no getter on any build, and
113
+ # irreversible for the session before 21.1 (issue #207). A write every gate
114
+ # must see, even though it touches no project content.
115
+ "resolve_control": frozenset({"delete_user_preferences_preset", "set_high_priority"}),
113
116
  "media_pool": frozenset({
114
117
  "delete_clips",
115
118
  "delete_folders",
@@ -282,6 +282,12 @@ class RiskClassificationHook(LifecycleHook):
282
282
  # wrappers refuse those unless acknowledge_trap. See archive_guard.py.
283
283
  ("project_manager", "archive"),
284
284
  ("project_manager", "safe_project_archive"),
285
+ # Script execution priority. App-wide, no getter on any build, and
286
+ # before 21.1 it cannot be lowered again within the session; on 21.1
287
+ # SetHighPriority(False) restores normal (reported, issue #207).
288
+ # MEDIUM: a real write to state nothing can read back, but no content
289
+ # is touched, so safe mode should not block it.
290
+ ("resolve_control", "set_high_priority"),
285
291
  # 21.0 AI deblur renders NEW media and never touches the source (it is
286
292
  # confirm-token gated for that reason). The `remove_` prefix rule rated
287
293
  # it HIGH on its name alone, which would make safe mode block a create.
@@ -28,6 +28,7 @@ import sys
28
28
  import time
29
29
 
30
30
  from src.utils.private_state import private_state_dir, write_private_json
31
+ from src.utils.proc import windows_pid_alive
31
32
 
32
33
  logger = logging.getLogger("davinci-resolve-mcp")
33
34
 
@@ -172,6 +173,12 @@ def read_transport_state():
172
173
  return None
173
174
  pid = state.get("pid")
174
175
  if isinstance(pid, int):
176
+ if os.name == "nt":
177
+ # os.kill(pid, 0) is TerminateProcess on Windows, not a probe: the
178
+ # state file normally names the running server itself.
179
+ if windows_pid_alive(pid) is not True:
180
+ return None
181
+ return state
175
182
  try:
176
183
  os.kill(pid, 0)
177
184
  except (OSError, ProcessLookupError):
package/src/utils/proc.py CHANGED
@@ -5,9 +5,12 @@ process that inherits stdin can race-read bytes off the protocol stream and
5
5
  corrupt it; ``capture_output`` only redirects stdout/stderr. These wrappers
6
6
  default ``stdin`` to ``DEVNULL`` so subprocess hygiene is centralized rather
7
7
  than re-applied at every call site.
8
+
9
+ ``windows_pid_alive`` lives here too: the Windows replacement for the
10
+ ``os.kill(pid, 0)`` liveness probe, which on Windows terminates the process.
8
11
  """
9
12
  import subprocess
10
- from typing import Any
13
+ from typing import Any, Optional
11
14
 
12
15
 
13
16
  def safe_run(*args: Any, **kwargs: Any) -> "subprocess.CompletedProcess":
@@ -24,3 +27,59 @@ def safe_popen(*args: Any, **kwargs: Any) -> "subprocess.Popen":
24
27
  """subprocess.Popen with stdin defaulted to DEVNULL (override by passing stdin)."""
25
28
  kwargs.setdefault("stdin", subprocess.DEVNULL)
26
29
  return subprocess.Popen(*args, **kwargs)
30
+
31
+
32
+ #: OpenProcess access rights and status codes used by `windows_pid_alive`.
33
+ _WIN_SYNCHRONIZE = 0x00100000
34
+ _WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
35
+ _WIN_WAIT_OBJECT_0 = 0x0
36
+ _WIN_WAIT_TIMEOUT = 0x102
37
+ _WIN_ERROR_ACCESS_DENIED = 5
38
+ _WIN_ERROR_INVALID_PARAMETER = 87
39
+
40
+
41
+ def windows_pid_alive(pid: int) -> Optional[bool]:
42
+ """Whether `pid` is a running process on Windows, without touching it.
43
+
44
+ The POSIX liveness probe, ``os.kill(pid, 0)``, must never run on Windows:
45
+ there any signal other than CTRL_C_EVENT/CTRL_BREAK_EVENT goes through
46
+ ``TerminateProcess``, so the "existence check" kills the process it asks
47
+ about — including this one, when a record names our own pid.
48
+
49
+ True: running (access-denied counts, since only a live process can refuse).
50
+ False: no such process, or it has exited. None: could not be determined.
51
+ """
52
+ if not isinstance(pid, int) or pid <= 0:
53
+ return False
54
+ try:
55
+ import ctypes
56
+ from ctypes import wintypes
57
+
58
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
59
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
60
+ kernel32.OpenProcess.restype = wintypes.HANDLE
61
+ kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
62
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
63
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
64
+ kernel32.CloseHandle.restype = wintypes.BOOL
65
+
66
+ access = _WIN_SYNCHRONIZE | _WIN_PROCESS_QUERY_LIMITED_INFORMATION
67
+ handle = kernel32.OpenProcess(access, False, pid)
68
+ if not handle:
69
+ error = ctypes.get_last_error()
70
+ if error == _WIN_ERROR_INVALID_PARAMETER:
71
+ return False
72
+ if error == _WIN_ERROR_ACCESS_DENIED:
73
+ return True
74
+ return None
75
+ try:
76
+ status = kernel32.WaitForSingleObject(handle, 0)
77
+ finally:
78
+ kernel32.CloseHandle(handle)
79
+ if status == _WIN_WAIT_OBJECT_0:
80
+ return False # signalled == exited
81
+ if status == _WIN_WAIT_TIMEOUT:
82
+ return True
83
+ return None
84
+ except Exception:
85
+ return None
@@ -34,6 +34,8 @@ import time
34
34
  from contextlib import contextmanager
35
35
  from typing import Any, Dict, Optional
36
36
 
37
+ from src.utils.proc import windows_pid_alive
38
+
37
39
  _SIDECAR = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_busy.json")
38
40
  # A long op that has run this long is presumed crashed/leaked and ignored.
39
41
  MAX_OP_AGE_SECONDS = 2 * 60 * 60
@@ -47,6 +49,10 @@ _local_owner: Optional[int] = None # thread ident of the in-process registrant
47
49
 
48
50
 
49
51
  def _pid_alive(pid: int) -> bool:
52
+ if os.name == "nt":
53
+ # os.kill(pid, 0) is TerminateProcess on Windows, not a probe: a record
54
+ # naming this process would kill it.
55
+ return windows_pid_alive(pid) is True
50
56
  try:
51
57
  os.kill(pid, 0)
52
58
  return True