davinci-resolve-mcp 2.33.5 → 2.33.7

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,34 @@
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.33.7
6
+
7
+ Read/write symmetry audit and a gap it surfaced.
8
+
9
+ - **Added** `scripts/audit_readwrite_symmetry.py` + generated
10
+ `docs/reference/readwrite-symmetry.md` — scans every tool's action surface and
11
+ reports `set_`/`add_`/`create_` writes that lack a read counterpart, so
12
+ write-without-read gaps surface before users hit them. A repeatable
13
+ feature-discovery method.
14
+ - **Added** `fusion_comp(action="get_frame_range")` — reads the comp's render
15
+ frame range, the read counterpart to `set_frame_range` (a gap the audit found).
16
+
17
+ ## What's New in v2.33.6
18
+
19
+ Internal consolidation: a declarative parameter-contract validator and centralized
20
+ subprocess hygiene.
21
+
22
+ - **Added** `src/utils/contracts.py` `validate(params, rules, invariants)` — one
23
+ validator for required/type/enum/min/max/non-empty/parent-dir-exists plus custom
24
+ invariants, returning consistent agent-friendly errors with coercion + defaults
25
+ applied. Replaces scattered, hand-written validation.
26
+ - **Changed** `export_frame_as_still` and `set_mark_in_out` (clip + timeline) now
27
+ validate through contracts. Behavior is preserved (same rejections); `mark_in`/
28
+ `mark_out` are now coerced to int.
29
+ - **Added** `src/utils/proc.py` `safe_run`/`safe_popen` — subprocess wrappers that
30
+ default `stdin` to DEVNULL so a child can't consume the MCP stdio protocol
31
+ stream. Inline Python execution now routes through `safe_run`.
32
+
5
33
  ## What's New in v2.33.5
6
34
 
7
35
  A queryable ledger of verified Resolve API behavior.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.33.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.33.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -997,7 +997,7 @@ Key actions:
997
997
  temp `.setting` file), optionally renaming and repositioning it
998
998
  - `auto_arrange(tool_names?, direction?, spacing?, x?, y?)` — lay tools out in a row
999
999
  (`direction="horizontal"`, default) or column (`"vertical"`)
1000
- - `get_comp_info`, `set_frame_range(start, end)`, `render`
1000
+ - `get_comp_info`, `set_frame_range(start, end)`, `get_frame_range`, `render`
1001
1001
  - `start_undo(name?)` / `end_undo(keep?)`
1002
1002
  - `bulk_set_inputs(ops)` — batch set inputs across multiple timeline item comps in
1003
1003
  one call; each op requires timeline scope plus `tool_name`, `input_name`, `value`
