davinci-resolve-mcp 2.207.0 → 2.208.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,62 @@
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.208.0 — agent execution lifecycle & pre-flight risk inspection
6
+
7
+ Adapted from the design contributed in PR #187.
8
+
9
+ ### Added
10
+
11
+ - **Agent execution lifecycle pipeline & hooks:**
12
+ Tools passing through `_guard_missing_params` now execute within a structured
13
+ lifecycle pipeline, supporting pre-flight inspection (`before_tool_call`),
14
+ post-execution enrichment (`after_tool_call`), and failure handling (`on_error`).
15
+ - **Pre-flight operation risk & blast radius assessment:**
16
+ `resolve_control(action="inspect_operation")` evaluates any tool and action
17
+ prior to execution, returning risk levels (`low`, `medium`, `high`, `critical`),
18
+ destructive flags, confirmation requirements, and blast radius scopes (`item`,
19
+ `track`, `timeline`, `project`, `system`).
20
+ - **Lifecycle hooks introspection:**
21
+ `resolve_control(action="list_lifecycle_hooks")` exposes registered pipeline
22
+ hooks and their active states.
23
+
24
+ ### Notes on the adaptation
25
+
26
+ - **The dry-run simulation interceptor is not included.** As contributed, any
27
+ call carrying `dry_run: true` outside a hardcoded four-entry allowlist was
28
+ short-circuited and answered with a synthesised `{"success": true,
29
+ "simulated": true}`. `src/server.py` has 273 `dry_run` references, so the
30
+ allowlist was not close: `setup.set_defaults` and
31
+ `resolve_control.clear_executions` both have real, tested dry-run paths and
32
+ were hijacked. It also answered `success: true` to
33
+ `set_defaults(result_envelope="banana")` — a dry run of an operation that
34
+ cannot succeed — and to adding a marker with no timeline in existence.
35
+ `dry_run` is the call an editor makes *because* they do not trust the next
36
+ one; a version of it that always succeeds is worse than none, because it is
37
+ believed. Nothing about dry-run behaviour changes in this release: every
38
+ `dry_run` reaches the handler that owns it.
39
+ - **The pipeline can gate a call, but nothing shipped does.**
40
+ `HookDecision(proceed=False)` and the public `register_hook` remain, so a
41
+ deliberately registered hook can intercept. Every default hook only observes,
42
+ and `test_no_default_hook_short_circuits` keeps it that way.
43
+ - **`inspect_operation` no longer contradicts itself about rollback.** It
44
+ reported `snapshot_available` two ways in one response — `false` inside
45
+ `risk`, and `true` at the top level whenever any pre-state could be read.
46
+ Reading a project name is not a restorable snapshot. It is now a single
47
+ `null`, meaning "not determined", with `pre_state_available` reporting
48
+ separately whether live state was read at all.
49
+ - **An unrecognised operation is no longer assessed as safe.** Any action
50
+ matching no rule fell into a general-mutation bucket and returned `medium` /
51
+ `destructive: false` / `confirmation_required: false` — a confident answer
52
+ about an operation the classifier had never heard of, including ones that do
53
+ not exist. Responses now carry `recognised: false` and say in `reasons` that
54
+ the levels are name-based defaults rather than a finding. The guard exists
55
+ for hallucinated calls; answering one with reassurance was the failure it was
56
+ built to prevent.
57
+ - The docs now state plainly that `inspect_operation` is a heuristic over
58
+ action names, not a simulation: it never touches the project and does not
59
+ validate parameters.
60
+
5
61
  ## What's New in v2.207.0 — execution audit report exports
6
62
 
7
63
  Contributed in PR #185.
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.207.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.208.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)
@@ -280,6 +280,17 @@ reviewable audit artifact with the same summary, defaulting to
280
280
  it instead — alongside a conform in a dated TransferFiles folder, say — and
281
281
  creates the directories to get there, so check the path before you send it.
282
282
  An existing file is never replaced without `overwrite: true`.
283
+ `inspect_operation(tool?, target_action?, target_params?)` evaluates pre-flight
284
+ risk level (`low`, `medium`, `high`, `critical`), destructive potential, and blast
285
+ radius (`item`, `track`, `timeline`, `project`, `system`) before taking action, while
286
+ `list_lifecycle_hooks()` inspects active execution interceptors.
287
+
288
+ It is a heuristic over action names, not a simulation — it never touches the
289
+ project and does not validate your parameters, so `recognised: false` means the
290
+ levels are defaults rather than a finding, and `snapshot_available: null` means
291
+ rollback availability was not determined rather than absent. Every shipped hook
292
+ observes; none replaces a tool's result, so `dry_run` always reaches the real
293
+ handler and nothing synthesises a preview for an action that has none.
283
294
 
