davinci-resolve-mcp 2.67.0 → 2.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,423 @@
1
+ """Client side of the in-app bridge: a proxy that looks exactly like Resolve.
2
+
3
+ The point is that **existing call sites do not change**. Code written against the
4
+ native API —
5
+
6
+ resolve.GetProjectManager().GetCurrentProject().GetMediaPool()
7
+
8
+ — works verbatim against a `BridgeProxy`. Each attribute access returns a
9
+ callable that signs a request, sends it over loopback to the script running
10
+ inside Resolve, and rehydrates the answer. Live Resolve objects come back as
11
+ opaque handles wrapped in further proxies, so chains of arbitrary depth work.
12
+
13
+ ## Why a proxy rather than a mapping layer
14
+
15
+ The MCP server already holds the thorough layer: confirm tokens, path
16
+ allowlists, timeline versioning, dry-run/confirm gates, the destructive-action
17
+ registry. All of it sits *above* this and keeps working unchanged. A mapping
18
+ layer would mean a second validation surface at the bridge boundary, and two
19
+ safety layers that must agree are a drift risk rather than extra safety.
20
+
21
+ ## Transparency includes the ugly parts
22
+
23
+ `False` is returned as `False`, not raised. Resolve signals failure that way far
24
+ more often than it raises, and every existing call site was written knowing it.
25
+ Converting it here would silently change the meaning of code that already handles
26
+ it correctly. The bridge's *named* operations do apply `false_is_error`; the
27
+ proxy deliberately does not.
28
+
29
+ ## What it does not hide
30
+
31
+ - **Latency.** Every call is a loopback round trip, measured at ~0.35 ms. Fine
32
+ for hundreds of calls, noticeable for tens of thousands — enumerating a large
33
+ timeline item-by-item is the shape that bites.
34
+ - **Bridge absence.** If the in-Resolve script is not running, construction fails
35
+ with a clear message rather than pretending; there is nothing to fall back to.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import logging
42
+ import os
43
+ import secrets
44
+ import socket
45
+ import threading
46
+ import time
47
+ import uuid
48
+ from pathlib import Path
49
+ from typing import Any, Dict, List, Optional
50
+
51
+ from src.utils import resolve_bridge as _bridge
52
+
53
+ logger = logging.getLogger("resolve-mcp.bridge-client")
54
+
55
+ DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
56
+ #: Env override, so a caller can point at a non-default bridge without editing code.
57
+ ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
58
+ #: Opt-in. Absent means "do not try the bridge", so nothing changes for existing
59
+ #: installs until someone asks for it.
60
+ ENV_ENABLE = "DAVINCI_RESOLVE_BRIDGE"
61
+
62
+ #: Operations this client cannot work without. The in-Resolve script is a *copy*
63
+ #: taken at install time, so it can be older than the client — and a stale bridge
64
+ #: must fail at connect with an actionable message, not mid-session on every
65
+ #: attribute access. `list_methods` is required because without it the proxy
66
+ #: cannot answer `hasattr` truthfully, which silently breaks capability detection.
67
+ REQUIRED_BRIDGE_OPERATIONS = ("call", "list_methods", "release_handles", "health")
68
+
69
+ DEFAULT_TIMEOUT_SECONDS = 30.0
70
+
71
+
72
+ class BridgeUnavailable(RuntimeError):
73
+ """The in-Resolve bridge could not be reached. Not a fallback condition."""
74
+
75
+
76
+ class BridgeCallError(RuntimeError):
77
+ """The bridge refused or failed a call, carrying its stable code."""
78
+
79
+ def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None) -> None:
80
+ super().__init__(f"{code}: {message}")
81
+ self.code = code
82
+ self.message = message
83
+ self.details = details or {}
84
+
85
+
86
+ def config_path() -> Path:
87
+ override = os.environ.get(ENV_CONFIG_PATH)
88
+ return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
89
+
90
+
91
+ def bridge_enabled() -> bool:
92
+ """Opt-in only — an unset env var must not change existing behaviour."""
93
+ return str(os.environ.get(ENV_ENABLE, "")).strip().lower() in {"1", "true", "yes", "on"}
94
+
95
+
96
+ class BridgeTransport:
97
+ """One authenticated loopback connection per request, serialised."""
98
+
99
+ def __init__(self, config: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
100
+ self.host = config["host"]
101
+ self.port = int(config["port"])
102
+ self._token = config["token"]
103
+ self.timeout = float(timeout)
104
+ #: Propagated to proxies decoded from this transport's replies.
105
+ self.strict_attributes = True
106
+ #: type name -> frozenset of method names, scoped to **this** transport.
107
+ #: Per-transport rather than per-class because the method set is the
108
+ #: answer to "what does this Resolve build have", and two builds can be
109
+ #: bridged from one process — the differential harness does exactly that.
110
+ #: A shared cache would let free 21's surface answer for Studio 19, which
111
+ #: is the precise question `list_methods` exists to get right.
112
+ self.method_cache: Dict[str, frozenset] = {}
113
+ self.cache_lock = threading.Lock()
114
+ # The in-Resolve side serialises native calls anyway; serialising here too
115
+ # keeps request/response pairing simple and matches the _bridge_lock
116
+ # discipline the rest of the server already follows.
117
+ self._lock = threading.RLock()
118
+
119
+ def request(self, operation: str, arguments: Dict[str, Any]) -> Any:
120
+ payload = {
121
+ "protocol": _bridge.PROTOCOL_VERSION,
122
+ "id": str(uuid.uuid4()),
123
+ "timestamp": int(time.time()),
124
+ "nonce": secrets.token_urlsafe(24),
125
+ "operation": operation,
126
+ "arguments": arguments,
127
+ }
128
+ payload["signature"] = _bridge.sign_request(self._token, payload)
129
+ line = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
130
+ if len(line) > _bridge.MAX_REQUEST_BYTES:
131
+ raise BridgeCallError("request_too_large", "request exceeds the bridge limit")
132
+
133
+ with self._lock:
134
+ try:
135
+ with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
136
+ sock.settimeout(self.timeout)
137
+ sock.sendall(line)
138
+ raw = sock.makefile("rb").readline(_bridge.MAX_REQUEST_BYTES + 1)
139
+ except (TimeoutError, socket.timeout) as exc:
140
+ raise BridgeCallError(
141
+ "bridge_timeout",
142
+ "Resolve did not answer in time — check for an open modal dialog, "
143
+ "which blocks its scripting API entirely",
144
+ ) from exc
145
+ except OSError as exc:
146
+ raise BridgeUnavailable(
147
+ "Cannot reach the in-Resolve bridge on "
148
+ f"{self.host}:{self.port} ({exc}). Start it from "
149
+ "Workspace > Scripts > resolve_bridge."
150
+ ) from exc
151
+
152
+ if not raw:
153
+ raise BridgeCallError("bridge_protocol_error", "bridge closed without replying")
154
+ try:
155
+ response = json.loads(raw.decode("utf-8"))
156
+ except (UnicodeDecodeError, ValueError) as exc:
157
+ raise BridgeCallError("bridge_protocol_error", "bridge returned invalid JSON") from exc
158
+ if not isinstance(response, dict):
159
+ raise BridgeCallError("bridge_protocol_error", "bridge returned a non-object")
160
+ if response.get("id") != payload["id"]:
161
+ # Mismatched ids mean interleaved responses; failing loudly beats
162
+ # handing a caller another request's answer.
163
+ raise BridgeCallError("bridge_protocol_error", "bridge response id does not match")
164
+ if response.get("ok") is not True:
165
+ error = response.get("error") or {}
166
+ raise BridgeCallError(
167
+ str(error.get("code", "bridge_error")),
168
+ str(error.get("message", "the bridge refused the call")),
169
+ error.get("details") if isinstance(error.get("details"), dict) else {},
170
+ )
171
+ return response.get("result")
172
+
173
+
174
+ def _encode_argument(value: Any) -> Any:
175
+ """Send a proxy back as the handle it stands for."""
176
+ if isinstance(value, BridgeProxy):
177
+ return {"__handle__": value._handle}
178
+ if isinstance(value, dict):
179
+ return {k: _encode_argument(v) for k, v in value.items()}
180
+ if isinstance(value, (list, tuple)):
181
+ return [_encode_argument(v) for v in value]
182
+ return value
183
+
184
+
185
+ def _decode_value(transport: BridgeTransport, value: Any) -> Any:
186
+ """Rehydrate a reply: handles become proxies, recursively."""
187
+ if isinstance(value, dict):
188
+ handle = value.get("__handle__")
189
+ if handle is not None:
190
+ return BridgeProxy(transport, handle, type_name=value.get("__type__"),
191
+ shape=value.get("__shape__"),
192
+ strict_attributes=getattr(transport, "strict_attributes", True))
193
+ return {k: _decode_value(transport, v) for k, v in value.items()}
194
+ if isinstance(value, list):
195
+ return [_decode_value(transport, v) for v in value]
196
+ return value
197
+
198
+
199
+ class _BoundMethod:
200
+ """One callable method on a proxied object."""
201
+
202
+ __slots__ = ("_transport", "_handle", "_name")
203
+
204
+ def __init__(self, transport: BridgeTransport, handle: str, name: str) -> None:
205
+ self._transport = transport
206
+ self._handle = handle
207
+ self._name = name
208
+
209
+ def __call__(self, *args: Any) -> Any:
210
+ result = self._transport.request(
211
+ "call",
212
+ {"target": self._handle, "method": self._name,
213
+ "args": [_encode_argument(a) for a in args]},
214
+ )
215
+ return _decode_value(self._transport, (result or {}).get("value"))
216
+
217
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
218
+ return f"<bridge method {self._handle}.{self._name}>"
219
+
220
+
221
+ class BridgeProxy:
222
+ """Stands in for a live Resolve object reached over the bridge.
223
+
224
+ Attribute access yields a callable, so any chain the existing code uses
225
+ behaves as it would against the native object. Return values are never
226
+ cached: Resolve's own proxies are views onto mutable state, and caching
227
+ would hand callers a stale answer.
228
+
229
+ **Attribute existence IS checked**, and that is not optional. A naive
230
+ `__getattr__` proxy answers `hasattr(obj, anything)` with True, so
231
+ capability detection — `getattr(timeline, "CreateMagicMask", None)`, of which
232
+ this codebase has ~50 instances — silently passes for methods the running
233
+ Resolve build does not have. The call then fails somewhere else with an
234
+ unrelated error instead of refusing cleanly at the check.
235
+
236
+ So an unknown name raises `AttributeError`, exactly as the native object
237
+ would.
238
+
239
+ **Method sets are cached by provenance, never by type name.** Every live
240
+ Resolve object reports `type(obj).__name__ == "PyRemoteObject"` — measured on
241
+ 21.0.3.7, where the Resolve root carries 34 methods, Project 49 and
242
+ TimelineItem 88, all under that single name. Caching on it let the first
243
+ object touched define `hasattr` for every object after it: `GetProjectManager()`
244
+ came back holding the *Resolve root's* method set, so `.GetCurrentProject()`
245
+ raised AttributeError and any chain longer than one call was broken. Nothing
246
+ caught it in unit tests because the fakes have distinct class names.
247
+
248
+ Provenance — "whatever `resolve.GetProjectManager` returns" — discriminates
249
+ properly and costs the bridge nothing. It is a *hint* (it assumes one method
250
+ always returns one shape), so **absence is re-verified against the object
251
+ itself** before raising. A wrong or stale hint therefore cannot manufacture a
252
+ missing method, which is the only direction that breaks capability detection;
253
+ in the other direction it yields a method that fails when called, which is
254
+ what native Resolve does for every name anyway.
255
+ """
256
+
257
+ __slots__ = ("_transport", "_handle", "_type_name", "_shape", "_strict")
258
+
259
+ def __init__(self, transport: BridgeTransport, handle: str, *, type_name: Optional[str] = None,
260
+ shape: Optional[str] = None, strict_attributes: bool = True) -> None:
261
+ object.__setattr__(self, "_transport", transport)
262
+ object.__setattr__(self, "_handle", handle)
263
+ object.__setattr__(self, "_type_name", type_name)
264
+ object.__setattr__(self, "_shape", shape)
265
+ # `strict_attributes=False` permits any name, i.e. the naive-proxy
266
+ # behaviour where hasattr is always True. Only for talking to a bridge
267
+ # too old to support `list_methods`; `connect()` refuses those outright,
268
+ # so this is an explicit opt-in for diagnostics, never the default.
269
+ object.__setattr__(self, "_strict", strict_attributes)
270
+
271
+ def _cache_key(self) -> str:
272
+ # Never `_type_name`: it is `PyRemoteObject` for every live object.
273
+ return self._shape or self._handle
274
+
275
+ def _methods(self, *, refresh: bool = False) -> frozenset:
276
+ # Cached on the transport, so sibling objects from one bridge share a
277
+ # single fetch while a second bridge keeps its own answer.
278
+ cache = self._transport.method_cache
279
+ key = self._cache_key()
280
+ if not refresh:
281
+ cached = cache.get(key)
282
+ if cached is not None:
283
+ return cached
284
+ result = self._transport.request("list_methods", {"target": self._handle}) or {}
285
+ names = frozenset(result.get("methods") or ())
286
+ with self._transport.cache_lock:
287
+ cache[result.get("shape") or key] = names
288
+ cache[key] = names
289
+ return names
290
+
291
+ def __getattr__(self, name: str) -> Any:
292
+ if name.startswith("__") and name.endswith("__"):
293
+ # Let normal dunder lookup fail rather than sending it over the wire.
294
+ raise AttributeError(name)
295
+ if self._strict and name not in self._methods():
296
+ # Only now pay for a fresh look. Absence is the answer that has to be
297
+ # right — it is what `getattr(tl, "CreateMagicMask", None)` acts on —
298
+ # and the cached set is keyed on a provenance hint, so it is not
299
+ # allowed to be the last word on it.
300
+ if name not in self._methods(refresh=True):
301
+ # Not a method. It may still be an API *constant*: `EXPORT_AAF`,
302
+ # `AUDIO_SYNC_*` and friends are plain attributes that `dir()`
303
+ # does not list, and returning them is the difference between a
304
+ # working conform export and one that silently posts the string
305
+ # "EXPORT_AAF" to Resolve.
306
+ probe = self._transport.request("get_attribute",
307
+ {"target": self._handle, "name": name}) or {}
308
+ if probe.get("kind") == "value":
309
+ return _decode_value(self._transport, probe.get("value"))
310
+ # Matches native semantics: hasattr() is False, getattr(..., None)
311
+ # is None, and a capability check refuses instead of guessing.
312
+ # A `callable` answer lands here too — Resolve fabricates one for
313
+ # any name, so it is not evidence the attribute exists.
314
+ raise AttributeError(
315
+ f"{self._shape or self._type_name or 'Resolve object'} has no attribute "
316
+ f"{name!r} in this Resolve build"
317
+ )
318
+ return _BoundMethod(self._transport, self._handle, name)
319
+
320
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
321
+ return f"<BridgeProxy {self._handle}>"
322
+
323
+ # -- lifecycle helpers, not part of the Resolve API -------------------
324
+
325
+ def bridge_health(self) -> Dict[str, Any]:
326
+ """The bridge's own health payload — not a Resolve method."""
327
+ return self._transport.request("health", {})
328
+
329
+ def bridge_release_handles(self, handles: Optional[List[str]] = None) -> Dict[str, Any]:
330
+ """Drop handles the bridge is holding. Long sessions should call this."""
331
+ return self._transport.request(
332
+ "release_handles", {} if handles is None else {"handles": handles}
333
+ )
334
+
335
+ def bridge_shutdown(self) -> Dict[str, Any]:
336
+ """Stop the in-Resolve listener so Workspace ▸ Scripts can start it again.
337
+
338
+ Does not wait: the reply is written before the listener goes away, and
339
+ there is nothing to come back to.
340
+ """
341
+ return self._transport.request("shutdown", {})
342
+
343
+ def bridge_reload(self, *, wait: bool = True, timeout: float = 30.0,
344
+ poll_seconds: float = 0.1) -> Dict[str, Any]:
345
+ """Re-import the in-Resolve runtime from disk, in place.
346
+
347
+ This is what makes the bridge iterable: re-run the installer, call this,
348
+ and the new code is live without touching Resolve's UI.
349
+
350
+ Waiting is keyed on the surface's `session` id rather than on health
351
+ merely answering, because for a short window after the reply the *old*
352
+ listener is still accepting connections — a naive "wait until health
353
+ works" returns immediately, against the code being replaced.
354
+
355
+ **Every handle from before is dead.** The new session mints new ids, so
356
+ existing proxies raise `stale_handle` and must be re-fetched from a root.
357
+ """
358
+ before = (self.bridge_health() or {}).get("session")
359
+ result = dict(self._transport.request("reload", {}) or {})
360
+ if not wait:
361
+ return result
362
+ deadline = time.monotonic() + timeout
363
+ last_error: Optional[Exception] = None
364
+ while time.monotonic() < deadline:
365
+ time.sleep(poll_seconds)
366
+ try:
367
+ health = self.bridge_health() or {}
368
+ except (BridgeUnavailable, BridgeCallError) as exc:
369
+ last_error = exc # expected while the listener is down
370
+ continue
371
+ session = health.get("session")
372
+ if session and session != before:
373
+ self._transport.method_cache.clear()
374
+ return {**result, "session": session, "reloaded": True,
375
+ "operations": health.get("operations")}
376
+ raise BridgeCallError(
377
+ "reload_timeout",
378
+ f"the bridge did not come back within {timeout:g}s"
379
+ + (f" (last error: {last_error})" if last_error else "")
380
+ + ". Check the script's output inside Resolve — a reload that fails to "
381
+ "import keeps serving the previous runtime.",
382
+ )
383
+
384
+
385
+ def connect(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, require_enabled: bool = True) -> BridgeProxy:
386
+ """Return a Resolve-shaped proxy backed by the in-app bridge.
387
+
388
+ Raises `BridgeUnavailable` rather than returning None so a caller cannot
389
+ accidentally treat "no bridge" as "no Resolve" and carry on.
390
+ """
391
+ if require_enabled and not bridge_enabled():
392
+ raise BridgeUnavailable(
393
+ f"The in-app bridge is opt-in: set {ENV_ENABLE}=1 to use it."
394
+ )
395
+ path = config_path()
396
+ try:
397
+ config = _bridge.load_config(str(path))
398
+ except _bridge.BridgeConfigError as exc:
399
+ raise BridgeUnavailable(
400
+ f"Bridge configuration unusable ({exc}). Run "
401
+ "`python scripts/install_resolve_bridge.py` first."
402
+ ) from exc
403
+
404
+ transport = BridgeTransport(config, timeout=timeout)
405
+ proxy = BridgeProxy(transport, "resolve", type_name="Resolve", shape="resolve")
406
+ # Prove the far end is alive now, rather than failing on the first real call
407
+ # somewhere deep in a tool.
408
+ health = transport.request("health", {})
409
+ available = set((health or {}).get("operations") or ())
410
+ missing = [op for op in REQUIRED_BRIDGE_OPERATIONS if op not in available]
411
+ if missing:
412
+ raise BridgeUnavailable(
413
+ "The running in-Resolve bridge is older than this client and is "
414
+ f"missing {missing}. The script inside Resolve is a copy taken at "
415
+ "install time, so it does not update when the repository does. Fix: "
416
+ "re-run `python scripts/install_resolve_bridge.py`, then stop and "
417
+ "restart Workspace > Scripts > resolve_bridge."
418
+ )
419
+ logger.info(
420
+ "connected to the in-Resolve bridge: %s %s (%s)",
421
+ (health or {}).get("product"), (health or {}).get("version"), (health or {}).get("edition"),
422
+ )
423
+ return proxy