davinci-resolve-mcp 2.214.4 → 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,55 @@
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
+
5
54
  ## What's New in v2.214.4 — the offline test suite no longer writes into the operator's server.log
6
55
 
7
56
  ### 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.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
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.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.4 版 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.4"
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.4",
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.4"
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.4"
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,
@@ -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
@@ -15560,6 +15567,8 @@ def _setup_destructive_defaults() -> Dict[str, Any]:
15560
15567
  "safe_mode": _setup_bool(destructive.get("safe_mode"), False),
15561
15568
  "audit_log": _setup_bool(destructive.get("audit_log"), True),
15562
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"),
15563
15572
  "preferences_path": _media_analysis_preferences_path(),
15564
15573
  }
15565
15574
 
@@ -15579,6 +15588,10 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
15579
15588
  "auditlog": "audit_log",
15580
15589
  "audit_log_path": "audit_log_path",
15581
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",
15582
15595
  }
15583
15596
  requested: Dict[str, Any] = {}
15584
15597
  for key, value in destructive_defaults.items():
@@ -15607,11 +15620,11 @@ def _setup_set_destructive_defaults(destructive_defaults: Dict[str, Any], dry_ru
15607
15620
  if clear_requested(raw_value):
15608
15621
  destructive.pop(key, None)
15609
15622
  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"}:
15623
+ elif key in {"require_confirm_token", "safe_mode", "audit_log", "operation_log"}:
15611
15624
  normalized = _setup_bool(raw_value, before.get(key, False))
15612
15625
  destructive[key] = normalized
15613
15626
  updates[key] = {"before": before.get(key), "after": normalized}
15614
- elif key == "audit_log_path":
15627
+ elif key in {"audit_log_path", "operation_log_path"}:
15615
15628
  path = os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value))))
15616
15629
  destructive[key] = path
15617
15630
  updates[key] = {"before": before.get(key), "after": path}
@@ -16162,6 +16175,8 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
16162
16175
  "safe_mode": "destructive.safe_mode",
16163
16176
  "audit_log": "destructive.audit_log",
16164
16177
  "audit_log_path": "destructive.audit_log_path",
16178
+ "operation_log": "destructive.operation_log",
16179
+ "operation_log_path": "destructive.operation_log_path",
16165
16180
  }
16166
16181
  destructive_payload: Dict[str, Any] = {}
16167
16182
  if clear_all or "destructive" in normalized_keys:
@@ -16327,6 +16342,16 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16327
16342
  "values": "absolute or expandable path",
16328
16343
  "storage": _media_analysis_preferences_path(),
16329
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
+ },
16330
16355
  },
16331
16356
  }
16332
16357
 
@@ -16441,6 +16466,22 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16441
16466
  default=None,
16442
16467
  )
16443
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 {}),
16444
16485
  }
16445
16486
 
16446
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.