davinci-resolve-mcp 2.33.5 → 2.33.6

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,22 @@
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.6
6
+
7
+ Internal consolidation: a declarative parameter-contract validator and centralized
8
+ subprocess hygiene.
9
+
10
+ - **Added** `src/utils/contracts.py` `validate(params, rules, invariants)` — one
11
+ validator for required/type/enum/min/max/non-empty/parent-dir-exists plus custom
12
+ invariants, returning consistent agent-friendly errors with coercion + defaults
13
+ applied. Replaces scattered, hand-written validation.
14
+ - **Changed** `export_frame_as_still` and `set_mark_in_out` (clip + timeline) now
15
+ validate through contracts. Behavior is preserved (same rejections); `mark_in`/
16
+ `mark_out` are now coerced to int.
17
+ - **Added** `src/utils/proc.py` `safe_run`/`safe_popen` — subprocess wrappers that
18
+ default `stdin` to DEVNULL so a child can't consume the MCP stdio protocol
19
+ stream. Inline Python execution now routes through `safe_run`.
20
+
5
21
  ## What's New in v2.33.5
6
22
 
7
23
  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.6-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/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.6"
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.6",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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.6"
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.6"
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":
@@ -19757,9 +19770,8 @@ def _execute_python_script(path: str, args: List[str],
19757
19770
  get_resolve()
19758
19771
  cmd = [sys.executable, path] + [str(a) for a in args]
19759
19772
  try:
19760
- result = subprocess.run(cmd, env=_python_env_for_resolve(),
19761
- capture_output=True, text=True,
19762
- timeout=timeout, stdin=subprocess.DEVNULL)
19773
+ result = safe_run(cmd, env=_python_env_for_resolve(),
19774
+ capture_output=True, text=True, timeout=timeout)
19763
19775
  except subprocess.TimeoutExpired as e:
19764
19776
  return _err(f"Script timed out after {timeout}s. "
19765
19777
  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)