davinci-resolve-mcp 2.24.0 → 2.25.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.
@@ -38,6 +38,35 @@ the user explicitly needs. A new request for edit advice is not automatically a
38
38
  reason to re-run visual analysis if the prior report already has current
39
39
  technical data, motion/keyframe evidence, and useful visual descriptions.
40
40
 
41
+ ## Pre-flight Coverage Check (required)
42
+
43
+ Before answering any cut, pacing, story-structure, or shot-selection question,
44
+ call `media_analysis(action="coverage_report")` against the target you're about
45
+ to reason about (timeline, bin, selection, or project). The action is a pure
46
+ read — it never triggers analysis.
47
+
48
+ The response includes an `evidence_base` string (e.g. `"evidence base: 38/40
49
+ clips analyzed (95%), 1 stale, 1 reuse-blocked."`) and a per-clip breakdown
50
+ covering layer presence, source-trust tier, staleness reasons, relink
51
+ supersedure, and a `recommended_action`.
52
+
53
+ **The response to the user must lead with the `evidence_base` line — before any
54
+ creative recommendation.** This mirrors the `pending_host_vision_analysis`
55
+ honesty pattern: do not silently answer from incomplete evidence. If coverage
56
+ is below the threshold the user's question depends on, surface that gap first
57
+ and offer to fill it (e.g. "3 of the clips on this timeline have no transcript;
58
+ analyzing them first will materially change my cut-point suggestions — run it
59
+ now?").
60
+
61
+ When the gap is small and the user is okay proceeding, still answer — but mark
62
+ the answer as evidence-thin so they can re-ask once gaps are filled. Do NOT
63
+ auto-trigger analysis to fix gaps unless the user explicitly asks.
64
+
65
+ Relink-superseded clips (`superseded_by_relink=true`) must never be reasoned
66
+ about as if the prior analysis still describes them. The prior report is
67
+ preserved for reference, but a re-analysis is required before the recommendation
68
+ is grounded.
69
+
41
70
  ## Frame Choice
42
71
 
43
72
  Find the decisive frame, not just the technically clean frame. Useful cut points
@@ -6,7 +6,7 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
6
6
 
7
7
  | Metric | Value |
8
8
  |--------|-------|
9
- | MCP Tools | **32** compound (default) / **329** granular |
9
+ | MCP Tools | **33** compound (default) / **329** granular |
10
10
  | Kernel Actions | **136** guarded MCP workflow actions across 9 compound tools |
11
11
  | API Methods Covered | **336/336** (100%) |
12
12
  | Methods Live Tested | **331/336** (98.5%) |
@@ -17,7 +17,13 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
17
17
 
18
18
  ## API Coverage
19
19
 
20
- Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **32 tools** that group related operations by action parameter, keeping LLM context windows lean. The full granular server provides **329 individual tools** for power users. Both modes cover all 13 API object classes. MCP-level kernel actions are tracked separately in [Kernel Action Coverage](../kernels/README.md).
20
+ Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **33 tools** that group related operations by action parameter, keeping LLM context windows lean. The full granular server provides **329 individual tools** for power users. Both modes cover all 13 API object classes. MCP-level kernel actions are tracked separately in [Kernel Action Coverage](../kernels/README.md).
21
+
22
+ The 33rd compound tool is `timeline_versioning` (C6) — an MCP-level workflow
23
+ tool, not a wrapper around a Resolve API method. It surfaces the
24
+ version-on-mutate hook that auto-archives the working timeline before any
25
+ destructive op, plus rollback and brain-edit history. See [SKILL.md](../SKILL.md)
26
+ for usage.
21
27
 
22
28
  Workflow helpers can go beyond one-to-one API method coverage while still using
23
29
  only public Resolve calls. For example, `media_pool.setup_multicam_timeline`
package/install.py CHANGED
@@ -21,6 +21,7 @@ import shutil
21
21
  import subprocess
22
22
  import sys
23
23
  import textwrap
24
+ import time
24
25
  from pathlib import Path
25
26
 
26
27
  from src.utils.update_check import (
@@ -34,7 +35,7 @@ from src.utils.update_check import (
34
35
 
35
36
  # ─── Version ──────────────────────────────────────────────────────────────────
36
37
 
37
- VERSION = "2.24.0"
38
+ VERSION = "2.25.0"
38
39
  SUPPORTED_PYTHON_MIN = (3, 10)
39
40
  SUPPORTED_PYTHON_MAX = (3, 12)
40
41
 
@@ -654,49 +655,315 @@ def _git_failure_message(result, fallback):
654
655
  return output or fallback
655
656
 
656
657
 
657
- def apply_safe_self_update(project_dir, dry_run=False):
658
- """Apply a guarded git fast-forward update for clean checkouts only."""
658
+ def _record_update_history(project_dir, entry):
659
+ """Append `entry` to `<project_dir>/logs/update_history.json` (best-effort)."""
660
+ import json as _json
661
+ log_dir = os.path.join(project_dir, "logs")
662
+ try:
663
+ os.makedirs(log_dir, exist_ok=True)
664
+ except OSError:
665
+ return
666
+ path = os.path.join(log_dir, "update_history.json")
667
+ history = {"entries": []}
668
+ if os.path.isfile(path):
669
+ try:
670
+ with open(path, "r", encoding="utf-8") as fh:
671
+ loaded = _json.load(fh)
672
+ if isinstance(loaded, dict) and isinstance(loaded.get("entries"), list):
673
+ history = loaded
674
+ except (OSError, ValueError):
675
+ pass
676
+ history.setdefault("entries", []).append(entry)
677
+ # Trim to most recent 200 entries — keep history bounded.
678
+ if len(history["entries"]) > 200:
679
+ history["entries"] = history["entries"][-200:]
680
+ history["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
681
+ tmp_path = path + ".tmp"
682
+ try:
683
+ with open(tmp_path, "w", encoding="utf-8") as fh:
684
+ _json.dump(history, fh, indent=2)
685
+ os.replace(tmp_path, path)
686
+ except OSError:
687
+ try:
688
+ os.remove(tmp_path)
689
+ except OSError:
690
+ pass
691
+
692
+
693
+ def _read_current_version(project_dir):
694
+ """Best-effort read of VERSION constant from src/server.py."""
695
+ server_path = os.path.join(project_dir, "src", "server.py")
696
+ try:
697
+ with open(server_path, "r", encoding="utf-8") as fh:
698
+ for line in fh:
699
+ if line.startswith("VERSION = "):
700
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
701
+ except OSError:
702
+ pass
703
+ return None
704
+
705
+
706
+ def _record_attempt(project_dir, *, kind, success, reason=None, message=None,
707
+ from_version=None, to_version=None, from_sha=None, to_sha=None,
708
+ initiator=None, extra=None):
709
+ """Append a structured row to update_history.json. `extra` is merged in flat."""
710
+ entry = {
711
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
712
+ "kind": kind, # "update" | "rollback" | "dry_run"
713
+ "success": bool(success),
714
+ "reason": reason,
715
+ "message": message,
716
+ "from_version": from_version,
717
+ "to_version": to_version,
718
+ "from_sha": from_sha,
719
+ "to_sha": to_sha,
720
+ "initiator": initiator,
721
+ }
722
+ if isinstance(extra, dict):
723
+ entry.update(extra)
724
+ _record_update_history(project_dir, entry)
725
+
726
+
727
+ def apply_safe_self_update(project_dir, dry_run=False, *, initiator="cli",
728
+ strategy="refuse_on_dirty"):
729
+ """Apply a guarded git fast-forward update.
730
+
731
+ `strategy` controls behavior when the working tree is dirty:
732
+ - `"refuse_on_dirty"` (default) — return reason="local_changes" without
733
+ touching anything.
734
+ - `"stash_if_needed"` — `git stash push`, apply the update, `git stash pop`.
735
+ If pop conflicts, leave the stash in place and return reason="stash_pop_conflict"
736
+ with the stash ref so the user can resolve.
737
+
738
+ Every attempt — success or failure — is recorded in
739
+ `<project_dir>/logs/update_history.json` so the dashboard can show what
740
+ happened and rollback can find the prior SHA.
741
+ """
659
742
  inside = _run_git(project_dir, ["rev-parse", "--is-inside-work-tree"])
660
743
  if inside is None or isinstance(inside, subprocess.TimeoutExpired) or inside.returncode != 0:
661
- return {"success": False, "reason": "not_git", "message": _git_failure_message(inside, "not a git checkout")}
744
+ msg = _git_failure_message(inside, "not a git checkout")
745
+ _record_attempt(project_dir, kind="update", success=False, reason="not_git", message=msg, initiator=initiator)
746
+ return {"success": False, "reason": "not_git", "message": msg}
662
747
 
663
748
  status = _run_git(project_dir, ["status", "--porcelain"])
664
749
  if status is None or isinstance(status, subprocess.TimeoutExpired) or status.returncode != 0:
665
- return {"success": False, "reason": "status_failed", "message": _git_failure_message(status, "could not inspect git status")}
750
+ msg = _git_failure_message(status, "could not inspect git status")
751
+ _record_attempt(project_dir, kind="update", success=False, reason="status_failed", message=msg, initiator=initiator)
752
+ return {"success": False, "reason": "status_failed", "message": msg}
753
+
754
+ stash_ref = None
666
755
  if status.stdout.strip():
667
- return {
668
- "success": False,
669
- "reason": "local_changes",
670
- "message": "local changes are present; continuing with the current build",
671
- }
756
+ if strategy != "stash_if_needed":
757
+ msg = "local changes are present; continuing with the current build"
758
+ _record_attempt(project_dir, kind="update", success=False, reason="local_changes", message=msg, initiator=initiator)
759
+ return {"success": False, "reason": "local_changes", "message": msg}
760
+ # Auto-stash path: push the working tree onto the stash, remember the
761
+ # ref so we can pop (or surface) it after the update.
762
+ stash_msg = f"mcp-update-autostash-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}"
763
+ stash = _run_git(project_dir, ["stash", "push", "-u", "-m", stash_msg], timeout=30)
764
+ if stash is None or stash.returncode != 0 or "No local changes to save" in (stash.stdout or ""):
765
+ # If we got here `status` was dirty, so a stash failure means trouble.
766
+ msg = _git_failure_message(stash, "git stash push failed")
767
+ _record_attempt(project_dir, kind="update", success=False, reason="stash_failed", message=msg, initiator=initiator)
768
+ return {"success": False, "reason": "stash_failed", "message": msg}
769
+ stash_ref = stash_msg
672
770
 
673
771
  upstream = _run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
674
772
  if upstream is None or isinstance(upstream, subprocess.TimeoutExpired) or upstream.returncode != 0:
675
- return {
676
- "success": False,
677
- "reason": "no_upstream",
678
- "message": _git_failure_message(upstream, "current branch has no configured upstream"),
679
- }
773
+ msg = _git_failure_message(upstream, "current branch has no configured upstream")
774
+ _record_attempt(project_dir, kind="update", success=False, reason="no_upstream", message=msg, initiator=initiator)
775
+ return {"success": False, "reason": "no_upstream", "message": msg}
776
+
777
+ # Capture pre-update SHA + version so rollback knows where to revert to.
778
+ head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
779
+ from_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
780
+ from_version = _read_current_version(project_dir)
680
781
 
681
782
  if dry_run:
682
- return {
683
- "success": True,
684
- "changed": False,
685
- "dry_run": True,
686
- "message": f"would fast-forward from {upstream.stdout.strip()}",
687
- }
783
+ msg = f"would fast-forward from {upstream.stdout.strip()}"
784
+ _record_attempt(project_dir, kind="dry_run", success=True, message=msg,
785
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
786
+ return {"success": True, "changed": False, "dry_run": True, "message": msg}
688
787
 
689
788
  fetch = _run_git(project_dir, ["fetch", "--tags", "--prune"], timeout=90)
690
789
  if fetch is None or isinstance(fetch, subprocess.TimeoutExpired) or fetch.returncode != 0:
691
- return {"success": False, "reason": "fetch_failed", "message": _git_failure_message(fetch, "git fetch failed")}
790
+ msg = _git_failure_message(fetch, "git fetch failed")
791
+ _record_attempt(project_dir, kind="update", success=False, reason="fetch_failed", message=msg,
792
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
793
+ return {"success": False, "reason": "fetch_failed", "message": msg}
692
794
 
693
795
  pull = _run_git(project_dir, ["pull", "--ff-only"], timeout=120)
694
796
  if pull is None or isinstance(pull, subprocess.TimeoutExpired) or pull.returncode != 0:
695
- return {"success": False, "reason": "pull_failed", "message": _git_failure_message(pull, "git pull --ff-only failed")}
797
+ msg = _git_failure_message(pull, "git pull --ff-only failed")
798
+ _record_attempt(project_dir, kind="update", success=False, reason="pull_failed", message=msg,
799
+ from_version=from_version, from_sha=from_sha, initiator=initiator)
800
+ return {"success": False, "reason": "pull_failed", "message": msg}
801
+
802
+ head_after = _run_git(project_dir, ["rev-parse", "HEAD"])
803
+ to_sha = head_after.stdout.strip() if head_after and head_after.returncode == 0 else None
804
+ to_version = _read_current_version(project_dir)
696
805
 
697
806
  output = "\n".join(part.strip() for part in (pull.stdout, pull.stderr) if part and part.strip())
698
807
  changed = "Already up to date." not in output
699
- return {"success": True, "changed": changed, "message": output or "update complete"}
808
+
809
+ # Integrity verification: confirm our local HEAD matches what GitHub said
810
+ # was the target SHA. Mismatch could mean: (a) user pushed to a fork mid-
811
+ # update, (b) the release we resolved was different from the branch tip
812
+ # (force-pushed), (c) corruption. We don't roll back automatically — the
813
+ # user may have intentional reasons — but we log it loudly.
814
+ integrity = {"verified": None, "expected_sha": None, "actual_sha": to_sha}
815
+ try:
816
+ from src.utils.update_check import check_for_updates as _cfu # type: ignore
817
+ info = _cfu(from_version or "0.0.0", project_dir, force=False)
818
+ expected = info.get("release_target_sha")
819
+ if expected:
820
+ integrity["expected_sha"] = expected
821
+ # Compare prefixes (release SHA may be a tag name or short SHA).
822
+ ok = bool(to_sha) and (
823
+ expected == to_sha
824
+ or (len(expected) >= 7 and to_sha.startswith(expected[:7]))
825
+ or (len(to_sha) >= 7 and expected.startswith(to_sha[:7]))
826
+ )
827
+ integrity["verified"] = ok
828
+ except Exception as exc:
829
+ integrity["error"] = f"{type(exc).__name__}: {exc}"
830
+
831
+ # If we stashed changes earlier, try to reapply them now.
832
+ stash_pop_conflict = False
833
+ if stash_ref:
834
+ pop = _run_git(project_dir, ["stash", "pop"], timeout=60)
835
+ if pop is None or pop.returncode != 0:
836
+ stash_pop_conflict = True
837
+
838
+ result = {
839
+ "success": True, "changed": changed,
840
+ "message": output or "update complete",
841
+ "from_version": from_version, "to_version": to_version,
842
+ "from_sha": from_sha, "to_sha": to_sha,
843
+ "stash_ref": stash_ref,
844
+ "stash_pop_conflict": stash_pop_conflict,
845
+ "integrity": integrity,
846
+ }
847
+ if stash_pop_conflict:
848
+ # Update applied successfully but the stash pop hit a conflict; the
849
+ # user's changes are still in the stash. Don't fail the overall update,
850
+ # but surface the conflict prominently.
851
+ result["reason"] = "stash_pop_conflict"
852
+ result["remediation"] = (
853
+ f"Update applied, but your stashed changes ({stash_ref}) conflict with the new build. "
854
+ "Resolve via `git stash list` + `git stash pop` after restarting; "
855
+ "use `git stash drop` if you want to discard them."
856
+ )
857
+
858
+ _record_attempt(project_dir, kind="update", success=True,
859
+ message=output or "update complete",
860
+ from_version=from_version, to_version=to_version,
861
+ from_sha=from_sha, to_sha=to_sha, initiator=initiator,
862
+ extra={"stash_ref": stash_ref,
863
+ "stash_pop_conflict": stash_pop_conflict,
864
+ "integrity": integrity})
865
+
866
+ return result
867
+
868
+
869
+ def rollback_to_previous_build(project_dir, *, initiator="cli"):
870
+ """git reset --hard to the from_sha of the most recent successful update.
871
+
872
+ Refuses if local changes exist (same guard as apply_safe_self_update) so we
873
+ never silently lose user work. Records a rollback row in update_history.
874
+ """
875
+ import json as _json
876
+ history_path = os.path.join(project_dir, "logs", "update_history.json")
877
+ if not os.path.isfile(history_path):
878
+ return {"success": False, "reason": "no_history", "message": "no update_history.json found"}
879
+ try:
880
+ with open(history_path, "r", encoding="utf-8") as fh:
881
+ history = _json.load(fh)
882
+ except (OSError, _json.JSONDecodeError) as exc:
883
+ return {"success": False, "reason": "history_read_failed", "message": str(exc)}
884
+
885
+ # Newest first; find the latest successful "update" entry with a from_sha.
886
+ candidates = [e for e in reversed(history.get("entries") or [])
887
+ if e.get("kind") == "update" and e.get("success") and e.get("from_sha")]
888
+ if not candidates:
889
+ return {"success": False, "reason": "no_target", "message": "no prior successful update to roll back to"}
890
+ target = candidates[0]
891
+ from_sha = target["from_sha"]
892
+
893
+ status = _run_git(project_dir, ["status", "--porcelain"])
894
+ if status is None or status.returncode != 0:
895
+ return {"success": False, "reason": "status_failed", "message": "could not inspect git status"}
896
+ if status.stdout.strip():
897
+ return {"success": False, "reason": "local_changes",
898
+ "message": "local changes are present; commit or stash before rolling back"}
899
+
900
+ head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
901
+ pre_rollback_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
902
+ pre_rollback_version = _read_current_version(project_dir)
903
+
904
+ reset = _run_git(project_dir, ["reset", "--hard", from_sha], timeout=60)
905
+ if reset is None or reset.returncode != 0:
906
+ msg = _git_failure_message(reset, f"git reset --hard {from_sha} failed")
907
+ _record_attempt(project_dir, kind="rollback", success=False, reason="reset_failed", message=msg,
908
+ from_version=pre_rollback_version, from_sha=pre_rollback_sha,
909
+ to_sha=from_sha, initiator=initiator)
910
+ return {"success": False, "reason": "reset_failed", "message": msg}
911
+
912
+ to_version = _read_current_version(project_dir)
913
+ _record_attempt(project_dir, kind="rollback", success=True,
914
+ message=f"rolled back to {from_sha[:10]}",
915
+ from_version=pre_rollback_version, to_version=to_version,
916
+ from_sha=pre_rollback_sha, to_sha=from_sha, initiator=initiator)
917
+ return {"success": True, "changed": pre_rollback_sha != from_sha,
918
+ "message": f"rolled back to {from_sha[:10]}",
919
+ "from_version": pre_rollback_version, "to_version": to_version,
920
+ "from_sha": pre_rollback_sha, "to_sha": from_sha}
921
+
922
+
923
+ def preview_update(project_dir):
924
+ """Fetch the target release metadata + scan for breaking-change markers.
925
+
926
+ Returns a dict the UI can render before the user confirms the update:
927
+ {success, latest_version, release_notes, breaking_changes, prerelease,
928
+ channel, current_version, target_sha}
929
+ """
930
+ try:
931
+ from src.utils.update_check import check_for_updates # type: ignore
932
+ except Exception as exc:
933
+ return {"success": False, "error": f"update_check unavailable: {exc}"}
934
+ current = _read_current_version(project_dir) or "0.0.0"
935
+ try:
936
+ result = check_for_updates(current, project_dir, force=True)
937
+ except Exception as exc:
938
+ return {"success": False, "error": str(exc)}
939
+ return {
940
+ "success": True,
941
+ "current_version": current,
942
+ "latest_version": result.get("latest_version"),
943
+ "release_notes": result.get("release_notes") or "",
944
+ "breaking_changes": result.get("release_notes_breaking") or [],
945
+ "prerelease": bool(result.get("prerelease")),
946
+ "channel": result.get("channel"),
947
+ "target_sha": result.get("release_target_sha"),
948
+ "release_url": result.get("release_url"),
949
+ "status": result.get("status"),
950
+ }
951
+
952
+
953
+ def read_update_history(project_dir, limit=20):
954
+ """Most-recent-first list of recorded update attempts. For the dashboard."""
955
+ import json as _json
956
+ path = os.path.join(project_dir, "logs", "update_history.json")
957
+ if not os.path.isfile(path):
958
+ return {"success": True, "entries": []}
959
+ try:
960
+ with open(path, "r", encoding="utf-8") as fh:
961
+ history = _json.load(fh)
962
+ except (OSError, _json.JSONDecodeError) as exc:
963
+ return {"success": False, "error": str(exc), "entries": []}
964
+ entries = (history.get("entries") or [])[-int(limit):]
965
+ entries.reverse()
966
+ return {"success": True, "entries": entries}
700
967
 
701
968
 
702
969
  def _restart_installer():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.24.0",
3
+ "version": "2.25.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",