284
295
  A report for a run where nothing was verified says **"not established — no
285
296
  checks recorded"**, not "passed". Absence of evidence is a question still open,
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.207.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.208.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.207.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.208.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -174,7 +174,9 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
174
174
 
175
175
  ### 导出执行审计报告
176
176
 
177
- `export_execution_report(execution_id?, format="markdown"|"json")` 会把一条轨迹写成可供审阅的审计文件,默认落在 `logs/execution-reports/<execution_id>.md`。传 `path` 可以写到任何你想要的位置——比如跟着某次套底放进当天的 TransferFiles 文件夹——并且会自动创建沿途的目录,所以发出去之前请先确认路径。已存在的文件不会被覆盖,除非显式传 `overwrite: true`。
177
+ `export_execution_report(execution_id?, format="markdown"|"json")` 会把一条轨迹写成可供审阅的审计文件,默认落在 `logs/execution-reports/<execution_id>.md`。传 `path` 可以写到任何你想要的位置——比如跟着某次套底放进当天的 TransferFiles 文件夹——并且会自动创建沿途的目录,所以发出去之前请先确认路径。已存在的文件不会被覆盖,除非显式传 `overwrite: true`。`inspect_operation(tool?, target_action?, target_params?)` 会在执行前评估操作的风险等级(`low`、`medium`、`high`、`critical`)、破坏性以及影响范围(`item`、`track`、`timeline`、`project`、`system`),而 `list_lifecycle_hooks()` 则可以查看当前生效的生命周期钩子。
178
+
179
+ 需要强调的是:这是一套基于动作名称的启发式判断,**不是模拟执行**——它完全不碰项目,也不会校验你传的参数。`recognised: false` 表示没有任何规则命中,那些等级只是按名字给出的默认值,而不是对这次操作的结论;`snapshot_available: null` 表示"是否能回滚未确定",而不是"不能回滚"。所有随包启用的钩子都只做观察,没有任何一个会替换工具的返回值——因此 `dry_run` 永远会走到真正的处理函数,不会有人替一个本身不支持 dry-run 的动作凭空编一份预览出来。
178
180
 
179
181
  如果这次运行根本没有做过校验,报告里写的是**"not established — no checks recorded"(未确立——没有记录任何检查)**,而不是"通过"。没有证据是一个仍然悬而未决的问题;审计文件恰恰是最不该让读者把它读成"一切正常"的地方。
180
182
 
package/docs/SKILL.md CHANGED
@@ -277,6 +277,24 @@ Example trace shape returned by `resolve_control(action="get_execution_trace")`:
277
277
  structured output, `include_steps: false` for a shorter summary, or
278
278
  `overwrite: true` to replace an existing report.
279
279
  - **`clear_executions(dry_run?)`**: Clears the in-memory execution trace buffer.
280
+ - **`inspect_operation(tool?, target_action?, target_params?)`**: Evaluates operation risk
281
+ level (`low`, `medium`, `high`, `critical`), destructive potential, confirmation
282
+ requirements, and blast radius scope before executing an action.
283
+
284
+ **It is a heuristic over action names, not a simulation.** It does not touch
285
+ the project, does not validate your parameters, and cannot tell you whether
286
+ the clip ids you are holding exist. Read three fields before trusting it:
287
+ `recognised: false` means no rule matched and the levels are name-based
288
+ defaults rather than a finding; `snapshot_available: null` means rollback
289
+ availability was not determined, never that there is none; and
290
+ `pre_state_available` separates "no project open" from "state never read".
291
+ For an actual preview, use the action's own `dry_run` where it has one.
292
+ - **`list_lifecycle_hooks()`**: Returns active execution lifecycle pipeline hooks
293
+ (`risk_classification`, `resolve_state_inspection`, `readback_verification`,
294
+ `drift_detection`, `provenance_trace`). All of them observe; none replaces a
295
+ tool result. `dry_run` therefore always reaches the real handler — a tool
296
+ either implements it or does not, and nothing synthesises a preview on its
297
+ behalf.
280
298
 
281
299
  Explicit correlation is also supported per-call: pass `params={"execution_id": ...}`
282
300
  or `params={"trace_id": ...}` in any tool call to associate it with a specific trace.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.207.0"
40
+ VERSION = "2.208.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.207.0",
3
+ "version": "2.208.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.207.0"
90
+ VERSION = "2.208.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.207.0"
14
+ VERSION = "2.208.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -76,6 +76,11 @@ from src.utils.execution_trace import (
76
76
  clear_executions,
77
77
  export_execution_report,
78
78
  )
