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 +131 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +21 -13
- package/docs/authoring/script-plugin-authoring.md +32 -64
- package/docs/kernels/extension-authoring-kernel.md +8 -13
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +39 -310
- package/src/utils/destructive_hook.py +54 -1
- package/src/utils/execution_lifecycle.py +16 -0
- package/src/utils/extension_authoring_live_probe.py +4 -18
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应
|
|
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
|
|
122
|
-
|
|
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
|
-
|
|
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?,
|
|
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
|
|
144
|
-
|
|
145
|
-
Resolve
|
|
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
|
|
1
|
+
# Script Plugin Authoring
|
|
2
2
|
|
|
3
|
-
The `script_plugin` compound tool (introduced in v2.5.0) generates,
|
|
4
|
-
and
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
19
|
-
| Custom workflow you want as a permanent menu item | `script_plugin('install', ...)
|
|
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
|
-
##
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
**
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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/
|
|
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,
|
|
63
|
-
|
|
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
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
97
|
-
|
|
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 = "
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "
|
|
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 = "
|
|
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
|
|
415
|
-
-
|
|
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 =
|
|
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"] =
|
|
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 =
|
|
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"] =
|
|
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 =
|
|
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"] =
|
|
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
|
|
32161
|
-
|
|
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?,
|
|
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
|
|
32381
|
-
|
|
32382
|
-
|
|
32383
|
-
|
|
32384
|
-
|
|
32385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
201
|
+
"probe_script_python_lifecycle_install_remove",
|
|
203
202
|
script_py_probe,
|
|
204
|
-
["install", "read", "list", "
|
|
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
|
-
"
|
|
219
|
+
"probe_script_lua_lifecycle_install_remove",
|
|
222
220
|
script_lua_probe,
|
|
223
|
-
["install", "read", "list", "
|
|
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",
|