davinci-resolve-mcp 4.7.10 → 4.8.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 v4.8.0 — one read before planning: `project_manager snapshot`
6
+
7
+ ### Added
8
+
9
+ - **`project_manager(action="snapshot")`** — a read-only readout of the state an agent
10
+ inspects before it plans an edit. ([#251](https://github.com/samuelgursky/davinci-resolve-mcp/pull/251), @tpellet)
11
+ Returns `project`, `timeline` (per-track items), `gaps_overlaps`, `render`
12
+ (`is_rendering` plus each job's status) and `media_pool` counts in one call, instead
13
+ of `get_current` + `timeline.get_current` + `probe_timeline_structure` +
14
+ `detect_gaps_overlaps` + `render.is_rendering` one LLM turn at a time. The
15
+ motivation is measured: in 8,407 mined agent tool turns from one real project,
16
+ 2,222 were state inspection. It composes the existing helpers, so the values match
17
+ what those actions already return, and it never switches page, timeline or folder.
18
+ - `include` picks sections; an unknown name or an empty list is refused rather than
19
+ widened to everything. `item_limit` (default 200) caps the items returned across
20
+ tracks and sets `timeline.items_truncated`, while `item_count` and
21
+ `gaps_overlaps` still cover the whole timeline. A failing section reports
22
+ `{error}` in its own place; the others still return.
23
+ - It saves turns and response size, not read time: the timeline sections cost what
24
+ `probe_timeline_structure` costs and `media_pool` walks every pool clip, so on a
25
+ large project pass `include` with only the sections you need. `docs/SKILL.md` says
26
+ so.
27
+ - Tested against stubs only (16 tests, including parity with the actions it replaces
28
+ and a sixty-item readout kept under 16 KB); not yet run against a live Resolve.
29
+
30
+ ### Fixed
31
+
32
+ - **The risk classifier now recognises `project_manager.snapshot` as a LOW read.** Its
33
+ name carries no read verb, so on the contributed branch it fell to the name-based
34
+ MEDIUM default with a "risk unestablished" reason on every call — noise on the one
35
+ read an agent makes before planning. Safe mode would not have blocked it (only an
36
+ established HIGH/CRITICAL is), but a pure read should not carry that. Explicit
37
+ verbless reads now live in `RiskClassificationHook._READ_ONLY_PAIRS` beside
38
+ `dctl.validate_native`; guard test `tests/test_snapshot_read_rule.py` also pins that
39
+ the table does not widen into a wildcard.
40
+
41
+ ## What's New in v4.7.11 — `allow_partial_item_delete="false"` no longer lets a range delete take whole clips
42
+
43
+ ### Fixed
44
+
45
+ - **The flag that decides whether a range delete may take clips the range only
46
+ partially covers was read with bare truthiness.** ([#249](https://github.com/samuelgursky/davinci-resolve-mcp/pull/249), @Dev-next-gen)
47
+ `_timeline_lift_range_impl` collects every item the range touches and blocks the
48
+ partially covered ones unless the caller opted in — but `bool("false")` is `True`,
49
+ so a caller who sent `allow_partial_item_delete="false"` to protect exactly that
50
+ case had a clip spanning frames 0–48 deleted whole by `lift_range(0, 24)`, with a
51
+ `{"success": true, "deleted": 1}` response and no `blocked` list. `timeline
52
+ apply_cuts` read the same flag the same way and passed it to every cut. Both reads
53
+ now go through `coerce_bool`, the helper the `ripple`, `overwrite` and
54
+ `allow_non_mcp_name` fixes used; `apply_cuts` keeps its `True` default as the
55
+ helper's default argument, and `lift_range` still reads the `allowPartialItemDelete`
56
+ alias. Real booleans and the true spellings are unchanged. Guard test:
57
+ `tests/test_lift_range_allow_partial_string.py` — 5 of 7 fail on the previous code.
58
+ Left alone on purpose, as a design call: `apply_cuts` does not accept the camelCase
59
+ alias that `lift_range` does, and neither alias is documented.
60
+
5
61
  ## What's New in v4.7.10 — drop-frame sync events are reported at the timecode they happen
6
62
 
7
63
  ### 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-4.7.10-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.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-37%20(389%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.7.10-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.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-37%20(389%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.7.10 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.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
@@ -821,6 +821,19 @@ Key actions: `list`, `list_attributes`, `get_current`,
821
821
  `notes`, and `liveCollaborationMode` per project in the current folder without
822
822
  loading any of them.
823
823
 
824
+ `snapshot(include?, track_types?, item_limit?)` is the one read to make before
825
+ planning: `project`, `timeline` (per-track items), `gaps_overlaps`, `render`
826
+ (`is_rendering` plus each job's status) and `media_pool` counts in a single
827
+ read-only call, instead of `get_current` + `timeline.get_current` +
828
+ `probe_timeline_structure` + `detect_gaps_overlaps` + `render.is_rendering` one
829
+ turn at a time. `include` picks sections, `item_limit` (default 200) caps the
830
+ items returned and sets `timeline.items_truncated`, and a section that fails
831
+ reports `{error}` in its own place. It saves turns and response size, not read
832
+ time: the timeline sections cost what `probe_timeline_structure` costs and
833
+ `media_pool` walks every pool clip, so on a large project pass `include` with
834
+ only the sections you need. Frame fields are `probe_timeline_structure`'s,
835
+ unchanged.
836
+
824
837
  Project / Database / Archive kernel actions (v2.15.0+) add guarded project
825
838
  lifecycle, settings, database, preset, and archive boundary helpers:
826
839
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.7.10"
40
+ VERSION = "4.8.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": "4.7.10",
3
+ "version": "4.8.0",
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.7.10"
96
+ VERSION = "4.8.0"
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.7.10"
14
+ VERSION = "4.8.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -5590,7 +5590,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any], *, resolve=None):
5590
5590
  start, end, items, err = _collect_timeline_items_in_range(tl, p)
5591
5591
  if err:
5592
5592
  return err
5593
- allow_partial = bool(p.get("allow_partial_item_delete", p.get("allowPartialItemDelete", False)))
5593
+ allow_partial = _coerce_bool(p.get("allow_partial_item_delete", p.get("allowPartialItemDelete")))
5594
5594
  delete_items = []
5595
5595
  blocked = []
5596
5596
  for _, _, item, overlap_start, overlap_end in items:
@@ -19077,6 +19077,124 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
19077
19077
  return _ok(**_project_lint.lint_report(state))
19078
19078
 
19079
19079
 
19080
+ _SNAPSHOT_SECTIONS = ["project", "timeline", "gaps_overlaps", "render", "media_pool"]
19081
+
19082
+ # The per-item fields a planning read needs. probe_timeline_structure carries
19083
+ # the rest (file path, media status, seconds, clip properties) for a caller
19084
+ # that wants them; repeating them here is what makes a 60-item readout too big
19085
+ # to be the first call of every turn.
19086
+ _SNAPSHOT_ITEM_FIELDS = (
19087
+ "item_index", "timeline_item_id", "name", "start", "end",
19088
+ "source_start", "source_end", "source_fps", "media_pool_item_id",
19089
+ )
19090
+
19091
+
19092
+ def _project_state_snapshot(r, proj, p: Dict[str, Any]) -> Dict[str, Any]:
19093
+ """One read-only readout of the state an agent inspects before planning.
19094
+
19095
+ Composes the helpers behind project_summary, probe_timeline_structure,
19096
+ detect_gaps_overlaps and the render status actions, so the numbers match
19097
+ what those actions return. Each section fails on its own: an exception is
19098
+ reported as that section's {"error"}, never as a whole-call failure.
19099
+ """
19100
+ include = p.get("include")
19101
+ if include is None:
19102
+ include = list(_SNAPSHOT_SECTIONS)
19103
+ # An empty include is refused: falling back to every section would hand a
19104
+ # caller that filtered down to nothing the most expensive read instead.
19105
+ if not isinstance(include, list) or not include:
19106
+ return _err("include must be a non-empty list", category="invalid_input")
19107
+ unknown = [name for name in include if name not in _SNAPSHOT_SECTIONS]
19108
+ if unknown:
19109
+ return _err(
19110
+ f"Unknown snapshot section(s): {unknown}",
19111
+ code="UNKNOWN_SECTION", category="invalid_input",
19112
+ state={"unknown": unknown, "supported": list(_SNAPSHOT_SECTIONS)},
19113
+ )
19114
+ track_types = p.get("track_types")
19115
+ if track_types is not None and not isinstance(track_types, list):
19116
+ return _err("track_types must be a list", category="invalid_input")
19117
+ try:
19118
+ item_limit = int(p.get("item_limit", 200))
19119
+ except (TypeError, ValueError):
19120
+ return _err("item_limit must be an integer", category="invalid_input")
19121
+ if item_limit < 0:
19122
+ return _err("item_limit must be 0 or greater", category="invalid_input")
19123
+
19124
+ out: Dict[str, Any] = {}
19125
+
19126
+ if "project" in include:
19127
+ try:
19128
+ section = _project_object_summary(proj) or {}
19129
+ section["current_page"] = r.GetCurrentPage()
19130
+ section["timeline_count"] = proj.GetTimelineCount()
19131
+ section["settings"] = {
19132
+ key: _ser(proj.GetSetting(key))
19133
+ for key in ("timelineFrameRate", "timelineResolutionWidth", "timelineResolutionHeight")
19134
+ }
19135
+ out["project"] = section
19136
+ except Exception as exc:
19137
+ out["project"] = {"error": str(exc)}
19138
+
19139
+ if "timeline" in include or "gaps_overlaps" in include:
19140
+ wanted = [name for name in ("timeline", "gaps_overlaps") if name in include]
19141
+ try:
19142
+ tl = proj.GetCurrentTimeline()
19143
+ if not tl:
19144
+ for name in wanted:
19145
+ out[name] = {"available": False, "error": "No current timeline"}
19146
+ else:
19147
+ snapshot = _timeline_conform_snapshot(
19148
+ tl, {"track_types": track_types, "include_markers": False})
19149
+ # Gaps are measured on the whole snapshot, before item_limit
19150
+ # trims what is returned.
19151
+ gaps_overlaps = _detect_gaps_overlaps_from_snapshot(
19152
+ snapshot, {"track_types": track_types})
19153
+ if "timeline" in include:
19154
+ snapshot.pop("markers", None)
19155
+ try:
19156
+ snapshot["fps"] = float(tl.GetSetting("timelineFrameRate"))
19157
+ except Exception:
19158
+ snapshot["fps"] = None
19159
+ remaining = item_limit
19160
+ for type_payload in snapshot["tracks"].values():
19161
+ for track in type_payload["tracks"]:
19162
+ kept = track["items"][:remaining]
19163
+ remaining -= len(kept)
19164
+ track["items"] = [
19165
+ {field: item.get(field) for field in _SNAPSHOT_ITEM_FIELDS}
19166
+ for item in kept
19167
+ ]
19168
+ snapshot["items_returned"] = item_limit - remaining
19169
+ snapshot["items_truncated"] = snapshot["items_returned"] < snapshot["item_count"]
19170
+ out["timeline"] = snapshot
19171
+ if "gaps_overlaps" in include:
19172
+ out["gaps_overlaps"] = gaps_overlaps
19173
+ except Exception as exc:
19174
+ for name in wanted:
19175
+ out.setdefault(name, {"error": str(exc)})
19176
+
19177
+ if "render" in include:
19178
+ try:
19179
+ jobs = []
19180
+ for job in (proj.GetRenderJobList() or []):
19181
+ row = _ser(job)
19182
+ if isinstance(row, dict) and row.get("JobId"):
19183
+ row["status"] = _ser(proj.GetRenderJobStatus(row["JobId"]))
19184
+ jobs.append(row)
19185
+ out["render"] = {"is_rendering": bool(proj.IsRenderingInProgress()), "jobs": jobs}
19186
+ except Exception as exc:
19187
+ out["render"] = {"error": str(exc)}
19188
+
19189
+ if "media_pool" in include:
19190
+ try:
19191
+ out["media_pool"] = _project_summary(proj)["media_pool"]
19192
+ except Exception as exc:
19193
+ out["media_pool"] = {"error": str(exc)}
19194
+
19195
+ return out
19196
+
19197
+
19080
19198
  @mcp.tool()
19081
19199
  @_guard_missing_params
19082
19200
  @_destructive_op("project_manager")
@@ -19089,6 +19207,19 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19089
19207
  — Resolve 21.0.4+. Per-project attributes for the current folder without
19090
19208
  loading any project.
19091
19209
  get_current() -> {name, id}
19210
+ snapshot(include?, track_types?, item_limit?) -> {project, timeline, gaps_overlaps, render, media_pool}
19211
+ One read-only readout of what an agent inspects before planning; it never
19212
+ switches page, timeline or folder. include picks sections (default all);
19213
+ item_limit caps the items returned across tracks (default 200) and sets
19214
+ timeline.items_truncated, while item_count and gaps_overlaps still cover
19215
+ the whole timeline. item_limit bounds the response, not the read: the
19216
+ timeline sections cost what probe_timeline_structure costs (they scale
19217
+ with item_count) and media_pool walks every pool clip, so on a large
19218
+ project leave out the sections you do not need. A section that fails
19219
+ reports {error} in its own place; an empty include is refused. Frame
19220
+ fields are probe_timeline_structure's, unchanged: start/end
19221
+ are TimelineItem GetStart/GetEnd record frames; source_start/source_end
19222
+ are file-relative frames at source_fps, source_end exclusive.
19092
19223
  create(name, media_location_path?) -> {success, name}
19093
19224
  load(name) -> {success}
19094
19225
  save() -> {success}
@@ -19181,6 +19312,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19181
19312
  elif action == "get_current":
19182
19313
  proj = pm.GetCurrentProject()
19183
19314
  return {"name": proj.GetName(), "id": proj.GetUniqueId()} if proj else _err("No project open")
19315
+ elif action == "snapshot":
19316
+ proj = pm.GetCurrentProject()
19317
+ if not proj:
19318
+ return _err("No project open")
19319
+ return _project_state_snapshot(r, proj, p)
19184
19320
  elif action == "create":
19185
19321
  if not p.get("name"):
19186
19322
  return _err("create requires name")
@@ -19280,7 +19416,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19280
19416
  if not p.get("path"):
19281
19417
  return _err("restore requires path")
19282
19418
  return {"success": bool(pm.RestoreProject(p["path"], p.get("name")))}
19283
- return _unknown(action, ["list","list_attributes","get_current","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
19419
+ return _unknown(action, ["list","list_attributes","get_current","snapshot","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
19284
19420
 
19285
19421
 
19286
19422
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -25856,7 +25992,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
25856
25992
  if blocked:
25857
25993
  return blocked
25858
25994
 
25859
- allow_partial = bool(p.get("allow_partial_item_delete", True))
25995
+ allow_partial = _coerce_bool(p.get("allow_partial_item_delete"), True)
25860
25996
  results = []
25861
25997
  resolve_obj = get_resolve()
25862
25998
  # Hold the Edit page once for the whole run. The per-delete guard nests
@@ -378,6 +378,14 @@ class RiskClassificationHook(LifecycleHook):
378
378
  return BlastRadius.TIMELINE, "the timeline node graph (every clip on the timeline)"
379
379
 
380
380
  _READ_ONLY_PREFIXES = ("get_", "list_", "query_", "probe_", "inspect_", "export_", "check_")
381
+ #: Reads whose action name carries no read verb. Without an entry here a
382
+ #: pure read falls to the name-based MEDIUM default and every call reports
383
+ #: "risk unestablished" — noise on the one call an agent makes before
384
+ #: planning (project_manager.snapshot, #251).
385
+ _READ_ONLY_PAIRS = frozenset({
386
+ ("dctl", "validate_native"),
387
+ ("project_manager", "snapshot"),
388
+ })
381
389
 
382
390
  @classmethod
383
391
  def classify(cls, tool_name: str, action: str, params: Dict[str, Any]) -> RiskAssessment:
@@ -448,7 +456,7 @@ class RiskClassificationHook(LifecycleHook):
448
456
  else BlastRadius.ITEM
449
457
  )
450
458
  reasons.append(f"Recoverable edit to existing state: {action}")
451
- elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"} or pair == ("dctl", "validate_native"):
459
+ elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"} or pair in cls._READ_ONLY_PAIRS:
452
460
  level = RiskLevel.LOW
453
461
  destructive = False
454
462
  radius = BlastRadius.ITEM