davinci-resolve-mcp 2.214.3 → 2.215.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 +78 -0
- package/README.md +12 -6
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +4 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +77 -6
- package/src/utils/execution_lifecycle.py +42 -1
- package/src/utils/operation_log.py +206 -0
- package/src/utils/resolve_bridge.py +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,84 @@
|
|
|
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 v2.215.0 — mutating operations write a structured operation log; the free-edition bridge is version-qualified for Resolve 21.1
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Recognised mutating operations now write compact JSONL records** to
|
|
10
|
+
`logs/operation-log.jsonl`: operation id, tool, action, `tool.action`, risk
|
|
11
|
+
level and whether the classifier established it, blast radius, dry-run
|
|
12
|
+
flag, timestamp, final status, duration, and a short summary. When a result
|
|
13
|
+
already reports semantic changes through the `_operation` envelope those
|
|
14
|
+
changes are copied, never guessed. A mutating handler that raises before
|
|
15
|
+
returning still writes a `failed` record with the exception type and
|
|
16
|
+
message; a destructive attempt refused before mutation is logged as
|
|
17
|
+
`blocked`; read-only operations are skipped. Contributed in #202 by
|
|
18
|
+
@Rohitkanithi.
|
|
19
|
+
- **Setup defaults for it:** `destructive.operation_log` (default on) and
|
|
20
|
+
`destructive.operation_log_path`; `RESOLVE_MCP_OPERATION_LOG_FILE` overrides
|
|
21
|
+
the path for deployments routing logs outside the repository. The offline
|
|
22
|
+
suite redirects the default path to a temporary file, the same way it
|
|
23
|
+
already redirects the security audit log.
|
|
24
|
+
- **Why a third log.** Execution traces explain how a multi-step run
|
|
25
|
+
unfolded; the security audit records destructive gate decisions. This is
|
|
26
|
+
the plain chronological trail of what tried to change the project and how
|
|
27
|
+
it ended, with no parameters or file paths recorded.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **The free-edition bridge's claim is now version-qualified.** The README,
|
|
32
|
+
SKILL and the bridge module said the Workspace ▸ Scripts menu "is not gated
|
|
33
|
+
… on any edition". That was measured on free 21.0.3.7 only. Resolve 21.1
|
|
34
|
+
(2026-09-08) moved Python scripting to Studio, and the first field report
|
|
35
|
+
(#203, Fedora 44, free 21.1) shows the Scripts menu no longer listing any
|
|
36
|
+
`.py` file while a Lua script in the same folder lists normally. The docs
|
|
37
|
+
now say so, and the connection-failure remediation that sent free-edition
|
|
38
|
+
users to start the bridge now names the 21.1 change instead of promising a
|
|
39
|
+
path that may not exist. Whether the Console still runs Python on free 21.1
|
|
40
|
+
is unconfirmed and is the open question on #203.
|
|
41
|
+
|
|
42
|
+
### Notes on the adaptation
|
|
43
|
+
|
|
44
|
+
- The PR's bundled version bump and CHANGELOG entry were dropped, as with
|
|
45
|
+
the contributor's earlier PRs; everything else landed as authored, and the
|
|
46
|
+
offline-guard redirect it adds is what keeps the suite from writing a real
|
|
47
|
+
operation log.
|
|
48
|
+
|
|
49
|
+
### Validation
|
|
50
|
+
|
|
51
|
+
- The PR's seven tests plus the full offline suite, drift guards and the
|
|
52
|
+
advanced Node suite. No Resolve behavior changed; no live run required.
|
|
53
|
+
|
|
54
|
+
## What's New in v2.214.4 — the offline test suite no longer writes into the operator's server.log
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **The unit suite was appending to `logs/server.log`.** Importing
|
|
59
|
+
`src/server.py` attaches the root logger's FileHandler to that file, and the
|
|
60
|
+
suite imports the server, so every MagicMock "connection" the bridge tests
|
|
61
|
+
provoke and every lifecycle warning about a MagicMock timeline landed in the
|
|
62
|
+
operator's real log — the file a live debugging session reads. On the
|
|
63
|
+
maintainer's machine that log had grown to 128 MB with 240 such lines in it.
|
|
64
|
+
The log target is now `RESOLVE_MCP_LOG_FILE`: unset means `logs/server.log`
|
|
65
|
+
as before, a path means that file, empty means no file. `tests/__init__.py`
|
|
66
|
+
sets it to a temporary file before any test module imports the server, on
|
|
67
|
+
both the `unittest` and `pytest` paths, and `tests/test_log_isolation.py`
|
|
68
|
+
fails the suite if a handler on the root or `resolve-mcp` logger ever targets
|
|
69
|
+
the real log during a run. The live server's behaviour is unchanged, and the
|
|
70
|
+
transport-token redaction test, which builds its own root FileHandler, still
|
|
71
|
+
passes.
|
|
72
|
+
- **The existing log is untouched.** The 128 MB file is the operator's; this
|
|
73
|
+
change only stops adding to it. Rotate or trim it by hand if wanted.
|
|
74
|
+
|
|
75
|
+
### Validation
|
|
76
|
+
|
|
77
|
+
- `tests/test_log_isolation.py` (four tests, including the unset-means-real-log
|
|
78
|
+
case that pins the live behaviour). Proven with an audited full run — a
|
|
79
|
+
`sys.addaudithook` on every `open` of the operator's log — that recorded zero
|
|
80
|
+
opens across 3371 tests. Full offline suite, drift guards and the advanced
|
|
81
|
+
Node suite green.
|
|
82
|
+
|
|
5
83
|
## What's New in v2.214.3 — the advanced launcher heals a wrong-Node registration
|
|
6
84
|
|
|
7
85
|
### Fixed
|
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)
|
|
@@ -42,10 +42,14 @@ The installer and server check the latest GitHub release for MCP updates. Checks
|
|
|
42
42
|
|
|
43
43
|
Blackmagic gates *external* scripting to Studio: on the free edition
|
|
44
44
|
`scriptapp("Resolve")` refuses a foreign process, whatever the preference says.
|
|
45
|
-
|
|
46
|
-
handed the live `resolve` object on
|
|
47
|
-
free edition through a small script that runs *inside*
|
|
48
|
-
over an authenticated loopback listener.
|
|
45
|
+
Through Resolve 21.0.x the **Workspace ▸ Scripts** menu was not gated — a script
|
|
46
|
+
launched from it is handed the live `resolve` object (measured on free 21.0.3.7) —
|
|
47
|
+
so the server can reach the free edition through a small script that runs *inside*
|
|
48
|
+
Resolve and re-exports it over an authenticated loopback listener. **Resolve 21.1
|
|
49
|
+
moved Python scripting to Studio.** On free 21.1 the Scripts menu no longer lists
|
|
50
|
+
`.py` files at all (reported on Fedora 44 in #203; a Lua script in the same folder
|
|
51
|
+
lists normally). Whether the Console still runs Python there is unconfirmed, so
|
|
52
|
+
treat the bridge as a 21.0.x path until that is measured.
|
|
49
53
|
|
|
50
54
|
```bash
|
|
51
55
|
python scripts/install_resolve_bridge.py
|
|
@@ -298,7 +302,9 @@ and an audit document is the last place to let a reader read it as an all-clear.
|
|
|
298
302
|
|
|
299
303
|
Traces live in a 100-entry in-memory ring and are appended to
|
|
300
304
|
`logs/execution-traces.jsonl` beside `server.log` — `RESOLVE_MCP_TRACE_FILE`
|
|
301
|
-
moves it
|
|
305
|
+
moves it, and `RESOLVE_MCP_LOG_FILE` moves `server.log` itself (a path, or empty
|
|
306
|
+
for no file; the offline test suite points it at a temporary file so it never
|
|
307
|
+
writes into the operator's log). `list_recent_executions` reports that path and whether it is
|
|
302
308
|
writable, so "the log is empty" and "nothing is being written" are
|
|
303
309
|
distinguishable without reading the source. What is recorded is tool name,
|
|
304
310
|
action, timing, status, semantic deltas and verification — no parameters and no
|
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
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.215.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
|
@@ -22,8 +22,10 @@ take up to 60 seconds.
|
|
|
22
22
|
**Free edition.** Both of those preferences are Studio features; on the free
|
|
23
23
|
edition `scriptapp("Resolve")` refuses a foreign process regardless. A third
|
|
24
24
|
transport reaches it — a script run from **Workspace ▸ Scripts** is handed the
|
|
25
|
-
live `resolve` object on
|
|
26
|
-
loopback listener.
|
|
25
|
+
live `resolve` object (measured on free 21.0.3.7) and re-exports it over an
|
|
26
|
+
authenticated loopback listener. Resolve 21.1 moved Python scripting to Studio:
|
|
27
|
+
on free 21.1 the Scripts menu no longer lists `.py` files (#203), so on that
|
|
28
|
+
build expect the bridge to have no launch path until the Console is checked. Install with `python scripts/install_resolve_bridge.py` and
|
|
27
29
|
start it from that menu; once running it is used automatically when external
|
|
28
30
|
scripting is unavailable, with no environment variable needed.
|
|
29
31
|
`DAVINCI_RESOLVE_BRIDGE=1` *forces* it — the bridge becomes the only transport
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.215.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 = "2.
|
|
90
|
+
VERSION = "2.215.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 353-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.215.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -61,6 +61,7 @@ from src.utils.page_lock import (
|
|
|
61
61
|
from src.utils.proc import safe_run
|
|
62
62
|
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
63
63
|
from src.utils import operation_result as _operation_result
|
|
64
|
+
from src.utils import operation_log as _operation_log
|
|
64
65
|
from src.utils.operation_result import (
|
|
65
66
|
build_operation_envelope as _build_operation_envelope,
|
|
66
67
|
get_envelope_mode as _get_envelope_mode,
|
|
@@ -185,10 +186,40 @@ if RESOLVE_MODULES_PATH not in sys.path:
|
|
|
185
186
|
|
|
186
187
|
log_dir = os.path.join(project_dir, "logs")
|
|
187
188
|
os.makedirs(log_dir, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
#: Where this process logs. Unset: `logs/server.log` under the repository, as
|
|
191
|
+
#: always. A path: that file (its directory is created). Empty: no file at all.
|
|
192
|
+
#:
|
|
193
|
+
#: The variable exists because importing this module attaches the file handler,
|
|
194
|
+
#: and the offline unit suite imports this module. Without it the suite's
|
|
195
|
+
#: MagicMock "connections" and lifecycle warnings landed in the operator's real
|
|
196
|
+
#: server.log — 240 such lines in a 128 MB log on the maintainer's machine —
|
|
197
|
+
#: which is the file every live debugging session reads. `tests/__init__.py`
|
|
198
|
+
#: points it at a temporary file before any test module runs.
|
|
199
|
+
ENV_LOG_FILE = "RESOLVE_MCP_LOG_FILE"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _log_file_from_env(env=None) -> str:
|
|
203
|
+
"""The log path this process should use, or "" for none."""
|
|
204
|
+
values = os.environ if env is None else env
|
|
205
|
+
value = values.get(ENV_LOG_FILE)
|
|
206
|
+
if value is None:
|
|
207
|
+
return os.path.join(log_dir, "server.log")
|
|
208
|
+
return os.path.expanduser(value.strip())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _log_handlers():
|
|
212
|
+
target = _log_file_from_env()
|
|
213
|
+
if not target:
|
|
214
|
+
return [logging.NullHandler()]
|
|
215
|
+
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
|
|
216
|
+
return [logging.FileHandler(target)]
|
|
217
|
+
|
|
218
|
+
|
|
188
219
|
logging.basicConfig(
|
|
189
220
|
level=logging.INFO,
|
|
190
221
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
191
|
-
handlers=
|
|
222
|
+
handlers=_log_handlers(),
|
|
192
223
|
)
|
|
193
224
|
logger = logging.getLogger("resolve-mcp")
|
|
194
225
|
|
|
@@ -207,7 +238,9 @@ mcp = FastMCP(
|
|
|
207
238
|
"reachable via the in-app bridge (Workspace > Scripts > resolve_bridge — it is "
|
|
208
239
|
"used automatically when external scripting is unavailable; "
|
|
209
240
|
"DAVINCI_RESOLVE_BRIDGE=1 only forces it), so a connection error does NOT mean "
|
|
210
|
-
"the free edition is unsupported."
|
|
241
|
+
"the free edition is unsupported — on Resolve 21.0.x. Resolve 21.1 moved Python "
|
|
242
|
+
"scripting to Studio and free 21.1 no longer lists Python scripts in that menu "
|
|
243
|
+
"(issue #203), so on 21.1+ a free-edition connection error may be final."
|
|
211
244
|
),
|
|
212
245
|
)
|
|
213
246
|
|
|
@@ -1055,7 +1088,10 @@ def _not_connected_error():
|
|
|
1055
1088
|
"On the free edition: install the in-app bridge and run "
|
|
1056
1089
|
"Workspace > Scripts > resolve_bridge — once it is running it is used "
|
|
1057
1090
|
"automatically, no environment variable needed "
|
|
1058
|
-
"(DAVINCI_RESOLVE_BRIDGE=1 only forces it and disables this fallback)."
|
|
1091
|
+
"(DAVINCI_RESOLVE_BRIDGE=1 only forces it and disables this fallback). "
|
|
1092
|
+
"On free 21.1+ Blackmagic moved Python scripting to Studio and the "
|
|
1093
|
+
"Scripts menu no longer lists Python scripts (issue #203), so the bridge "
|
|
1094
|
+
"may have no launch path there.",
|
|
1059
1095
|
# Which Resolve is refusing matters to the reader: a headless render
|
|
1060
1096
|
# worker and the editor the user is looking at warrant different
|
|
1061
1097
|
# responses, and the in-app bridge is not an option for the former.
|
|
@@ -1259,6 +1295,7 @@ def _destructive_preference_provider(key: str) -> Any:
|
|
|
1259
1295
|
|
|
1260
1296
|
|
|
1261
1297
|
_destructive_hook.register_preference_provider(_destructive_preference_provider)
|
|
1298
|
+
_operation_log.register_preference_provider(_destructive_preference_provider)
|
|
1262
1299
|
|
|
1263
1300
|
|
|
1264
1301
|
# Gated (tool, action) pairs routed through the destructive_hook wrapper that
|
|
@@ -15530,6 +15567,8 @@ def _setup_destructive_defaults() -> Dict[str, Any]:
|
|
|
15530
15567
|
"safe_mode": _setup_bool(destructive.get("safe_mode"), False),
|
|
15531
15568
|
"audit_log": _setup_bool(destructive.get("audit_log"), True),
|
|
15532
15569
|
"audit_log_path": destructive.get("audit_log_path") or os.path.join(project_dir, "logs", "security-audit.jsonl"),
|
|
15570
|
+
"operation_log": _setup_bool(destructive.get("operation_log"), True),
|
|
15571
|
+
"operation_log_path": destructive.get("operation_log_path") or os.path.join(project_dir, "logs", "operation-log.jsonl"),
|
|
15533
15572
|
"preferences_path": _media_analysis_preferences_path(),
|
|
15534
15573
|
}
|
|
15535
15574
|
|
|
@@ -15549,6 +15588,10 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
|
|
|
15549
15588
|
"auditlog": "audit_log",
|
|
15550
15589
|
"audit_log_path": "audit_log_path",
|
|
15551
15590
|
"auditlogpath": "audit_log_path",
|
|
15591
|
+
"operation_log": "operation_log",
|
|
15592
|
+
"operationlog": "operation_log",
|
|
15593
|
+
"operation_log_path": "operation_log_path",
|
|
15594
|
+
"operationlogpath": "operation_log_path",
|
|
15552
15595
|
}
|
|
15553
15596
|
requested: Dict[str, Any] = {}
|
|
15554
15597
|
for key, value in destructive_defaults.items():
|
|
@@ -15577,11 +15620,11 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
|
|
|
15577
15620
|
if clear_requested(raw_value):
|
|
15578
15621
|
destructive.pop(key, None)
|
|
15579
15622
|
updates[key] = {"before": before.get(key), "after": _setup_destructive_defaults().get(key), "cleared": True}
|
|
15580
|
-
elif key in {"require_confirm_token", "safe_mode", "audit_log"}:
|
|
15623
|
+
elif key in {"require_confirm_token", "safe_mode", "audit_log", "operation_log"}:
|
|
15581
15624
|
normalized = _setup_bool(raw_value, before.get(key, False))
|
|
15582
15625
|
destructive[key] = normalized
|
|
15583
15626
|
updates[key] = {"before": before.get(key), "after": normalized}
|
|
15584
|
-
elif key
|
|
15627
|
+
elif key in {"audit_log_path", "operation_log_path"}:
|
|
15585
15628
|
path = os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
|
|
15586
15629
|
destructive[key] = path
|
|
15587
15630
|
updates[key] = {"before": before.get(key), "after": path}
|
|
@@ -16132,6 +16175,8 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
|
|
|
16132
16175
|
"safe_mode": "destructive.safe_mode",
|
|
16133
16176
|
"audit_log": "destructive.audit_log",
|
|
16134
16177
|
"audit_log_path": "destructive.audit_log_path",
|
|
16178
|
+
"operation_log": "destructive.operation_log",
|
|
16179
|
+
"operation_log_path": "destructive.operation_log_path",
|
|
16135
16180
|
}
|
|
16136
16181
|
destructive_payload: Dict[str, Any] = {}
|
|
16137
16182
|
if clear_all or "destructive" in normalized_keys:
|
|
@@ -16297,6 +16342,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16297
16342
|
"values": "absolute or expandable path",
|
|
16298
16343
|
"storage": _media_analysis_preferences_path(),
|
|
16299
16344
|
},
|
|
16345
|
+
"destructive.operation_log": {
|
|
16346
|
+
"description": "Write compact JSONL records for every recognised mutating operation.",
|
|
16347
|
+
"values": [True, False],
|
|
16348
|
+
"storage": _media_analysis_preferences_path(),
|
|
16349
|
+
},
|
|
16350
|
+
"destructive.operation_log_path": {
|
|
16351
|
+
"description": "Absolute path for the mutating-operation JSONL log.",
|
|
16352
|
+
"values": "absolute or expandable path",
|
|
16353
|
+
"storage": _media_analysis_preferences_path(),
|
|
16354
|
+
},
|
|
16300
16355
|
},
|
|
16301
16356
|
}
|
|
16302
16357
|
|
|
@@ -16411,6 +16466,22 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16411
16466
|
default=None,
|
|
16412
16467
|
)
|
|
16413
16468
|
} if any(key in merged for key in ("audit_log_path", "auditLogPath")) else {}),
|
|
16469
|
+
**({
|
|
16470
|
+
"operation_log": _first_param(
|
|
16471
|
+
merged,
|
|
16472
|
+
"operation_log",
|
|
16473
|
+
"operationLog",
|
|
16474
|
+
default=None,
|
|
16475
|
+
)
|
|
16476
|
+
} if any(key in merged for key in ("operation_log", "operationLog")) else {}),
|
|
16477
|
+
**({
|
|
16478
|
+
"operation_log_path": _first_param(
|
|
16479
|
+
merged,
|
|
16480
|
+
"operation_log_path",
|
|
16481
|
+
"operationLogPath",
|
|
16482
|
+
default=None,
|
|
16483
|
+
)
|
|
16484
|
+
} if any(key in merged for key in ("operation_log_path", "operationLogPath")) else {}),
|
|
16414
16485
|
}
|
|
16415
16486
|
|
|
16416
16487
|
general_result = _setup_set_general_defaults(general_defaults, dry_run)
|
|
@@ -24,6 +24,8 @@ import threading
|
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
25
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
26
26
|
|
|
27
|
+
from src.utils import operation_log
|
|
28
|
+
|
|
27
29
|
logger = logging.getLogger("resolve-mcp.execution-lifecycle")
|
|
28
30
|
|
|
29
31
|
|
|
@@ -542,6 +544,45 @@ class ProvenanceTraceHook(LifecycleHook):
|
|
|
542
544
|
}
|
|
543
545
|
|
|
544
546
|
|
|
547
|
+
class OperationLogHook(LifecycleHook):
|
|
548
|
+
"""Writes a compact append-only record for each mutating operation."""
|
|
549
|
+
name = "operation_log"
|
|
550
|
+
|
|
551
|
+
def after_tool_call(
|
|
552
|
+
self, ctx: ToolCallContext, result: Any, duration_ms: int
|
|
553
|
+
) -> Optional[Dict[str, Any]]:
|
|
554
|
+
if not ctx.risk.destructive:
|
|
555
|
+
return None
|
|
556
|
+
record = operation_log.build_record(
|
|
557
|
+
tool_name=ctx.tool_name,
|
|
558
|
+
action=ctx.action,
|
|
559
|
+
params=ctx.params,
|
|
560
|
+
result=result,
|
|
561
|
+
risk=ctx.risk.to_dict(),
|
|
562
|
+
)
|
|
563
|
+
operation_log.write_record(record)
|
|
564
|
+
return {
|
|
565
|
+
"logged": operation_log.operation_log_enabled(),
|
|
566
|
+
"operation_id": record["operation_id"],
|
|
567
|
+
"path": operation_log.operation_log_path(),
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
def on_error(
|
|
571
|
+
self, ctx: ToolCallContext, exc: Exception, duration_ms: int
|
|
572
|
+
) -> None:
|
|
573
|
+
if not ctx.risk.destructive:
|
|
574
|
+
return
|
|
575
|
+
record = operation_log.build_exception_record(
|
|
576
|
+
tool_name=ctx.tool_name,
|
|
577
|
+
action=ctx.action,
|
|
578
|
+
params=ctx.params,
|
|
579
|
+
exc=exc,
|
|
580
|
+
risk=ctx.risk.to_dict(),
|
|
581
|
+
duration_ms=duration_ms,
|
|
582
|
+
)
|
|
583
|
+
operation_log.write_record(record)
|
|
584
|
+
|
|
585
|
+
|
|
545
586
|
# ─── Pipeline Coordinator ───────────────────────────────────────────────────
|
|
546
587
|
|
|
547
588
|
|
|
@@ -577,6 +618,7 @@ class LifecyclePipeline:
|
|
|
577
618
|
self._hooks.append(ReadbackVerificationHook())
|
|
578
619
|
self._hooks.append(DriftDetectionHook())
|
|
579
620
|
self._hooks.append(ProvenanceTraceHook())
|
|
621
|
+
self._hooks.append(OperationLogHook())
|
|
580
622
|
|
|
581
623
|
def register_hook(self, hook: LifecycleHook) -> None:
|
|
582
624
|
with self._lock:
|
|
@@ -711,4 +753,3 @@ def classify_operation_risk(
|
|
|
711
753
|
tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
|
|
712
754
|
) -> RiskAssessment:
|
|
713
755
|
return RiskClassificationHook.classify(tool_name, action, params or {})
|
|
714
|
-
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Structured operation log for mutating tool calls.
|
|
2
|
+
|
|
3
|
+
This log is deliberately narrower than execution traces and separate from the
|
|
4
|
+
security audit log. It records the user-visible mutation attempt: which tool
|
|
5
|
+
action ran, its risk, whether it was a dry run, a compact summary, and the
|
|
6
|
+
final status.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from src.utils import operation_result
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("resolve-mcp.operation-log")
|
|
22
|
+
|
|
23
|
+
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
24
|
+
_PreferenceProvider = Callable[[str], Any]
|
|
25
|
+
_PREFERENCE_PROVIDER: Optional[_PreferenceProvider] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_preference_provider(fn: _PreferenceProvider) -> None:
|
|
29
|
+
global _PREFERENCE_PROVIDER
|
|
30
|
+
_PREFERENCE_PROVIDER = fn
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_preference(key: str, default: Any = None) -> Any:
|
|
34
|
+
if _PREFERENCE_PROVIDER is None:
|
|
35
|
+
return default
|
|
36
|
+
try:
|
|
37
|
+
value = _PREFERENCE_PROVIDER(key)
|
|
38
|
+
except Exception:
|
|
39
|
+
return default
|
|
40
|
+
return default if value is None else value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
|
44
|
+
if value is None:
|
|
45
|
+
return default
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
lowered = value.strip().lower()
|
|
48
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
49
|
+
return True
|
|
50
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
51
|
+
return False
|
|
52
|
+
return default
|
|
53
|
+
return bool(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def operation_log_enabled() -> bool:
|
|
57
|
+
return _coerce_bool(_read_preference("destructive.operation_log", True), True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def operation_log_path() -> str:
|
|
61
|
+
configured = (
|
|
62
|
+
os.environ.get("RESOLVE_MCP_OPERATION_LOG_FILE")
|
|
63
|
+
or _read_preference("destructive.operation_log_path", None)
|
|
64
|
+
)
|
|
65
|
+
if configured:
|
|
66
|
+
return os.path.realpath(os.path.abspath(os.path.expanduser(str(configured))))
|
|
67
|
+
return str(_REPO_ROOT / "logs" / "operation-log.jsonl")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _now_iso() -> str:
|
|
71
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dry_run_requested(params: Optional[Dict[str, Any]], result: Any) -> bool:
|
|
75
|
+
if isinstance(params, dict):
|
|
76
|
+
if "dry_run" in params:
|
|
77
|
+
return bool(params["dry_run"])
|
|
78
|
+
if "dryRun" in params:
|
|
79
|
+
return bool(params["dryRun"])
|
|
80
|
+
return bool(isinstance(result, dict) and result.get("dry_run") is True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _envelope(result: Any) -> Dict[str, Any]:
|
|
84
|
+
if not isinstance(result, dict):
|
|
85
|
+
return {}
|
|
86
|
+
nested = result.get(operation_result.ENVELOPE_KEY)
|
|
87
|
+
if isinstance(nested, dict):
|
|
88
|
+
return nested
|
|
89
|
+
if {"operation", "execution_id", "status"}.intersection(result):
|
|
90
|
+
return result
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _operation_id(result: Any, envelope: Dict[str, Any]) -> str:
|
|
95
|
+
if isinstance(result, dict) and result.get("operation_id"):
|
|
96
|
+
return str(result["operation_id"])
|
|
97
|
+
execution_id = envelope.get("execution_id")
|
|
98
|
+
if execution_id:
|
|
99
|
+
return str(execution_id)
|
|
100
|
+
return f"op_{uuid.uuid4().hex[:12]}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _status(result: Any, envelope: Dict[str, Any]) -> str:
|
|
104
|
+
raw_status = result.get("status") if isinstance(result, dict) else None
|
|
105
|
+
error = result.get("error") if isinstance(result, dict) else None
|
|
106
|
+
category = error.get("category") if isinstance(error, dict) else None
|
|
107
|
+
if raw_status in {
|
|
108
|
+
"blocked_by_security_policy",
|
|
109
|
+
"dry_run_unavailable",
|
|
110
|
+
"confirmation_required",
|
|
111
|
+
}:
|
|
112
|
+
return "blocked"
|
|
113
|
+
if category in {
|
|
114
|
+
"destructive_blocked",
|
|
115
|
+
"dry_run_unavailable",
|
|
116
|
+
"pending_user_decision",
|
|
117
|
+
}:
|
|
118
|
+
return "blocked"
|
|
119
|
+
return str(envelope.get("status") or operation_result.normalize_status(result))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _summary(tool_name: str, action: str, status: str, envelope: Dict[str, Any]) -> str:
|
|
123
|
+
operation = f"{tool_name}.{action}"
|
|
124
|
+
changes = envelope.get("changes")
|
|
125
|
+
if isinstance(changes, dict) and changes:
|
|
126
|
+
parts = [f"{key}={value}" for key, value in sorted(changes.items())[:4]]
|
|
127
|
+
return f"{operation} {status}; " + ", ".join(parts)
|
|
128
|
+
if status == "blocked":
|
|
129
|
+
return f"{operation} blocked before mutation"
|
|
130
|
+
return f"{operation} {status}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_record(
|
|
134
|
+
*,
|
|
135
|
+
tool_name: str,
|
|
136
|
+
action: str,
|
|
137
|
+
params: Optional[Dict[str, Any]],
|
|
138
|
+
result: Any,
|
|
139
|
+
risk: Dict[str, Any],
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
env = _envelope(result)
|
|
142
|
+
status = _status(result, env)
|
|
143
|
+
record = {
|
|
144
|
+
"operation_id": _operation_id(result, env),
|
|
145
|
+
"tool": tool_name,
|
|
146
|
+
"action": action,
|
|
147
|
+
"operation": f"{tool_name}.{action}",
|
|
148
|
+
"risk_level": risk.get("level", "unknown"),
|
|
149
|
+
"risk_established": risk.get("recognised"),
|
|
150
|
+
"dry_run": _dry_run_requested(params, result),
|
|
151
|
+
"timestamp": _now_iso(),
|
|
152
|
+
"summary": _summary(tool_name, action, status, env),
|
|
153
|
+
"status": status,
|
|
154
|
+
}
|
|
155
|
+
if env.get("execution_id") and env.get("execution_id") != record["operation_id"]:
|
|
156
|
+
record["execution_id"] = env["execution_id"]
|
|
157
|
+
if risk.get("blast_radius"):
|
|
158
|
+
record["blast_radius"] = risk["blast_radius"]
|
|
159
|
+
if env.get("duration_ms") is not None:
|
|
160
|
+
record["duration_ms"] = env["duration_ms"]
|
|
161
|
+
if env.get("changes") is not None:
|
|
162
|
+
record["changes"] = env["changes"]
|
|
163
|
+
return record
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_exception_record(
|
|
167
|
+
*,
|
|
168
|
+
tool_name: str,
|
|
169
|
+
action: str,
|
|
170
|
+
params: Optional[Dict[str, Any]],
|
|
171
|
+
exc: Exception,
|
|
172
|
+
risk: Dict[str, Any],
|
|
173
|
+
duration_ms: int,
|
|
174
|
+
) -> Dict[str, Any]:
|
|
175
|
+
exception_type = exc.__class__.__name__
|
|
176
|
+
operation = f"{tool_name}.{action}"
|
|
177
|
+
return {
|
|
178
|
+
"operation_id": f"op_{uuid.uuid4().hex[:12]}",
|
|
179
|
+
"tool": tool_name,
|
|
180
|
+
"action": action,
|
|
181
|
+
"operation": operation,
|
|
182
|
+
"risk_level": risk.get("level", "unknown"),
|
|
183
|
+
"risk_established": risk.get("recognised"),
|
|
184
|
+
"dry_run": _dry_run_requested(params, None),
|
|
185
|
+
"timestamp": _now_iso(),
|
|
186
|
+
"summary": f"{operation} failed with {exception_type}",
|
|
187
|
+
"status": "failed",
|
|
188
|
+
"blast_radius": risk.get("blast_radius"),
|
|
189
|
+
"duration_ms": max(0, int(duration_ms)),
|
|
190
|
+
"exception": {
|
|
191
|
+
"type": exception_type,
|
|
192
|
+
"message": str(exc),
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_record(record: Dict[str, Any]) -> None:
|
|
198
|
+
if not operation_log_enabled():
|
|
199
|
+
return
|
|
200
|
+
path = operation_log_path()
|
|
201
|
+
try:
|
|
202
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
203
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
204
|
+
handle.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
logger.warning("operation log write failed: %s", exc)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Authenticated loopback bridge that runs *inside* DaVinci Resolve.
|
|
2
2
|
|
|
3
3
|
External scripting is a Studio feature: `fusionscript` refuses a connection from
|
|
4
|
-
a foreign process on the free edition.
|
|
5
|
-
|
|
6
|
-
script launched from it is handed the same `resolve` object Studio
|
|
7
|
-
module is that script: it re-exports a
|
|
8
|
-
127.0.0.1 so the MCP server can drive
|
|
4
|
+
a foreign process on the free edition. Through 21.0.x the in-app path was not gated —
|
|
5
|
+
Blackmagic's README documented the Workspace ▸ Scripts menu with no edition
|
|
6
|
+
qualifier, and a script launched from it is handed the same `resolve` object Studio
|
|
7
|
+
exposes (measured on free 21.0.3.7). This module is that script: it re-exports a
|
|
8
|
+
*named, allowlisted* operation surface over 127.0.0.1 so the MCP server can drive
|
|
9
|
+
Resolve on any edition that still runs Python scripts in-app. Resolve 21.1 moved
|
|
10
|
+
Python scripting to Studio; on free 21.1 the Scripts menu no longer lists `.py`
|
|
11
|
+
files (#203, Fedora 44), and whether the Console still runs Python is unconfirmed.
|
|
9
12
|
|
|
10
13
|
This is the documented in-app path, not a circumvention of the licence check —
|
|
11
14
|
but Blackmagic could close it, so treat this tier as supported-until-it-isn't.
|