davinci-resolve-mcp 3.1.1 → 3.2.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,97 @@
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 v3.2.1 — three correctness fixes to the LUT tool
6
+
7
+ Contributed by @Dev-next-gen (#225, #226, #227), each found by reading the v3.2.0
8
+ `lut` tool rather than by hitting it in use.
9
+
10
+ ### Fixed
11
+
12
+ - **`install` reported `overwritten: true` on a fresh write.** The flag was
13
+ `bool(overwrite and payload is not None)`, and `payload` can never be `None`
14
+ where that line runs, so the field handed the caller's own `overwrite`
15
+ argument back instead of an observation. An install passing `overwrite=true`
16
+ for idempotence, landing on an empty `MCP/`, was reported as having replaced
17
+ existing work. `execution_lifecycle` rates `lut install` MEDIUM precisely
18
+ because it "can replace with overwrite=true", so this flag is what a caller
19
+ and the execution trace read to learn whether an install destroyed anything.
20
+ It is now the `os.path.exists` observation already taken one line above — the
21
+ measured pre-state, not the permission. (#225)
22
+ - **`install(source_path=...)` could not copy a binary LUT.** The copy went
23
+ through a UTF-8 text round-trip, so the two binary extensions this server
24
+ advertises — `.dat` and `.olut` — raised `UnicodeDecodeError` before anything
25
+ was written, surfacing as `LUT_ERROR`. So did an ordinary `.cube` whose vendor
26
+ wrote its `TITLE` line in latin-1. The file-copy branch is now byte-exact; the
27
+ text branch (`source=`) is unchanged. (#226)
28
+ - **`list` marked siblings of the writable subdir as writable.** The `writable`
29
+ flag used `realpath(current).startswith(writable_root)`, a string prefix with
30
+ no separator, so `MCP_old/` left by a hand backup or a vendor pack unpacking
31
+ as `MCPresets/` cleared it. The listing then contradicted the only tool that
32
+ consumes the flag: `remove` resolves names inside `MCP/` and refused the very
33
+ `set_lut_path` the listing had just handed out. It now compares by path
34
+ segment with `commonpath`, which is what `_is_relative_to` and `resolve_writable`
35
+ already use elsewhere in this repo. Nothing that worked before stops working —
36
+ the flag only flips for paths `remove` was already refusing. (#227)
37
+
38
+ ## What's New in v3.2.0 — LUT files: find them, install them, remove them, gated
39
+
40
+ Contributed by @legionsound (#223), live-validated on Studio 21.1.0.14.
41
+
42
+ ### Added
43
+
44
+ - **The `lut` tool** — `path`, `list`, `read`, `install`, `remove`, `attenuate`,
45
+ `capabilities` — and seven granular twins. `graph set_lut` could already put
46
+ a LUT on a node, but nothing answered the question it raises: which LUTs
47
+ exist? This closes the gap against Blackmagic's own `list_luts` and
48
+ `delete_lut`, and matches what `dctl` already offered for shaders in the same
49
+ directory tree. Tool count 36/377 → 37/384.
50
+ - **Reads roam, writes do not.** `list` and `read` walk the whole master LUT
51
+ root, so stock, vendor and hand-installed LUTs are all discoverable. `install`,
52
+ `remove` and `attenuate` touch only the namespaced `MCP/` subfolder: stock and
53
+ vendor LUTs are never modified or removed. Every listing reports `set_lut_path`
54
+ in the exact form `graph set_lut` accepts.
55
+ - **The master root, not the per-user LUT folder**, because `Graph.SetLUT`
56
+ resolves names only against the master root.
57
+
58
+ ### Gating
59
+
60
+ - **Gated from its first release**, the way v3.0.0 gates plugin-folder writes.
61
+ The tool carries `@_destructive_op`. `install` and `attenuate` are MEDIUM, and
62
+ `remove` is HIGH, so it is blocked in safe mode. All three honour `dry_run`
63
+ natively, are audited, and sit in the non-timeline exemption. The
64
+ write-enforcement ratchet passes with nothing added to its backlog, making this
65
+ the first new tool to land under it.
66
+ - **No code execution.** Blackmagic's `generate_lut` evaluates a caller-supplied
67
+ function at every lattice point, and this server does not execute caller code.
68
+ So authoring is limited to writing a provided `.cube`, and to blending an
69
+ existing LUT toward identity. `lut capabilities` reports
70
+ `generate_from_code: false` together with the reason.
71
+
72
+ ### Changed
73
+
74
+ - Adapted on merge: the granular `remove_lut_file` is labelled
75
+ `EXTERNAL_DESTRUCTIVE_TOOL` rather than `EXTERNAL_WRITE_TOOL`. The granular
76
+ server has no gate decorator, so for it the MCP annotation is the signal a
77
+ client reads, and a file delete labelled a plain write undersold it. A test
78
+ pins the label.
79
+
80
+ ### Validation
81
+
82
+ - Full suite green: 3,549 passed, 1 skipped.
83
+ - Live evidence is @legionsound's on Studio 21.1.0.14, and it covers the claim
84
+ the tool rests on: install → `refresh_luts` → `graph set_lut` → `get_lut`,
85
+ then a rendered TIFF, through both interfaces. A solid-red clip's centre pixel
86
+ went from `[255, 0, 0]` to `[0, 255, 0]` under a constant-green LUT, and the
87
+ relocation fallback did not fire, so the installed path resolved on its own.
88
+ Discovery listed 249 LUTs identically through both interfaces with Resolve
89
+ closed. Three containment checks are kept as tests: `../escaped.cube` is
90
+ refused, removing a stock LUT is refused, and a re-install without
91
+ `overwrite` is refused. Not reproduced here.
92
+ - **Not claimed**: LUT formats other than 3D `.cube`; `layer_index` above 1 and
93
+ colour-group graphs; `attenuate` against a real vendor LUT; the Windows and
94
+ Linux master roots; and a read-only master root.
95
+
5
96
  ## What's New in v3.1.1 — chat-drafted issues are labelled for every reporter
6
97
 
7
98
  A repository workflow change. Nothing in the server or the npm package behaves
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-3.1.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.2.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-36%20(377%20full)-blue.svg)](#server-modes)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%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)
@@ -132,8 +132,8 @@ The command starts a loopback-only server and opens the control panel in your br
132
132
 
133
133
  | Mode | Entry point | Tools | Best for |
134
134
  |------|-------------|-------|----------|
135
- | Compound | `src/server.py` | 36 | 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` | 377 | Power users who want one MCP tool per Resolve API method. |
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` | 384 | 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
 
@@ -365,7 +365,7 @@ The default server is a local stdio process launched by your MCP client; it does
365
365
 
366
366
  | Metric | Value |
367
367
  |--------|-------|
368
- | MCP Tools | **36** compound / **377** granular (live server) |
368
+ | MCP Tools | **37** compound / **384** granular (live server) |
369
369
  | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
370
370
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
371
371
  | 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-3.1.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
- [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#服务器模式)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%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
- > 本翻译对应 v3.1.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.2.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -87,8 +87,8 @@ venv/bin/python -m src.control_panel
87
87
 
88
88
  | 模式 | 入口 | 工具数 | 适合谁 |
89
89
  |------|------|--------|--------|
90
- | Compound(复合) | `src/server.py` | 36 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
91
- | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 377 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
90
+ | Compound(复合) | `src/server.py` | 37 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
91
+ | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 384 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
92
92
 
93
93
  除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
94
94
 
@@ -226,7 +226,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
226
226
 
227
227
  | 指标 | 数值 |
228
228
  |------|------|
229
- | MCP 工具 | **36** 复合 / **377** 细粒度(实时服务器) |
229
+ | MCP 工具 | **37** 复合 / **384** 细粒度(实时服务器) |
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
@@ -352,8 +352,8 @@ to the user as verified.
352
352
 
353
353
  | Mode | Entry point | Tool count | Use when |
354
354
  |---|---|---|---|
355
- | Compound (default) | `src/server.py` | 36 tools | Most workflows — keeps context lean |
356
- | Granular (full) | `src/server.py --full` | 377 tools | Power users needing one tool per API method |
355
+ | Compound (default) | `src/server.py` | 37 tools | Most workflows — keeps context lean |
356
+ | Granular (full) | `src/server.py --full` | 384 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
@@ -63,7 +63,7 @@ This MCP server controls DaVinci Resolve via its Scripting API. Some tools perfo
63
63
  davinci-resolve-mcp/
64
64
  ├── install.py # Universal installer (macOS/Windows/Linux)
65
65
  ├── src/
66
- │ ├── server.py # Compound MCP server — 36 tools (default)
66
+ │ ├── server.py # Compound MCP server — 37 tools (default)
67
67
  │ ├── resolve_mcp_server.py # Thin full-server entrypoint — 377 tools
68
68
  │ ├── granular/ # Modular full-server implementation
69
69
  │ └── utils/ # Platform detection, Resolve connection helpers
package/docs/install.md CHANGED
@@ -143,8 +143,8 @@ The MCP server comes in two modes:
143
143
 
144
144
  | Mode | File | Tools | Best For |
145
145
  |------|------|-------|----------|
146
- | **Compound** (default) | `src/server.py` | 36 | Most users — fast, clean, low context usage |
147
- | **Full** | `src/resolve_mcp_server.py` | 377 | Power users who want one tool per API method |
146
+ | **Compound** (default) | `src/server.py` | 37 | Most users — fast, clean, low context usage |
147
+ | **Full** | `src/resolve_mcp_server.py` | 384 | 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 377-tool server
162
+ python src/server.py --full # Launch full 384-tool server
163
163
  # Or point your MCP config directly at src/resolve_mcp_server.py
164
164
  ```
165
165
 
@@ -23,9 +23,9 @@ additions are tracked separately in the section below.
23
23
 
24
24
  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
- default compound server exposes **36 tools** that group related operations by
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 **377 individual tools** for power users. The legacy coverage spans
28
+ provides **384 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
 
@@ -34,6 +34,11 @@ are implemented in both interfaces and were live-measured by a contributor on
34
34
  Studio 21.1.0.14. Their evidence and limitations are recorded separately from
35
35
  the historical aggregate above.
36
36
 
37
+ Parity with Blackmagic's own MCP surfaced one more gap: it exposes
38
+ `list_luts`/`generate_lut`/`delete_lut`, and this server could set a LUT on a
39
+ node but never list, install or remove LUT files. The `lut` tool and its
40
+ granular twins close that. See [LUT file controls](lut-file-controls.md).
41
+
37
42
  The 34th compound tool is `timeline_versioning` (C6) — an MCP-level workflow
38
43
  tool, not a wrapper around a Resolve API method. It surfaces the
39
44
  version-on-mutate hook that auto-archives the working timeline before any
@@ -0,0 +1,85 @@
1
+ # LUT file controls
2
+
3
+ `graph set_lut` could already put a LUT on a node, and `export_lut` could pull
4
+ one out of a grade. Nothing answered the question `set_lut` raises: **which
5
+ LUTs exist?** There was no listing, no install and no removal — even though the
6
+ same needs for DCTL shaders are served by the `dctl` tool, in the same directory
7
+ tree, and `project_settings refresh_luts` exists precisely because files get
8
+ added by something else.
9
+
10
+ Blackmagic's own MCP exposes `list_luts`, `generate_lut` and `delete_lut`. This
11
+ closes that gap.
12
+
13
+ ## Two rules
14
+
15
+ **Reads roam, writes do not.** `list` and `read` walk the whole master LUT root,
16
+ so stock, vendor and hand-installed LUTs are all discoverable. `install`,
17
+ `remove` and `attenuate` write only inside the namespaced `MCP/` subfolder —
18
+ the same confinement Blackmagic's MCP applies to its own write tools. Stock and
19
+ vendor LUTs are never modified or removed here.
20
+
21
+ **Master root, not the user LUT dir.** `Graph.SetLUT()` resolves relative names,
22
+ and even absolute paths, only against the master root — never the per-user dir
23
+ the `dctl` tool installs into. That is measured behaviour already recorded in
24
+ `utils/lut_paths.py`, so installs land where `set_lut` can reach them, and every
25
+ listing reports `set_lut_path` in the exact form `set_lut` accepts.
26
+
27
+ ## Live results
28
+
29
+ Measured against a real LUT tree of 249 LUTs. Resolve was **not** running, which
30
+ is itself worth noting: these are filesystem operations, so discovery works
31
+ whether or not Resolve is up, where Blackmagic's `list_luts` refuses without it.
32
+
33
+ | Check | Result |
34
+ | --- | --- |
35
+ | LUTs discovered | 249 |
36
+ | Compound and granular listings identical | yes |
37
+ | Install | `MCP/mcp_live_probe.cube`, 179 bytes, appeared in listing, marked writable, count +1 |
38
+ | Read | parsed, size 2, 8 entries, whole table not returned |
39
+ | Re-install without `overwrite` | refused |
40
+ | Path traversal (`../escaped.cube`) | refused, no file created outside the root |
41
+ | Removing a stock LUT | refused, file intact |
42
+ | Cleanup | probe removed, count back to 249 |
43
+
44
+ ## Application proof
45
+
46
+ The tool's purpose rests on one claim: the `set_lut_path` that `install` returns
47
+ is resolved by `Graph.SetLUT` on a live node. Measured on Studio 21.1.0.14 in a
48
+ disposable project, with a solid-red clip and a constant-green 3D LUT so that
49
+ application is unmistakable in decoded pixels. Each row is a real render.
50
+
51
+ | Path | set_lut | Readback | Rendered centre pixel |
52
+ | --- | --- | --- | --- |
53
+ | Baseline, no LUT | — | — | `[255, 0, 0]` |
54
+ | Compound: `lut install` → `refresh_luts` → `graph set_lut` → `get_lut` | True, no fallback | `MCP/mcp_apply_probe_compound.cube` | `[0, 255, 0]` |
55
+ | Granular: `install_lut_file` → `refresh_lut_list` → `graph_set_lut` → `graph_get_lut` | True, no fallback | `MCP/mcp_apply_probe_granular.cube` | `[0, 255, 0]` |
56
+
57
+ "No fallback" matters: the compound `set_lut` has a relocation retry for LUTs
58
+ outside the master root, and a success through that retry would have hidden
59
+ whether the installed path resolved on its own. It did not fire in either run.
60
+
61
+ Three native facts came out of it. `SetLUT` on a just-installed path works
62
+ immediately after `RefreshLUTList`, with no delay. `GetLUT` reads back the exact
63
+ master-relative path. `SetLUT(1, "")` clears the node.
64
+
65
+ Two pre-existing behaviours worth knowing, neither changed here: the compound
66
+ `graph` tool targets the **timeline** node graph by default, which had no nodes,
67
+ so `set_lut` on a clip needs `source="item"`; and compound `get_lut` answers
68
+ `{lut}` where granular `graph_get_lut` answers `{lut_path}`.
69
+
70
+ ## Not ported from the official MCP
71
+
72
+ Its `generate_lut` takes a Python function body from the caller and executes it
73
+ on every lattice point. This server does not execute caller-supplied code, so
74
+ authoring here is limited to declarative operations already implemented in
75
+ `utils/cube_lut.py`: writing a provided `.cube`, and blending an existing one
76
+ toward identity. `capabilities` says so explicitly rather than leaving a caller
77
+ to guess why `generate` is missing.
78
+
79
+ ## Scope
80
+
81
+ Application was proved for a 3D `.cube` on a clip's first node graph. Other LUT
82
+ formats, `layer_index` > 1 graphs and colour-group graphs were not measured.
83
+ `attenuate` is unit-tested for bounds and missing sources but was not run
84
+ against a real vendor LUT. Windows and Linux master roots, and a read-only
85
+ master root, are untested.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.1.1"
40
+ VERSION = "3.2.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 = "36 compound · 377 full · 3 platforms"
1546
+ subtitle = "37 compound · 384 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": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.1.1"
90
+ VERSION = "3.2.1"
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()}")
@@ -2,6 +2,7 @@
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
4
  from src.utils.lut_paths import ensure_lut_in_master
5
+ from src.utils import lut_files
5
6
 
6
7
  resolve = ResolveProxy()
7
8
 
@@ -325,3 +326,121 @@ def get_color_group_post_clip_node_graph(group_name: str) -> Dict[str, Any]:
325
326
  if graph:
326
327
  return {"group": group_name, "graph_type": "post_clip", "num_nodes": graph.GetNumNodes()}
327
328
  return {"error": "No post-clip node graph available"}
329
+
330
+
331
+ def _lut_error(exc):
332
+ return {"error": str(exc)}
333
+
334
+
335
+ @mcp.tool()
336
+ def get_lut_directories() -> Dict[str, Any]:
337
+ """Report Resolve's master LUT directory and the folder this server writes to.
338
+
339
+ Installs go to the MASTER root, not the per-user LUT dir the dctl tool
340
+ uses: Graph.SetLUT() resolves LUT names only against the master root.
341
+ """
342
+ return {"lut_dir": lut_files.master_lut_dir(),
343
+ "writable_dir": lut_files.writable_dir()}
344
+
345
+
346
+ @mcp.tool()
347
+ def list_lut_files(subdir: Optional[str] = None) -> Dict[str, Any]:
348
+ """List LUT files Resolve can see, with the path graph_set_lut accepts.
349
+
350
+ Walks the whole master LUT root so stock, vendor and installed LUTs all
351
+ appear. Each entry reports set_lut_path (master-relative, the form
352
+ Graph.SetLUT resolves) and whether this server may remove it.
353
+
354
+ Args:
355
+ subdir: Optional folder under the master LUT root to limit the walk to.
356
+ """
357
+ try:
358
+ return lut_files.list_luts(subdir)
359
+ except lut_files.LutPathError as exc:
360
+ return _lut_error(exc)
361
+
362
+
363
+ @mcp.tool()
364
+ def read_lut_file(name: str) -> Dict[str, Any]:
365
+ """Summarize a 3D .cube LUT: size, title, domain. Never the whole table.
366
+
367
+ Args:
368
+ name: Master-relative path, e.g. "MCP/warm.cube".
369
+ """
370
+ try:
371
+ return lut_files.read_lut_summary(name)
372
+ except (lut_files.LutPathError, OSError, ValueError) as exc:
373
+ return _lut_error(exc)
374
+
375
+
376
+ @mcp.tool(annotations=EXTERNAL_WRITE_TOOL)
377
+ def install_lut_file(name: str, source: Optional[str] = None,
378
+ source_path: Optional[str] = None,
379
+ overwrite: bool = False) -> Dict[str, Any]:
380
+ """Install a LUT into the writable MCP folder under the master LUT root.
381
+
382
+ Refuses an existing destination unless overwrite is set. Call
383
+ refresh_lut_list() afterwards so Resolve picks the file up.
384
+
385
+ Args:
386
+ name: File name, e.g. "warm.cube". Relative only; .cube if no extension.
387
+ source: LUT file text. Provide this or source_path, not both.
388
+ source_path: A LUT file on disk to copy in.
389
+ overwrite: Replace an existing file of the same name.
390
+ """
391
+ try:
392
+ return lut_files.install_lut(name, source=source, source_path=source_path,
393
+ overwrite=overwrite)
394
+ except (lut_files.LutPathError, OSError) as exc:
395
+ return _lut_error(exc)
396
+
397
+
398
+ @mcp.tool(annotations=EXTERNAL_DESTRUCTIVE_TOOL)
399
+ def remove_lut_file(name: str) -> Dict[str, Any]:
400
+ """Delete a LUT from the writable MCP folder only.
401
+
402
+ Stock and vendor LUTs live outside it and are not removable here.
403
+
404
+ Args:
405
+ name: File name under MCP/, e.g. "warm.cube".
406
+ """
407
+ try:
408
+ return lut_files.remove_lut(name)
409
+ except (lut_files.LutPathError, OSError) as exc:
410
+ return _lut_error(exc)
411
+
412
+
413
+ @mcp.tool(annotations=EXTERNAL_WRITE_TOOL)
414
+ def attenuate_lut_file(source: str, strength: float, name: str) -> Dict[str, Any]:
415
+ """Blend an existing .cube toward identity and install the result.
416
+
417
+ Reads from anywhere under the master LUT root, writes only into MCP/.
418
+ Refuses a non-unit DOMAIN_MIN/MAX, because identity is only identity on
419
+ a 0..1 domain.
420
+
421
+ Args:
422
+ source: Master-relative path of the LUT to weaken.
423
+ strength: 0.0 (identity) to 1.0 (unchanged).
424
+ name: Output file name under MCP/.
425
+ """
426
+ try:
427
+ return lut_files.attenuate_lut(source, strength, name)
428
+ except (lut_files.LutPathError, OSError, ValueError) as exc:
429
+ return _lut_error(exc)
430
+
431
+
432
+ @mcp.tool()
433
+ def get_lut_file_capabilities() -> Dict[str, Any]:
434
+ """What this server can do with LUT files, and what it refuses.
435
+
436
+ Notably it will not generate a LUT from caller-supplied Python code.
437
+ """
438
+ from src.utils import cube_lut
439
+ caps = dict(cube_lut.capabilities())
440
+ caps["writable_dir"] = lut_files.writable_dir()
441
+ caps["extensions"] = list(lut_files.LUT_EXTENSIONS)
442
+ caps["generate_from_code"] = False
443
+ caps["generate_from_code_reason"] = (
444
+ "This server does not execute caller-supplied Python. Use "
445
+ "install_lut_file with .cube text, or attenuate_lut_file.")
446
+ return caps
@@ -34,7 +34,7 @@ from src.utils.update_check import start_background_update_check
34
34
  if __name__ == "__main__":
35
35
  try:
36
36
  start_background_update_check(VERSION, project_dir, logger)
37
- logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (377 granular tools)")
37
+ logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (384 granular tools)")
38
38
  run_fastmcp_stdio(mcp)
39
39
  except KeyboardInterrupt:
40
40
  logger.info("Server shutdown requested")
package/src/server.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """
3
3
  DaVinci Resolve MCP Server (Compound Tools)
4
4
 
5
- 36 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
5
+ 37 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
6
6
  plus Fusion Fuse, DCTL, and Resolve-page Script authoring tools.
7
7
  Each tool groups related operations via an 'action' parameter.
8
8
 
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "3.1.1"
14
+ VERSION = "3.2.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -52,6 +52,7 @@ from src.utils.resolve211_edits import validate_edit_options, validate_transitio
52
52
 
53
53
  # Platform-specific Resolve paths
54
54
  from src.utils.cdl import normalize_cdl_payload
55
+ from src.utils import lut_files
55
56
  from src.utils import resolve_writes as _resolve_writes
56
57
  from src.utils.mcp_stdio import run_fastmcp_stdio
57
58
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
@@ -384,7 +385,7 @@ def davinci_resolve_workflow() -> str:
384
385
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
385
386
 
386
387
  Core pattern:
387
- - Prefer the 36 compound tools and their action names over raw scripting.
388
+ - Prefer the 37 compound tools and their action names over raw scripting.
388
389
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
389
390
  - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
390
391
  - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
@@ -31199,6 +31200,101 @@ def _validate_dctl_source(source: str) -> Dict[str, Any]:
31199
31200
  _DCTL_VALID_CATEGORIES = ("lut", "aces_idt", "aces_odt")
31200
31201
 
31201
31202
 
31203
+ @mcp.tool()
31204
+ @_guard_missing_params
31205
+ @_destructive_op("lut")
31206
+ def lut(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
31207
+ """Discover, install and remove LUT files under Resolve's master LUT root.
31208
+
31209
+ `graph set_lut` could already put a LUT on a node, but nothing answered the
31210
+ question it raises — which LUTs exist? These actions do, and they report
31211
+ each LUT's master-relative `set_lut_path`, which is the exact form
31212
+ `set_lut` resolves.
31213
+
31214
+ Reads roam, writes do not. `list` and `read` walk the whole master LUT root
31215
+ so stock, vendor and hand-installed LUTs are discoverable. `install`,
31216
+ `remove` and `attenuate` write only inside the namespaced `MCP/` subfolder,
31217
+ so stock and vendor LUTs are never modified or removed here.
31218
+
31219
+ Installs land in the MASTER root, not the per-user LUT dir the `dctl` tool
31220
+ uses: Graph.SetLUT() resolves names only against the master root (measured;
31221
+ see utils/lut_paths.py). After installing, call
31222
+ project_settings(action='refresh_luts') before applying.
31223
+
31224
+ Actions:
31225
+ path() -> {lut_dir, writable_dir}
31226
+ list(subdir?) -> {luts, count, lut_dir, writable_dir}
31227
+ — each entry: {name, set_lut_path, bytes, writable}
31228
+ read(name) -> {size, title, domain_min, domain_max, entries, ...}
31229
+ — 3D .cube only; reports shape and header, never the whole table.
31230
+ install(name, source | source_path, overwrite?) -> {success, set_lut_path}
31231
+ — source: .cube text. source_path: a file to copy in. Exactly one.
31232
+ remove(name) -> {success, removed}
31233
+ — MCP/ only.
31234
+ attenuate(source, strength, name) -> {success, set_lut_path, ...}
31235
+ — blend an existing .cube toward identity, 0..1, and install the result.
31236
+ capabilities() -> {numpy_available, supported, refused, size_range}
31237
+
31238
+ Not provided: the official MCP's generate_lut executes a caller-supplied
31239
+ Python function body per lattice point. This server does not accept
31240
+ caller-supplied code, so authoring here is limited to writing a provided
31241
+ .cube and attenuating an existing one.
31242
+ """
31243
+ p = _params(params)
31244
+ try:
31245
+ if action == "path":
31246
+ return {"lut_dir": lut_files.master_lut_dir(),
31247
+ "writable_dir": lut_files.writable_dir()}
31248
+ if action == "list":
31249
+ return lut_files.list_luts(p.get("subdir"))
31250
+ if action == "read":
31251
+ if not p.get("name"):
31252
+ return _err("read requires name")
31253
+ return lut_files.read_lut_summary(p["name"])
31254
+ if action == "capabilities":
31255
+ from src.utils import cube_lut
31256
+ caps = dict(cube_lut.capabilities())
31257
+ caps["writable_dir"] = lut_files.writable_dir()
31258
+ caps["extensions"] = list(lut_files.LUT_EXTENSIONS)
31259
+ caps["generate_from_code"] = False
31260
+ caps["generate_from_code_reason"] = (
31261
+ "This server does not execute caller-supplied Python. Use install "
31262
+ "with .cube text, or attenuate an existing LUT.")
31263
+ return caps
31264
+ if action == "install":
31265
+ if not p.get("name"):
31266
+ return _err("install requires name")
31267
+ if p.get("dry_run"):
31268
+ return _ok(would_install=p["name"], writable_dir=lut_files.writable_dir())
31269
+ return lut_files.install_lut(
31270
+ p["name"], source=p.get("source"), source_path=p.get("source_path"),
31271
+ overwrite=bool(p.get("overwrite", False)))
31272
+ if action == "remove":
31273
+ if not p.get("name"):
31274
+ return _err("remove requires name")
31275
+ if p.get("dry_run"):
31276
+ return _ok(would_remove=p["name"], writable_dir=lut_files.writable_dir())
31277
+ return lut_files.remove_lut(p["name"])
31278
+ if action == "attenuate":
31279
+ for required in ("source", "strength", "name"):
31280
+ if p.get(required) is None:
31281
+ return _err(f"attenuate requires {required}")
31282
+ if p.get("dry_run"):
31283
+ return _ok(would_write=p["name"], source=p["source"], strength=p["strength"])
31284
+ return lut_files.attenuate_lut(p["source"], p["strength"], p["name"])
31285
+ except lut_files.LutPathError as exc:
31286
+ return _err(str(exc), code="INVALID_LUT_PATH", category="invalid_input")
31287
+ except FileNotFoundError as exc:
31288
+ return _err(str(exc), code="LUT_NOT_FOUND", category="invalid_input")
31289
+ except OSError as exc:
31290
+ return _err(f"{type(exc).__name__}: {exc}", code="LUT_IO_ERROR",
31291
+ category="filesystem")
31292
+ except Exception as exc:
31293
+ return _err(f"{type(exc).__name__}: {exc}", code="LUT_ERROR")
31294
+ return _unknown(action, ["path", "list", "read", "install", "remove",
31295
+ "attenuate", "capabilities"])
31296
+
31297
+
31202
31298
  @mcp.tool()
31203
31299
  @_guard_missing_params
31204
31300
  @_destructive_op("dctl")
@@ -32605,5 +32701,5 @@ if __name__ == "__main__":
32605
32701
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
32606
32702
  sys.exit(2)
32607
32703
 
32608
- logger.info("Starting DaVinci Resolve MCP Server (36 compound tools)")
32704
+ logger.info("Starting DaVinci Resolve MCP Server (37 compound tools)")
32609
32705
  run_fastmcp_stdio(mcp)
@@ -70,6 +70,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
70
70
  # real. They never mutate the timeline, so NON_TIMELINE_WRITE_TOOLS keeps
71
71
  # them out of timeline archiving while every gate still sees them.
72
72
  "dctl": frozenset({"encrypt_native", "install", "remove"}),
73
+ # LUT files under Resolve's master LUT root. Writes are confined to the
74
+ # namespaced MCP/ subfolder by src/utils/lut_files.py; these entries make
75
+ # every gate see them as the writes they are.
76
+ "lut": frozenset({"attenuate", "install", "remove"}),
73
77
  "fuse_plugin": frozenset({"install", "remove"}),
74
78
  "script_plugin": frozenset({
75
79
  "install",
@@ -280,7 +284,7 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
280
284
  # that there is no project state to log.
281
285
 
282
286
  NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({
283
- "dctl", "fuse_plugin", "script_plugin",
287
+ "dctl", "fuse_plugin", "lut", "script_plugin",
284
288
  "folder", "gallery_stills", "media_pool_item", "media_pool_item_markers",
285
289
  "project_manager", "project_settings", "render", "render_presets",
286
290
  "resolve_control",
@@ -354,6 +358,9 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
354
358
  ("script_plugin", "safe_install_extension"),
355
359
  ("script_plugin", "safe_remove_extension"),
356
360
  ("project_manager", "safe_project_delete"),
361
+ ("lut", "attenuate"),
362
+ ("lut", "install"),
363
+ ("lut", "remove"),
357
364
  })
358
365
 
359
366
 
@@ -142,6 +142,9 @@ class RiskClassificationHook(LifecycleHook):
142
142
  ("fuse_plugin", "remove"),
143
143
  ("script_plugin", "remove"),
144
144
  ("script_plugin", "safe_remove_extension"),
145
+ # LUT deletes. Confined to the MCP/ subfolder, but permanent, and the
146
+ # file may be applied on a node in some open project.
147
+ ("lut", "remove"),
145
148
  # Guarded delete: disposable `_mcp_` projects only, and the open one
146
149
  # only with close_current=True — but still permanent.
147
150
  ("project_manager", "safe_project_delete"),
@@ -262,6 +265,11 @@ class RiskClassificationHook(LifecycleHook):
262
265
  ("fuse_plugin", "install"),
263
266
  ("script_plugin", "install"),
264
267
  ("script_plugin", "safe_install_extension"),
268
+ # LUT installs write into the folder Resolve loads LUTs from, like the
269
+ # plugin installs above. `install` can replace with overwrite=true;
270
+ # `attenuate` refuses an existing destination but writes the same tree.
271
+ ("lut", "install"),
272
+ ("lut", "attenuate"),
265
273
  # 21.0 AI deblur renders NEW media and never touches the source (it is
266
274
  # confirm-token gated for that reason). The `remove_` prefix rule rated
267
275
  # it HIGH on its name alone, which would make safe mode block a create.
@@ -0,0 +1,305 @@
1
+ """LUT file discovery and safe installation under Resolve's master LUT root.
2
+
3
+ The server could already put a LUT on a node (`graph set_lut`) and pull one out
4
+ of a grade (`export_lut`), but nothing could answer the question `set_lut`
5
+ raises: *which LUTs exist?* There was no listing, no install and no removal,
6
+ even though the same needs for DCTL shaders are served by the `dctl` tool and
7
+ the two live in the same directory tree. Blackmagic's own MCP exposes
8
+ `list_luts`, `generate_lut` and `delete_lut`; this closes that gap.
9
+
10
+ Two rules shape everything here.
11
+
12
+ **Reads roam, writes do not.** Listing walks the whole master LUT root so
13
+ stock, vendor and hand-installed LUTs are all discoverable. Writing and
14
+ deleting are confined to one namespaced subfolder, `MCP/`, which is the same
15
+ confinement Blackmagic's own MCP applies to `generate_lut`/`delete_lut`. Stock
16
+ and vendor LUTs are never modified or removed by this server.
17
+
18
+ **Master root, not the user LUT dir.** `Graph.SetLUT()` resolves relative names
19
+ — and even absolute paths — only against the master root, never the per-user
20
+ dir the `dctl` tool installs into. That is measured behaviour recorded in
21
+ `lut_paths`, so installs land where `set_lut` can actually reach them, and
22
+ every listing reports the master-relative path in the exact form `set_lut`
23
+ accepts.
24
+
25
+ Deliberately **not** ported from the official MCP: its `generate_lut` takes a
26
+ Python function body from the caller and executes it per lattice point. This
27
+ server does not accept caller-supplied code, so LUT authoring here is limited
28
+ to declarative operations already implemented in `cube_lut` — writing a
29
+ provided `.cube` and attenuating an existing one toward identity.
30
+ """
31
+
32
+ import os
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+ from src.utils.lut_paths import MASTER_LUT_RELOCATE_SUBDIR, master_lut_dir
36
+
37
+ # The extensions Resolve's LUT browser picks up, matching the official MCP's list.
38
+ LUT_EXTENSIONS = (".3dl", ".cube", ".dat", ".lut", ".olut")
39
+
40
+ # Writes and deletes are confined to this subfolder of the master root.
41
+ WRITABLE_SUBDIR = MASTER_LUT_RELOCATE_SUBDIR
42
+
43
+
44
+ class LutPathError(ValueError):
45
+ """A caller-supplied LUT name or subdir could not be used safely."""
46
+
47
+
48
+ def writable_dir() -> str:
49
+ """The one directory this server installs into and deletes from."""
50
+ return os.path.join(master_lut_dir(), WRITABLE_SUBDIR)
51
+
52
+
53
+ def _is_within(path: str, parent: str) -> bool:
54
+ """Whether ``path`` is ``parent`` itself or sits under it, compared by segment.
55
+
56
+ ``commonpath`` is the comparison the rest of the repo uses for confinement
57
+ checks (`media_analysis._is_relative_to`), and it is the only one that is
58
+ correct here: a string prefix test also accepts a sibling whose name merely
59
+ starts with the same characters.
60
+ """
61
+ try:
62
+ return os.path.commonpath([path, parent]) == parent
63
+ except (ValueError, OSError):
64
+ return False
65
+
66
+
67
+ def normalize_relative(name: str, *, default_ext: Optional[str] = None) -> str:
68
+ """Validate a caller-supplied LUT name and return it as a POSIX relative path.
69
+
70
+ Rejects absolute paths, drive letters, empty segments and any `.`/`..`
71
+ segment, so a name can never escape the directory it is resolved against.
72
+ """
73
+ if not isinstance(name, str) or not name.strip():
74
+ raise LutPathError("name is required")
75
+ candidate = name.strip().replace("\\", "/")
76
+ if candidate.startswith("/") or (len(candidate) > 1 and candidate[1] == ":"):
77
+ raise LutPathError(f"name must be relative, not an absolute path: {name!r}")
78
+ parts = [segment.strip() for segment in candidate.split("/") if segment.strip()]
79
+ if not parts:
80
+ raise LutPathError("name is required")
81
+ for segment in parts:
82
+ if segment in (".", ".."):
83
+ raise LutPathError(f"unsafe path segment in {name!r}: {segment!r}")
84
+ relative = "/".join(parts)
85
+ if default_ext and not os.path.splitext(relative)[1]:
86
+ relative += default_ext
87
+ ext = os.path.splitext(relative)[1].lower()
88
+ if ext not in LUT_EXTENSIONS:
89
+ raise LutPathError(
90
+ f"{relative!r} is not a LUT file. Expected one of: "
91
+ + ", ".join(LUT_EXTENSIONS)
92
+ )
93
+ return relative
94
+
95
+
96
+ def resolve_writable(name: str, *, default_ext: Optional[str] = ".cube") -> Tuple[str, str]:
97
+ """Return ``(absolute_path, master_relative_path)`` inside the writable subdir.
98
+
99
+ The second value is what `graph set_lut` wants, so a caller can install and
100
+ then apply without constructing a path by hand.
101
+ """
102
+ relative = normalize_relative(name, default_ext=default_ext)
103
+ absolute = os.path.join(writable_dir(), *relative.split("/"))
104
+ root = os.path.realpath(writable_dir())
105
+ if os.path.commonpath([root, os.path.realpath(os.path.dirname(absolute)) or root]) != root:
106
+ raise LutPathError(f"{name!r} resolves outside {WRITABLE_SUBDIR}/")
107
+ return absolute, f"{WRITABLE_SUBDIR}/{relative}"
108
+
109
+
110
+ def list_luts(subdir: Optional[str] = None) -> Dict[str, Any]:
111
+ """Walk the master LUT root and report every LUT Resolve would see.
112
+
113
+ Each entry carries `set_lut_path` — the master-relative form `set_lut`
114
+ resolves — plus `writable`, which says whether this server may remove it.
115
+ """
116
+ root = master_lut_dir()
117
+ base = root
118
+ if subdir:
119
+ relative_parts = [s for s in subdir.replace("\\", "/").split("/") if s.strip()]
120
+ for segment in relative_parts:
121
+ if segment in (".", ".."):
122
+ raise LutPathError(f"unsafe subdir segment: {segment!r}")
123
+ base = os.path.join(root, *relative_parts)
124
+ if not os.path.isdir(base):
125
+ return {"lut_dir": root, "searched": base, "exists": False, "luts": [], "count": 0}
126
+
127
+ writable_root = os.path.realpath(writable_dir())
128
+ found: List[Dict[str, Any]] = []
129
+ for current, _dirs, files in os.walk(base):
130
+ for filename in sorted(files):
131
+ if os.path.splitext(filename)[1].lower() not in LUT_EXTENSIONS:
132
+ continue
133
+ absolute = os.path.join(current, filename)
134
+ relative = os.path.relpath(absolute, root).replace(os.sep, "/")
135
+ try:
136
+ size = os.path.getsize(absolute)
137
+ except OSError:
138
+ size = None
139
+ try:
140
+ # Segment-wise, not a string prefix: a sibling of the writable
141
+ # subdir whose name starts with it -- MCP_old/ after a manual
142
+ # backup, MCPresets/ from a vendor pack -- cleared
143
+ # `startswith(writable_root)` and was listed as writable, while
144
+ # `remove` resolves names under MCP/ and refuses the very path
145
+ # the listing handed back.
146
+ is_writable = _is_within(os.path.realpath(current), writable_root)
147
+ except OSError:
148
+ is_writable = False
149
+ found.append({
150
+ "name": filename,
151
+ "set_lut_path": relative,
152
+ "bytes": size,
153
+ "writable": is_writable,
154
+ })
155
+ found.sort(key=lambda row: row["set_lut_path"])
156
+ return {
157
+ "lut_dir": root,
158
+ "searched": base,
159
+ "exists": True,
160
+ "writable_dir": writable_dir(),
161
+ "luts": found,
162
+ "count": len(found),
163
+ }
164
+
165
+
166
+ def install_lut(name: str, *, source: Optional[str] = None,
167
+ source_path: Optional[str] = None,
168
+ overwrite: bool = False) -> Dict[str, Any]:
169
+ """Write a LUT into the writable subdir from text or by copying a file.
170
+
171
+ Refuses an existing destination unless `overwrite` is set, so an install
172
+ never silently replaces something already in use.
173
+ """
174
+ if (source is None) == (source_path is None):
175
+ raise LutPathError("provide exactly one of source (text) or source_path (a file to copy)")
176
+ absolute, set_lut_path = resolve_writable(name)
177
+ existed = os.path.exists(absolute)
178
+ if existed and not overwrite:
179
+ raise LutPathError(
180
+ f"{set_lut_path} already exists. Pass overwrite=true to replace it."
181
+ )
182
+ if source_path is not None:
183
+ if not os.path.isfile(source_path):
184
+ raise LutPathError(f"source_path not found: {source_path}")
185
+ # A copy has to be byte-exact. Two of the extensions this server
186
+ # advertises -- .dat and .olut -- are binary, and a .cube carries its
187
+ # TITLE in whatever encoding the vendor wrote it in, so decoding the
188
+ # source as UTF-8 turned "install the LUT I already have on disk" into
189
+ # a UnicodeDecodeError surfacing as LUT_ERROR.
190
+ with open(source_path, "rb") as handle:
191
+ payload = handle.read()
192
+ else:
193
+ payload = source
194
+ if not payload.strip():
195
+ raise LutPathError("refusing to install an empty LUT")
196
+ os.makedirs(os.path.dirname(absolute), exist_ok=True)
197
+ if isinstance(payload, bytes):
198
+ with open(absolute, "wb") as handle:
199
+ handle.write(payload)
200
+ else:
201
+ with open(absolute, "w", encoding="utf-8") as handle:
202
+ handle.write(payload)
203
+ return {
204
+ "success": True,
205
+ "path": absolute,
206
+ "set_lut_path": set_lut_path,
207
+ "bytes": os.path.getsize(absolute),
208
+ # Whether a file was actually replaced, not whether the caller allowed
209
+ # it: `payload is not None` is always true here, so this reported a
210
+ # replacement for every overwrite=true install, including the ones that
211
+ # landed on an empty MCP/. This flag is the record of what an install
212
+ # destroyed -- execution_lifecycle rates `lut install` on the fact that
213
+ # it "can replace with overwrite=true" -- so it has to be the observed
214
+ # pre-state, not the permission.
215
+ "overwritten": existed,
216
+ "note": ("Call project_settings(action='refresh_luts') so Resolve picks up "
217
+ "the new file before applying it."),
218
+ }
219
+
220
+
221
+ def remove_lut(name: str) -> Dict[str, Any]:
222
+ """Delete a LUT from the writable subdir only.
223
+
224
+ Stock and vendor LUTs live outside it and cannot be removed through here.
225
+ """
226
+ absolute, set_lut_path = resolve_writable(name)
227
+ if not os.path.isfile(absolute):
228
+ raise LutPathError(
229
+ f"{set_lut_path} is not present in {WRITABLE_SUBDIR}/. This server only "
230
+ "removes LUTs it can install; stock and vendor LUTs are left alone."
231
+ )
232
+ os.remove(absolute)
233
+ return {"success": True, "removed": set_lut_path, "path": absolute}
234
+
235
+
236
+ def read_lut_summary(name: str) -> Dict[str, Any]:
237
+ """Summarize a `.cube` without returning the whole table.
238
+
239
+ A 65-cube is 274,625 rows; handing that back through a tool result is
240
+ useless to a caller and expensive, so this reports shape and header only.
241
+ """
242
+ from src.utils import cube_lut
243
+
244
+ root = master_lut_dir()
245
+ relative = normalize_relative(name)
246
+ absolute = os.path.join(root, *relative.split("/"))
247
+ if not os.path.isfile(absolute):
248
+ raise LutPathError(f"LUT not found under the master LUT dir: {relative}")
249
+ if os.path.splitext(absolute)[1].lower() != ".cube":
250
+ return {
251
+ "set_lut_path": relative,
252
+ "path": absolute,
253
+ "bytes": os.path.getsize(absolute),
254
+ "parsed": False,
255
+ "note": "Only 3D .cube files are parsed; other formats report size only.",
256
+ }
257
+ parsed = cube_lut.read_cube(absolute)
258
+ return {
259
+ "set_lut_path": relative,
260
+ "path": absolute,
261
+ "bytes": os.path.getsize(absolute),
262
+ "parsed": True,
263
+ "size": parsed["size"],
264
+ "title": parsed["title"],
265
+ "domain_min": parsed["domain_min"],
266
+ "domain_max": parsed["domain_max"],
267
+ "entries": int(parsed["size"]) ** 3,
268
+ }
269
+
270
+
271
+ def attenuate_lut(source: str, strength: float, name: str) -> Dict[str, Any]:
272
+ """Blend an existing `.cube` toward identity and install the result.
273
+
274
+ Reads from anywhere under the master root, writes only into the writable
275
+ subdir. `cube_lut` refuses a non-unit domain, because identity is only
276
+ identity on 0..1.
277
+ """
278
+ from src.utils import cube_lut
279
+
280
+ try:
281
+ value = float(strength)
282
+ except (TypeError, ValueError):
283
+ raise LutPathError("strength must be a number between 0 and 1")
284
+ if not 0.0 <= value <= 1.0:
285
+ raise LutPathError(f"strength must be between 0 and 1, got {value}")
286
+ root = master_lut_dir()
287
+ source_relative = normalize_relative(source)
288
+ source_absolute = os.path.join(root, *source_relative.split("/"))
289
+ if not os.path.isfile(source_absolute):
290
+ raise LutPathError(f"source LUT not found under the master LUT dir: {source_relative}")
291
+ absolute, set_lut_path = resolve_writable(name)
292
+ if os.path.exists(absolute):
293
+ raise LutPathError(f"{set_lut_path} already exists. Choose another name.")
294
+ os.makedirs(os.path.dirname(absolute), exist_ok=True)
295
+ summary = cube_lut.attenuate_file(source_absolute, value, absolute,
296
+ title_suffix=f"@{value:g}")
297
+ summary.update({
298
+ "success": True,
299
+ "source": source_relative,
300
+ "strength": value,
301
+ "set_lut_path": set_lut_path,
302
+ "note": ("Call project_settings(action='refresh_luts') so Resolve picks up "
303
+ "the new file before applying it."),
304
+ })
305
+ return summary