79
+ from src.utils import execution_lifecycle as _execution_lifecycle
80
+ from src.utils.execution_lifecycle import (
81
+ inspect_operation,
82
+ list_lifecycle_hooks,
83
+ )
79
84
  from src.utils.render_ids import (
80
85
  render_codec_id_from_codecs as _render_codec_id_from_codecs,
81
86
  render_format_id_from_formats as _render_format_id_from_formats,
@@ -887,6 +892,37 @@ def _try_connect():
887
892
  resolve = None
888
893
  return None
889
894
 
895
+ def _get_resolve_lifecycle_state() -> Optional[Dict[str, Any]]:
896
+ """Capture non-blocking pre-flight Resolve project and timeline metadata."""
897
+ global resolve
898
+ r = resolve
899
+ if r is None:
900
+ try:
901
+ r = _try_connect()
902
+ except Exception:
903
+ r = None
904
+ if r is None:
905
+ return None
906
+ try:
907
+ pm = r.GetProjectManager()
908
+ if not pm:
909
+ return None
910
+ proj = pm.GetCurrentProject()
911
+ if not proj:
912
+ return None
913
+ state: Dict[str, Any] = {"project_name": proj.GetName()}
914
+ tl = proj.GetCurrentTimeline()
915
+ if tl:
916
+ state["timeline_name"] = tl.GetName()
917
+ state["duration_frames"] = tl.GetEndFrame() - tl.GetStartFrame()
918
+ state["track_count_video"] = tl.GetTrackCount("video")
919
+ state["track_count_audio"] = tl.GetTrackCount("audio")
920
+ return state
921
+ except Exception:
922
+ return None
923
+
924
+ _execution_lifecycle.get_lifecycle_pipeline().set_state_provider(_get_resolve_lifecycle_state)
925
+
890
926
  def _launch_resolve(headless: Optional[bool] = None):
891
927
  """Launch DaVinci Resolve and wait for it to become available.
892
928
 
@@ -1501,6 +1537,7 @@ _TRACE_OBSERVER_ACTIONS = {
1501
1537
  "get_execution_trace", "get_execution", "list_recent_executions",
1502
1538
  "clear_executions", "begin_execution", "end_execution",
1503
1539
  "export_execution_report",
1540
+ "inspect_operation", "list_lifecycle_hooks",
1504
1541
  }
1505
1542
 
1506
1543
 
@@ -1573,14 +1610,29 @@ def _guard_missing_params(fn):
1573
1610
  async def wrapper(*args, **kwargs):
1574
1611
  action = _guarded_action_name(args, kwargs)
1575
1612
  params = _guarded_params(args, kwargs)
1613
+ lifecycle = _execution_lifecycle.get_lifecycle_pipeline()
1614
+ ctx = _execution_lifecycle.ToolCallContext(
1615
+ tool_name=tool_name,
1616
+ action=action,
1617
+ params=params or {},
1618
+ )
1619
+ decision = lifecycle.run_before(ctx)
1620
+ if decision and not decision.proceed and decision.short_circuit_result is not None:
1621
+ return _build_operation_envelope(tool_name, action, params, decision.short_circuit_result, duration_ms=0)
1622
+
1576
1623
  t0 = time.perf_counter()
1577
1624
  try:
1578
1625
  result = await fn(*args, **kwargs)
1579
1626
  except _MissingParam as exc:
1580
1627
  result = _missing_param_error(exc, action)
1628
+ except Exception as exc:
1629
+ duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1630
+ lifecycle.run_on_error(ctx, exc, duration_ms)
1631
+ raise
1581
1632
  duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1582
1633
  enveloped = _build_operation_envelope(
1583
1634
  tool_name, action, params, result, duration_ms=duration_ms)
1635
+ enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
1584
1636
  _record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
1585
1637
  return enveloped
1586
1638
  else:
@@ -1588,14 +1640,29 @@ def _guard_missing_params(fn):
1588
1640
  def wrapper(*args, **kwargs):
1589
1641
  action = _guarded_action_name(args, kwargs)
1590
1642
  params = _guarded_params(args, kwargs)
1643
+ lifecycle = _execution_lifecycle.get_lifecycle_pipeline()
1644
+ ctx = _execution_lifecycle.ToolCallContext(
1645
+ tool_name=tool_name,
1646
+ action=action,
1647
+ params=params or {},
1648
+ )
1649
+ decision = lifecycle.run_before(ctx)
1650
+ if decision and not decision.proceed and decision.short_circuit_result is not None:
1651
+ return _build_operation_envelope(tool_name, action, params, decision.short_circuit_result, duration_ms=0)
1652
+
1591
1653
  t0 = time.perf_counter()
1592
1654
  try:
1593
1655
  result = fn(*args, **kwargs)
1594
1656
  except _MissingParam as exc:
1595
1657
  result = _missing_param_error(exc, action)
1658
+ except Exception as exc:
1659
+ duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1660
+ lifecycle.run_on_error(ctx, exc, duration_ms)
1661
+ raise
1596
1662
  duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1597
1663
  enveloped = _build_operation_envelope(
1598
1664
  tool_name, action, params, result, duration_ms=duration_ms)
1665
+ enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
1599
1666
  _record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
1600
1667
  return enveloped
1601
1668
 
@@ -16126,6 +16193,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16126
16193
  — Write a Markdown or JSON audit report for an execution trace (no connection needed).
16127
16194
  clear_executions(dry_run?) -> {success, cleared}
16128
16195
  — Clear the in-memory execution trace buffer.
16196
+ inspect_operation(tool?, target_action?, target_params?) -> {tool, action, risk, destructive, blast_radius, confirmation_required, snapshot_available, recognised, reasons, pre_state, pre_state_available}
16197
+ — Name-based heuristic, NOT a simulation: it never touches the project and does not validate params. recognised=false means no rule matched; snapshot_available=null means rollback was not determined, not that none exists.
16198
+ — Pre-flight risk assessment and blast radius inspection for any tool action before execution (no connection needed).
16199
+ list_lifecycle_hooks() -> {success, hooks, count}
16200
+ — List active agent tool execution lifecycle hooks and their enabled status (no connection needed).
16129
16201
  """
16130
16202
  p = _params(params)
16131
16203
 
@@ -16286,6 +16358,14 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16286
16358
  return {"success": True, "dry_run": True, "count": len(_execution_trace.list_recent_executions(100))}
16287
16359
  res = _execution_trace.clear_executions()
16288
16360
  return res
16361
+ if action == "inspect_operation":
16362
+ target_tool = p.get("tool") or p.get("tool_name") or "timeline"
16363
+ target_action = p.get("target_action") or p.get("action") or p.get("op") or "delete_clips"
16364
+ target_params = p.get("target_params") or p.get("params") or {}
16365
+ return _execution_lifecycle.inspect_operation(target_tool, target_action, target_params)
16366
+ if action == "list_lifecycle_hooks":
16367
+ hooks = _execution_lifecycle.list_lifecycle_hooks()
16368
+ return {"success": True, "hooks": hooks, "count": len(hooks)}
16289
16369
 
16290
16370
  # Control-panel actions don't require Resolve to be running.
16291
16371
  if action == "open_control_panel":
@@ -16501,7 +16581,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16501
16581
  if err:
16502
16582
  return _err(err)
16503
16583
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
16504
- return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16584
+ return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16505
16585
 
16506
16586
 
16507
16587
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -0,0 +1,496 @@
1
+ """Universal MCP Tool Execution Lifecycle and Hook Pipeline.
2
+
3
+ Provides a pluggable, server-wide lifecycle middleware for all compound MCP tools.
4
+ Every tool invocation (synchronous or asynchronous) passes through three distinct
5
+ lifecycle phases:
6
+
7
+ 1. Pre-flight (run_before):
8
+ - Risk classification and blast radius calculation (low -> critical)
9
+ - Resolve state inspection (pre-flight timeline duration, track count, project)
10
+ - Safe dry-run simulation interception for non-native dry-run actions
11
+ 2. Execution / Error tracking (run_on_error):
12
+ - Catches exceptions, records duration, informs error observers
13
+ 3. Post-flight (run_after):
14
+ - Readback verification and contradiction evaluation
15
+ - State drift detection (unintended timeline duration/structure shifts)
16
+ - Correlated execution trace aggregation
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import enum
22
+ import logging
23
+ import threading
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
26
+
27
+ logger = logging.getLogger("resolve-mcp.execution-lifecycle")
28
+
29
+
30
+ class RiskLevel(str, enum.Enum):
31
+ """Categorized risk level for tool operations."""
32
+ LOW = "low" # Read-only queries, info probes, status checks
33
+ MEDIUM = "medium" # Reversible edits, markers, non-destructive properties
34
+ HIGH = "high" # Deletions, ripples, timeline restructuring, batch edits
35
+ CRITICAL = "critical" # Project deletion, database resets, permanent loss
36
+
37
+
38
+ class BlastRadius(str, enum.Enum):
39
+ """Scope of impact when an operation executes."""
40
+ ITEM = "item" # Single clip, single marker, node
41
+ TRACK = "track" # Single track or stem
42
+ TIMELINE = "timeline" # Entire active timeline
43
+ PROJECT = "project" # Entire Resolve project or Media Pool
44
+ SYSTEM = "system" # System preferences, filesystem, host process
45
+
46
+
47
+ @dataclass
48
+ class RiskAssessment:
49
+ """Calculated risk evaluation for a tool action."""
50
+ level: RiskLevel = RiskLevel.LOW
51
+ destructive: bool = False
52
+ blast_radius: BlastRadius = BlastRadius.ITEM
53
+ confirmation_required: bool = False
54
+ #: None = not determined. The classifier reads action names; it does not
55
+ #: know whether timeline_versioning would archive a predecessor, and False
56
+ #: would assert "no rollback" as a finding it never made.
57
+ snapshot_available: Optional[bool] = None
58
+ #: False when no rule matched, i.e. the fields below are name-based
59
+ #: defaults rather than an assessment of this specific operation.
60
+ recognised: bool = True
61
+ reasons: List[str] = field(default_factory=list)
62
+
63
+ def to_dict(self) -> Dict[str, Any]:
64
+ return {
65
+ "level": self.level.value,
66
+ "destructive": self.destructive,
67
+ "blast_radius": self.blast_radius.value,
68
+ "confirmation_required": self.confirmation_required,
69
+ "snapshot_available": self.snapshot_available,
70
+ "recognised": self.recognised,
71
+ "reasons": list(self.reasons),
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class ToolCallContext:
77
+ """Contextual metadata describing an in-flight tool invocation."""
78
+ tool_name: str
79
+ action: str
80
+ params: Dict[str, Any] = field(default_factory=dict)
81
+ execution_id: Optional[str] = None
82
+ risk: RiskAssessment = field(default_factory=RiskAssessment)
83
+ pre_state: Optional[Dict[str, Any]] = None
84
+ post_state: Optional[Dict[str, Any]] = None
85
+ metadata: Dict[str, Any] = field(default_factory=dict)
86
+
87
+
88
+ @dataclass
89
+ class HookDecision:
90
+ """Decision returned by a pre-flight hook."""
91
+ proceed: bool = True
92
+ short_circuit_result: Optional[Dict[str, Any]] = None
93
+ reason: Optional[str] = None
94
+
95
+
96
+ class LifecycleHook:
97
+ """Base class for all MCP tool lifecycle hooks."""
98
+ name: str = "base_hook"
99
+ enabled: bool = True
100
+
101
+ def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
102
+ """Runs before tool invocation. Can short-circuit or modify context."""
103
+ return None
104
+
105
+ def after_tool_call(
106
+ self, ctx: ToolCallContext, result: Any, duration_ms: int
107
+ ) -> Optional[Dict[str, Any]]:
108
+ """Runs after successful tool execution. Can enrich result or emit telemetry."""
109
+ return None
110
+
111
+ def on_error(
112
+ self, ctx: ToolCallContext, exc: Exception, duration_ms: int
113
+ ) -> None:
114
+ """Runs when tool execution raises an unhandled exception."""
115
+ pass
116
+
117
+
118
+ # ─── Built-in Hook Implementations ──────────────────────────────────────────
119
+
120
+
121
+ class RiskClassificationHook(LifecycleHook):
122
+ """Evaluates tool + action + params to classify danger level and blast radius."""
123
+ name = "risk_classification"
124
+
125
+ _CRITICAL_ACTIONS: Set[Tuple[str, str]] = {
126
+ ("project_manager", "delete_project"),
127
+ ("project_manager", "close_project_without_saving"),
128
+ ("media_pool", "delete_timelines"),
129
+ ("media_pool", "delete_clips"),
130
+ }
131
+
132
+ _HIGH_RISK_ACTIONS: Set[Tuple[str, str]] = {
133
+ ("timeline", "delete_clips"),
134
+ ("timeline", "delete_clip_by_id"),
135
+ ("timeline", "delete_markers"),
136
+ ("timeline", "ripple_delete"),
137
+ ("timeline", "cut_clip"),
138
+ ("edit_engine", "execute_selects"),
139
+ ("edit_engine", "auto_cut_silence"),
140
+ ("edit_engine", "ripple_trim"),
141
+ ("project_manager", "save_project_as"),
142
+ }
143
+
144
+ _READ_ONLY_PREFIXES = ("get_", "list_", "query_", "probe_", "inspect_", "export_", "check_")
145
+
146
+ @classmethod
147
+ def classify(cls, tool_name: str, action: str, params: Dict[str, Any]) -> RiskAssessment:
148
+ reasons: List[str] = []
149
+ destructive = False
150
+ level = RiskLevel.LOW
151
+ radius = BlastRadius.ITEM
152
+ conf_required = False
153
+ recognised = True
154
+
155
+ pair = (tool_name, action)
156
+
157
+ if pair in cls._CRITICAL_ACTIONS:
158
+ level = RiskLevel.CRITICAL
159
+ destructive = True
160
+ radius = BlastRadius.PROJECT if "project" in tool_name else BlastRadius.TIMELINE
161
+ conf_required = True
162
+ reasons.append(f"Action '{action}' is permanently destructive across {radius.value}")
163
+ elif pair in cls._HIGH_RISK_ACTIONS or action.startswith("delete_") or action.startswith("remove_"):
164
+ level = RiskLevel.HIGH
165
+ destructive = True
166
+ if params.get("ripple", False):
167
+ radius = BlastRadius.TIMELINE
168
+ reasons.append("Ripple mode alters downstream timeline synchronization")
169
+ else:
170
+ radius = BlastRadius.ITEM
171
+ conf_required = True
172
+ reasons.append(f"Destructive timeline edit: {action}")
173
+ elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"}:
174
+ level = RiskLevel.LOW
175
+ destructive = False
176
+ radius = BlastRadius.ITEM
177
+ else:
178
+ # Everything the rules do not recognise. This is a heuristic over
179
+ # action NAMES, so "unrecognised" covers both a real action nobody
180
+ # listed and an action that does not exist — and it must not come
181
+ # back as a confident "medium, not destructive". The guard exists
182
+ # for hallucinated calls; answering one with reassurance is the
183
+ # failure it was built to prevent.
184
+ level = RiskLevel.MEDIUM
185
+ destructive = action.startswith("reset_") or action.startswith("clear_")
186
+ radius = BlastRadius.ITEM
187
+ recognised = False
188
+ reasons.append(
189
+ f"'{tool_name}.{action}' matches no risk rule. This assessment is a "
190
+ "name-based default, not a finding — treat the risk as unestablished "
191
+ "and check the tool's own documented behaviour before proceeding."
192
+ )
193
+
194
+ return RiskAssessment(
195
+ level=level,
196
+ destructive=destructive,
197
+ blast_radius=radius,
198
+ confirmation_required=conf_required,
199
+ snapshot_available=None,
200
+ reasons=reasons,
201
+ recognised=recognised,
202
+ )
203
+
204
+ def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
205
+ ctx.risk = self.classify(ctx.tool_name, ctx.action, ctx.params)
206
+ return None
207
+
208
+
209
+ class ResolveStateInspectionHook(LifecycleHook):
210
+ """Captures pre-flight Resolve project and timeline state non-blockingly."""
211
+ name = "resolve_state_inspection"
212
+
213
+ def __init__(self, state_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None):
214
+ self._state_provider = state_provider
215
+
216
+ def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
217
+ if self._state_provider is None:
218
+ return None
219
+ try:
220
+ state = self._state_provider()
221
+ if state:
222
+ ctx.pre_state = state
223
+ ctx.risk.snapshot_available = True
224
+ except Exception as exc:
225
+ logger.debug(f"Pre-flight state inspection skipped: {exc}")
226
+ return None
227
+
228
+
229
+ class ReadbackVerificationHook(LifecycleHook):
230
+ """Evaluates readback verification data attached to operation results."""
231
+ name = "readback_verification"
232
+
233
+ def after_tool_call(
234
+ self, ctx: ToolCallContext, result: Any, duration_ms: int
235
+ ) -> Optional[Dict[str, Any]]:
236
+ if not isinstance(result, dict):
237
+ return None
238
+
239
+ # Check if result carries a verification block
240
+ verif = result.get("verification")
241
+ if isinstance(verif, dict):
242
+ contradiction = verif.get("contradiction", False)
243
+ verified = verif.get("verified", False)
244
+ if contradiction:
245
+ logger.warning(
246
+ f"Readback contradiction detected on {ctx.tool_name}.{ctx.action}: {verif}"
247
+ )
248
+ return {
249
+ "readback_checked": True,
250
+ "verified": verified,
251
+ "contradiction": contradiction,
252
+ }
253
+
254
+ # Track unverified destructive operations
255
+ if ctx.risk.destructive and "verification" not in result:
256
+ return {
257
+ "readback_checked": False,
258
+ "status": "unverified",
259
+ "notice": f"Destructive operation {ctx.tool_name}.{ctx.action} completed without readback verification",
260
+ }
261
+ return None
262
+
263
+
264
+ class DriftDetectionHook(LifecycleHook):
265
+ """Detects unexpected timeline duration or track structure drift."""
266
+ name = "drift_detection"
267
+
268
+ _DURATION_ALTERING_ACTIONS = {
269
+ "ripple_delete", "ripple_insert", "auto_cut_silence", "execute_selects",
270
+ "delete_clips", "cut_clip", "delete_item", "ripple_trim"
271
+ }
272
+
273
+ def __init__(self, state_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None):
274
+ self._state_provider = state_provider
275
+
276
+ def after_tool_call(
277
+ self, ctx: ToolCallContext, result: Any, duration_ms: int
278
+ ) -> Optional[Dict[str, Any]]:
279
+ if not ctx.pre_state or self._state_provider is None:
280
+ return None
281
+
282
+ try:
283
+ post_state = self._state_provider()
284
+ if not post_state:
285
+ return None
286
+ ctx.post_state = post_state
287
+
288
+ pre_dur = ctx.pre_state.get("duration_frames")
289
+ post_dur = post_state.get("duration_frames")
290
+
291
+ # Check if duration changed on a non-duration-altering action
292
+ if (
293
+ pre_dur is not None
294
+ and post_dur is not None
295
+ and pre_dur != post_dur
296
+ and ctx.action not in self._DURATION_ALTERING_ACTIONS
297
+ ):
298
+ warning = (
299
+ f"Timeline duration drifted unexpectedly from {pre_dur} to {post_dur} frames "
300
+ f"during non-duration altering action '{ctx.action}'"
301
+ )
302
+ logger.warning(warning)
303
+ return {
304
+ "drift_detected": True,
305
+ "drift_warnings": [warning],
306
+ "duration_delta_frames": post_dur - pre_dur,
307
+ }
308
+ except Exception as exc:
309
+ logger.debug(f"Drift detection evaluation skipped: {exc}")
310
+ return None
311
+
312
+
313
+ class ProvenanceTraceHook(LifecycleHook):
314
+ """Correlates tool lifecycle events into the active execution trace."""
315
+ name = "provenance_trace"
316
+
317
+ def after_tool_call(
318
+ self, ctx: ToolCallContext, result: Any, duration_ms: int
319
+ ) -> Optional[Dict[str, Any]]:
320
+ return {
321
+ "execution_id": ctx.execution_id,
322
+ "duration_ms": duration_ms,
323
+ "risk_level": ctx.risk.level.value,
324
+ }
325
+
326
+
327
+ # ─── Pipeline Coordinator ───────────────────────────────────────────────────
328
+
329
+
330
+ class LifecyclePipeline:
331
+ """Thread-safe coordinator running registered lifecycle hooks."""
332
+
333
+ def __init__(self):
334
+ self._hooks: List[LifecycleHook] = []
335
+ self._lock = threading.RLock()
336
+ self._register_default_hooks()
337
+
338
+ def _register_default_hooks(self):
339
+ """The hooks that ship enabled. All of them OBSERVE; none short-circuit.
340
+
341
+ `HookDecision(proceed=False)` exists so a deliberately registered hook
342
+ can gate a call, and `register_hook` is public for that. Nothing
343
+ shipping uses it, on purpose: a hook that replaces a tool's result is
344
+ answering on behalf of code that never ran.
345
+
346
+ The original of this pipeline shipped a dry-run interceptor that did
347
+ exactly that — any `dry_run: true` call outside a four-entry allowlist
348
+ was short-circuited with a synthesised `success: true`. Against 273
349
+ `dry_run` references in `src/server.py` it hijacked actions with real
350
+ dry-run paths (`setup.set_defaults`, `resolve_control.clear_executions`),
351
+ and it answered `success: true` for an invalid enum value and for adding
352
+ a marker with no timeline in existence. `dry_run` is the one thing an
353
+ editor reaches for before a destructive edit; a version of it that
354
+ always succeeds is worse than none, because it is trusted.
355
+ `test_no_default_hook_short_circuits` keeps it that way.
356
+ """
357
+ self._hooks.append(RiskClassificationHook())
358
+ self._hooks.append(ResolveStateInspectionHook())
359
+ self._hooks.append(ReadbackVerificationHook())
360
+ self._hooks.append(DriftDetectionHook())
361
+ self._hooks.append(ProvenanceTraceHook())
362
+
363
+ def register_hook(self, hook: LifecycleHook) -> None:
364
+ with self._lock:
365
+ # Replace existing hook with same name if present
366
+ self._hooks = [h for h in self._hooks if h.name != hook.name]
367
+ self._hooks.append(hook)
368
+
369
+ def set_state_provider(self, provider: Callable[[], Optional[Dict[str, Any]]]) -> None:
370
+ """Configures state provider callable on inspection and drift hooks."""
371
+ with self._lock:
372
+ for hook in self._hooks:
373
+ if isinstance(hook, (ResolveStateInspectionHook, DriftDetectionHook)):
374
+ hook._state_provider = provider
375
+
376
+ def run_before(self, ctx: ToolCallContext) -> HookDecision:
377
+ with self._lock:
378
+ hooks = list(self._hooks)
379
+
380
+ for hook in hooks:
381
+ if not hook.enabled:
382
+ continue
383
+ try:
384
+ decision = hook.before_tool_call(ctx)
385
+ if decision and not decision.proceed:
386
+ return decision
387
+ except Exception as exc:
388
+ logger.error(f"Error in hook '{hook.name}.before_tool_call': {exc}", exc_info=True)
389
+ return HookDecision(proceed=True)
390
+
391
+ def run_after(self, ctx: ToolCallContext, result: Any, duration_ms: int) -> Any:
392
+ with self._lock:
393
+ hooks = list(self._hooks)
394
+
395
+ contributions: Dict[str, Any] = {}
396
+ for hook in hooks:
397
+ if not hook.enabled:
398
+ continue
399
+ try:
400
+ contrib = hook.after_tool_call(ctx, result, duration_ms)
401
+ if contrib and isinstance(contrib, dict):
402
+ contributions[hook.name] = contrib
403
+ except Exception as exc:
404
+ logger.error(f"Error in hook '{hook.name}.after_tool_call': {exc}", exc_info=True)
405
+
406
+ # Attach telemetry into operation envelope or result if it's a dict
407
+ if isinstance(result, dict) and contributions:
408
+ if "_operation" in result and isinstance(result["_operation"], dict):
409
+ result["_operation"].setdefault("lifecycle", {}).update(contributions)
410
+ return result
411
+
412
+ def run_on_error(self, ctx: ToolCallContext, exc: Exception, duration_ms: int) -> None:
413
+ with self._lock:
414
+ hooks = list(self._hooks)
415
+
416
+ for hook in hooks:
417
+ if not hook.enabled:
418
+ continue
419
+ try:
420
+ hook.on_error(ctx, exc, duration_ms)
421
+ except Exception as hook_exc:
422
+ logger.error(f"Error in hook '{hook.name}.on_error': {hook_exc}", exc_info=True)
423
+
424
+ def list_hooks(self) -> List[Dict[str, Any]]:
425
+ with self._lock:
426
+ return [
427
+ {
428
+ "name": h.name,
429
+ "enabled": h.enabled,
430
+ "class": h.__class__.__name__,
431
+ }
432
+ for h in self._hooks
433
+ ]
434
+
435
+ def inspect_operation(
436
+ self, tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
437
+ ) -> Dict[str, Any]:
438
+ """Inspects pre-flight risk, blast radius, and state before execution."""
439
+ p = params or {}
440
+ assessment = RiskClassificationHook.classify(tool_name, action, p)
441
+ pre_state = None
442
+ for hook in self._hooks:
443
+ if isinstance(hook, ResolveStateInspectionHook) and hook._state_provider:
444
+ try:
445
+ pre_state = hook._state_provider()
446
+ except Exception:
447
+ pass
448
+ break
449
+
450
+ return {
451
+ "success": True,
452
+ "tool": tool_name,
453
+ "action": action,
454
+ "risk": assessment.to_dict(),
455
+ "destructive": assessment.destructive,
456
+ "blast_radius": assessment.blast_radius.value,
457
+ "confirmation_required": assessment.confirmation_required,
458
+ # One value, from one place. This previously reported
459
+ # `assessment.snapshot_available or (pre_state is not None)` at the
460
+ # top level while `risk.snapshot_available` stayed False — the same
461
+ # response answering "can I roll this back?" both ways. Reading a
462
+ # project name is not a restorable snapshot, and the classifier
463
+ # never sets the flag, so the honest answer is "not determined".
464
+ "snapshot_available": assessment.snapshot_available,
465
+ "reasons": assessment.reasons,
466
+ "recognised": assessment.recognised,
467
+ "pre_state": pre_state,
468
+ # Whether pre_state reflects a live Resolve at all, so a caller can
469
+ # tell "no project open" from "never asked".
470
+ "pre_state_available": pre_state is not None,
471
+ }
472
+
473
+
474
+ # Global singleton pipeline
475
+ _GLOBAL_PIPELINE = LifecyclePipeline()
476
+
477
+
478
+ def get_lifecycle_pipeline() -> LifecyclePipeline:
479
+ return _GLOBAL_PIPELINE
480
+
481
+
482
+ def inspect_operation(
483
+ tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
484
+ ) -> Dict[str, Any]:
485
+ return _GLOBAL_PIPELINE.inspect_operation(tool_name, action, params)
486
+
487
+
488
+ def list_lifecycle_hooks() -> List[Dict[str, Any]]:
489
+ return _GLOBAL_PIPELINE.list_hooks()
490
+
491
+
492
+ def classify_operation_risk(
493
+ tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
494
+ ) -> RiskAssessment:
495
+ return RiskClassificationHook.classify(tool_name, action, params or {})
496
+