davinci-resolve-mcp 2.214.4 → 2.215.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,81 @@
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.1 — single-frame capture survives per-clip render mode and a vanished stills folder
6
+
7
+ ### Fixed
8
+
9
+ - **`timeline_frame capture` forces Single-clip render mode and restores it.**
10
+ Measured 2026-09-09 on a project whose delivery preset was "Individual
11
+ clips": every single-frame capture reported success, wrote no file, and took
12
+ 30+ seconds, because in that mode Resolve ignores `CustomName`, renders the
13
+ whole clip under its own naming, and the frame this helper waits for never
14
+ appears. The helper now reads `GetCurrentRenderMode()`, switches to single
15
+ clip (1) for the render, puts the mode back afterwards, and refuses with
16
+ `RENDER_MODE_REFUSED` before adding a job if the switch fails.
17
+ - **The shared stills folder is recreated before every directory listing.**
18
+ Every sandbox path redirects to one `~/Documents/resolve-stills`, and the
19
+ helper's own cleanup removes it once it empties, so a concurrent capture (or
20
+ anything else) can take it away between the makedirs at the top and the
21
+ `os.listdir` that follows. Frame 81 of a 214-frame QC batch died on exactly
22
+ that. Recreate, do not assume.
23
+
24
+ ### Measured (not code)
25
+
26
+ - A per-clip `.drx` carries the clip node graph losslessly (applying a clip's
27
+ own emitted grade back onto it re-renders bit-identically, PSNR 99 on 5/5),
28
+ but NOT colour-group pre/post grades, NOT Colour-page input sizing, and with
29
+ `grade_mode` 0 NOT keyframes. A traced conform whose source used groups needs
30
+ the group grades carried separately.
31
+ ## 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
32
+
33
+ ### Added
34
+
35
+ - **Recognised mutating operations now write compact JSONL records** to
36
+ `logs/operation-log.jsonl`: operation id, tool, action, `tool.action`, risk
37
+ level and whether the classifier established it, blast radius, dry-run
38
+ flag, timestamp, final status, duration, and a short summary. When a result
39
+ already reports semantic changes through the `_operation` envelope those
40
+ changes are copied, never guessed. A mutating handler that raises before
41
+ returning still writes a `failed` record with the exception type and
42
+ message; a destructive attempt refused before mutation is logged as
43
+ `blocked`; read-only operations are skipped. Contributed in #202 by
44
+ @Rohitkanithi.
45
+ - **Setup defaults for it:** `destructive.operation_log` (default on) and
46
+ `destructive.operation_log_path`; `RESOLVE_MCP_OPERATION_LOG_FILE` overrides
47
+ the path for deployments routing logs outside the repository. The offline
48
+ suite redirects the default path to a temporary file, the same way it
49
+ already redirects the security audit log.
50
+ - **Why a third log.** Execution traces explain how a multi-step run
51
+ unfolded; the security audit records destructive gate decisions. This is
52
+ the plain chronological trail of what tried to change the project and how
53
+ it ended, with no parameters or file paths recorded.
54
+
55
+ ### Changed
56
+
57
+ - **The free-edition bridge's claim is now version-qualified.** The README,
58
+ SKILL and the bridge module said the Workspace ▸ Scripts menu "is not gated
59
+ … on any edition". That was measured on free 21.0.3.7 only. Resolve 21.1
60
+ (2026-09-08) moved Python scripting to Studio, and the first field report
61
+ (#203, Fedora 44, free 21.1) shows the Scripts menu no longer listing any
62
+ `.py` file while a Lua script in the same folder lists normally. The docs
63
+ now say so, and the connection-failure remediation that sent free-edition
64
+ users to start the bridge now names the 21.1 change instead of promising a
65
+ path that may not exist. Whether the Console still runs Python on free 21.1
66
+ is unconfirmed and is the open question on #203.
67
+
68
+ ### Notes on the adaptation
69
+
70
+ - The PR's bundled version bump and CHANGELOG entry were dropped, as with
71
+ the contributor's earlier PRs; everything else landed as authored, and the
72
+ offline-guard redirect it adds is what keeps the suite from writing a real
73
+ operation log.
74
+
75
+ ### Validation
76
+
77
+ - The PR's seven tests plus the full offline suite, drift guards and the
78
+ advanced Node suite. No Resolve behavior changed; no live run required.
79
+
5
80
  ## What's New in v2.214.4 — the offline test suite no longer writes into the operator's server.log
6
81
 
7
82
  ### 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.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.215.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
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
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.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.215.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
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.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.215.1 版 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.4"
40
+ VERSION = "2.215.1"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.214.4",
3
+ "version": "2.215.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.214.4"
90
+ VERSION = "2.215.1"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
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.4"
14
+ VERSION = "2.215.1"
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,
@@ -237,7 +238,9 @@ mcp = FastMCP(
237
238
  "reachable via the in-app bridge (Workspace > Scripts > resolve_bridge — it is "
238
239
  "used automatically when external scripting is unavailable; "
239
240
  "DAVINCI_RESOLVE_BRIDGE=1 only forces it), so a connection error does NOT mean "
240
- "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."
241
244
  ),
242
245
  )
