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 +16 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +29 -17
- package/src/utils/contracts.py +102 -0
- package/src/utils/proc.py +26 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.33.
|
|
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.
|
|
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
|
-
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
|
|
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
|
-
|
|
14083
|
-
|
|
14084
|
-
|
|
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
|
-
|
|
15489
|
-
|
|
15490
|
-
|
|
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 =
|
|
19761
|
-
|
|
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)
|