@@ -0,0 +1,25 @@
1
+ <!-- Generated by scripts/audit_readwrite_symmetry.py — do not edit by hand. -->
2
+
3
+ # Read/Write Symmetry Audit
4
+
5
+ - write-style actions scanned: **106**
6
+ - with a matching read: **57**
7
+ - `set_` actions with no `get_`/`list_` (real readback gaps): **11**
8
+
9
+ ## High-signal gaps — `set_` with no read counterpart
10
+
11
+ - `set_cache`
12
+ - `set_cdl`
13
+ - `set_clips_linked`
14
+ - `set_high_priority`
15
+ - `set_keyframe_interpolation`
16
+ - `set_mcp_update_policy`
17
+ - `set_name`
18
+ - `set_node_enabled`
19
+ - `set_title_text`
20
+ - `set_track_enable`
21
+ - `set_track_lock`
22
+
23
+ ## Low-signal (create/add/insert/import — usually expected): 37
24
+
25
+ `add_clip_mattes`, `add_comp`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_burnin`, `import_comp`, `import_folder`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.33.5"
38
+ VERSION = "2.33.7"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (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.33.5",
3
+ "version": "2.33.7",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Audit read/write symmetry across the compound server's action surface.
3
+
4
+ For every mutating action (set_/add_/clear_/enable_/...) it checks whether a
5
+ matching read action (get_/list_/...) exists on the same tool, and reports the
6
+ asymmetries. The goal is to find write-without-read gaps before users have to —
7
+ the repeatable feature-discovery method behind R5.
8
+
9
+ Reads the `_unknown(action, [...])` lists in src/server.py, which enumerate every
10
+ action a tool accepts. Prints a markdown report.
11
+ """
12
+ import ast
13
+ import os
14
+ import re
15
+ import sys
16
+
17
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ SERVER = os.path.join(ROOT, "src", "server.py")
19
+
20
+ READ_PREFIXES = ("get_", "list_", "probe_", "is_", "has_", "find_")
21
+ # `set_` is the high-signal class: a set with no get is a genuine readback gap.
22
+ # create_/add_/insert_/import_ are inherently writes that usually have no paired
23
+ # read of the same noun, so they're reported separately as low-signal.
24
+ HIGH_SIGNAL = ("set_",)
25
+ LOW_SIGNAL = ("add_", "create_", "insert_", "apply_", "import_")
26
+
27
+
28
+ def _action_lists(src: str):
29
+ """Yield lists of action-name string literals from each _unknown(action, [...])."""
30
+ for m in re.finditer(r"_unknown\(action,\s*\[(.*?)\]\)", src, re.DOTALL):
31
+ names = re.findall(r'"([a-z][a-z0-9_]*)"', m.group(1))
32
+ if names:
33
+ yield names
34
+
35
+
36
+ def _has_read(stem: str, aset: set) -> bool:
37
+ # Match get_<stem>, list_<stem>, and plural get_<stem>s (e.g. add_keyframe -> get_keyframes).
38
+ candidates = {rp + stem for rp in READ_PREFIXES}
39
+ candidates |= {rp + stem + "s" for rp in READ_PREFIXES}
40
+ candidates |= {rp + stem.rstrip("s") for rp in READ_PREFIXES}
41
+ return bool(candidates & aset)
42
+
43
+
44
+ def audit(src: str):
45
+ high, low, covered, total = set(), set(), 0, 0
46
+ for actions in _action_lists(src):
47
+ aset = set(actions)
48
+ for a in actions:
49
+ for wp in HIGH_SIGNAL + LOW_SIGNAL:
50
+ if a.startswith(wp):
51
+ total += 1
52
+ stem = a[len(wp):]
53
+ if _has_read(stem, aset):
54
+ covered += 1
55
+ elif wp in HIGH_SIGNAL:
56
+ high.add(a)
57
+ else:
58
+ low.add(a)
59
+ break
60
+ return total, covered, sorted(high), sorted(low)
61
+
62
+
63
+ def main():
64
+ src = open(SERVER, encoding="utf-8").read()
65
+ total, covered, high, low = audit(src)
66
+ print("# Read/Write Symmetry Audit\n")
67
+ print(f"- write-style actions scanned: **{total}**")
68
+ print(f"- with a matching read: **{covered}**")
69
+ print(f"- `set_` actions with no `get_`/`list_` (real readback gaps): **{len(high)}**\n")
70
+ if high:
71
+ print("## High-signal gaps — `set_` with no read counterpart\n")
72
+ for a in high:
73
+ print(f"- `{a}`")
74
+ print(f"\n## Low-signal (create/add/insert/import — usually expected): {len(low)}\n")
75
+ print(", ".join(f"`{a}`" for a in low))
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.33.5"
83
+ VERSION = "2.33.7"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  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 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.33.5"
14
+ VERSION = "2.33.7"
15
15
 
16
16
  import base64
17
17
  import os
@@ -44,6 +44,8 @@ for p in [current_dir, project_dir]:
44
44
  from src.utils.cdl import normalize_cdl_payload
45
45
  from src.utils.mcp_stdio import run_fastmcp_stdio
46
46
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
47
+ from src.utils.contracts import validate as _validate_params
48
+ from src.utils.proc import safe_run
47
49
  from src.utils.readback import verify_by_readback
48
50
  from src.utils.update_check import (
49
51
  check_for_updates,
@@ -12626,13 +12628,12 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12626
12628
  g = proj.GetGallery()
12627
12629
  return {"available": g is not None}
12628
12630
  elif action == "export_frame_as_still":
12629
- still_path = p.get("path")
12630
- if not still_path:
12631
- return _err("export_frame_as_still requires a non-empty 'path'")
12632
- parent = os.path.dirname(os.path.expanduser(still_path))
12633
- if parent and not os.path.isdir(parent):
12634
- return _err(f"export_frame_as_still target directory does not exist: {parent}")
12635
- return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
12631
+ err, clean = _validate_params(p, {
12632
+ "path": {"type": str, "required": True, "non_empty": True, "parent_dir_exists": True},
12633
+ })
12634
+ if err:
12635
+ return _err(err)
12636
+ return {"success": bool(proj.ExportCurrentFrameAsStill(clean["path"]))}
12636
12637
  elif action == "project_summary":
12637
12638
  return _project_summary(
12638
12639
  proj,
@@ -14079,9 +14080,15 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14079
14080
  elif action == "get_mark_in_out":
14080
14081
  return _ser(clip.GetMarkInOut())
14081
14082
  elif action == "set_mark_in_out":
14082
- if p["mark_in"] > p["mark_out"]:
14083
- return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
14084
- return {"success": bool(clip.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
14083
+ err, clean = _validate_params(
14084
+ p,
14085
+ {"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}},
14086
+ invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})"
14087
+ if c["mark_in"] > c["mark_out"] else None],
14088
+ )
14089
+ if err:
14090
+ return _err(err)
14091
+ return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
14085
14092
  elif action == "clear_mark_in_out":
14086
14093
  return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
14087
14094
  return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
@@ -15485,9 +15492,15 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15485
15492
  elif action == "get_mark_in_out":
15486
15493
  return _ser(tl.GetMarkInOut())
15487
15494
  elif action == "set_mark_in_out":
15488
- if p["mark_in"] > p["mark_out"]:
15489
- return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
15490
- return {"success": bool(tl.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
15495
+ err, clean = _validate_params(
15496
+ p,
15497
+ {"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}},
15498
+ invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})"
15499
+ if c["mark_in"] > c["mark_out"] else None],
15500
+ )
15501
+ if err:
15502
+ return _err(err)
15503
+ return {"success": bool(tl.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
15491
15504
  elif action == "clear_mark_in_out":
15492
15505
  return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
15493
15506
  elif action == "convert_to_stereo":
@@ -18717,6 +18730,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18717
18730
  auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count}
18718
18731
  Lay tools out in a row (direction="horizontal", default) or column ("vertical").
18719
18732
  set_frame_range(start, end) -> {success}
18733
+ get_frame_range() -> {start, end} — read the comp's render frame range
18720
18734
  render() -> {success}
18721
18735
  start_undo(name?) -> {success}
18722
18736
  end_undo(keep?) -> {success}
@@ -19008,6 +19022,13 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19008
19022
  comp.SetAttrs({"COMPN_RenderStartTime": p["start"], "COMPN_RenderEndTime": p["end"]})
19009
19023
  return _ok()
19010
19024
 
19025
+ elif action == "get_frame_range":
19026
+ attrs = comp.GetAttrs() or {}
19027
+ return {
19028
+ "start": attrs.get("COMPN_RenderStartTime"),
19029
+ "end": attrs.get("COMPN_RenderEndTime"),
19030
+ }
19031
+
19011
19032
  elif action == "render":
19012
19033
  comp.Render()
19013
19034
  return _ok()
@@ -19146,7 +19167,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19146
19167
  "connect","disconnect","get_inputs","get_outputs",
19147
19168
  "set_input","get_input","set_attrs","get_attrs",
19148
19169
  "add_keyframe","get_keyframes","delete_keyframe",
19149
- "get_comp_info","set_frame_range","render",
19170
+ "get_comp_info","set_frame_range","get_frame_range","render",
19150
19171
  "start_undo","end_undo",
19151
19172
  "get_position","set_position","copy_tool","auto_arrange",
19152
19173
  "bulk_set_inputs",
@@ -19757,9 +19778,8 @@ def _execute_python_script(path: str, args: List[str],
19757
19778
  get_resolve()
19758
19779
  cmd = [sys.executable, path] + [str(a) for a in args]
19759
19780
  try:
19760
- result = subprocess.run(cmd, env=_python_env_for_resolve(),
19761
- capture_output=True, text=True,
19762
- timeout=timeout, stdin=subprocess.DEVNULL)
19781
+ result = safe_run(cmd, env=_python_env_for_resolve(),
19782
+ capture_output=True, text=True, timeout=timeout)
19763
19783
  except subprocess.TimeoutExpired as e:
19764
19784
  return _err(f"Script timed out after {timeout}s. "
19765
19785
  f"Partial stdout: {(e.stdout or '')[:1000]}")
@@ -0,0 +1,102 @@
1
+ """Declarative parameter validation for compound-tool actions.
2
+
3
+ Input validation in the server has historically been scattered: hand-built
4
+ "Must be one of" strings, ad-hoc empty/range/path checks inline in each action.
5
+ This module centralizes it into one declarative validator that emits consistent,
6
+ agent-friendly error messages, so the validation bug class is closed once rather
7
+ than patched action by action.
8
+
9
+ Usage:
10
+
11
+ err, clean = validate(params, {
12
+ "path": {"type": str, "required": True, "non_empty": True, "parent_dir_exists": True},
13
+ "mark_in": {"type": int, "required": True},
14
+ "mark_out": {"type": int, "required": True},
15
+ "kind": {"enum": ["all", "video", "audio"], "default": "all"},
16
+ }, invariants=[
17
+ lambda c: "mark_in must be <= mark_out" if c["mark_in"] > c["mark_out"] else None,
18
+ ])
19
+ if err:
20
+ return _err(err)
21
+ # use clean[...] — coerced + defaulted
22
+
23
+ Rule keys: ``type`` (int/str/float/bool), ``required``, ``default``, ``enum``,
24
+ ``min``, ``max``, ``non_empty`` (str), ``parent_dir_exists`` (str path).
25
+ """
26
+ import os
27
+ from typing import Any, Callable, Dict, List, Optional, Tuple
28
+
29
+
30
+ def _coerce(name: str, value: Any, t: type) -> Tuple[Any, Optional[str]]:
31
+ if t is bool:
32
+ if isinstance(value, bool):
33
+ return value, None
34
+ if isinstance(value, str):
35
+ return value.strip().lower() in ("1", "true", "yes", "on"), None
36
+ return bool(value), None
37
+ if t is int:
38
+ try:
39
+ return int(value), None
40
+ except (TypeError, ValueError):
41
+ return None, f"'{name}' must be an integer"
42
+ if t is float:
43
+ try:
44
+ return float(value), None
45
+ except (TypeError, ValueError):
46
+ return None, f"'{name}' must be a number"
47
+ if t is str:
48
+ if not isinstance(value, str):
49
+ return None, f"'{name}' must be a string"
50
+ return value, None
51
+ return value, None
52
+
53
+
54
+ def validate(
55
+ params: Dict[str, Any],
56
+ rules: Dict[str, Dict[str, Any]],
57
+ invariants: Optional[List[Callable[[Dict[str, Any]], Optional[str]]]] = None,
58
+ ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
59
+ """Validate ``params`` against ``rules``.
60
+
61
+ Returns ``(None, cleaned)`` on success (cleaned has coercions + defaults
62
+ applied), or ``(error_message, None)`` on the first violation.
63
+ """
64
+ cleaned = dict(params)
65
+
66
+ for name, rule in rules.items():
67
+ present = name in params and params[name] is not None
68
+ if not present:
69
+ if rule.get("required"):
70
+ return f"'{name}' is required", None
71
+ if "default" in rule:
72
+ cleaned[name] = rule["default"]
73
+ continue
74
+
75
+ val = params[name]
76
+ t = rule.get("type")
77
+ if t is not None:
78
+ val, err = _coerce(name, val, t)
79
+ if err:
80
+ return err, None
81
+
82
+ if "enum" in rule and val not in rule["enum"]:
83
+ return f"'{name}' must be one of: {', '.join(map(str, rule['enum']))}", None
84
+ if rule.get("non_empty") and isinstance(val, str) and not val.strip():
85
+ return f"'{name}' must be non-empty", None
86
+ if "min" in rule and val < rule["min"]:
87
+ return f"'{name}' must be >= {rule['min']}", None
88
+ if "max" in rule and val > rule["max"]:
89
+ return f"'{name}' must be <= {rule['max']}", None
90
+ if rule.get("parent_dir_exists"):
91
+ parent = os.path.dirname(os.path.expanduser(str(val)))
92
+ if parent and not os.path.isdir(parent):
93
+ return f"'{name}' target directory does not exist: {parent}", None
94
+
95
+ cleaned[name] = val
96
+
97
+ for inv in (invariants or []):
98
+ msg = inv(cleaned)
99
+ if msg:
100
+ return msg, None
101
+
102
+ return None, cleaned
@@ -0,0 +1,26 @@
1
+ """Subprocess helpers that are safe to call while the MCP stdio server is live.
2
+
3
+ The server owns the JSON-RPC stdin/stdout while serving over stdio. A child
4
+ process that inherits stdin can race-read bytes off the protocol stream and
5
+ corrupt it; ``capture_output`` only redirects stdout/stderr. These wrappers
6
+ default ``stdin`` to ``DEVNULL`` so subprocess hygiene is centralized rather
7
+ than re-applied at every call site.
8
+ """
9
+ import subprocess
10
+ from typing import Any
11
+
12
+
13
+ def safe_run(*args: Any, **kwargs: Any) -> "subprocess.CompletedProcess":
14
+ """subprocess.run with stdin defaulted to DEVNULL (override by passing stdin).
15
+
16
+ If ``input`` is given, stdin is left alone — subprocess forbids passing both.
17
+ """
18
+ if "input" not in kwargs:
19
+ kwargs.setdefault("stdin", subprocess.DEVNULL)
20
+ return subprocess.run(*args, **kwargs)
21
+
22
+
23
+ def safe_popen(*args: Any, **kwargs: Any) -> "subprocess.Popen":
24
+ """subprocess.Popen with stdin defaulted to DEVNULL (override by passing stdin)."""
25
+ kwargs.setdefault("stdin", subprocess.DEVNULL)
26
+ return subprocess.Popen(*args, **kwargs)