243
246
 
@@ -1085,7 +1088,10 @@ def _not_connected_error():
1085
1088
  "On the free edition: install the in-app bridge and run "
1086
1089
  "Workspace > Scripts > resolve_bridge — once it is running it is used "
1087
1090
  "automatically, no environment variable needed "
1088
- "(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.",
1089
1095
  # Which Resolve is refusing matters to the reader: a headless render
1090
1096
  # worker and the editor the user is looking at warrant different
1091
1097
  # responses, and the in-app bridge is not an option for the former.
@@ -1289,6 +1295,7 @@ def _destructive_preference_provider(key: str) -> Any:
1289
1295
 
1290
1296
 
1291
1297
  _destructive_hook.register_preference_provider(_destructive_preference_provider)
1298
+ _operation_log.register_preference_provider(_destructive_preference_provider)
1292
1299
 
1293
1300
 
1294
1301
  # Gated (tool, action) pairs routed through the destructive_hook wrapper that
@@ -14956,6 +14963,18 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
14956
14963
  original_fc = proj.GetCurrentRenderFormatAndCodec()
14957
14964
  except Exception:
14958
14965
  pass
14966
+ # Render MODE is project state too, and it decides whether the capture can
14967
+ # work at all. In "Individual clips" mode (0) Resolve ignores CustomName,
14968
+ # renders the WHOLE clip under the frame's own file naming, and the
14969
+ # single-frame file this helper waits for never appears — measured
14970
+ # 2026-09-09 on a project whose delivery preset was per-clip: every capture
14971
+ # reported success, wrote no file, and took 30+ s rendering the clip.
14972
+ # Force single clip (1) for the capture and put the mode back afterwards.
14973
+ original_mode = None
14974
+ try:
14975
+ original_mode = proj.GetCurrentRenderMode()
14976
+ except Exception:
14977
+ original_mode = None
14959
14978
  # Rendering pulls Resolve onto the Deliver page and moves the playhead;
14960
14979
  # measured leaving the user on Deliver at a different frame. Both are ours
14961
14980
  # to put back.
@@ -15007,6 +15026,16 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15007
15026
 
15008
15027
  job = None
15009
15028
  try:
15029
+ if original_mode is not None and original_mode != 1:
15030
+ if not proj.SetCurrentRenderMode(1):
15031
+ return _err(
15032
+ "Resolve refused to switch the render mode to single clip; in "
15033
+ "individual-clips mode a single-frame capture renders the whole clip "
15034
+ "under Resolve's own naming and the captured file never appears",
15035
+ code="RENDER_MODE_REFUSED", category="api_error",
15036
+ remediation="render(action='set_mode', params={'mode': 1}) then retry.",
15037
+ state={"render_mode": original_mode},
15038
+ )
15010
15039
  codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
15011
15040
  codec = list(codecs.values())[0] if codecs else fmt
15012
15041
  if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
@@ -15033,6 +15062,13 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15033
15062
  job = proj.AddRenderJob()
15034
15063
  if not job:
15035
15064
  return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
15065
+ # The folder is shared (every sandbox path redirects to one
15066
+ # ~/Documents/resolve-stills) and the cleanup below removes it when it
15067
+ # empties, so another capture — or anything else — can take it away
15068
+ # between the makedirs above and here. Measured 2026-09-09: frame 81 of a
15069
+ # 214-frame QC batch died in os.listdir on the missing folder. Recreate,
15070
+ # don't assume.
15071
+ os.makedirs(folder, exist_ok=True)
15036
15072
  before = set(os.listdir(folder))
15037
15073
  # Positional on purpose: the free-edition bridge proxies method calls
15038
15074
  # positionally, and a keyword argument dies inside _BoundMethod with
@@ -15080,6 +15116,19 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
15080
15116
  proj.DeleteRenderJob(job)
15081
15117
  except Exception:
15082
15118
  pass
15119
+ if original_mode is not None and original_mode != 1:
15120
+ # Same teardown contract as the format/codec restore below.
15121
+ try:
15122
+ restored_mode = bool(proj.SetCurrentRenderMode(original_mode))
15123
+ except Exception as exc:
15124
+ restored_mode, mode_exc = False, exc
15125
+ else:
15126
+ mode_exc = None
15127
+ if not restored_mode:
15128
+ logger.warning(
15129
+ "frame capture could not restore render mode %r: %s",
15130
+ original_mode, mode_exc or "SetCurrentRenderMode returned False",
15131
+ )
15083
15132
  if original_fc:
