davinci-resolve-mcp 2.224.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,137 @@
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.0.0 — the server no longer executes caller-supplied code, and every plugin write is gated
6
+
7
+ **A breaking release.** Two public actions are removed. The rest of the change
8
+ is a security fix, published as [GHSA-vh75-g46q-hgcw](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-vh75-g46q-hgcw).
9
+
10
+ ### Removed (breaking)
11
+
12
+ - **`script_plugin run_inline`** ran a caller's source directly. Python ran as a
13
+ subprocess on the host, with the user's privileges and a live Resolve handle.
14
+ Lua ran inside Resolve's Fusion engine with `os` and `io` in scope, so
15
+ `os.execute` reached the shell.
16
+ - **`script_plugin execute`** ran an installed script. `install` accepts
17
+ caller-supplied source, so the two together did the same thing in two steps.
18
+ - **`probe_script_lifecycle`'s `execute` option.** The probe now **refuses**
19
+ `execute=true` up front, before generating or installing anything. Skipping it
20
+ silently would have reported a probe as complete for a step it never ran.
21
+
22
+ Both actions shipped in v2.5.0 as documented features, and the agent guidance
23
+ recommended `run_inline` for conversational queries. They are removed under the
24
+ maintainer's policy that this server does not execute caller-supplied code, in
25
+ any form. Calling either now returns an error that names the removal and points
26
+ to the replacement, rather than a bare "unknown action".
27
+
28
+ ### Migration
29
+
30
+ - Install the script with `script_plugin install`, then run it yourself from
31
+ **Workspace → Scripts** inside Resolve. Python output appears in Resolve's
32
+ Console.
33
+ - For queries and edits in conversation, use the typed tools.
34
+
35
+ ### Security
36
+
37
+ - **Plugin-folder writes passed every gate as reads.** `install` and `remove`
38
+ on `dctl`, `fuse_plugin` and `script_plugin`, plus `safe_install_extension`
39
+ and `safe_remove_extension`, were in neither write table. The risk classifier
40
+ returned `recognised=False` — a bare `remove` misses the `remove_*` prefix
41
+ rule — the destructive registry had no entry, and `fuse_plugin` and
42
+ `script_plugin` carried no `@_destructive_op` at all. So safe mode, dry-run
43
+ refusal and the security audit log treated them as reads. These are the
44
+ actions that put files into folders Resolve and Fusion later load and run: a
45
+ Fuse registers on the next restart, a script runs when clicked.
46
+ - **`run_inline` and `execute` were in the same state.** With safe mode on, the
47
+ setting whose whole purpose is to block dangerous operations let arbitrary
48
+ code execution through as a read.
49
+ - All of this is fixed here, for every version from v2.5.0 onward, and published
50
+ as the advisory linked above. A read-only audit confirmed `script_plugin` was
51
+ the only path in the repository that ran caller-supplied code: the Node
52
+ advanced server spawns fixed binaries only, never with `shell: true`.
53
+
54
+ ### Fixed
55
+
56
+ - **A dry run of `install` or `remove` wrote or deleted the file for real.**
57
+ Dry-run refusal only applies to registered actions, so `dry_run=true` was
58
+ silently ignored. It is now refused with `DRY_RUN_UNAVAILABLE`. For a genuine
59
+ preview, use `safe_install_extension` / `safe_remove_extension`, which honour
60
+ `dry_run` themselves.
61
+ - **The lifecycle probes skipped the gate.** They called the raw `_safe_*`
62
+ helpers directly, and `safe_remove_extension` unlinks the file itself, so
63
+ their installs and cleanup deletes reached disk ungated. They now go through
64
+ `script_plugin(...)`, and an AST guard keeps it that way.
65
+ - **Plugin writes would have snapshotted the open timeline.** Once registered,
66
+ every write falls into version-on-mutate archiving — and `dctl encrypt_native`,
67
+ registered in v2.224.0, already archived a timeline version on every call. A
68
+ new non-timeline exemption keeps these writes gated and audited but skips the
69
+ archive. It also never resolves the versioning context, which reaches Resolve:
70
+ installing a shader must neither touch the project nor launch Resolve.
71
+
72
+ ### Changed — risk ratings
73
+
74
+ - `install` and `safe_install_extension`: **MEDIUM** — audited and dry-run-honest,
75
+ not blocked by safe mode, like the other create-style writes.
76
+ - `remove` and `safe_remove_extension`: **HIGH** — blocked while safe mode is on.
77
+ `allow_risky_operation: true` overrides a single call.
78
+ - Safe mode is off by default, and `confirmation_required` is informational, not
79
+ a token demand. So for most users the visible change is that these calls are
80
+ now audited, and a dry run means a dry run.
81
+
82
+ ### Documentation
83
+
84
+ - `docs/SKILL.md`, `docs/authoring/script-plugin-authoring.md` (retitled; its
85
+ execution section replaced by how to run an installed script) and the
86
+ extension-authoring kernel map describe the gated, execution-free surface.
87
+ So does the agent-facing prompt guidance, which had told agents to prefer
88
+ `run_inline` for inspecting Resolve state.
89
+ - Two measured facts about Resolve's Lua bridge, found while building the
90
+ removed `run_inline`, are kept as reference because they describe Resolve
91
+ itself: `fusion.Execute()` is a no-op from the Python bridge in 20.x, and
92
+ `fusion.RunScript()` returns before the script finishes.
93
+
94
+ ### Validation
95
+
96
+ - Full suite green: 3,472 passed, 1 skipped. The drop from the previous run is
97
+ exactly the deleted execution tests, less the five new policy tests.
98
+ - New tests pin both halves: every plugin write is a rated, recognised write; a
99
+ dry run on the real tools is refused rather than executed; the safe-install
100
+ dry run still works; plugin writes never archive or reach Resolve; safe mode
101
+ blocks deletes and not installs; the removed actions refuse with a migration
102
+ pointer; the probe refuses `execute` before any side effect; and an AST scan
103
+ finds no `RunScript`, `Execute`, `exec` or `eval` call anywhere in `src/`.
104
+ - No live Resolve run. The actions that remain behave as before apart from the
105
+ gate, which is decorator-level and verified offline. The removed actions can
106
+ only be verified absent, which the tests do.
107
+
108
+ ## What's New in v2.224.3 — the Windows import guard covers the advanced server
109
+
110
+ Contributed by @Dev-next-gen (#222). Test-only; no behaviour changed.
111
+
112
+ ### Changed
113
+
114
+ - The static guard added in v2.224.2 fails if a dynamic `import()` is given a
115
+ bare filesystem path — the pattern Node's ESM loader rejects on Windows. It
116
+ covered only `scripts/*.mjs` and `bin/*.mjs`, so a bare-path import added
117
+ under `resolve-advanced/server/` would have passed it, and with CI running
118
+ only on Linux the Windows failure would have stayed invisible there too. It
119
+ now also walks `resolve-advanced/server/` recursively, since the advanced
120
+ server loads modules from its `tools/` subfolder as well.
121
+ - Offenders are reported by their path from the repository root, so a hit in a
122
+ nested file names that file. `node_modules` is skipped.
123
+
124
+ ### Validation
125
+
126
+ - There is nothing under `resolve-advanced/server/` to catch today — every
127
+ dynamic import there passes a string literal — so the widened guard was
128
+ verified against a planted file, reproduced independently here: a probe at
129
+ `resolve-advanced/server/tools/zz_bare_import_probe.mjs` containing
130
+ `await import(path.join(...))` fails it, naming that file and line; with the
131
+ probe removed it passes. Per the contributor, the original
132
+ `scripts/author_interchange.mjs:45` case is still caught against the
133
+ pre-v2.224.2 bridge.
134
+ - Full suite green: 3,485 passed, 1 skipped.
135
+
5
136
  ## What's New in v2.224.2 — offline authoring works on Windows
6
137
 
7
138
  Contributed by @Dev-next-gen (#221), found and verified on Windows.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.224.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.224.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.224.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.0.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -118,39 +118,47 @@ directories. They are *authoring* tools — every other tool in this server wrap
118
118
  Resolve's scripting API, while these three emit and install plugin/script
119
119
  source. Status: lifecycle-verified in DaVinci Resolve Studio 20.3.2.9 for
120
120
  MCP-marked install/read/list/remove, regular DCTL `refresh_luts`, ACES/Fuse
121
- restart-required classification, Python installed-script execution, and
122
- Python/Lua `run_inline`. Use `docs/kernels/extension-authoring-kernel.md` for the
121
+ restart-required classification. Script execution — `execute` and `run_inline` —
122
+ was removed in v3.0.0: this server does not run caller-supplied code. Use
123
+ `docs/kernels/extension-authoring-kernel.md` for the
123
124
  kernel boundary map, `docs/authoring/fuse-dctl-authoring.md` for the Fuse + DCTL coverage
124
125
  matrix, and `docs/authoring/script-plugin-authoring.md` for the script DSL spec and the
125
- conversational-execution model. For hand-authoring `.setting` template files
126
+ install paths. For hand-authoring `.setting` template files
126
127
  (Edit effects/transitions/titles/generators and Fusion macros) — the format,
127
128
  control catalog, thumbnail conventions, install paths, and gotchas, plus copyable
128
129
  starter templates — see `docs/authoring/setting-files/`.
129
130
 
131
+ **Plugin writes are gated like every other write.** `install` and `remove` on all
132
+ three tools, and `script_plugin`'s `safe_install_extension` / `safe_remove_extension`,
133
+ are registered destructive actions. An explicit `dry_run=true` on `install` or
134
+ `remove` is refused with `DRY_RUN_UNAVAILABLE` rather than executed — for a real
135
+ preview use `safe_install_extension` / `safe_remove_extension`, which honour
136
+ `dry_run` themselves. `remove` is rated HIGH and is blocked while safe mode is on
137
+ (`allow_risky_operation: true` overrides a single call); `install` is MEDIUM.
138
+ Every call is recorded in the security audit log, and none of them archives the
139
+ timeline — they write plugin folders, not the project. The `probe_*_lifecycle`
140
+ actions route their installs and cleanup deletes through the same gate.
141
+
130
142
  Extension Authoring kernel actions (v2.16.0+) are exposed through
131
143
  `script_plugin`:
132
144
 
133
145
  - `extension_capabilities`
134
146
  - `probe_fuse_lifecycle(name?, kind?, install?, cleanup?)`
135
147
  - `probe_dctl_lifecycle(name?, kind?, category?, install?, refresh_luts?, cleanup?)`
136
- - `probe_script_lifecycle(name?, language?, category?, install?, execute?, cleanup?)`
148
+ - `probe_script_lifecycle(name?, language?, category?, install?, cleanup?)`
137
149
  - `safe_install_extension(extension_type, name, source?|kind?, dry_run?)`
138
150
  - `safe_remove_extension(extension_type, name, dry_run?)`
139
151
  - `refresh_or_restart_required(extension_type, category?)`
140
152
  - `extension_boundary_report(include_template_matrix?)`
141
153
 
142
154
  Key behavioral notes for `script_plugin`:
143
- - `run_inline(source, language)` runs ad-hoc Lua/Python in Resolve and returns
144
- stdout + result use this for one-off conversational queries against the
145
- Resolve API instead of building+installing a script.
155
+ - **No script execution.** `run_inline` and `execute` were removed in v3.0.0:
156
+ this server does not run caller-supplied code, in any form. `install` puts a
157
+ script in Resolve's Workspace Scripts menu; running it is the user's action
158
+ inside Resolve. For conversational queries against the Resolve API, use the
159
+ typed tools rather than a script.
146
160
  - `language` accepts `lua`, `py`, or the human-facing aliases `python` and
147
161
  `python3`.
148
- - `execute(name, category, language)` runs an installed script; Python stdout
149
- and stderr are captured, while installed Lua execution can return false from
150
- the Python bridge even when install/read/list/remove worked.
151
- - Lua scripts: `fusion.Execute()` from the Python bridge is a no-op in
152
- Resolve 20.x — `_run_inline_lua` works around this with `RunScript` against
153
- a temp file plus completion-sentinel polling on `app:SetData/GetData`.
154
162
  - Fuse install path on macOS is `…/DaVinci Resolve/Fusion/Fuses/` (NOT
155
163
  `Support/Fusion/Fuses/` as the SDK doc lists). The MCP path helpers handle
156
164
  this; if you're staging files manually, use the path the implementation
@@ -1,10 +1,10 @@
1
- # Script Plugin Authoring & Conversational Resolve Scripting
1
+ # Script Plugin Authoring
2
2
 
3
- The `script_plugin` compound tool (introduced in v2.5.0) generates, installs,
4
- and **executes** Resolve-page Lua/Python scripts. It closes the conversational
5
- loop: an LLM with access to the MCP can describe a workflow, generate the
6
- script, install it as a Resolve menu item, and execute it — all in one turn —
7
- with the script's stdout streamed back into the conversation.
3
+ The `script_plugin` compound tool (introduced in v2.5.0) generates, validates
4
+ and installs Resolve-page Lua/Python scripts. **It does not run them.** Script
5
+ execution `run_inline` and `execute` was removed in v3.0.0, because this
6
+ server does not execute caller-supplied code. An installed script appears as a
7
+ Resolve menu item, and running it is the user's action, inside Resolve.
8
8
 
9
9
  Unlike `fuse_plugin` (which authors Fusion image-processing tools) and `dctl`
10
10
  (which authors color-page shaders), `script_plugin` targets the
@@ -15,8 +15,8 @@ automation.
15
15
 
16
16
  | Goal | Use |
17
17
  |---|---|
18
- | One-off conversational query against Resolve | `script_plugin('run_inline', ...)` |
19
- | Custom workflow you want as a permanent menu item | `script_plugin('install', ...)` then `('execute', ...)` |
18
+ | One-off query or change against Resolve | The typed Resolve API tools — no script, no execution |
19
+ | Custom workflow you want as a permanent menu item | `script_plugin('install', ...)`, then the user runs it from Workspace → Scripts |
20
20
  | Image-processing node for the Fusion page | `fuse_plugin` |
21
21
  | Color-page programmable transform | `dctl` |
22
22
  | Anything the existing 28 wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
@@ -93,43 +93,24 @@ Real-world example: a script supervisor's CSV with Filename, Scene, Take,
93
93
  Camera, Lens columns. Single rule maps each clip to its row and populates
94
94
  all metadata fields plus organizes into Scene bins. Six lines of RULES.
95
95
 
96
- ## Conversational execution: `run_inline` and `execute`
97
-
98
- The two actions that close the loop:
99
-
100
- ### `run_inline(source, language, timeout?)`
101
- Run an ad-hoc Lua or Python snippet inside Resolve, get stdout + return
102
- value back. No file persistence.
103
-
104
- **Python**: writes source to a temp file with `resolve`/`project`/`mp`/
105
- `timeline` pre-bound, runs as subprocess, captures stdout/stderr.
106
-
107
- **Lua**: wraps source so `print()` is intercepted into a buffer, runs via
108
- `fusion.RunScript()`, polls a completion sentinel, reads stdout + return
109
- value back via `app:SetData()`/`fusion.GetData()`. (Note: `fusion.Execute()`
110
- from the Python bridge is a no-op in Resolve 20.x — `RunScript()` against a
111
- file is the only working path. The implementation handles this.)
112
-
113
- Example:
114
- ```python
115
- script_plugin('run_inline', {
116
- 'source': '''
117
- print(f"Project: {project.GetName()}")
118
- print(f"Bins: {len(mp.GetRootFolder().GetSubFolderList() or [])}")
119
- ''',
120
- 'language': 'py',
121
- })
122
- # → {success: True, stdout: "Project: My Show\nBins: 12\n", exit_code: 0}
123
- ```
96
+ ## Running an installed script
97
+
98
+ `script_plugin` installs scripts; it does not run them. v3.0.0 removed the two
99
+ actions that did:
100
+
101
+ - `run_inline` ran a caller's source directly Python as a subprocess on the
102
+ host, with a live Resolve handle, or Lua inside Resolve's Fusion engine with
103
+ `os` and `io` in scope.
104
+ - `execute` ran an installed script, which `install` could have just written
105
+ from caller-supplied source.
124
106
 
125
- ### `execute(name, category, language, args?, timeout?)`
126
- Run an installed script. Same return shape as `run_inline`.
107
+ Neither passed any of the server's safety gates, and the maintainer policy is
108
+ that the server never executes caller-supplied code. Calling either now returns
109
+ an error that says so and points here.
127
110
 
128
- **Python**: subprocess captures full stdout/stderr.
129
- **Lua**: `fusion.RunScript()`; print() output goes to Resolve Console
130
- (can't capture). For Lua scripts that need to return data, have them write
131
- to `app:SetData()` and the caller reads via the existing `fusion_comp`
132
- tooling.
111
+ After `install`, the user runs the script from **Workspace Scripts
112
+ \<category\>** inside Resolve; Python output appears in Resolve's Console. For
113
+ queries and edits in conversation, use the typed tools.
133
114
 
134
115
  ## Install paths
135
116
 
@@ -158,32 +139,19 @@ familiar for data-heavy workflows. The same RULES table syntax works in both
158
139
  Verified on DaVinci Resolve Studio 20.3.2.9, macOS:
159
140
 
160
141
  - ✅ Scripts appear in Workspace → Scripts → \<category\> after install (no restart needed)
161
- - ⚠️ Installed Lua script execution via `fusion.RunScript(path)` can return
162
- `False` from the Python bridge even when install/read/list/remove work. Use
163
- `run_inline(language="lua")` when captured output or return values matter.
164
- - ✅ Python scripts execute via subprocess with full stdout/stderr capture
165
- - ✅ `run_inline` Lua: stdout captured (with tabs), return value captured, errors trapped with line numbers
166
- - ✅ `run_inline` Python: full Resolve API access, project + media-pool + timeline pre-bound
167
142
  - ✅ Both engines (Lua and Python) compile without errors
168
143
  - ✅ DSL coverage tests confirm every documented source/action/target/transform/strategy is present in both engines
169
144
 
170
- ## Implementation notes (for maintainers)
171
-
172
- Two non-obvious behaviors of Resolve's Lua bridge surfaced during live
173
- testing and are encoded in the implementation:
174
-
175
- 1. **`fusion.Execute(luaSource)` is a no-op** when called from the Python
176
- `DaVinciResolveScript` bridge in Resolve 20.x. It returns `None` and has
177
- no observable side effects. Don't use it. Use `fusion.RunScript(filepath)`
178
- against a temp file instead.
145
+ ## Resolve's Lua bridge — reference
179
146
 
180
- 2. **`fusion.RunScript()` is asynchronous.** It returns before the script
181
- finishes. Reading `fusion.GetData()` immediately gives stale values. The
182
- implementation polls a completion-sentinel slot (`__mcp_done__`) until
183
- the wrapped Lua sets it to `"1"`, then reads results.
147
+ Two measured facts about Resolve itself, found while building the now-removed
148
+ `run_inline`, stay true of Resolve and are kept here for reference:
184
149
 
185
- These constraints are unique to the Lua side; Python's subprocess approach
186
- is straightforwardly synchronous.
150
+ 1. **`fusion.Execute(luaSource)` is a no-op** from the Python
151
+ `DaVinciResolveScript` bridge in Resolve 20.x: it returns `None` with no
152
+ observable side effects.
153
+ 2. **`fusion.RunScript(filepath)` is asynchronous**: it returns before the
154
+ script finishes, so an immediate `fusion.GetData()` reads stale values.
187
155
 
188
156
  ## Source media integrity
189
157
 
@@ -33,7 +33,7 @@ All kernel actions are exposed through `script_plugin`.
33
33
  | `extension_capabilities` | Report Fuse, DCTL, script paths, template kinds, MCP markers, lifecycle rules, and safety guards. |
34
34
  | `probe_fuse_lifecycle` | Generate, validate, optionally install/read/list/remove a Fuse template. |
35
35
  | `probe_dctl_lifecycle` | Generate, validate, optionally install/read/list/remove a LUT or ACES DCTL template. |
36
- | `probe_script_lifecycle` | Generate, validate, optionally install/read/list/execute/remove a Resolve-page script. |
36
+ | `probe_script_lifecycle` | Generate, validate, optionally install/read/list/remove a Resolve-page script. Refuses `execute` (removed in v3.0.0). |
37
37
  | `safe_install_extension` | Install Fuse, DCTL, or script source/templates with `_mcp_` name and marker guards. |
38
38
  | `safe_remove_extension` | Remove Fuse, DCTL, or script files only when the file is MCP-marked by default. |
39
39
  | `refresh_or_restart_required` | Classify whether an extension needs LUT refresh, menu refresh, UI reload, or Resolve restart. |
@@ -47,8 +47,6 @@ All kernel actions are exposed through `script_plugin`.
47
47
  | Regular DCTL | LUT directory | `project_settings.refresh_luts` picks it up. | Not required for LUT-category DCTLs. |
48
48
  | ACES IDT/ODT DCTL | ACES Transforms IDT/ODT | Not picked up by LUT refresh. | Required. |
49
49
  | Resolve-page script | Fusion/Scripts category directory | Workspace Scripts menu refreshes when opened. | Not required. |
50
- | Inline Python script | Temp file subprocess | Captured synchronously. | Not required. |
51
- | Inline Lua script | Temp Lua file via `fusion.RunScript` | Captured through Fusion app data bridge. | Not required. |
52
50
 
53
51
  ## Supported Findings
54
52
 
@@ -59,20 +57,17 @@ All kernel actions are exposed through `script_plugin`.
59
57
  - ACES IDT DCTL template generation, install into `ACES Transforms/IDT/MCP`,
60
58
  read, list, and safe remove worked. It remains restart-required before Resolve
61
59
  can use the transform.
62
- - Python Resolve-page script template generation, install, read, list, execute,
63
- stdout/stderr capture, and safe remove worked.
64
- - `script_plugin.run_inline` worked for Python with stdout capture.
65
- - `script_plugin.run_inline` worked for Lua with stdout and return-value capture.
60
+ - Python Resolve-page script template generation, install, read, list, and safe
61
+ remove worked.
66
62
  - The template matrix generated and validated every Fuse, DCTL, and script
67
63
  template kind.
68
64
  - Safe install rejected unmarked provided source by default.
69
65
 
70
66
  ## Boundaries
71
67
 
72
- - Installed Lua script execution through `fusion.RunScript(path)` returned
73
- `success=False` in the release probe, even though install/read/list/remove
74
- worked and inline Lua execution worked. Use `run_inline(language="lua")` when
75
- captured output/return values matter.
68
+ - **No script execution.** `run_inline` and `execute` were removed in v3.0.0:
69
+ the server does not execute caller-supplied code, and the probes no longer run
70
+ scripts. Installed scripts are run by the user from Workspace → Scripts.
76
71
  - New Fuses still require a Resolve restart to appear as registered Fusion
77
72
  tools. The MCP can install/remove files but cannot force Fusion to register a
78
73
  new Fuse in-process.
@@ -93,8 +88,8 @@ python3.11 tests/live_extension_authoring_validation.py --output-dir /tmp/extens
93
88
  ```
94
89
 
95
90
  The harness creates a disposable `_mcp_` project, installs and removes a
96
- generated Fuse, regular DCTL, ACES DCTL, Python script, and Lua script, probes
97
- inline Python/Lua execution, writes JSON and Markdown reports, deletes the
91
+ generated Fuse, regular DCTL, ACES DCTL, Python script, and Lua script, writes
92
+ JSON and Markdown reports, deletes the
98
93
  project, and removes its temp work directory.
99
94
 
100
95
  Use `--keep-open` only when you intentionally want to inspect the disposable
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.224.2"
40
+ VERSION = "3.0.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.224.2",
3
+ "version": "3.0.0",
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 = "2.224.2"
90
+ VERSION = "3.0.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.224.2"
14
+ VERSION = "3.0.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -411,8 +411,8 @@ Editorial improvements + versioning (C6 — always on for destructive timeline o
411
411
  - Read-only inspection (list, get_current, get_property, etc.) bypasses versioning entirely — no setup needed.
412
412
  - Inspect history via `timeline_versioning(action="get_history", timeline_name=…)`, `list_versions`, `diff_versions(from_version, to_version)`, or `list_runs`. Roll back via `timeline_versioning(action="rollback", timeline_name=…, version=…)`.
413
413
 
414
- For one-off scripting:
415
- - Prefer script_plugin(action="run_inline") over arbitrary persistent code changes. Use it to inspect Resolve state, then move durable behavior into guarded compound actions when it proves valuable.
414
+ For one-off queries:
415
+ - Do not reach for scripts to inspect or change Resolve state: this server does not execute caller-supplied code (script_plugin execution was removed in v3.0.0). Use the typed tools, and move durable behavior into guarded compound actions.
416
416
  """
417
417
 
418
418
 
@@ -30871,6 +30871,7 @@ def _validate_glsl_minimal(source: str) -> Dict[str, Any]:
30871
30871
 
30872
30872
  @mcp.tool()
30873
30873
  @_guard_missing_params
30874
+ @_destructive_op("fuse_plugin")
30874
30875
  def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
30875
30876
  """Author and install Fusion Fuse plugins (.fuse files).
30876
30877
 
@@ -31413,244 +31414,6 @@ def _validate_script_source(source: str, language: str) -> Dict[str, Any]:
31413
31414
  return _validate_lua_syntax(source)
31414
31415
 
31415
31416
 
31416
- # ─── Script execution ─────────────────────────────────────────────────────────
31417
-
31418
- def _python_env_for_resolve() -> Dict[str, str]:
31419
- """Build env vars so a Python subprocess can import DaVinciResolveScript."""
31420
- env = os.environ.copy()
31421
- env["RESOLVE_SCRIPT_API"] = RESOLVE_API_PATH
31422
- env["RESOLVE_SCRIPT_LIB"] = RESOLVE_LIB_PATH
31423
- # The child writes its stdout into a pipe, so Python picks the locale
31424
- # codepage rather than the console's — cp1252 on a default Windows install.
31425
- # A script that prints a non-Latin-1 character then dies with
31426
- # UnicodeEncodeError instead of returning its output, and the failure is
31427
- # attributed to the script rather than to the pipe it was handed (#153).
31428
- env["PYTHONIOENCODING"] = "utf-8"
31429
- pp = env.get("PYTHONPATH", "")
31430
- if RESOLVE_MODULES_PATH not in pp:
31431
- env["PYTHONPATH"] = (RESOLVE_MODULES_PATH +
31432
- (os.pathsep + pp if pp else ""))
31433
- return env
31434
-
31435
-
31436
- # fusionscript's RemoteApp thread keeps dispatching packets from Resolve while
31437
- # the interpreter tears down at exit, and can SIGSEGV *after* the script has
31438
- # finished — turning a successful run into exit code -11 / success:false.
31439
- # Run the script via runpy and hard-exit before teardown so the exit code is
31440
- # truthful. SystemExit must be caught here: uncaught, a plain sys.exit(0) at
31441
- # the end of a script would take the normal teardown path and reopen the
31442
- # segfault window. sys.path[0] is pointed at the script's directory to mimic
31443
- # `python script.py` (under -c it points at the server's cwd, which both
31444
- # breaks sibling imports and lets stray files there shadow real modules).
31445
- # Cost of os._exit: atexit handlers never run and non-daemon threads are not
31446
- # joined — documented in script_plugin's execute action.
31447
- _PY_SCRIPT_EXIT_GUARD = (
31448
- "import os, runpy, sys, traceback\n"
31449
- "sys.argv = sys.argv[1:]\n"
31450
- "sys.path[0] = os.path.dirname(os.path.abspath(sys.argv[0]))\n"
31451
- "code = 0\n"
31452
- "try:\n"
31453
- " runpy.run_path(sys.argv[0], run_name='__main__')\n"
31454
- "except SystemExit as e:\n"
31455
- " if isinstance(e.code, int):\n"
31456
- " code = e.code\n"
31457
- " elif e.code is not None:\n"
31458
- " print(e.code, file=sys.stderr)\n"
31459
- " code = 1\n"
31460
- "except BaseException:\n"
31461
- " traceback.print_exc()\n"
31462
- " code = 1\n"
31463
- "sys.stdout.flush()\n"
31464
- "sys.stderr.flush()\n"
31465
- "os._exit(code)\n"
31466
- )
31467
-
31468
-
31469
- def _execute_python_script(path: str, args: List[str],
31470
- timeout: int) -> Dict[str, Any]:
31471
- # Ensure Resolve is running so the script can connect.
31472
- get_resolve()
31473
- cmd = [sys.executable, "-c", _PY_SCRIPT_EXIT_GUARD, path] + [str(a) for a in args]
31474
- try:
31475
- result = safe_run(cmd, env=_python_env_for_resolve(),
31476
- capture_output=True, text=True, encoding="utf-8",
31477
- errors="replace", timeout=timeout)
31478
- except subprocess.TimeoutExpired as e:
31479
- return _err(f"Script timed out after {timeout}s. "
31480
- f"Partial stdout: {(e.stdout or '')[:1000]}")
31481
- except OSError as e:
31482
- return _err(f"Failed to launch Python subprocess: {e}")
31483
- return {
31484
- "success": result.returncode == 0,
31485
- "stdout": result.stdout,
31486
- "stderr": result.stderr,
31487
- "exit_code": result.returncode,
31488
- "language": "py",
31489
- }
31490
-
31491
-
31492
- def _execute_lua_script(path: str) -> Dict[str, Any]:
31493
- r = get_resolve()
31494
- if r is None:
31495
- return _not_connected_error()
31496
- fusion = r.Fusion()
31497
- if fusion is None:
31498
- return _err("handle.Fusion() returned None — cannot run Lua scripts.")
31499
- try:
31500
- success = bool(fusion.RunScript(path))
31501
- except Exception as e:
31502
- return _err(f"Lua RunScript failed: {e}")
31503
- return {
31504
- "success": success,
31505
- "language": "lua",
31506
- "output_note": ("Lua print() output goes to Resolve's "
31507
- "Workspace → Console → Lua tab. The MCP cannot capture "
31508
- "Lua stdout. Use the Console to see what the script printed."),
31509
- }
31510
-
31511
-
31512
- def _run_inline_python(source: str, timeout: int) -> Dict[str, Any]:
31513
- """Write source to a temp file, run it, return captured output.
31514
-
31515
- Prepends a boilerplate header that connects to Resolve and exposes
31516
- `resolve`, `project`, `mp`, `timeline` as globals — same shape as the
31517
- scaffold template, so inline snippets feel like a REPL.
31518
- """
31519
- boilerplate = (
31520
- "import sys\n"
31521
- "import DaVinciResolveScript as dvr_script\n"
31522
- "resolve = dvr_script.scriptapp('Resolve')\n"
31523
- "project = (resolve.GetProjectManager().GetCurrentProject()\n"
31524
- " if resolve else None)\n"
31525
- "mp = project.GetMediaPool() if project else None\n"
31526
- "timeline = project.GetCurrentTimeline() if project else None\n"
31527
- "\n"
31528
- )
31529
- with tempfile.NamedTemporaryFile(mode='w', suffix='.py',
31530
- delete=False, encoding='utf-8') as f:
31531
- f.write(boilerplate)
31532
- f.write(source)
31533
- tmp = f.name
31534
- try:
31535
- return _execute_python_script(tmp, [], timeout)
31536
- finally:
31537
- try:
31538
- os.unlink(tmp)
31539
- except OSError:
31540
- pass
31541
-
31542
-
31543
- def _run_inline_lua(source: str) -> Dict[str, Any]:
31544
- """Run a Lua snippet inside Resolve's Fusion engine.
31545
-
31546
- Implementation note: Fusion's `Execute()` is effectively a no-op from the
31547
- Python bridge in Resolve 20.x — it runs without propagating return values
31548
- or side effects observable from Python. `RunScript()` against a file path
31549
- DOES work and gives the script full access to the standard Lua context
31550
- (`fu`, `fusion`, `app`, `bmd`, `io`, `os`, ...). We bridge results back
31551
- via `app:SetData(key, value)` which IS visible from Python's
31552
- `fusion.GetData(key)`.
31553
-
31554
- The wrapper captures `print()` output into a string and stores stdout,
31555
- return value, and any pcall error in three Fusion-app SetData slots that
31556
- the Python side reads after RunScript returns.
31557
- """
31558
- r = get_resolve()
31559
- if r is None:
31560
- return _not_connected_error()
31561
- fusion = r.Fusion()
31562
- if fusion is None:
31563
- return _err("handle.Fusion() returned None — cannot run inline Lua.")
31564
-
31565
- wrapped = (
31566
- 'local _mcp_stdout = {}\n'
31567
- 'local _mcp_orig_print = print\n'
31568
- 'print = function(...)\n'
31569
- ' local args = {...}\n'
31570
- ' local parts = {}\n'
31571
- ' for i, v in ipairs(args) do parts[i] = tostring(v) end\n'
31572
- ' table.insert(_mcp_stdout, table.concat(parts, "\\t"))\n'
31573
- 'end\n'
31574
- 'local _mcp_ok, _mcp_result = pcall(function()\n'
31575
- + source + '\n'
31576
- 'end)\n'
31577
- 'print = _mcp_orig_print\n'
31578
- 'local _mcp_app = fu or fusion or app\n'
31579
- 'if _mcp_app then\n'
31580
- ' _mcp_app:SetData("__mcp_stdout__", table.concat(_mcp_stdout, "\\n"))\n'
31581
- ' if _mcp_ok then\n'
31582
- ' _mcp_app:SetData("__mcp_result__",\n'
31583
- ' _mcp_result ~= nil and tostring(_mcp_result) or "")\n'
31584
- ' _mcp_app:SetData("__mcp_error__", "")\n'
31585
- ' else\n'
31586
- ' _mcp_app:SetData("__mcp_result__", "")\n'
31587
- ' _mcp_app:SetData("__mcp_error__", tostring(_mcp_result))\n'
31588
- ' end\n'
31589
- ' _mcp_app:SetData("__mcp_done__", "1")\n' # completion sentinel
31590
- 'end\n'
31591
- )
31592
-
31593
- # Clear prior slots so we can detect if RunScript silently did nothing.
31594
- # SetData goes through the Lua bridge and returns nil whether or not it
31595
- # took, so the return is not evidence -- but GetData is. The __mcp_done__
31596
- # slot is the one that matters: a stale "1" left by the previous run makes
31597
- # the poll below exit immediately and return the PREVIOUS run's stdout,
31598
- # result and error as this run's.
31599
- for slot in ("__mcp_done__", "__mcp_stdout__", "__mcp_result__", "__mcp_error__"):
31600
- fusion.SetData(slot, "")
31601
- stale = fusion.GetData("__mcp_done__")
31602
- if stale not in ("", None):
31603
- return _err(
31604
- "Could not clear the Fusion completion sentinel before running.",
31605
- code="FUSION_SENTINEL_NOT_CLEARED", category="api_error", retryable=True,
31606
- reason=f"__mcp_done__ still reads {stale!r} after SetData. The poll would "
31607
- "exit immediately and hand back the previous run's output as this "
31608
- "run's.",
31609
- remediation="Retry; if it persists, restart Resolve to clear the Fusion "
31610
- "app's data slots.",
31611
- )
31612
-
31613
- with tempfile.NamedTemporaryFile(mode='w', suffix='.lua',
31614
- prefix='mcp-lua-inline-',
31615
- delete=False, encoding='utf-8') as tf:
31616
- tf.write(wrapped)
31617
- tmp = tf.name
31618
-
31619
- try:
31620
- try:
31621
- fusion.RunScript(tmp)
31622
- except Exception as e:
31623
- return _err(f"Lua RunScript failed: {e}")
31624
-
31625
- # RunScript is async — poll the completion sentinel until set.
31626
- deadline = time.time() + 60
31627
- while fusion.GetData("__mcp_done__") != "1":
31628
- if time.time() > deadline:
31629
- return _err("Lua run_inline timed out after 60s waiting for "
31630
- "the script to complete.")
31631
- time.sleep(0.1)
31632
- finally:
31633
- try:
31634
- os.unlink(tmp)
31635
- except OSError:
31636
- pass
31637
-
31638
- stdout = fusion.GetData("__mcp_stdout__") or ""
31639
- result = fusion.GetData("__mcp_result__") or ""
31640
- error = fusion.GetData("__mcp_error__") or ""
31641
-
31642
- response: Dict[str, Any] = {
31643
- "success": not error,
31644
- "stdout": stdout + ("\n" if stdout and not stdout.endswith("\n") else ""),
31645
- "language": "lua",
31646
- }
31647
- if result:
31648
- response["result"] = result
31649
- if error:
31650
- response["error"] = error
31651
- return response
31652
-
31653
-
31654
31417
  _EXTENSION_KERNEL_ACTIONS = [
31655
31418
  "extension_capabilities",
31656
31419
  "probe_fuse_lifecycle",
@@ -31989,7 +31752,7 @@ def _probe_fuse_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
31989
31752
  if p.get("include_template_matrix"):
31990
31753
  out["template_matrix"] = _extension_template_matrix()["fuse"]
31991
31754
  if p.get("install"):
31992
- install = _safe_install_extension({
31755
+ install = script_plugin("safe_install_extension", {
31993
31756
  "extension_type": "fuse",
31994
31757
  "name": name,
31995
31758
  "source": source,
@@ -31999,7 +31762,7 @@ def _probe_fuse_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
31999
31762
  out["read"] = fuse_plugin("read", {"name": name}) if install.get("success") else None
32000
31763
  out["list"] = fuse_plugin("list")
32001
31764
  if p.get("cleanup", True):
32002
- out["remove"] = _safe_remove_extension({"extension_type": "fuse", "name": name})
31765
+ out["remove"] = script_plugin("safe_remove_extension", {"extension_type": "fuse", "name": name})
32003
31766
  return out
32004
31767
 
32005
31768
 
@@ -32026,7 +31789,7 @@ def _probe_dctl_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
32026
31789
  if p.get("include_template_matrix"):
32027
31790
  out["template_matrix"] = _extension_template_matrix()["dctl"]
32028
31791
  if p.get("install"):
32029
- install = _safe_install_extension({
31792
+ install = script_plugin("safe_install_extension", {
32030
31793
  "extension_type": "dctl",
32031
31794
  "name": name,
32032
31795
  "source": source,
@@ -32040,11 +31803,19 @@ def _probe_dctl_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
32040
31803
  if p.get("refresh_luts") and category == "lut":
32041
31804
  out["refresh_luts"] = project_settings("refresh_luts")
32042
31805
  if p.get("cleanup", True):
32043
- out["remove"] = _safe_remove_extension({"extension_type": "dctl", "name": name, "category": category, "subdir": subdir})
31806
+ out["remove"] = script_plugin("safe_remove_extension", {"extension_type": "dctl", "name": name, "category": category, "subdir": subdir})
32044
31807
  return out
32045
31808
 
32046
31809
 
32047
31810
  def _probe_script_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
31811
+ if p.get("execute"):
31812
+ # Refused rather than ignored: silently skipping it would report a
31813
+ # lifecycle probe as complete for a step it never ran.
31814
+ return _err(
31815
+ "probe_script_lifecycle no longer executes scripts: script execution was "
31816
+ "removed in v3.0.0. Drop `execute`; the probe still generates, validates, "
31817
+ "installs, reads, lists and removes."
31818
+ )
32048
31819
  name = p.get("name", "_mcp_script_lifecycle_probe")
32049
31820
  kind = p.get("kind", "scaffold")
32050
31821
  language = _normalize_script_language(p.get("language", "py"))
@@ -32070,7 +31841,7 @@ def _probe_script_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
32070
31841
  if p.get("include_template_matrix"):
32071
31842
  out["template_matrix"] = _extension_template_matrix()["script"]
32072
31843
  if p.get("install"):
32073
- install = _safe_install_extension({
31844
+ install = script_plugin("safe_install_extension", {
32074
31845
  "extension_type": "script",
32075
31846
  "name": name,
32076
31847
  "source": source,
@@ -32081,15 +31852,8 @@ def _probe_script_lifecycle(p: Dict[str, Any]) -> Dict[str, Any]:
32081
31852
  out["install"] = install
32082
31853
  out["read"] = script_plugin("read", {"name": name, "category": category, "language": language}) if install.get("success") else None
32083
31854
  out["list"] = script_plugin("list", {"category": category, "language": language})
32084
- if p.get("execute") and install.get("success"):
32085
- out["execute"] = script_plugin("execute", {
32086
- "name": name,
32087
- "category": category,
32088
- "language": language,
32089
- "timeout": p.get("timeout", 120),
32090
- })
32091
31855
  if p.get("cleanup", True):
32092
- out["remove"] = _safe_remove_extension({
31856
+ out["remove"] = script_plugin("safe_remove_extension", {
32093
31857
  "extension_type": "script",
32094
31858
  "name": name,
32095
31859
  "category": category,
@@ -32120,8 +31884,19 @@ def _extension_boundary_report(p: Dict[str, Any]) -> Dict[str, Any]:
32120
31884
  }
32121
31885
 
32122
31886
 
31887
+ #: Removed in v3.0.0 (maintainer policy: the server does not execute
31888
+ #: caller-supplied code). `run_inline` ran a caller's Python as a subprocess on
31889
+ #: the host, or Lua inside Resolve's Fusion engine with `os` and `io` in scope;
31890
+ #: `execute` ran an installed script. Neither passed any gate. Kept as a named
31891
+ #: set so a stale caller gets a migration pointer rather than "unknown action",
31892
+ #: and so the action-list drift guard, which reads literal comparisons, does not
31893
+ #: count them as live actions.
31894
+ _REMOVED_SCRIPT_ACTIONS = frozenset({"execute", "run_inline"})
31895
+
31896
+
32123
31897
  @mcp.tool()
32124
31898
  @_guard_missing_params
31899
+ @_destructive_op("script_plugin")
32125
31900
  def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
32126
31901
  """Author and install Resolve-page Lua/Python scripts (Workspace → Scripts menu).
32127
31902
 
@@ -32157,25 +31932,12 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
32157
31932
  — kind: 'scaffold' | 'media_rules'
32158
31933
  — options: {language: 'lua'|'py', ...kind-specific}
32159
31934
  list_templates() -> {kinds}
32160
- execute(name, category, language, args?, timeout?) -> {success, stdout?, stderr?, exit_code?}
32161
- Python: subprocess with stdout/stderr captured.
32162
- — Lua: fusion.RunScript(); print() output goes to Resolve Console.
32163
- — args: list of CLI args for the Python subprocess (Python only).
32164
- — timeout: seconds (default 120 for execute, 60 for run_inline).
32165
- — Auto-launches Resolve if not running.
32166
- — Python scripts hard-exit after the script body (guards against
32167
- fusionscript's segfault-at-exit race), so atexit handlers do not
32168
- run and non-daemon threads are not joined. Do cleanup inline or
32169
- in try/finally, not in atexit.
32170
- run_inline(source, language, timeout?) -> {success, stdout?, stderr?, result?}
32171
- — Python: writes to temp file with `resolve`/`project`/`mp`/`timeline`
32172
- pre-bound, runs as subprocess, captures stdout/stderr.
32173
- — Lua: fusion.Execute(source); return value comes back as `result`.
32174
- — Use this for ad-hoc one-shot queries without persisting a file.
31935
+ execute / run_inline REMOVED in v3.0.0. This server does not execute
31936
+ caller-supplied code; install a script and run it from Workspace > Scripts.
32175
31937
  extension_capabilities() -> {paths, templates, lifecycle, safe_guards}
32176
31938
  probe_fuse_lifecycle(name?, kind?, install?, cleanup?) -> {template, validation, install?, remove?}
32177
31939
  probe_dctl_lifecycle(name?, kind?, category?, install?, refresh_luts?, cleanup?) -> {template, validation, install?, remove?}
32178
- probe_script_lifecycle(name?, language?, category?, install?, execute?, cleanup?) -> {template, validation, install?, execute?, remove?}
31940
+ probe_script_lifecycle(name?, language?, category?, install?, cleanup?) -> {template, validation, install?, remove?}
32179
31941
  safe_install_extension(extension_type, name, source?|kind?, dry_run?) -> {success}
32180
31942
  safe_remove_extension(extension_type, name, dry_run?) -> {success}
32181
31943
  refresh_or_restart_required(extension_type, category?) -> {refresh_luts, restart_required}
@@ -32377,49 +32139,16 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
32377
32139
  return {"source": source, "kind": kind, "name": name,
32378
32140
  "language": language}
32379
32141
 
32380
- if action == "execute":
32381
- name = p.get("name", "")
32382
- invalid = _validate_script_name(name)
32383
- if invalid:
32384
- return invalid
32385
- category = p.get("category")
32386
- if not category:
32387
- return _err("execute requires a 'category'.")
32388
- language = _normalize_script_language(p.get("language", "lua"))
32389
- invalid = _validate_script_language(language)
32390
- if invalid:
32391
- return invalid
32392
- timeout = int(p.get("timeout", 120))
32393
- try:
32394
- target = _script_path(name, category, language)
32395
- except ValueError as e:
32396
- return _err(str(e))
32397
- if not os.path.isfile(target):
32398
- return _err(f"No script named '{name}{_SCRIPT_LANG_EXT[language]}' "
32399
- f"at {target}")
32400
- if language == "py":
32401
- args = p.get("args", [])
32402
- if not isinstance(args, list):
32403
- return _err("'args' must be a list of strings.")
32404
- return _execute_python_script(target, args, timeout)
32405
- return _execute_lua_script(target)
32406
-
32407
- if action == "run_inline":
32408
- source = p.get("source")
32409
- if not isinstance(source, str) or not source.strip():
32410
- return _err("run_inline requires a non-empty 'source' string.")
32411
- language = _normalize_script_language(p.get("language", "lua"))
32412
- invalid = _validate_script_language(language)
32413
- if invalid:
32414
- return invalid
32415
- timeout = int(p.get("timeout", 60))
32416
- if language == "py":
32417
- return _run_inline_python(source, timeout)
32418
- return _run_inline_lua(source)
32142
+ if action in _REMOVED_SCRIPT_ACTIONS:
32143
+ return _err(
32144
+ f"script_plugin.{action} was removed in v3.0.0: this server does not "
32145
+ "execute caller-supplied code. Install the script with `install`, then "
32146
+ "run it yourself from Resolve's Workspace > Scripts menu."
32147
+ )
32419
32148
 
32420
32149
  return _unknown(action, ["path", "categories", "list", "install", "remove",
32421
32150
  "read", "validate", "template", "list_templates",
32422
- "execute", "run_inline", *_EXTENSION_KERNEL_ACTIONS])
32151
+ *_EXTENSION_KERNEL_ACTIONS])
32423
32152
 
32424
32153
 
32425
32154
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -63,7 +63,20 @@ SAFE_MODE_BLOCKED_RISK_LEVELS: FrozenSet[str] = frozenset({
63
63
  # replace_clip/link_*). The test_destructive_registry_drift guard asserts every
64
64
  # string here is a real handler so this can't regress.
65
65
  DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
66
- "dctl": frozenset({"encrypt_native"}),
66
+ # Plugin-folder writes. These create, replace and delete files that Resolve
67
+ # and Fusion later load and run: a Fuse registers on the next restart, a
68
+ # Resolve-page script runs when clicked. Until they were listed here every
69
+ # gate treated them as reads, and a dry run of `install` wrote the file for
70
+ # real. They never mutate the timeline, so NON_TIMELINE_WRITE_TOOLS keeps
71
+ # them out of timeline archiving while every gate still sees them.
72
+ "dctl": frozenset({"encrypt_native", "install", "remove"}),
73
+ "fuse_plugin": frozenset({"install", "remove"}),
74
+ "script_plugin": frozenset({
75
+ "install",
76
+ "remove",
77
+ "safe_install_extension",
78
+ "safe_remove_extension",
79
+ }),
67
80
  "media_pool": frozenset({
68
81
  "delete_clips",
69
82
  "delete_folders",
@@ -235,6 +248,19 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
235
248
  }
236
249
 
237
250
 
251
+ # ── Non-timeline write tools ────────────────────────────────────────────────
252
+ #
253
+ # Tools whose registered actions write the filesystem, not the timeline. Every
254
+ # gate applies to them — safe mode, dry-run refusal, the audit log — but they
255
+ # skip version-on-mutate archiving, and skip resolving the versioning context
256
+ # at all: that goes through the project-root provider, which reaches Resolve,
257
+ # and installing a shader must neither snapshot the open timeline nor touch
258
+ # Resolve. `media_pool` has its own branch for the same reason; these differ in
259
+ # that there is no project state to log.
260
+
261
+ NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({"dctl", "fuse_plugin", "script_plugin"})
262
+
263
+
238
264
  # ── Strict-mode allowlist ───────────────────────────────────────────────────
239
265
  #
240
266
  # Actions in this set REFUSE to run if the version-on-mutate archive fails. For
@@ -299,6 +325,8 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
299
325
  ("timeline", "apply_cuts"),
300
326
  ("timeline", "ripple_insert"),
301
327
  ("timeline_ai", "create_subtitles"),
328
+ ("script_plugin", "safe_install_extension"),
329
+ ("script_plugin", "safe_remove_extension"),
302
330
  })
303
331
 
304
332
 
@@ -791,6 +819,31 @@ def destructive_op(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..
791
819
  recognised=risk_recognised,
792
820
  )
793
821
 
822
+ if tool_name in NON_TIMELINE_WRITE_TOOLS:
823
+ result = fn(action, params, *args, **kwargs)
824
+ _audit_security_event(
825
+ operation_id=operation_id,
826
+ tool_name=tool_name,
827
+ action=action,
828
+ risk_level=risk_level,
829
+ status="allowed",
830
+ params=params,
831
+ reason="not_a_timeline_mutation",
832
+ recognised=risk_recognised,
833
+ )
834
+ if isinstance(result, dict):
835
+ result.setdefault("_versioning", {
836
+ "analysis_run_id": None,
837
+ "archived": False,
838
+ "skipped_reason": "not_a_timeline_mutation",
839
+ })
840
+ return _annotate_security(
841
+ result,
842
+ operation_id=operation_id,
843
+ risk_level=risk_level,
844
+ recognised=risk_recognised,
845
+ )
846
+
794
847
  # F4 — token-issuance calls don't mutate; skip the archive entirely
795
848
  # so that token preview/cancel paths don't litter the version chain.
796
849
  # The wrapper still annotates `_versioning` on the result so callers
@@ -132,6 +132,14 @@ class RiskClassificationHook(LifecycleHook):
132
132
  }
133
133
 
134
134
  _HIGH_RISK_ACTIONS: Set[Tuple[str, str]] = {
135
+ # Plugin-folder deletes. A bare `remove` misses the `remove_` prefix
136
+ # rule below, so these were unrecognised and every gate read them as
137
+ # reads. `safe_remove_extension` unlinks directly rather than through
138
+ # the per-tool `remove`, so it needs its own entry.
139
+ ("dctl", "remove"),
140
+ ("fuse_plugin", "remove"),
141
+ ("script_plugin", "remove"),
142
+ ("script_plugin", "safe_remove_extension"),
135
143
  ("timeline", "delete_clips"),
136
144
  ("timeline", "delete_clip_by_id"),
137
145
  ("timeline", "delete_markers"),
@@ -248,6 +256,14 @@ class RiskClassificationHook(LifecycleHook):
248
256
  #: MEDIUM was overwhelmingly the `else` fallthrough, which made an assessed
249
257
  #: MEDIUM and an unrated action indistinguishable by level alone.
250
258
  _MEDIUM_RISK_ACTIONS: Set[Tuple[str, str]] = {
259
+ # Plugin-folder installs: a new file, or a replaced one with
260
+ # overwrite=true, that Resolve or Fusion will later load and run.
261
+ # MEDIUM, not HIGH: audited and dry-run-honest, but not blocked by
262
+ # safe mode, in line with the other create-style writes.
263
+ ("dctl", "install"),
264
+ ("fuse_plugin", "install"),
265
+ ("script_plugin", "install"),
266
+ ("script_plugin", "safe_install_extension"),
251
267
  # Additive edits that place content into an existing timeline. Nothing
252
268
  # is deleted (`overwrite_range`, which does delete, is HIGH), but the
253
269
  # timeline is no longer what it was.
@@ -191,7 +191,6 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
191
191
  "language": "py",
192
192
  "category": "Utility",
193
193
  "install": True,
194
- "execute": True,
195
194
  "cleanup": True,
196
195
  "overwrite": True,
197
196
  "timeout": 120,
@@ -199,9 +198,9 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
199
198
  _record_nested_success(
200
199
  recorder,
201
200
  "script",
202
- "probe_script_python_lifecycle_install_execute_remove",
201
+ "probe_script_python_lifecycle_install_remove",
203
202
  script_py_probe,
204
- ["install", "read", "list", "execute", "remove"],
203
+ ["install", "read", "list", "remove"],
205
204
  )
206
205
 
207
206
  script_lua_probe = server.script_plugin("probe_script_lifecycle", {
@@ -210,7 +209,6 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
210
209
  "language": "lua",
211
210
  "category": "Utility",
212
211
  "install": True,
213
- "execute": True,
214
212
  "cleanup": True,
215
213
  "overwrite": True,
216
214
  "timeout": 120,
@@ -218,23 +216,11 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
218
216
  _record_nested_success(
219
217
  recorder,
220
218
  "script",
221
- "probe_script_lua_lifecycle_install_execute_remove",
219
+ "probe_script_lua_lifecycle_install_remove",
222
220
  script_lua_probe,
223
- ["install", "read", "list", "execute", "remove"],
221
+ ["install", "read", "list", "remove"],
224
222
  )
225
223
 
226
- _record_tool_result(
227
- recorder,
228
- "script",
229
- "run_inline_python_stdout",
230
- server.script_plugin("run_inline", {"language": "py", "source": "print('extension inline py ok')", "timeout": 60}),
231
- )
232
- _record_tool_result(
233
- recorder,
234
- "script",
235
- "run_inline_lua_stdout_result",
236
- server.script_plugin("run_inline", {"language": "lua", "source": "print('extension inline lua ok')\nreturn 'lua-result'", "timeout": 60}),
237
- )
238
224
  _record_tool_result(
239
225
  recorder,
240
226
  "guards",