davinci-resolve-mcp 4.5.0 → 4.5.2

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,112 @@
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 v4.5.2 — granular safety stops guessing, and the audit log stops lying
6
+
7
+ Two findings from a review of the v4.5.0 enforcement hook, both measured before and
8
+ after rather than reasoned about.
9
+
10
+ ### Fixed
11
+
12
+ - **The verb table was the sole authority on risk, and it disagreed with the
13
+ compound server on 20 tools.** The same operation is exposed on both servers under
14
+ the same action name, and the compound tables are where someone actually assessed
15
+ it. Three granular tools were rated **below** their compound assessment — the
16
+ direction that matters, because safe mode then lets them through:
17
+
18
+ | tool | was | now |
19
+ |---|---|---|
20
+ | `ti_copy_grades` | medium | **high** |
21
+ | `timeline_delete_clips` | high | **critical** |
22
+ | `timeline_detect_scene_cuts` | medium | **high** |
23
+
24
+ `ti_copy_grades` is the tool this entire effort began with — the one that reaches
25
+ `TimelineItem.CopyGrades` and replaces a node graph with no recovery version. The
26
+ verb table called it MEDIUM, because `copy` appears in no table, so safe mode did
27
+ not stop it.
28
+
29
+ The other seventeen were rated **above** their compound assessment: `clear_*` and
30
+ `set_*` tools called HIGH here while compound rates them LOW. That is not the safe
31
+ direction either — `_safe_mode_allows` documents why at length. A gate that refuses
32
+ work the compound server considers low-risk teaches people to switch safe mode off,
33
+ and a setting left off protects nothing.
34
+
35
+ Rating order is now: a symbol the ledger marks `destroys_prior_work`, then the
36
+ compound server's established rating for the same action name, then the verb.
37
+ Most-severe-wins where two compound tools rate one name differently, because a gate
38
+ should resolve ambiguity by refusing more rather than less.
39
+
40
+ - **The audit log misreported two of its three outcomes.** It is this surface's only
41
+ record of what ran — there is no archive behind it — so a row that overstates, or
42
+ is simply absent, is the whole artifact failing:
43
+
44
+ - a first call that only minted a confirm token was recorded `allowed`, claiming a
45
+ mutation that had not happened. It is now `pending_confirmation`, matching what
46
+ the compound hook records for the same case.
47
+ - **an exception wrote no row at all**, so the log went silent exactly when
48
+ something broke. It is now `failed`, carrying the exception type. The hook is a
49
+ witness, not a handler: the exception is re-raised untouched.
50
+
51
+ ### Changed
52
+
53
+ - Four existing tests asserted the old verb ratings. `ti_clear_flags` is LOW now
54
+ because compound rates `clear_flags` LOW — the change working, not a regression —
55
+ so the two behavioural tests moved to `ti_delete_version` as their HIGH exemplar,
56
+ and namespace stripping is asserted directly rather than through a rating that may
57
+ now come from the compound tables.
58
+
59
+ ### Validation
60
+
61
+ - Full offline suite: **3,693 passed, 1 skipped, 0 failed**, 1,419 subtests.
62
+ - Each fix was reverted in turn to confirm its guard fails rather than passing
63
+ vacuously — 9, 1 and 1 failures respectively, all green on restore.
64
+ - A guard now walks every decorated tool and fails if any is rated *below* the
65
+ compound server's established assessment, so the class cannot return one tool at a
66
+ time. A trap-symbol tool may still be raised above it.
67
+
68
+ ## What's New in v4.5.1 — the safe-mode refusal reaches the caller on 27 more tools
69
+
70
+ v4.5.0 gave the granular server a working safe-mode gate. On 27 tools it then threw
71
+ the answer away.
72
+
73
+ ### Fixed
74
+
75
+ - **A blocked call raised `ToolError` instead of returning the refusal.** FastMCP
76
+ builds an output schema from a tool's return annotation and validates against it,
77
+ so handing the block dict to a tool annotated `-> str` failed validation:
78
+
79
+ ```
80
+ ToolError: ...Output / result / Input should be a valid string
81
+ ```
82
+
83
+ The caller received a generic execution error carrying none of the
84
+ `SAFE_MODE_BLOCKED` code, reason or remediation — the gate fired correctly and its
85
+ answer was destroyed on the way out. Measured on the real `--full` entry path
86
+ against shipped v4.5.0: `clear_folder_transcription` raised rather than refusing.
87
+
88
+ It lands hardest exactly where it matters. The HIGH-rated string-returning tools
89
+ are the calls safe mode exists to stop: `clear_folder_transcription`,
90
+ `unlink_proxy_media`, `replace_clip`, `delete_keyframe`, `quit_app`, `restart_app`.
91
+
92
+ A `-> str` tool is now refused with the message and its remediation as text,
93
+ prefixed with the code. That loses the machine-readable field, which is a real
94
+ cost and worth stating plainly — but a refusal the client can read beats a
95
+ `ToolError` that discards it, and it is the only shape that tool's own schema will
96
+ accept. The 105 tools annotated `-> Dict[str, Any]` or `-> dict` keep the
97
+ structured envelope unchanged.
98
+
99
+ ### Validation
100
+
101
+ - Full offline suite: **3,684 passed, 1 skipped, 0 failed**, 1,412 subtests.
102
+ - Both new assertions were confirmed to **fail with the fix reverted**, then pass on
103
+ restore — the guard is not vacuous.
104
+ - Verified end-to-end through the real registry with `destructive.safe_mode` on: a
105
+ `-> str` tool returns readable refusal text, a `-> dict` tool returns the full
106
+ envelope, and `allow_risky_operation=true` still lets a permitted call through on
107
+ both paths.
108
+ - A static check now walks every destructive-decorated tool annotated `-> str` and
109
+ asserts the hook would hand it a string, so tool number 28 cannot reintroduce this.
110
+
5
111
  ## What's New in v4.5.0 — safe mode and the audit log reach the granular server
