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 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
- [![Version](https://img.shields.io/badge/version-2.214.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.215.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(353%20full)-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
- The **Workspace ▸ Scripts** menu is not gated — a script launched from it is
46
- handed the live `resolve` object on any edition so the server can reach the
47
- free edition through a small script that runs *inside* Resolve and re-exports it
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. `list_recent_executions` reports that path and whether it is
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
- [![Version](https://img.shields.io/badge/version-2.214.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.215.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(353%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.214.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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 any edition and re-exports it over an authenticated
26
- loopback listener. Install with `python scripts/install_resolve_bridge.py` and
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.214.3"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.214.3",
3
+ "version": "2.215.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.214.3"
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.214.3"
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=[logging.FileHandler(os.path.join(log_dir, "server.log"))]
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 == "audit_log_path":
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. The in-app path is not gated — Blackmagic's
5
- own README documents the Workspace ▸ Scripts menu with no edition qualifier, and a
6
- script launched from it is handed the same `resolve` object Studio exposes. This
7
- module is that script: it re-exports a *named, allowlisted* operation surface over
8
- 127.0.0.1 so the MCP server can drive Resolve on any edition.
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.