davinci-resolve-mcp 2.68.0 → 2.68.2
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 +120 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +22 -4
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +303 -51
- package/src/utils/bridge_differential.py +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,126 @@
|
|
|
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.68.2
|
|
6
|
+
|
|
7
|
+
Bug fixes. The server no longer opens a second DaVinci Resolve, and a failed
|
|
8
|
+
connection now tells the caller what is actually wrong.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The server launched Resolve when one was already running — usually the wrong
|
|
13
|
+
one.** The free edition refuses external scripting by design, so on a machine
|
|
14
|
+
where only it is running `scriptapp("Resolve")` always returns None.
|
|
15
|
+
`get_resolve()` read that as *"Resolve is not running"* and ran `open` on the
|
|
16
|
+
application; with both editions installed that started **Studio**, a second and
|
|
17
|
+
different application, on every tool call.
|
|
18
|
+
|
|
19
|
+
`resolve_is_running()` now answers the question that was being assumed. It
|
|
20
|
+
returns None when it cannot tell, deliberately — "cannot tell" must never decay
|
|
21
|
+
into "nothing is running", because that is the answer that leads to launching
|
|
22
|
+
something. A genuinely absent Resolve is still launched, as documented, and the
|
|
23
|
+
macOS candidate list now contains **both** editions rather than only the
|
|
24
|
+
installer path.
|
|
25
|
+
- **The error a caller received described something that had not happened.**
|
|
26
|
+
Eleven sites asserted that Resolve was not running, that starting it had been
|
|
27
|
+
tried and failed, and that the reader should check their Studio install. After
|
|
28
|
+
the fix above none of that was true, and the accurate guidance was only reaching
|
|
29
|
+
the log. `_not_connected_error()` derives the message from the situation and
|
|
30
|
+
names the applicable remedy — external scripting on Studio, or the in-app bridge
|
|
31
|
+
on the free edition, including the framework-Python prerequisite. Three codes:
|
|
32
|
+
`SCRIPTING_UNAVAILABLE`, `BRIDGE_UNAVAILABLE`, `RESOLVE_NOT_RUNNING`.
|
|
33
|
+
- **Those two are marked `retryable: false`.** The `not_connected` category
|
|
34
|
+
defaults to retryable because auto-launch may succeed next time; neither of
|
|
35
|
+
these can — one needs a preference changed, the other a script started inside
|
|
36
|
+
Resolve — and reporting them as retryable sends an agent into a loop it cannot
|
|
37
|
+
win.
|
|
38
|
+
- **The offline test suite opened Resolve, and connected to whatever was running.**
|
|
39
|
+
A stub-based audio test reached for `AUDIO_SYNC_*`, which are attributes on the
|
|
40
|
+
*live* object, so it called `get_resolve()`, found nothing, and started Resolve;
|
|
41
|
+
when Resolve *was* open it connected instead, making the test's result depend on
|
|
42
|
+
the state of the machine. `tests/conftest.py` closes both suite-wide and names
|
|
43
|
+
any launch attempt in the terminal summary.
|
|
44
|
+
- **`src/server.py` reported the wrong tool count to every agent.** Its module
|
|
45
|
+
docstring said "34 compound tools" while the agent-facing workflow prompt and
|
|
46
|
+
the startup log line said 32. The drift guard only required the correct number
|
|
47
|
+
to appear *somewhere* in the file, so the wrong one shipped. It now rejects any
|
|
48
|
+
other count in front of that phrase, and counts decorators from the parsed
|
|
49
|
+
syntax tree rather than by matching text — a docstring mentioning
|
|
50
|
+
`@mcp.tool()` had been counted as a 35th tool.
|
|
51
|
+
- **The bridge harness graded the wrong clip and reported a known trap as a
|
|
52
|
+
finding.** It took the first video item, which is often a generator or title
|
|
53
|
+
that has no MediaPoolItem and answers None to every node query, so the grading
|
|
54
|
+
surface read as unreachable; it now takes the first gradeable item. And it
|
|
55
|
+
passed the codec *description* `"H.264"` to `SetCurrentRenderFormatAndCodec`,
|
|
56
|
+
which only accepts the id `"H264"`, making that observation permanently false
|
|
57
|
+
for a documented reason.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- `scripts/install_resolve_bridge.py` no longer claims Resolve ignores Fusion's
|
|
62
|
+
standalone script tree. Measured on free 21.0.3.7: markers left in two
|
|
63
|
+
undocumented container paths both appeared under Workspace ▸ Scripts, so Lite
|
|
64
|
+
scans more locations than the README documents. Install behaviour is unchanged —
|
|
65
|
+
the documented paths work, and each extra target is another chance for the macOS
|
|
66
|
+
App Management prompt to stall the copy — but the stated reason is now the true
|
|
67
|
+
one.
|
|
68
|
+
|
|
69
|
+
### Validated
|
|
70
|
+
|
|
71
|
+
Free edition alone (21.0.3.7, App Store), Studio never launched at any point,
|
|
72
|
+
confirmed by process check after every stage: 165/165 read-shaped MCP actions
|
|
73
|
+
clean with Blackmagic's module blocked; 109/112 API read methods exercised; render
|
|
74
|
+
to a non-empty file with `set_format` true; AAF/DRT/EDL/FCPXML all written;
|
|
75
|
+
SetLUT clears and reads back. Suite 1913 passing.
|
|
76
|
+
|
|
77
|
+
## What's New in v2.68.1
|
|
78
|
+
|
|
79
|
+
Bug fix. A missing tool argument now returns the structured error envelope the
|
|
80
|
+
rest of the surface uses, instead of escaping as a raw `KeyError`.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- **A missing argument reached the client as an unroutable crash.** Actions read
|
|
85
|
+
`p["track_type"]` directly; when the key was absent the resulting `KeyError`
|
|
86
|
+
escaped the action, escaped the tool, and arrived as
|
|
87
|
+
`ToolError: Error executing tool timeline: 'track_type'` — no code, no
|
|
88
|
+
category, no remediation. Walking the whole surface found **99 of 512 declared
|
|
89
|
+
actions** failing this way across 16 tools (`timeline`, `graph`, `render`,
|
|
90
|
+
`media_pool`, `media_storage`, `fusion_comp`, `folder`, `gallery_stills`,
|
|
91
|
+
`layout_presets`, `project_settings`, `project_manager_*`, `render_presets`,
|
|
92
|
+
`resolve_control`, `timeline_markers`). They now return, for example:
|
|
93
|
+
|
|
94
|
+
{"error": {"message": "'track_type' is required",
|
|
95
|
+
"code": "MISSING_TRACK_TYPE", "category": "invalid_input",
|
|
96
|
+
"retryable": false, "remediation": "...", "state": {...}}}
|
|
97
|
+
|
|
98
|
+
`retryable: false` matters as much as the code: an input error the caller must
|
|
99
|
+
fix was previously reported through a path that defaulted to retryable, which
|
|
100
|
+
sends an agent into a loop it cannot win.
|
|
101
|
+
|
|
102
|
+
The mechanism is a params dict whose missing keys raise a dedicated
|
|
103
|
+
`KeyError` subclass, caught at the tool boundary. Catching plain `KeyError`
|
|
104
|
+
there would have been simpler and wrong — it cannot tell a missing argument
|
|
105
|
+
from an internal lookup failure on some other dict, so our own bugs would have
|
|
106
|
+
been relabelled as the caller's mistake. `contracts.validate` remains the
|
|
107
|
+
preferred tool where a type, range or enum also needs checking, and the actions
|
|
108
|
+
already using it are unchanged.
|
|
109
|
+
|
|
110
|
+
### Added
|
|
111
|
+
|
|
112
|
+
- `tests/test_tool_argument_validation.py` walks **every declared action** with an
|
|
113
|
+
empty params dict and asserts none leaks a `KeyError`. Reading the source cannot
|
|
114
|
+
find this class — `p[...]` after a guard is correct and `p[...]` without one is a
|
|
115
|
+
bug — so the walk executes each branch against a stub Resolve. It fails on the
|
|
116
|
+
pre-fix tree and runs in 2 s.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- `test_doc_tool_counts` counts `@mcp.tool()` decorators from the parsed syntax
|
|
121
|
+
tree rather than by matching the text. A docstring explaining where the
|
|
122
|
+
decorator has to sit was counted as a 35th tool; prose that mentions a
|
|
123
|
+
decorator is not a tool.
|
|
124
|
+
|
|
5
125
|
## What's New in v2.68.0
|
|
6
126
|
|
|
7
127
|
Six engines that let an agent judge its own output before shipping it — audio
|
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
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.68.
|
|
39
|
+
VERSION = "2.68.2"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -75,11 +75,21 @@ def scenario_render(resolve: Any, workspace: Path) -> Dict[str, Any]:
|
|
|
75
75
|
observations: Dict[str, Any] = {}
|
|
76
76
|
formats = project.GetRenderFormats() or {}
|
|
77
77
|
observations["format_count"] = len(formats)
|
|
78
|
-
|
|
78
|
+
codecs = project.GetRenderCodecs("mov") or {}
|
|
79
|
+
observations["codec_count_for_mov"] = len(codecs)
|
|
79
80
|
|
|
80
81
|
output = workspace / "bridge_render_probe"
|
|
81
82
|
output.mkdir(parents=True, exist_ok=True)
|
|
82
|
-
|
|
83
|
+
# `GetRenderCodecs` returns {description: id} and only the **id** is accepted.
|
|
84
|
+
# Passing the description shown in the Deliver page returns False — verified
|
|
85
|
+
# again here on free 21.0.3.7: 'H.264' -> False, 'H264' -> True. Feeding it the
|
|
86
|
+
# description made this observation permanently False for a documented reason,
|
|
87
|
+
# which is a harness reporting a known trap as if it were a finding.
|
|
88
|
+
codec_id = codecs.get("H.264") or next(iter(codecs.values()), None)
|
|
89
|
+
observations["codec_id"] = codec_id
|
|
90
|
+
observations["set_format"] = (
|
|
91
|
+
project.SetCurrentRenderFormatAndCodec("mov", codec_id) if codec_id else False
|
|
92
|
+
)
|
|
83
93
|
observations["set_settings"] = project.SetRenderSettings({
|
|
84
94
|
"TargetDir": str(output),
|
|
85
95
|
"CustomName": "bridge_render_probe",
|
|
@@ -147,8 +157,16 @@ def scenario_set_lut(resolve: Any, _workspace: Path) -> Dict[str, Any]:
|
|
|
147
157
|
items = timeline.GetItemListInTrack("video", 1) or []
|
|
148
158
|
if not items:
|
|
149
159
|
return {"skipped": "no clip on video track 1"}
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
# Not items[0]: a generator or title has no MediaPoolItem and answers None to
|
|
161
|
+
# every node question, so grading it silently produces a row of nulls that
|
|
162
|
+
# reads like a bridge failure. Measured on a real timeline — SMPTE Color Bar
|
|
163
|
+
# returns None for GetNumNodes and GetNodeGraph, the clip beside it returns 1
|
|
164
|
+
# and a live graph.
|
|
165
|
+
item = next((i for i in items if i.GetMediaPoolItem()), None)
|
|
166
|
+
if item is None:
|
|
167
|
+
return {"skipped": "video track 1 holds only generators/titles, which cannot be graded",
|
|
168
|
+
"items_seen": len(items)}
|
|
169
|
+
observations: Dict[str, Any] = {"clip": item.GetName(), "node_count": item.GetNumNodes()}
|
|
152
170
|
original = item.GetLUT(1)
|
|
153
171
|
observations["lut_before"] = original
|
|
154
172
|
# An empty path is the documented way to clear a node's LUT, so it is a
|
|
@@ -37,9 +37,9 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
|
|
|
37
37
|
#: Two traps, both hit on a real install:
|
|
38
38
|
#:
|
|
39
39
|
#: 1. `<container>/Data/Library/Application Support/Fusion/Scripts/Utility`
|
|
40
|
-
#: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility
|
|
41
|
-
#:
|
|
42
|
-
#:
|
|
40
|
+
#: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility, but
|
|
41
|
+
#: it is **Fusion's standalone tree**, not Resolve's. Keying on "which
|
|
42
|
+
#: directory already exists" picks it over the one the README documents.
|
|
43
43
|
#: 2. Resolve does not create its own `Fusion/Scripts` tree until a script is
|
|
44
44
|
#: installed, so requiring the target to pre-exist skips the free edition
|
|
45
45
|
#: entirely — the one build the bridge exists for.
|
|
@@ -47,6 +47,16 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
|
|
|
47
47
|
#: So: a container that exists means the edition is installed, and the tree is
|
|
48
48
|
#: *created*. Blackmagic's README is the authority on the suffix, not the
|
|
49
49
|
#: filesystem.
|
|
50
|
+
#:
|
|
51
|
+
#: **Correction, measured on free 21.0.3.7:** an earlier version of trap 1 said
|
|
52
|
+
#: Resolve "does not scan" the Fusion standalone tree. It does — a marker left in
|
|
53
|
+
#: `<container>/Data/Library/Application Support/Fusion/Scripts/Utility` appeared
|
|
54
|
+
#: under Workspace ▸ Scripts, as did one in
|
|
55
|
+
#: `<container>/Data/Documents/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility`.
|
|
56
|
+
#: So Lite scans more locations than the README documents. That changes nothing
|
|
57
|
+
#: about *where to install* — the documented paths work, and each extra target is
|
|
58
|
+
#: another chance for the App Management prompt to stall the copy — but the reason
|
|
59
|
+
#: to prefer them is that they are documented, not that the others are dead.
|
|
50
60
|
_SANDBOX_MARKER = "com.blackmagic-design."
|
|
51
61
|
#: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
|
|
52
62
|
_NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.68.
|
|
88
|
+
VERSION = "2.68.2"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,11 +11,13 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.68.
|
|
14
|
+
VERSION = "2.68.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
18
18
|
import sys
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
19
21
|
import json
|
|
20
22
|
import logging
|
|
21
23
|
import math
|
|
@@ -294,7 +296,7 @@ def davinci_resolve_workflow() -> str:
|
|
|
294
296
|
return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
|
|
295
297
|
|
|
296
298
|
Core pattern:
|
|
297
|
-
- Prefer the
|
|
299
|
+
- Prefer the 34 compound tools and their action names over raw scripting.
|
|
298
300
|
- Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
|
|
299
301
|
- Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
|
|
300
302
|
- Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
|
|
@@ -897,6 +899,37 @@ def _launch_resolve():
|
|
|
897
899
|
logger.warning("Resolve did not respond within 60s after launch")
|
|
898
900
|
return False
|
|
899
901
|
|
|
902
|
+
#: Process names that mean a DaVinci Resolve application is already running.
|
|
903
|
+
#: Deliberately not the full path: the App Store build lives at
|
|
904
|
+
#: `/Applications/DaVinci Resolve.app` and the installer build inside
|
|
905
|
+
#: `/Applications/DaVinci Resolve/`, and either one counts.
|
|
906
|
+
_RESOLVE_PROCESS_PATTERNS = (
|
|
907
|
+
"DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
|
|
908
|
+
"Resolve.exe", # Windows
|
|
909
|
+
"/opt/resolve/bin/resolve", # Linux
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def resolve_is_running() -> Optional[bool]:
|
|
914
|
+
"""Is a Resolve application already up? None when it cannot be determined.
|
|
915
|
+
|
|
916
|
+
Best-effort and dependency-free. `None` rather than `False` on failure, so an
|
|
917
|
+
unanswerable question never silently becomes "nothing is running" — which is
|
|
918
|
+
the answer that leads to launching something.
|
|
919
|
+
"""
|
|
920
|
+
try:
|
|
921
|
+
if platform.system().lower() == "windows":
|
|
922
|
+
out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
|
|
923
|
+
else:
|
|
924
|
+
out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
|
|
925
|
+
if out.returncode != 0 and not out.stdout:
|
|
926
|
+
return None
|
|
927
|
+
listing = out.stdout or ""
|
|
928
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
929
|
+
return None
|
|
930
|
+
return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
|
|
931
|
+
|
|
932
|
+
|
|
900
933
|
def get_resolve():
|
|
901
934
|
"""Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
|
|
902
935
|
global resolve
|
|
@@ -907,12 +940,84 @@ def get_resolve():
|
|
|
907
940
|
# Try to connect to an already-running Resolve.
|
|
908
941
|
if _try_connect():
|
|
909
942
|
return resolve
|
|
910
|
-
#
|
|
943
|
+
# Failing to connect is NOT the same as nothing running, and conflating
|
|
944
|
+
# them opened an application nobody asked for. The free edition refuses
|
|
945
|
+
# external scripting by design, so on a machine where only it is running
|
|
946
|
+
# `_try_connect` always fails — and this then launched *Studio*, a second,
|
|
947
|
+
# different application, on every tool call. Reported three times before
|
|
948
|
+
# it was traced.
|
|
949
|
+
already_running = resolve_is_running()
|
|
950
|
+
if already_running:
|
|
951
|
+
logger.error(
|
|
952
|
+
"DaVinci Resolve is already running but is not answering the scripting API, "
|
|
953
|
+
"so it will NOT be launched again. Either enable Preferences > General > "
|
|
954
|
+
"'External scripting using' = Local (Studio only), or, on the free edition, "
|
|
955
|
+
"use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
|
|
956
|
+
"and set DAVINCI_RESOLVE_BRIDGE=1."
|
|
957
|
+
)
|
|
958
|
+
return None
|
|
959
|
+
if already_running is None:
|
|
960
|
+
logger.warning("Could not determine whether Resolve is running; not launching it.")
|
|
961
|
+
return None
|
|
911
962
|
logger.info("Resolve not running, attempting to launch automatically...")
|
|
912
963
|
_launch_resolve()
|
|
913
964
|
return resolve
|
|
914
965
|
|
|
915
966
|
|
|
967
|
+
def _not_connected_error():
|
|
968
|
+
"""The caller-facing "no Resolve" error, describing what is actually the case.
|
|
969
|
+
|
|
970
|
+
Eleven call sites used to assert that Resolve was not running, that starting it
|
|
971
|
+
had been tried and failed, and that the reader should check their Studio
|
|
972
|
+
install. Once `get_resolve` stopped launching a second application, all three
|
|
973
|
+
claims were wrong: nothing was launched, Resolve *is* running, and a
|
|
974
|
+
free-edition user was being sent to check an install they may not have. The
|
|
975
|
+
accurate guidance was only reaching the log.
|
|
976
|
+
|
|
977
|
+
So the message is derived from the situation instead of asserted, and it names
|
|
978
|
+
the fix that applies: enable external scripting on Studio, or use the in-app
|
|
979
|
+
bridge on the free edition.
|
|
980
|
+
"""
|
|
981
|
+
running = resolve_is_running()
|
|
982
|
+
bridge_on = _bridge_requested()
|
|
983
|
+
if bridge_on:
|
|
984
|
+
return _err(
|
|
985
|
+
"The in-app bridge is enabled but not answering.",
|
|
986
|
+
code="BRIDGE_UNAVAILABLE", category="not_connected",
|
|
987
|
+
# `not_connected` defaults to retryable because auto-launch may
|
|
988
|
+
# succeed next time. Nothing here will: the listener only appears once
|
|
989
|
+
# someone runs the script inside Resolve, so an agent retrying on a
|
|
990
|
+
# loop it cannot win is strictly worse than being told to stop.
|
|
991
|
+
retryable=False,
|
|
992
|
+
reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
|
|
993
|
+
remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
|
|
994
|
+
"that menu, run `python scripts/install_resolve_bridge.py` and restart "
|
|
995
|
+
"Resolve; a framework Python from python.org is required for Resolve to "
|
|
996
|
+
"list .py scripts at all.",
|
|
997
|
+
state={"resolve_running": running, "bridge_enabled": True},
|
|
998
|
+
)
|
|
999
|
+
if running:
|
|
1000
|
+
return _err(
|
|
1001
|
+
"DaVinci Resolve is running but is not answering the scripting API.",
|
|
1002
|
+
code="SCRIPTING_UNAVAILABLE", category="not_connected",
|
|
1003
|
+
# Not retryable: a preference has to change, or the bridge has to be
|
|
1004
|
+
# started. Retrying the same call cannot make either happen.
|
|
1005
|
+
retryable=False,
|
|
1006
|
+
reason="External scripting is a Studio feature; the free edition refuses it "
|
|
1007
|
+
"regardless of the preference. Resolve was NOT launched again.",
|
|
1008
|
+
remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
|
|
1009
|
+
"On the free edition: install the in-app bridge, run "
|
|
1010
|
+
"Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
|
|
1011
|
+
state={"resolve_running": True, "bridge_enabled": False},
|
|
1012
|
+
)
|
|
1013
|
+
return _err(
|
|
1014
|
+
"DaVinci Resolve is not running and could not be started.",
|
|
1015
|
+
code="RESOLVE_NOT_RUNNING", category="not_connected",
|
|
1016
|
+
remediation="Start DaVinci Resolve and open a project, then retry.",
|
|
1017
|
+
state={"resolve_running": bool(running), "bridge_enabled": False},
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
916
1021
|
def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
|
|
917
1022
|
"""Provider used by the C6 version-on-mutate hook.
|
|
918
1023
|
|
|
@@ -1225,6 +1330,120 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
|
|
|
1225
1330
|
_RETRYABLE_UNSET = object()
|
|
1226
1331
|
|
|
1227
1332
|
|
|
1333
|
+
class _MissingParam(KeyError):
|
|
1334
|
+
"""A tool action subscripted a parameter the caller did not supply.
|
|
1335
|
+
|
|
1336
|
+
Subclasses `KeyError` so any existing `except KeyError` in the codebase keeps
|
|
1337
|
+
behaving as it did; what it adds is the ability to tell a *missing argument*
|
|
1338
|
+
apart from a KeyError on some other dict — a Resolve settings map, a parsed
|
|
1339
|
+
JSON body — which would otherwise be reported to the caller as "you forgot an
|
|
1340
|
+
argument" when the real fault is ours.
|
|
1341
|
+
"""
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
class _Params(dict):
|
|
1345
|
+
"""The params dict, wired so a missing key is a diagnosable refusal.
|
|
1346
|
+
|
|
1347
|
+
Actions have historically reached into `p["track_type"]` directly. When the
|
|
1348
|
+
key is absent that raises a bare `KeyError`, which escapes the action, escapes
|
|
1349
|
+
the tool, and reaches the client as
|
|
1350
|
+
`ToolError: Error executing tool timeline: 'track_type'` — no code, no
|
|
1351
|
+
category, no remediation, and nothing an agent can route on. Measured across
|
|
1352
|
+
the whole surface: **99 of 512 declared actions** failed that way.
|
|
1353
|
+
|
|
1354
|
+
Two ways to fix that were available. Rewriting all 99 sites to use
|
|
1355
|
+
`contracts.validate` gives the best individual errors but is 99 chances to get
|
|
1356
|
+
a spec wrong, and leaves the 100th site to be written next month. Catching
|
|
1357
|
+
`KeyError` at the tool boundary closes the class in one place but cannot tell
|
|
1358
|
+
which dict raised, so an unrelated internal KeyError would be mislabelled as
|
|
1359
|
+
the caller's mistake.
|
|
1360
|
+
|
|
1361
|
+
This is the third option: keep the boundary catch, but make the *parameters*
|
|
1362
|
+
raise something only they can raise. False positives become impossible by
|
|
1363
|
+
construction.
|
|
1364
|
+
|
|
1365
|
+
`contracts.validate` remains the better tool wherever a type, a range or an
|
|
1366
|
+
enum also needs checking, and the actions that already use it keep doing so.
|
|
1367
|
+
This is deliberately **not** a migration of the 99 — rewriting them would add
|
|
1368
|
+
99 opportunities to write a spec wrong without improving the defect being
|
|
1369
|
+
fixed here, and it would still leave the next new action unguarded. What is
|
|
1370
|
+
lost by not migrating is bounded: a *wrong* value still reaches Resolve, which
|
|
1371
|
+
answers None or False, so the result is a truthful failure rather than a
|
|
1372
|
+
misleading success.
|
|
1373
|
+
"""
|
|
1374
|
+
|
|
1375
|
+
def __missing__(self, key):
|
|
1376
|
+
raise _MissingParam(key)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _params(params: Optional[Dict[str, Any]]) -> "_Params":
|
|
1380
|
+
"""Every tool body starts here, so every action gets the diagnosable dict.
|
|
1381
|
+
|
|
1382
|
+
Note `_Params(params or {})` rather than a truthiness dance: an empty
|
|
1383
|
+
`_Params` is falsy like any empty dict, so `p = params or {}` would have
|
|
1384
|
+
quietly handed back a *plain* dict in exactly the no-arguments case this
|
|
1385
|
+
exists to serve.
|
|
1386
|
+
"""
|
|
1387
|
+
return _Params(params or {})
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _missing_param_error(exc: _MissingParam, action: str) -> Dict[str, Any]:
|
|
1391
|
+
"""Turn a missing parameter into the envelope the rest of the surface uses."""
|
|
1392
|
+
name = exc.args[0] if exc.args else "parameter"
|
|
1393
|
+
slug = re.sub(r"[^A-Z0-9]+", "_", str(name).upper()).strip("_") or "PARAMETER"
|
|
1394
|
+
return _err(
|
|
1395
|
+
f"'{name}' is required",
|
|
1396
|
+
code=f"MISSING_{slug}",
|
|
1397
|
+
category="invalid_input",
|
|
1398
|
+
remediation=f"pass {name} in params, e.g. params={{\"{name}\": ...}}",
|
|
1399
|
+
state={"action": action, "missing": str(name)},
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _guarded_action_name(args, kwargs) -> str:
|
|
1404
|
+
"""The `action` this call was for, wherever it was passed."""
|
|
1405
|
+
if "action" in kwargs:
|
|
1406
|
+
return str(kwargs["action"])
|
|
1407
|
+
return str(args[0]) if args else "?"
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _guard_missing_params(fn):
|
|
1411
|
+
"""Tool decorator: report a missing parameter instead of leaking a KeyError.
|
|
1412
|
+
|
|
1413
|
+
Sits *under* `@mcp.tool()` so FastMCP registers the guarded callable.
|
|
1414
|
+
`functools.wraps` keeps the name, docstring and signature FastMCP builds the
|
|
1415
|
+
tool schema from.
|
|
1416
|
+
|
|
1417
|
+
Two things this has to preserve, both learned by breaking them:
|
|
1418
|
+
|
|
1419
|
+
- **Async tools must stay coroutine functions.** `media_analysis` is `async`,
|
|
1420
|
+
and a synchronous wrapper around it returned an un-awaited coroutine that
|
|
1421
|
+
FastMCP could not use — the tool stopped answering entirely, taking its 71
|
|
1422
|
+
actions with it. `inspect.iscoroutinefunction` decides which wrapper to
|
|
1423
|
+
build, and a test now pins that async tools are still async afterwards.
|
|
1424
|
+
- **The signature is not (action, params).** `media_analysis` also takes
|
|
1425
|
+
`ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
|
|
1426
|
+
than naming parameters it does not know about.
|
|
1427
|
+
"""
|
|
1428
|
+
if inspect.iscoroutinefunction(fn):
|
|
1429
|
+
@functools.wraps(fn)
|
|
1430
|
+
async def wrapper(*args, **kwargs):
|
|
1431
|
+
try:
|
|
1432
|
+
return await fn(*args, **kwargs)
|
|
1433
|
+
except _MissingParam as exc:
|
|
1434
|
+
return _missing_param_error(exc, _guarded_action_name(args, kwargs))
|
|
1435
|
+
else:
|
|
1436
|
+
@functools.wraps(fn)
|
|
1437
|
+
def wrapper(*args, **kwargs):
|
|
1438
|
+
try:
|
|
1439
|
+
return fn(*args, **kwargs)
|
|
1440
|
+
except _MissingParam as exc:
|
|
1441
|
+
return _missing_param_error(exc, _guarded_action_name(args, kwargs))
|
|
1442
|
+
|
|
1443
|
+
wrapper.__wrapped_by_missing_param_guard__ = True
|
|
1444
|
+
return wrapper
|
|
1445
|
+
|
|
1446
|
+
|
|
1228
1447
|
def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
|
|
1229
1448
|
remediation=None, reason=None, state=None):
|
|
1230
1449
|
"""Return a structured error envelope.
|
|
@@ -12687,6 +12906,7 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
|
|
|
12687
12906
|
|
|
12688
12907
|
|
|
12689
12908
|
@mcp.tool()
|
|
12909
|
+
@_guard_missing_params
|
|
12690
12910
|
def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
12691
12911
|
"""Configure MCP conversational defaults and setup preferences.
|
|
12692
12912
|
|
|
@@ -12700,7 +12920,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
12700
12920
|
media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults
|
|
12701
12921
|
updates.*: MCP update policy, interval, and snooze defaults
|
|
12702
12922
|
"""
|
|
12703
|
-
p = params
|
|
12923
|
+
p = _params(params)
|
|
12704
12924
|
if action in {"schema", "capabilities", "options"}:
|
|
12705
12925
|
return {
|
|
12706
12926
|
"actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"],
|
|
@@ -12840,6 +13060,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
12840
13060
|
|
|
12841
13061
|
|
|
12842
13062
|
@mcp.tool()
|
|
13063
|
+
@_guard_missing_params
|
|
12843
13064
|
def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
12844
13065
|
"""App-level DaVinci Resolve operations.
|
|
12845
13066
|
|
|
@@ -12882,7 +13103,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
12882
13103
|
restore_state(state_token) -> {success, restored: {...}}
|
|
12883
13104
|
— Returns Resolve to a previously-saved state.
|
|
12884
13105
|
"""
|
|
12885
|
-
p = params
|
|
13106
|
+
p = _params(params)
|
|
12886
13107
|
|
|
12887
13108
|
# api_truth is a static knowledge lookup — no Resolve connection needed.
|
|
12888
13109
|
if action == "api_truth":
|
|
@@ -12947,11 +13168,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
12947
13168
|
r = get_resolve() # auto-launches if not running
|
|
12948
13169
|
if r is not None:
|
|
12949
13170
|
return _ok(message="DaVinci Resolve is running and connected.")
|
|
12950
|
-
return
|
|
13171
|
+
return _not_connected_error()
|
|
12951
13172
|
|
|
12952
13173
|
r = get_resolve() # auto-launches if not running
|
|
12953
13174
|
if r is None:
|
|
12954
|
-
return
|
|
13175
|
+
return _not_connected_error()
|
|
12955
13176
|
|
|
12956
13177
|
if action == "get_version":
|
|
12957
13178
|
update_env = _setup_update_env()
|
|
@@ -13859,6 +14080,7 @@ def _close_control_panel() -> Dict[str, Any]:
|
|
|
13859
14080
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
13860
14081
|
|
|
13861
14082
|
@mcp.tool()
|
|
14083
|
+
@_guard_missing_params
|
|
13862
14084
|
def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
13863
14085
|
"""Manage DaVinci Resolve UI layout presets.
|
|
13864
14086
|
|
|
@@ -13870,10 +14092,10 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
13870
14092
|
import_preset(path, name?) -> {success}
|
|
13871
14093
|
delete(name) -> {success}
|
|
13872
14094
|
"""
|
|
13873
|
-
p = params
|
|
14095
|
+
p = _params(params)
|
|
13874
14096
|
r = get_resolve()
|
|
13875
14097
|
if r is None:
|
|
13876
|
-
return
|
|
14098
|
+
return _not_connected_error()
|
|
13877
14099
|
|
|
13878
14100
|
if action == "save":
|
|
13879
14101
|
if not p.get("name"):
|
|
@@ -13905,6 +14127,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
13905
14127
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
13906
14128
|
|
|
13907
14129
|
@mcp.tool()
|
|
14130
|
+
@_guard_missing_params
|
|
13908
14131
|
def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
13909
14132
|
"""Import/export render and burn-in presets.
|
|
13910
14133
|
|
|
@@ -13914,10 +14137,10 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
13914
14137
|
import_burnin(path) -> {success}
|
|
13915
14138
|
export_burnin(name, path) -> {success}
|
|
13916
14139
|
"""
|
|
13917
|
-
p = params
|
|
14140
|
+
p = _params(params)
|
|
13918
14141
|
r = get_resolve()
|
|
13919
14142
|
if r is None:
|
|
13920
|
-
return
|
|
14143
|
+
return _not_connected_error()
|
|
13921
14144
|
|
|
13922
14145
|
if action == "import_render":
|
|
13923
14146
|
return {"success": bool(r.ImportRenderPreset(p["path"]))}
|
|
@@ -14815,6 +15038,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
|
|
|
14815
15038
|
|
|
14816
15039
|
|
|
14817
15040
|
@mcp.tool()
|
|
15041
|
+
@_guard_missing_params
|
|
14818
15042
|
def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
14819
15043
|
"""Manage DaVinci Resolve projects.
|
|
14820
15044
|
|
|
@@ -14856,10 +15080,10 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14856
15080
|
settings are applied in dependency order; markers only added if absent. Hooks run only
|
|
14857
15081
|
when run_hooks=true (executes shell from the spec — opt-in).
|
|
14858
15082
|
"""
|
|
14859
|
-
p = params
|
|
15083
|
+
p = _params(params)
|
|
14860
15084
|
r = get_resolve()
|
|
14861
15085
|
if r is None:
|
|
14862
|
-
return
|
|
15086
|
+
return _not_connected_error()
|
|
14863
15087
|
pm = r.GetProjectManager()
|
|
14864
15088
|
|
|
14865
15089
|
if action == "lint":
|
|
@@ -14963,6 +15187,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14963
15187
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
14964
15188
|
|
|
14965
15189
|
@mcp.tool()
|
|
15190
|
+
@_guard_missing_params
|
|
14966
15191
|
def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
14967
15192
|
"""Navigate and manage project folders in the Project Manager.
|
|
14968
15193
|
|
|
@@ -14975,10 +15200,10 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
14975
15200
|
goto_root() -> {success}
|
|
14976
15201
|
goto_parent() -> {success}
|
|
14977
15202
|
"""
|
|
14978
|
-
p = params
|
|
15203
|
+
p = _params(params)
|
|
14979
15204
|
r = get_resolve()
|
|
14980
15205
|
if r is None:
|
|
14981
|
-
return
|
|
15206
|
+
return _not_connected_error()
|
|
14982
15207
|
pm = r.GetProjectManager()
|
|
14983
15208
|
|
|
14984
15209
|
if action == "list":
|
|
@@ -15007,6 +15232,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
15007
15232
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
15008
15233
|
|
|
15009
15234
|
@mcp.tool()
|
|
15235
|
+
@_guard_missing_params
|
|
15010
15236
|
def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
15011
15237
|
"""Cloud project operations (requires DaVinci Resolve cloud infrastructure).
|
|
15012
15238
|
|
|
@@ -15016,10 +15242,10 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
15016
15242
|
import_project(path, settings) -> {success}
|
|
15017
15243
|
restore(folder_path, settings) -> {success}
|
|
15018
15244
|
"""
|
|
15019
|
-
p = params
|
|
15245
|
+
p = _params(params)
|
|
15020
15246
|
r = get_resolve()
|
|
15021
15247
|
if r is None:
|
|
15022
|
-
return
|
|
15248
|
+
return _not_connected_error()
|
|
15023
15249
|
pm = r.GetProjectManager()
|
|
15024
15250
|
|
|
15025
15251
|
# cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
|
|
@@ -15045,6 +15271,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
15045
15271
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
15046
15272
|
|
|
15047
15273
|
@mcp.tool()
|
|
15274
|
+
@_guard_missing_params
|
|
15048
15275
|
def project_manager_database(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
15049
15276
|
"""Manage DaVinci Resolve project databases.
|
|
15050
15277
|
|
|
@@ -15053,10 +15280,10 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
|
|
|
15053
15280
|
list() -> {databases}
|
|
15054
15281
|
set_current(db_info) -> {success} — db_info: {DbType, DbName}
|
|
15055
15282
|
"""
|
|
15056
|
-
p = params
|
|
15283
|
+
p = _params(params)
|
|
15057
15284
|
r = get_resolve()
|
|
15058
15285
|
if r is None:
|
|
15059
|
-
return
|
|
15286
|
+
return _not_connected_error()
|
|
15060
15287
|
pm = r.GetProjectManager()
|
|
15061
15288
|
|
|
15062
15289
|
if action == "get_current":
|
|
@@ -15076,6 +15303,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
|
|
|
15076
15303
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
15077
15304
|
|
|
15078
15305
|
@mcp.tool()
|
|
15306
|
+
@_guard_missing_params
|
|
15079
15307
|
def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
15080
15308
|
"""Project metadata, settings, and color groups.
|
|
15081
15309
|
|
|
@@ -15100,7 +15328,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
15100
15328
|
apply_fairlight_preset(preset_name) -> {success}
|
|
15101
15329
|
generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated)
|
|
15102
15330
|
"""
|
|
15103
|
-
p = params
|
|
15331
|
+
p = _params(params)
|
|
15104
15332
|
_, proj, err = _check()
|
|
15105
15333
|
if err:
|
|
15106
15334
|
return err
|
|
@@ -15853,6 +16081,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
|
|
|
15853
16081
|
|
|
15854
16082
|
|
|
15855
16083
|
@mcp.tool()
|
|
16084
|
+
@_guard_missing_params
|
|
15856
16085
|
def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
15857
16086
|
"""Render pipeline: jobs, presets, formats, codecs, and rendering.
|
|
15858
16087
|
|
|
@@ -15901,7 +16130,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
15901
16130
|
resolved against the live matrix, so an intent unavailable on this
|
|
15902
16131
|
machine/license fails with the actual available lists.
|
|
15903
16132
|
"""
|
|
15904
|
-
p = params
|
|
16133
|
+
p = _params(params)
|
|
15905
16134
|
_, proj, err = _check()
|
|
15906
16135
|
if err:
|
|
15907
16136
|
return err
|
|
@@ -16044,6 +16273,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
16044
16273
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16045
16274
|
|
|
16046
16275
|
@mcp.tool()
|
|
16276
|
+
@_guard_missing_params
|
|
16047
16277
|
def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16048
16278
|
"""Browse storage volumes and import media into the Media Pool.
|
|
16049
16279
|
|
|
@@ -16060,10 +16290,10 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
16060
16290
|
add_clip_mattes(clip_id, paths, stereo_eye?) -> {success}
|
|
16061
16291
|
add_timeline_mattes(paths) -> {items}
|
|
16062
16292
|
"""
|
|
16063
|
-
p = params
|
|
16293
|
+
p = _params(params)
|
|
16064
16294
|
r = get_resolve()
|
|
16065
16295
|
if r is None:
|
|
16066
|
-
return
|
|
16296
|
+
return _not_connected_error()
|
|
16067
16297
|
ms = r.GetMediaStorage()
|
|
16068
16298
|
|
|
16069
16299
|
if action == "get_volumes":
|
|
@@ -16111,6 +16341,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
16111
16341
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16112
16342
|
|
|
16113
16343
|
@mcp.tool()
|
|
16344
|
+
@_guard_missing_params
|
|
16114
16345
|
@_destructive_op("media_pool")
|
|
16115
16346
|
def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16116
16347
|
"""Manage the Media Pool: folders, clips, timelines, import/export.
|
|
@@ -16198,7 +16429,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
16198
16429
|
copy_clip_annotations(source_clip_id, target_clip_ids, include_markers?, include_flags?, include_clip_color?, dry_run?) -> {success, results}
|
|
16199
16430
|
media_pool_boundary_report(depth?, clip_ids?, selected?) -> {capabilities, media_pool, items?}
|
|
16200
16431
|
"""
|
|
16201
|
-
p = params
|
|
16432
|
+
p = _params(params)
|
|
16202
16433
|
_, proj, mp, err = _get_mp()
|
|
16203
16434
|
if err:
|
|
16204
16435
|
return err
|
|
@@ -16575,6 +16806,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
16575
16806
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16576
16807
|
|
|
16577
16808
|
@mcp.tool()
|
|
16809
|
+
@_guard_missing_params
|
|
16578
16810
|
def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16579
16811
|
"""Operations on Media Pool folders.
|
|
16580
16812
|
|
|
@@ -16593,7 +16825,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
16593
16825
|
analyze_for_slate(path?, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra
|
|
16594
16826
|
remove_motion_blur(path?, deblur_option?) -> {success, created} — Resolve 21+; renders NEW media (confirm-gated)
|
|
16595
16827
|
"""
|
|
16596
|
-
p = params
|
|
16828
|
+
p = _params(params)
|
|
16597
16829
|
_, _, mp, err = _get_mp()
|
|
16598
16830
|
if err:
|
|
16599
16831
|
return err
|
|
@@ -16716,6 +16948,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
16716
16948
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16717
16949
|
|
|
16718
16950
|
@mcp.tool()
|
|
16951
|
+
@_guard_missing_params
|
|
16719
16952
|
def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16720
16953
|
"""Operations on a media pool clip. Identify clip by clip_id.
|
|
16721
16954
|
|
|
@@ -16767,7 +17000,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16767
17000
|
verified empirically on Resolve Studio 20.3.2.9. If BMD changes the
|
|
16768
17001
|
behavior the clip will still be selected — editor double-clicks to load.
|
|
16769
17002
|
"""
|
|
16770
|
-
p = params
|
|
17003
|
+
p = _params(params)
|
|
16771
17004
|
_, _, mp, err = _get_mp()
|
|
16772
17005
|
if err:
|
|
16773
17006
|
return err
|
|
@@ -17080,6 +17313,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17080
17313
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
17081
17314
|
|
|
17082
17315
|
@mcp.tool()
|
|
17316
|
+
@_guard_missing_params
|
|
17083
17317
|
def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
17084
17318
|
"""Markers and flags on media pool clips. Identify clip by clip_id.
|
|
17085
17319
|
|
|
@@ -17100,7 +17334,7 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
17100
17334
|
monitor_growing_file(clip_id) -> {success}
|
|
17101
17335
|
replace_clip_preserve_sub_clip(clip_id, path) -> {success}
|
|
17102
17336
|
"""
|
|
17103
|
-
p = params
|
|
17337
|
+
p = _params(params)
|
|
17104
17338
|
_, _, mp, err = _get_mp()
|
|
17105
17339
|
if err:
|
|
17106
17340
|
return err
|
|
@@ -17212,6 +17446,7 @@ def _opt_number(value: Any) -> Optional[float]:
|
|
|
17212
17446
|
|
|
17213
17447
|
|
|
17214
17448
|
@mcp.tool()
|
|
17449
|
+
@_guard_missing_params
|
|
17215
17450
|
async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]:
|
|
17216
17451
|
"""Project-scoped media analysis and guarded metadata publishing.
|
|
17217
17452
|
|
|
@@ -17308,7 +17543,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17308
17543
|
vision-dependent metadata to Resolve. Works with any MCP client whose chat
|
|
17309
17544
|
model is vision-capable; no sampling/createMessage support required.
|
|
17310
17545
|
"""
|
|
17311
|
-
p = _media_analysis_apply_setup_defaults(action, dict(params or {}))
|
|
17546
|
+
p = _params(_media_analysis_apply_setup_defaults(action, dict(params or {})))
|
|
17312
17547
|
|
|
17313
17548
|
# First-run frame-sampling prompt: if the user has never chosen a sampling
|
|
17314
17549
|
# mode (and didn't pass one this call), ask before spending any vision
|
|
@@ -18384,6 +18619,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
18384
18619
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
18385
18620
|
|
|
18386
18621
|
@mcp.tool()
|
|
18622
|
+
@_guard_missing_params
|
|
18387
18623
|
def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
18388
18624
|
"""Timeline version-on-mutate, archive, rollback, and brain-edit history (C6).
|
|
18389
18625
|
|
|
@@ -18431,7 +18667,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
18431
18667
|
if ctx is None:
|
|
18432
18668
|
return _err("No current project / can't resolve project root")
|
|
18433
18669
|
resolve_h, project_h, project_root, project_name = ctx
|
|
18434
|
-
p = params
|
|
18670
|
+
p = _params(params)
|
|
18435
18671
|
|
|
18436
18672
|
if action == "begin_run":
|
|
18437
18673
|
return _analysis_runs.begin_run(
|
|
@@ -18756,6 +18992,7 @@ def _edit_engine_linked_audio_tracks(
|
|
|
18756
18992
|
|
|
18757
18993
|
|
|
18758
18994
|
@mcp.tool()
|
|
18995
|
+
@_guard_missing_params
|
|
18759
18996
|
@_destructive_op("edit_engine")
|
|
18760
18997
|
def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
18761
18998
|
"""Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
|
|
@@ -18797,7 +19034,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18797
19034
|
timeline (version-archived first).
|
|
18798
19035
|
- list_plans(limit?) / get_plan(plan_id)
|
|
18799
19036
|
"""
|
|
18800
|
-
p = params
|
|
19037
|
+
p = _params(params)
|
|
18801
19038
|
|
|
18802
19039
|
def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
|
|
18803
19040
|
# An explicit analysis_root always wins — the evidence DB the caller
|
|
@@ -19562,6 +19799,7 @@ _TIMELINE_ACTIONS = [
|
|
|
19562
19799
|
|
|
19563
19800
|
|
|
19564
19801
|
@mcp.tool()
|
|
19802
|
+
@_guard_missing_params
|
|
19565
19803
|
@_destructive_op("timeline")
|
|
19566
19804
|
def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
19567
19805
|
"""Timeline operations: tracks, clips, import/export, generators, titles.
|
|
@@ -19747,7 +19985,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
19747
19985
|
For long-form per-action guidance and a worked example, call:
|
|
19748
19986
|
timeline(action="action_help", params={"name": "<action>"})
|
|
19749
19987
|
"""
|
|
19750
|
-
p = params
|
|
19988
|
+
p = _params(params)
|
|
19751
19989
|
# action_help is pull-on-demand metadata; no Resolve connection needed.
|
|
19752
19990
|
if action == "action_help":
|
|
19753
19991
|
return _action_help("timeline", p)
|
|
@@ -20148,7 +20386,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
20148
20386
|
result["ignored_state_keys"] = ignored_state
|
|
20149
20387
|
return result
|
|
20150
20388
|
elif action == "extract_source_frame_ranges":
|
|
20151
|
-
p = params
|
|
20389
|
+
p = _params(params)
|
|
20152
20390
|
handles = int(p.get("handles", 24))
|
|
20153
20391
|
gap_max = int(p.get("gap_max", 30))
|
|
20154
20392
|
audio_ext = tuple(
|
|
@@ -20335,6 +20573,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
20335
20573
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20336
20574
|
|
|
20337
20575
|
@mcp.tool()
|
|
20576
|
+
@_guard_missing_params
|
|
20338
20577
|
@_destructive_op("timeline_markers")
|
|
20339
20578
|
def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
20340
20579
|
"""Markers and playhead operations on the current timeline.
|
|
@@ -20370,7 +20609,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
|
|
|
20370
20609
|
export_review_report(scope?, include_capabilities?) -> {title, annotations, capabilities?}
|
|
20371
20610
|
annotation_boundary_report(scope?) -> {capabilities, annotations}
|
|
20372
20611
|
"""
|
|
20373
|
-
p = params
|
|
20612
|
+
p = _params(params)
|
|
20374
20613
|
_, tl, err = _get_tl()
|
|
20375
20614
|
if err:
|
|
20376
20615
|
return err
|
|
@@ -20455,6 +20694,7 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
|
|
|
20455
20694
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20456
20695
|
|
|
20457
20696
|
@mcp.tool()
|
|
20697
|
+
@_guard_missing_params
|
|
20458
20698
|
@_destructive_op("timeline_ai")
|
|
20459
20699
|
def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
20460
20700
|
"""AI and analysis operations on the current timeline.
|
|
@@ -20468,7 +20708,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20468
20708
|
grab_still() -> {success}
|
|
20469
20709
|
grab_all_stills(source?) -> {count}
|
|
20470
20710
|
"""
|
|
20471
|
-
p = params
|
|
20711
|
+
p = _params(params)
|
|
20472
20712
|
_, tl, err = _get_tl()
|
|
20473
20713
|
if err:
|
|
20474
20714
|
return err
|
|
@@ -20509,6 +20749,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20509
20749
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20510
20750
|
|
|
20511
20751
|
@mcp.tool()
|
|
20752
|
+
@_guard_missing_params
|
|
20512
20753
|
@_destructive_op("timeline_item")
|
|
20513
20754
|
def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
20514
20755
|
"""Properties, transforms, speed, keyframes, and metadata for a timeline item.
|
|
@@ -20560,7 +20801,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
20560
20801
|
|
|
20561
20802
|
Default: track_type="video", track_index=1, item_index=0
|
|
20562
20803
|
"""
|
|
20563
|
-
p = params
|
|
20804
|
+
p = _params(params)
|
|
20564
20805
|
tl, item, err = _get_item(p)
|
|
20565
20806
|
if err:
|
|
20566
20807
|
return err
|
|
@@ -20748,6 +20989,7 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
20748
20989
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20749
20990
|
|
|
20750
20991
|
@mcp.tool()
|
|
20992
|
+
@_guard_missing_params
|
|
20751
20993
|
@_destructive_op("timeline_item_markers")
|
|
20752
20994
|
def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
20753
20995
|
"""Markers, flags, and clip color on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
|
|
@@ -20770,7 +21012,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
20770
21012
|
|
|
20771
21013
|
Default: track_type="video", track_index=1, item_index=0
|
|
20772
21014
|
"""
|
|
20773
|
-
p = params
|
|
21015
|
+
p = _params(params)
|
|
20774
21016
|
_, item, err = _get_item(p)
|
|
20775
21017
|
if err:
|
|
20776
21018
|
return err
|
|
@@ -20825,6 +21067,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
20825
21067
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20826
21068
|
|
|
20827
21069
|
@mcp.tool()
|
|
21070
|
+
@_guard_missing_params
|
|
20828
21071
|
@_destructive_op("timeline_item_fusion")
|
|
20829
21072
|
def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
20830
21073
|
"""Fusion composition operations on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
|
|
@@ -20845,7 +21088,7 @@ def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -
|
|
|
20845
21088
|
|
|
20846
21089
|
Default: track_type="video", track_index=1, item_index=0
|
|
20847
21090
|
"""
|
|
20848
|
-
p = params
|
|
21091
|
+
p = _params(params)
|
|
20849
21092
|
_, item, err = _get_item(p)
|
|
20850
21093
|
if err:
|
|
20851
21094
|
return err
|
|
@@ -22126,6 +22369,7 @@ def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
22126
22369
|
}
|
|
22127
22370
|
|
|
22128
22371
|
@mcp.tool()
|
|
22372
|
+
@_guard_missing_params
|
|
22129
22373
|
@_destructive_op("timeline_item_color")
|
|
22130
22374
|
def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22131
22375
|
"""Color grading, versions, LUTs, cache, and AI tools on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
|
|
@@ -22216,7 +22460,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
22216
22460
|
For long-form per-action guidance and a worked example, call:
|
|
22217
22461
|
timeline_item_color(action="action_help", params={"name": "<action>"})
|
|
22218
22462
|
"""
|
|
22219
|
-
p = params
|
|
22463
|
+
p = _params(params)
|
|
22220
22464
|
if action == "action_help":
|
|
22221
22465
|
return _action_help("timeline_item_color", p)
|
|
22222
22466
|
_, item, err = _get_item(p)
|
|
@@ -22331,6 +22575,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
22331
22575
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22332
22576
|
|
|
22333
22577
|
@mcp.tool()
|
|
22578
|
+
@_guard_missing_params
|
|
22334
22579
|
@_destructive_op("timeline_item_takes")
|
|
22335
22580
|
def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22336
22581
|
"""Take management on timeline items. Identify by track_type, track_index, item_index (item_index is 0-BASED: 0 = first clip; track_index is 1-based).
|
|
@@ -22346,7 +22591,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
22346
22591
|
|
|
22347
22592
|
Default: track_type="video", track_index=1, item_index=0
|
|
22348
22593
|
"""
|
|
22349
|
-
p = params
|
|
22594
|
+
p = _params(params)
|
|
22350
22595
|
_, item, err = _get_item(p)
|
|
22351
22596
|
if err:
|
|
22352
22597
|
return err
|
|
@@ -22379,6 +22624,7 @@ def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
22379
22624
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22380
22625
|
|
|
22381
22626
|
@mcp.tool()
|
|
22627
|
+
@_guard_missing_params
|
|
22382
22628
|
def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22383
22629
|
"""Gallery album management.
|
|
22384
22630
|
|
|
@@ -22394,7 +22640,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
|
|
|
22394
22640
|
|
|
22395
22641
|
album_index is 0-based into the still albums list.
|
|
22396
22642
|
"""
|
|
22397
|
-
p = params
|
|
22643
|
+
p = _params(params)
|
|
22398
22644
|
_, proj, err = _check()
|
|
22399
22645
|
if err:
|
|
22400
22646
|
return err
|
|
@@ -22444,6 +22690,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
|
|
|
22444
22690
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22445
22691
|
|
|
22446
22692
|
@mcp.tool()
|
|
22693
|
+
@_guard_missing_params
|
|
22447
22694
|
def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22448
22695
|
"""Manage stills in gallery albums (best results on Color page).
|
|
22449
22696
|
|
|
@@ -22464,7 +22711,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
22464
22711
|
File data is inlined in the response (DRX as text, images as base64).
|
|
22465
22712
|
cleanup (default true) deletes exported files from disk after inlining.
|
|
22466
22713
|
"""
|
|
22467
|
-
p = params
|
|
22714
|
+
p = _params(params)
|
|
22468
22715
|
_, proj, err = _check()
|
|
22469
22716
|
if err:
|
|
22470
22717
|
return err
|
|
@@ -22597,6 +22844,7 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
22597
22844
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22598
22845
|
|
|
22599
22846
|
@mcp.tool()
|
|
22847
|
+
@_guard_missing_params
|
|
22600
22848
|
@_destructive_op("graph")
|
|
22601
22849
|
def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22602
22850
|
"""Node graph operations (color grading nodes). Source can be timeline, timeline item, or color group.
|
|
@@ -22636,7 +22884,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
22636
22884
|
For long-form per-action guidance and a worked example, call:
|
|
22637
22885
|
graph(action="action_help", params={"name": "<action>"})
|
|
22638
22886
|
"""
|
|
22639
|
-
p = params
|
|
22887
|
+
p = _params(params)
|
|
22640
22888
|
if action == "action_help":
|
|
22641
22889
|
return _action_help("graph", p)
|
|
22642
22890
|
source = p.get("source", "timeline")
|
|
@@ -22739,6 +22987,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
22739
22987
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22740
22988
|
|
|
22741
22989
|
@mcp.tool()
|
|
22990
|
+
@_guard_missing_params
|
|
22742
22991
|
def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
22743
22992
|
"""Manage color groups and their node graphs.
|
|
22744
22993
|
|
|
@@ -22750,7 +22999,7 @@ def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
22750
22999
|
get_pre_clip_graph(group_name) -> {available, num_nodes}
|
|
22751
23000
|
get_post_clip_graph(group_name) -> {available, num_nodes}
|
|
22752
23001
|
"""
|
|
22753
|
-
p = params
|
|
23002
|
+
p = _params(params)
|
|
22754
23003
|
_, proj, err = _check()
|
|
22755
23004
|
if err:
|
|
22756
23005
|
return err
|
|
@@ -23661,6 +23910,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
23661
23910
|
|
|
23662
23911
|
|
|
23663
23912
|
@mcp.tool()
|
|
23913
|
+
@_guard_missing_params
|
|
23664
23914
|
def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
23665
23915
|
"""Fusion composition node graph operations.
|
|
23666
23916
|
|
|
@@ -23731,7 +23981,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
23731
23981
|
ColorCorrector, RectangleMask, EllipseMask, Tracker, MediaIn, MediaOut,
|
|
23732
23982
|
Loader, Saver, Glow, FilmGrain, CornerPositioner, DeltaKeyer, UltraKeyer
|
|
23733
23983
|
"""
|
|
23734
|
-
p = params
|
|
23984
|
+
p = _params(params)
|
|
23735
23985
|
|
|
23736
23986
|
if action == "bulk_set_inputs":
|
|
23737
23987
|
return _fusion_comp_bulk_set_inputs(p)
|
|
@@ -24231,6 +24481,7 @@ def _validate_glsl_minimal(source: str) -> Dict[str, Any]:
|
|
|
24231
24481
|
|
|
24232
24482
|
|
|
24233
24483
|
@mcp.tool()
|
|
24484
|
+
@_guard_missing_params
|
|
24234
24485
|
def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
24235
24486
|
"""Author and install Fusion Fuse plugins (.fuse files).
|
|
24236
24487
|
|
|
@@ -24257,7 +24508,7 @@ def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
24257
24508
|
docs/authoring/fuse-dctl-authoring.md for the per-kind option spec.
|
|
24258
24509
|
list_templates() -> {kinds}
|
|
24259
24510
|
"""
|
|
24260
|
-
p = params
|
|
24511
|
+
p = _params(params)
|
|
24261
24512
|
|
|
24262
24513
|
if action == "path":
|
|
24263
24514
|
return {"fuses_dir": _fuses_dir()}
|
|
@@ -24474,6 +24725,7 @@ _DCTL_VALID_CATEGORIES = ("lut", "aces_idt", "aces_odt")
|
|
|
24474
24725
|
|
|
24475
24726
|
|
|
24476
24727
|
@mcp.tool()
|
|
24728
|
+
@_guard_missing_params
|
|
24477
24729
|
def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
24478
24730
|
"""Author and install DCTL files (Color page custom shaders + ACES transforms).
|
|
24479
24731
|
|
|
@@ -24510,7 +24762,7 @@ def dctl(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]
|
|
|
24510
24762
|
pass that as the `category` argument to install().
|
|
24511
24763
|
list_templates() -> {kinds, kind_categories}
|
|
24512
24764
|
"""
|
|
24513
|
-
p = params
|
|
24765
|
+
p = _params(params)
|
|
24514
24766
|
|
|
24515
24767
|
def _category(default: str = "lut") -> Tuple[Optional[Dict[str, Any]], str]:
|
|
24516
24768
|
cat = p.get("category", default)
|
|
@@ -24787,8 +25039,7 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
24787
25039
|
def _execute_lua_script(path: str) -> Dict[str, Any]:
|
|
24788
25040
|
r = get_resolve()
|
|
24789
25041
|
if r is None:
|
|
24790
|
-
return
|
|
24791
|
-
"auto-launch failed.")
|
|
25042
|
+
return _not_connected_error()
|
|
24792
25043
|
fusion = r.Fusion()
|
|
24793
25044
|
if fusion is None:
|
|
24794
25045
|
return _err("handle.Fusion() returned None — cannot run Lua scripts.")
|
|
@@ -24853,7 +25104,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
|
|
|
24853
25104
|
"""
|
|
24854
25105
|
r = get_resolve()
|
|
24855
25106
|
if r is None:
|
|
24856
|
-
return
|
|
25107
|
+
return _not_connected_error()
|
|
24857
25108
|
fusion = r.Fusion()
|
|
24858
25109
|
if fusion is None:
|
|
24859
25110
|
return _err("handle.Fusion() returned None — cannot run inline Lua.")
|
|
@@ -25403,6 +25654,7 @@ def _extension_boundary_report(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
25403
25654
|
|
|
25404
25655
|
|
|
25405
25656
|
@mcp.tool()
|
|
25657
|
+
@_guard_missing_params
|
|
25406
25658
|
def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
25407
25659
|
"""Author and install Resolve-page Lua/Python scripts (Workspace → Scripts menu).
|
|
25408
25660
|
|
|
@@ -25458,7 +25710,7 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
25458
25710
|
refresh_or_restart_required(extension_type, category?) -> {refresh_luts, restart_required}
|
|
25459
25711
|
extension_boundary_report(include_template_matrix?) -> {capabilities, template_matrix, dry_run_probes}
|
|
25460
25712
|
"""
|
|
25461
|
-
p = params
|
|
25713
|
+
p = _params(params)
|
|
25462
25714
|
|
|
25463
25715
|
if action == "extension_capabilities":
|
|
25464
25716
|
return _extension_capabilities()
|
|
@@ -25962,5 +26214,5 @@ if __name__ == "__main__":
|
|
|
25962
26214
|
logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
|
|
25963
26215
|
sys.exit(2)
|
|
25964
26216
|
|
|
25965
|
-
logger.info(
|
|
26217
|
+
logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
|
|
25966
26218
|
run_fastmcp_stdio(mcp)
|
|
@@ -182,12 +182,22 @@ def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
|
|
|
182
182
|
#: MediaPoolItem or Graph — which between them carry most of the read surface
|
|
183
183
|
#: worth qualifying (LUTs, node graphs, source timings, metadata).
|
|
184
184
|
_INDEX = "[]"
|
|
185
|
+
#: Pick the first element of a returned list for which `method()` is truthy.
|
|
186
|
+
#: Indexing blindly is not good enough: a timeline's first video item is often a
|
|
187
|
+
#: generator or a title, which has no MediaPoolItem and answers None to every
|
|
188
|
+
#: node-graph question. Measured on a real timeline — `GetNumNodes` and
|
|
189
|
+
#: `GetNodeGraph` both None on an SMPTE Color Bar, and 1 and a live graph on the
|
|
190
|
+
#: clip sitting next to it. Taking index 0 therefore reported the whole grading
|
|
191
|
+
#: surface as unreachable, which is the silent coverage gap this module exists to
|
|
192
|
+
#: refuse.
|
|
193
|
+
_FIRST_WITH = "[first-with]"
|
|
185
194
|
|
|
186
195
|
_PM = ("GetProjectManager", ())
|
|
187
196
|
_PROJECT = (_PM, ("GetCurrentProject", ()))
|
|
188
197
|
_POOL = _PROJECT + (("GetMediaPool", ()),)
|
|
189
198
|
_TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
|
|
190
|
-
_TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
|
|
199
|
+
_TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
|
|
200
|
+
(_FIRST_WITH, ("GetMediaPoolItem",)))
|
|
191
201
|
_GALLERY = _PROJECT + (("GetGallery", ()),)
|
|
192
202
|
|
|
193
203
|
OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
|
|
@@ -229,6 +239,22 @@ def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
|
|
|
229
239
|
return None
|
|
230
240
|
current = current[index]
|
|
231
241
|
continue
|
|
242
|
+
if method == _FIRST_WITH:
|
|
243
|
+
if not isinstance(current, (list, tuple)):
|
|
244
|
+
return None
|
|
245
|
+
probe = args[0]
|
|
246
|
+
chosen = None
|
|
247
|
+
for candidate in current:
|
|
248
|
+
try:
|
|
249
|
+
if getattr(candidate, probe, lambda: None)():
|
|
250
|
+
chosen = candidate
|
|
251
|
+
break
|
|
252
|
+
except Exception: # noqa: BLE001 - an unusable candidate, not a verdict
|
|
253
|
+
continue
|
|
254
|
+
if chosen is None:
|
|
255
|
+
return None
|
|
256
|
+
current = chosen
|
|
257
|
+
continue
|
|
232
258
|
function = getattr(current, method, None)
|
|
233
259
|
if function is None:
|
|
234
260
|
return None
|