6
112
 
7
113
  v4.4.1 froze 131 destructive-hinted granular tools in a backlog and said plainly
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-4.5.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.5.2-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-37%20(387%20full)-blue.svg)](#server-modes)
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-4.5.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.5.2-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-37%20(387%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
- > 本翻译对应 v4.5.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.5.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.5.0"
40
+ VERSION = "4.5.2"
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": "4.5.0",
3
+ "version": "4.5.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.5.0"
96
+ VERSION = "4.5.2"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.5.0"
14
+ VERSION = "4.5.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -38,7 +38,12 @@ from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
38
38
  from src.utils import analysis_runs, brain_edits, media_pool_changes, timeline_versioning
39
39
  from src.utils.api_truth import API_TRUTH, traps_for, trap_notice
40
40
  from src.utils.bool_params import coerce_bool, explicit_bool_param
41
- from src.utils.execution_lifecycle import RiskAssessment, RiskLevel, classify_operation_risk
41
+ from src.utils.execution_lifecycle import (
42
+ RiskAssessment,
43
+ RiskClassificationHook,
44
+ RiskLevel,
45
+ classify_operation_risk,
46
+ )
42
47
 
43
48
  logger = logging.getLogger("resolve-mcp.destructive-hook")
44
49
 
@@ -947,22 +952,72 @@ def _reaches_trap_symbol(fn: Callable[..., Any]) -> bool:
947
952
  return False
948
953
 
949
954
 
955
+ _RISK_ORDER = {
956
+ RiskLevel.LOW.value: 0,
957
+ RiskLevel.MEDIUM.value: 1,
958
+ RiskLevel.HIGH.value: 2,
959
+ RiskLevel.CRITICAL.value: 3,
960
+ }
961
+
962
+
963
+ @functools.lru_cache(maxsize=1)
964
+ def _established_compound_ratings() -> Dict[str, str]:
965
+ """Compound risk ratings keyed by action name, most severe wins.
966
+
967
+ The same operation is often exposed on both servers under the same action
968
+ name — `clear_flags`, `delete_clips`, `copy_grades` — and the compound tables
969
+ are where someone actually assessed it. Guessing from the verb when a real
970
+ rating exists produced 20 disagreements, three of them UNDER-rating: the verb
971
+ table called `ti_copy_grades` MEDIUM (so safe mode let it through) while the
972
+ compound tables rate `copy_grades` HIGH, and `delete_clips` is CRITICAL there
973
+ against HIGH here.
974
+
975
+ Most-severe-wins because a name rated differently by two compound tools is
976
+ ambiguous, and a gate should resolve ambiguity by refusing more, not less.
977
+ """
978
+ ratings: Dict[str, str] = {}
979
+ for table, level in (
980
+ ("_CRITICAL_ACTIONS", RiskLevel.CRITICAL.value),
981
+ ("_HIGH_RISK_ACTIONS", RiskLevel.HIGH.value),
982
+ ("_MEDIUM_RISK_ACTIONS", RiskLevel.MEDIUM.value),
983
+ ("_LOW_RISK_ACTIONS", RiskLevel.LOW.value),
984
+ ):
985
+ for _tool, action in getattr(RiskClassificationHook, table, ()) or ():
986
+ current = ratings.get(action)
987
+ if current is None or _RISK_ORDER[level] > _RISK_ORDER[current]:
988
+ ratings[action] = level
989
+ return ratings
990
+
991
+
992
+ def _granular_action_name(tool_name: str) -> str:
993
+ """The tool name with its namespace removed — the compound action name."""
994
+ name = (tool_name or "").lower()
995
+ for prefix in _GRANULAR_NAMESPACES:
996
+ if name.startswith(prefix):
997
+ return name[len(prefix):]
998
+ return name
999
+
1000
+
950
1001
  def granular_risk_level(tool_name: str, fn: Optional[Callable[..., Any]] = None) -> str:
951
- """Rate a granular tool: HIGH from the ledger if it reaches a trap symbol,
952
- otherwise from its verb. Unknown verbs are MEDIUM, never HIGH.
1002
+ """Rate a granular tool, preferring an assessment over a guess.
1003
+
1004
+ In order: HIGH if the body reaches a symbol the ledger marks
1005
+ `destroys_prior_work`; else the compound server's established rating for the
1006
+ same action name; else the verb. Unknown verbs are MEDIUM, never HIGH.
953
1007
 
954
1008
  Rating an unrecognised verb HIGH would let safe mode block writes nobody has
955
1009
  assessed, which is the failure `_safe_mode_allows` documents: over-blocking
956
1010
  teaches people to switch the setting off, and a setting left off protects
957
- nothing.
1011
+ nothing. The compound lookup cuts both ways for the same reason — it raises
1012
+ `copy_grades` to HIGH, and it lowers the seventeen `clear_*`/`set_*` tools the
1013
+ verb table called HIGH while compound rates them LOW.
958
1014
  """
959
1015
  if fn is not None and _reaches_trap_symbol(fn):
960
1016
  return RiskLevel.HIGH.value
961
- name = (tool_name or "").lower()
962
- for prefix in _GRANULAR_NAMESPACES:
963
- if name.startswith(prefix):
964
- name = name[len(prefix):]
965
- break
1017
+ name = _granular_action_name(tool_name)
1018
+ established = _established_compound_ratings().get(name)
1019
+ if established is not None:
1020
+ return established
966
1021
  return GRANULAR_RISK_BY_VERB.get(name.split("_", 1)[0], RiskLevel.MEDIUM.value)
967
1022
 
968
1023
 
@@ -991,6 +1046,39 @@ def _with_override_param(fn: Callable[..., Any]) -> Tuple[inspect.Signature, boo
991
1046
  return sig.replace(parameters=head + [extra] + tail), True
992
1047
 
993
1048
 
1049
+ def _returns_plain_string(fn: Callable[..., Any]) -> bool:
1050
+ """Does this tool declare `-> str`?
1051
+
1052
+ FastMCP builds an output schema from the return annotation and validates
1053
+ against it, so handing a dict to a tool annotated `-> str` raises ToolError
1054
+ and the caller never sees the refusal — only a generic execution error with
1055
+ none of its code or remediation. 27 granular tools declare `-> str`, and the
1056
+ HIGH-risk ones among them are exactly the calls safe mode exists to stop.
1057
+ """
1058
+ annotation = getattr(fn, "__annotations__", {}).get("return")
1059
+ return annotation is str or annotation in ("str", "'str'")
1060
+
1061
+
1062
+ def _block_for(fn: Callable[..., Any], block: Dict[str, Any]) -> Any:
1063
+ """The refusal, shaped to the contract the tool declares.
1064
+
1065
+ A string-returning tool gets the message and its remediation as text. That
1066
+ loses the machine-readable `code`, which is a real cost — but a refusal the
1067
+ client can read beats a ToolError that discards it entirely, and it is the
1068
+ only shape that tool's own schema will accept.
1069
+ """
1070
+ if not _returns_plain_string(fn):
1071
+ return block
1072
+ error = block.get("error") or {}
1073
+ message = error.get("message") or "Blocked by destructive.safe_mode."
1074
+ remediation = error.get("remediation")
1075
+ code = error.get("code")
1076
+ parts = [f"{code}: {message}" if code else message]
1077
+ if remediation:
1078
+ parts.append(remediation)
1079
+ return " ".join(parts)
1080
+
1081
+
994
1082
  def granular_destructive_op(
995
1083
  tool_name: Optional[str] = None,
996
1084
  *,
@@ -1043,23 +1131,46 @@ def granular_destructive_op(
1043
1131
  params=params,
1044
1132
  reason="safe_mode",
1045
1133
  )
1046
- return _security_block_response(
1134
+ return _block_for(fn, _security_block_response(
1047
1135
  operation_id=operation_id,
1048
1136
  tool_name="granular",
1049
1137
  action=action,
1050
1138
  risk_level=level,
1051
1139
  override_hint=f"{GRANULAR_OVERRIDE_PARAM}=true",
1140
+ ))
1141
+
1142
+ try:
1143
+ result = fn(*args, **kwargs)
1144
+ except BaseException as exc:
1145
+ # A call that reached Resolve and then failed used to leave no row
1146
+ # at all. The audit log is this surface's only record of what ran,
1147
+ # so going silent precisely when something broke is the worst place
1148
+ # to have a gap. Record it, then let the exception continue
1149
+ # untouched — the hook is a witness, not a handler.
1150
+ _audit_security_event(
1151
+ operation_id=operation_id,
1152
+ tool_name="granular",
1153
+ action=action,
1154
+ risk_level=level,
1155
+ status="failed",
1156
+ params=params,
1157
+ reason=type(exc).__name__,
1052
1158
  )
1159
+ raise
1053
1160
 
1054
- result = fn(*args, **kwargs)
1161
+ # A first call that mints a confirm token has not mutated anything, and
1162
+ # recording it as "allowed" overstates what happened — the compound hook
1163
+ # distinguishes the same case via _PENDING_CONFIRM_CHECK.
1164
+ pending = (isinstance(result, dict)
1165
+ and result.get("status") == "confirmation_required")
1055
1166
  _audit_security_event(
1056
1167
  operation_id=operation_id,
1057
1168
  tool_name="granular",
1058
1169
  action=action,
1059
1170
  risk_level=level,
1060
- status="allowed",
1171
+ status="pending_confirmation" if pending else "allowed",
1061
1172
  params=params,
1062
- reason="no_archive_on_granular",
1173
+ reason="confirm_token_required" if pending else "no_archive_on_granular",
1063
1174
  )
1064
1175
  return _annotate_security(result, operation_id=operation_id, risk_level=level)
1065
1176