15084
15133
  # A failed restore leaves the Deliver page on the capture's format
15085
15134
  # and codec, which the user's next render would silently inherit.
@@ -15560,6 +15609,8 @@ def _setup_destructive_defaults() -> Dict[str, Any]:
15560
15609
  "safe_mode": _setup_bool(destructive.get("safe_mode"), False),
15561
15610
  "audit_log": _setup_bool(destructive.get("audit_log"), True),
15562
15611
  "audit_log_path": destructive.get("audit_log_path") or os.path.join(project_dir, "logs", "security-audit.jsonl"),
15612
+ "operation_log": _setup_bool(destructive.get("operation_log"), True),
15613
+ "operation_log_path": destructive.get("operation_log_path") or os.path.join(project_dir, "logs", "operation-log.jsonl"),
15563
15614
  "preferences_path": _media_analysis_preferences_path(),
15564
15615
  }
15565
15616
 
@@ -15579,6 +15630,10 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
15579
15630
  "auditlog": "audit_log",
15580
15631
  "audit_log_path": "audit_log_path",
15581
15632
  "auditlogpath": "audit_log_path",
15633
+ "operation_log": "operation_log",
15634
+ "operationlog": "operation_log",
15635
+ "operation_log_path": "operation_log_path",
15636
+ "operationlogpath": "operation_log_path",
15582
15637
  }
15583
15638
  requested: Dict[str, Any] = {}
15584
15639
  for key, value in destructive_defaults.items():
@@ -15607,11 +15662,11 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
15607
15662
  if clear_requested(raw_value):
15608
15663
  destructive.pop(key, None)
15609
15664
  updates[key] = {"before": before.get(key), "after": _setup_destructive_defaults().get(key), "cleared": True}
15610
- elif key in {"require_confirm_token", "safe_mode", "audit_log"}:
15665
+ elif key in {"require_confirm_token", "safe_mode", "audit_log", "operation_log"}:
15611
15666
  normalized = _setup_bool(raw_value, before.get(key, False))
15612
15667
  destructive[key] = normalized
15613
15668
  updates[key] = {"before": before.get(key), "after": normalized}
15614
- elif key == "audit_log_path":
15669
+ elif key in {"audit_log_path", "operation_log_path"}:
15615
15670
  path = os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
15616
15671
  destructive[key] = path
15617
15672
  updates[key] = {"before": before.get(key), "after": path}
@@ -16162,6 +16217,8 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
16162
16217
  "safe_mode": "destructive.safe_mode",
16163
16218
  "audit_log": "destructive.audit_log",
16164
16219
  "audit_log_path": "destructive.audit_log_path",
16220
+ "operation_log": "destructive.operation_log",
16221
+ "operation_log_path": "destructive.operation_log_path",
16165
16222
  }
16166
16223
  destructive_payload: Dict[str, Any] = {}
16167
16224
  if clear_all or "destructive" in normalized_keys:
@@ -16327,6 +16384,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16327
16384
  "values": "absolute or expandable path",
16328
16385
  "storage": _media_analysis_preferences_path(),
16329
16386
  },
16387
+ "destructive.operation_log": {
16388
+ "description": "Write compact JSONL records for every recognised mutating operation.",
16389
+ "values": [True, False],
16390
+ "storage": _media_analysis_preferences_path(),
16391
+ },
16392
+ "destructive.operation_log_path": {
16393
+ "description": "Absolute path for the mutating-operation JSONL log.",
16394
+ "values": "absolute or expandable path",
16395
+ "storage": _media_analysis_preferences_path(),
16396
+ },
16330
16397
  },
16331
16398
  }
16332
16399
 
@@ -16441,6 +16508,22 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16441
16508
  default=None,
16442
16509
  )
16443
16510
  } if any(key in merged for key in ("audit_log_path", "auditLogPath")) else {}),
16511
+ **({
16512
+ "operation_log": _first_param(
16513
+ merged,
16514
+ "operation_log",
16515
+ "operationLog",
16516
+ default=None,
16517
+ )
16518
+ } if any(key in merged for key in ("operation_log", "operationLog")) else {}),
16519
+ **({
16520
+ "operation_log_path": _first_param(
16521
+ merged,
16522
+ "operation_log_path",
16523
+ "operationLogPath",
16524
+ default=None,
16525
+ )
16526
+ } if any(key in merged for key in ("operation_log_path", "operationLogPath")) else {}),
16444
16527
  }
16445
16528
 
16446
16529
  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.