davinci-resolve-mcp 2.67.1 → 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,714 @@
1
+ """The named operation surface the in-app bridge exposes. Stdlib-only.
2
+
3
+ `resolve_bridge` is transport: authentication, framing, lifecycle. This is what
4
+ it is allowed to *do*. The split matters because the transport is generic and the
5
+ surface is the actual security boundary.
6
+
7
+ ## Named operations, not a method-name proxy
8
+
9
+ The tempting design is one generic `call(object, method, args)` with a
10
+ method-name prefix allowlist. It costs nothing to write and covers the whole API.
11
+ It is also not a security boundary: `startswith("Delete")` admits
12
+ `DeleteProject`, and prefix-matched `Import`/`Export` admit arbitrary filesystem
13
+ paths. Anything that reaches the socket then has the run of the project.
14
+
15
+ So operations are named, arguments are validated, and paths are checked against
16
+ configured roots. Adding an operation is a deliberate act.
17
+
18
+ ## The generic `call` operation, and its actual security model
19
+
20
+ `call` proxies one method invocation onto a live Resolve object, which is what
21
+ lets the MCP server's existing call sites work over the bridge unchanged. It is
22
+ deliberately NOT gated by a method-name allowlist — that was never a security
23
+ boundary anyway (`startswith("Delete")` admits `DeleteProject`).
24
+
25
+ The honest model: **`call` is gated by the token, and that is stronger than the
26
+ baseline it replaces.** Resolve's own external scripting API has no
27
+ authentication at all — any local process that can load `fusionscript` gets
28
+ unrestricted control of the running application. This bridge adds HMAC-signed
29
+ requests, one-use nonces, a freshness window, loopback-only binding and
30
+ pre-auth bounds on top of the same capability. Refusing `call` on security
31
+ grounds while native scripting sits wide open would be theatre.
32
+
33
+ What `call` genuinely constrains:
34
+
35
+ - **Only objects the API itself returned.** Targets are either one of the named
36
+ roots or a handle this bridge minted from a Resolve return value. A caller can
37
+ never name an arbitrary Python object, reach a module, or touch `__globals__`.
38
+ - **Dunder and private names are refused**, so `__class__`/`__globals__`
39
+ traversal is closed.
40
+ - **The handle table is bounded and session-scoped.** An unbounded table is a
41
+ slow memory leak inside Resolve — every enumerated timeline item pinning a
42
+ live proxy object forever.
43
+
44
+ The named operations below still exist and remain the stricter surface. Use them
45
+ when a caller only needs the common path; use `call` when the existing code has
46
+ to work unmodified.
47
+
48
+ ## Reads may be broad; writes are narrow
49
+
50
+ Reading cannot damage a project, so the read surface covers what an agent needs
51
+ to orient. Writes are deliberately few here: this bridge exists to reach the free
52
+ edition, and the destructive surface belongs behind the main server's existing
53
+ confirm-token and versioning discipline, not duplicated inside Resolve where
54
+ neither is available.
55
+
56
+ **This is a subset, and says so.** `health` reports exactly which operations
57
+ exist, so a caller discovers the surface rather than assuming parity with the
58
+ direct transport.
59
+
60
+ ## Lifecycle operations act on the bridge, not the project
61
+
62
+ `shutdown` and `reload` stop the listener. They exist because the launcher blocks
63
+ — the script that started the bridge cannot be re-run while it is alive, and the
64
+ runtime inside Resolve's Scripts folder is a copy taken at install time, so a
65
+ repository change strands a stale bridge with no way back short of quitting
66
+ Resolve. `reload` re-imports from disk in place and needs no UI interaction.
67
+
68
+ They carry no gate beyond the token, deliberately: a caller holding it can
69
+ already drive Resolve through `call`, so refusing them would guard nothing while
70
+ removing the only remote recovery path.
71
+
72
+ ## False is an error
73
+
74
+ Resolve's API returns False or None for failure far more often than it raises.
75
+ `_call(..., false_is_error=True)` turns that into a real failure instead of a
76
+ silent success — the single most common way these connectors lie.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import os
82
+ from collections import OrderedDict
83
+ from typing import Any, Callable, Dict, List, Optional, Tuple
84
+
85
+ PROTOCOL_SURFACE_VERSION = "1.0"
86
+
87
+
88
+ class OperationError(RuntimeError):
89
+ """A refused or failed operation, carrying a stable code."""
90
+
91
+ def __init__(self, code: str, message: str, **details: Any) -> None:
92
+ super().__init__(message)
93
+ self.code = code
94
+ self.message = message
95
+ self.details = details
96
+
97
+
98
+ # ── path policy ──────────────────────────────────────────────────────────────
99
+
100
+
101
+ class PathPolicy:
102
+ """Confine filesystem arguments to configured roots, after symlink resolution.
103
+
104
+ Resolution happens before the check, so a symlink pointing out of an allowed
105
+ root is rejected rather than followed.
106
+ """
107
+
108
+ def __init__(self, media_roots: List[str], output_roots: List[str]) -> None:
109
+ self.media_roots = self._normalize(media_roots, "media")
110
+ self.output_roots = self._normalize(output_roots, "output")
111
+
112
+ @staticmethod
113
+ def _normalize(roots: Any, kind: str) -> Tuple[str, ...]:
114
+ if not isinstance(roots, list) or not roots:
115
+ raise OperationError("policy_invalid", f"no {kind} roots configured")
116
+ resolved = []
117
+ for raw in roots:
118
+ if not isinstance(raw, str) or not raw:
119
+ raise OperationError("policy_invalid", f"invalid {kind} root")
120
+ resolved.append(os.path.realpath(os.path.expanduser(raw)))
121
+ return tuple(dict.fromkeys(resolved))
122
+
123
+ @staticmethod
124
+ def _under(path: str, roots: Tuple[str, ...]) -> bool:
125
+ return any(path == root or path.startswith(root + os.sep) for root in roots)
126
+
127
+ def input_file(self, raw: Any) -> str:
128
+ if not isinstance(raw, str) or not raw:
129
+ raise OperationError("invalid_path", "input path must be a non-empty string")
130
+ expanded = os.path.expanduser(raw)
131
+ if not os.path.isabs(expanded):
132
+ raise OperationError("invalid_path", "input path must be absolute")
133
+ path = os.path.realpath(expanded)
134
+ if not os.path.isfile(path):
135
+ raise OperationError("input_not_found", "input file does not exist", path=raw)
136
+ if not self._under(path, self.media_roots):
137
+ raise OperationError("path_not_allowed", "input path is outside the configured media roots")
138
+ return path
139
+
140
+
141
+ # ── the surface ──────────────────────────────────────────────────────────────
142
+
143
+
144
+ class ResolveOperations:
145
+ """Explicit router over Resolve's native proxy objects."""
146
+
147
+ #: Read-only: cannot damage a project, so the surface is generous.
148
+ READ_OPERATIONS = (
149
+ "health",
150
+ "list_projects",
151
+ "get_project",
152
+ "list_timelines",
153
+ "get_timeline",
154
+ "list_media",
155
+ "get_render_formats",
156
+ )
157
+ #: Writes: deliberately narrow. Destructive work stays behind the main
158
+ #: server's confirm-token + versioning discipline, which does not exist here.
159
+ WRITE_OPERATIONS = (
160
+ "save_project",
161
+ "set_current_timeline",
162
+ )
163
+ #: The transparent proxy. See the module docstring for its security model.
164
+ PROXY_OPERATIONS = ("call", "release_handles", "list_methods", "get_attribute")
165
+ #: Lifecycle: they act on the bridge, never on the project. Separated from the
166
+ #: write surface so "writes stay narrower than reads" keeps measuring what it
167
+ #: was written to measure.
168
+ LIFECYCLE_OPERATIONS = ("shutdown", "reload")
169
+ OPERATIONS = READ_OPERATIONS + WRITE_OPERATIONS + PROXY_OPERATIONS + LIFECYCLE_OPERATIONS
170
+
171
+ #: Handle-table ceiling. Every entry pins a live Resolve proxy object, so an
172
+ #: unbounded table is a memory leak inside Resolve that grows with every
173
+ #: timeline item anyone enumerates. Oldest entries are evicted; a caller that
174
+ #: loses a handle gets a clear `stale_handle` error and can re-fetch.
175
+ MAX_HANDLES = 4096
176
+
177
+ def __init__(
178
+ self,
179
+ resolve: Any,
180
+ *,
181
+ media_roots: List[str],
182
+ output_roots: List[str],
183
+ max_items: int = 500,
184
+ lifecycle: Optional[Callable[[str], Dict[str, Any]]] = None,
185
+ ) -> None:
186
+ if resolve is None:
187
+ raise OperationError("resolve_unavailable", "Resolve object is not available")
188
+ self.resolve = resolve
189
+ # Supplied by whatever owns the listener (the launcher). Absent means the
190
+ # lifecycle operations report `capability_unavailable` rather than
191
+ # pretending to stop something they have no handle on.
192
+ self._lifecycle = lifecycle
193
+ self.policy = PathPolicy(media_roots, output_roots)
194
+ self.max_items = max(1, min(int(max_items), 5000))
195
+ self._routes: Dict[str, Callable[[Dict[str, Any]], Any]] = {
196
+ name: getattr(self, f"op_{name}") for name in self.OPERATIONS
197
+ }
198
+ # Handles are session-scoped: a new ResolveOperations means new ids, so a
199
+ # stale handle from a previous bridge run can never silently resolve to a
200
+ # different object.
201
+ self._handles: "OrderedDict[str, Any]" = OrderedDict()
202
+ self._handle_counter = 0
203
+ self._session = os.urandom(4).hex()
204
+
205
+ # -- plumbing ---------------------------------------------------------
206
+
207
+ def dispatch(self, operation: str, arguments: Optional[Dict[str, Any]] = None) -> Any:
208
+ route = self._routes.get(operation)
209
+ if route is None:
210
+ raise OperationError(
211
+ "operation_not_allowed",
212
+ f"operation {operation!r} is not exposed by this bridge",
213
+ available=sorted(self.OPERATIONS),
214
+ )
215
+ if arguments is None:
216
+ arguments = {}
217
+ if not isinstance(arguments, dict):
218
+ raise OperationError("invalid_arguments", "arguments must be an object")
219
+ return route(arguments)
220
+
221
+ @staticmethod
222
+ def _call(obj: Any, method: str, *args: Any, false_is_error: bool = False) -> Any:
223
+ fn = getattr(obj, method, None)
224
+ if not callable(fn):
225
+ raise OperationError("capability_unavailable", f"Resolve does not expose {method} in this build")
226
+ value = fn(*args)
227
+ if false_is_error and (value is False or value is None):
228
+ raise OperationError("operation_failed", f"Resolve returned failure from {method}")
229
+ return value
230
+
231
+ def _project_manager(self) -> Any:
232
+ manager = self._call(self.resolve, "GetProjectManager")
233
+ if manager is None:
234
+ raise OperationError("resolve_not_ready", "Resolve has no project manager yet")
235
+ return manager
236
+
237
+ def _project(self) -> Any:
238
+ project = self._call(self._project_manager(), "GetCurrentProject")
239
+ if project is None:
240
+ raise OperationError("no_project", "no Resolve project is currently open")
241
+ return project
242
+
243
+ def _timeline(self, arguments: Dict[str, Any]) -> Any:
244
+ project = self._project()
245
+ name = arguments.get("timeline_name")
246
+ if not name:
247
+ timeline = self._call(project, "GetCurrentTimeline")
248
+ if timeline is None:
249
+ raise OperationError("no_timeline", "no current timeline is selected")
250
+ return timeline
251
+ matches = []
252
+ for index in range(1, int(self._call(project, "GetTimelineCount") or 0) + 1):
253
+ candidate = self._call(project, "GetTimelineByIndex", index)
254
+ if candidate is not None and self._call(candidate, "GetName") == name:
255
+ matches.append(candidate)
256
+ if not matches:
257
+ raise OperationError("not_found", f"timeline {name!r} was not found")
258
+ if len(matches) > 1:
259
+ # Resolve permits duplicate timeline names; acting on an arbitrary
260
+ # one of them is exactly the kind of silent wrong-target this refuses.
261
+ raise OperationError("ambiguous_locator", f"more than one timeline is named {name!r}")
262
+ return matches[0]
263
+
264
+ # -- reads ------------------------------------------------------------
265
+
266
+ def op_health(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
267
+ manager = self._project_manager()
268
+ project = self._call(manager, "GetCurrentProject")
269
+ product = str(self._call(self.resolve, "GetProductName") or "")
270
+ return {
271
+ "connected": True,
272
+ "product": product,
273
+ "version": self._call(self.resolve, "GetVersionString"),
274
+ "edition": "studio" if "studio" in product.lower() else "free",
275
+ "current_page": self._call(self.resolve, "GetCurrentPage"),
276
+ "current_project": self._call(project, "GetName") if project is not None else None,
277
+ "surface_version": PROTOCOL_SURFACE_VERSION,
278
+ # Handles are scoped to this id. A client that sees it change knows
279
+ # its handles are gone — which is how `reload` is waited on, since
280
+ # the bridge comes back on the same port with a new object graph.
281
+ "session": self._session,
282
+ # The caller discovers the surface rather than assuming parity with
283
+ # the direct transport — this bridge is deliberately a subset.
284
+ "operations": sorted(self.OPERATIONS),
285
+ "read_operations": sorted(self.READ_OPERATIONS),
286
+ "write_operations": sorted(self.WRITE_OPERATIONS),
287
+ # False when nothing owns the listener, so a client can tell
288
+ # "this bridge cannot be stopped remotely" from "I forgot to ask".
289
+ "lifecycle_available": self._lifecycle is not None,
290
+ "policy": {
291
+ "media_roots": list(self.policy.media_roots),
292
+ "output_roots": list(self.policy.output_roots),
293
+ "max_items": self.max_items,
294
+ },
295
+ }
296
+
297
+ def op_list_projects(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
298
+ manager = self._project_manager()
299
+ current = self._call(manager, "GetCurrentProject")
300
+ database = self._call(manager, "GetCurrentDatabase") or {}
301
+ if isinstance(database, dict):
302
+ database = {k: v for k, v in database.items() if k != "IpAddress"}
303
+ return {
304
+ "folder": self._call(manager, "GetCurrentFolder"),
305
+ "projects": list(self._call(manager, "GetProjectListInCurrentFolder") or [])[: self.max_items],
306
+ "current_project": self._call(current, "GetName") if current else None,
307
+ "database": database,
308
+ }
309
+
310
+ def op_get_project(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
311
+ project = self._project()
312
+ return {
313
+ "name": self._call(project, "GetName"),
314
+ "timeline_count": int(self._call(project, "GetTimelineCount") or 0),
315
+ "current_timeline": (
316
+ self._call(self._call(project, "GetCurrentTimeline"), "GetName")
317
+ if self._call(project, "GetCurrentTimeline") is not None
318
+ else None
319
+ ),
320
+ }
321
+
322
+ def op_list_timelines(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
323
+ project = self._project()
324
+ count = int(self._call(project, "GetTimelineCount") or 0)
325
+ timelines = []
326
+ for index in range(1, min(count, self.max_items) + 1):
327
+ timeline = self._call(project, "GetTimelineByIndex", index)
328
+ if timeline is None:
329
+ continue
330
+ timelines.append({
331
+ "index": index,
332
+ "name": self._call(timeline, "GetName"),
333
+ "start_frame": self._call(timeline, "GetStartFrame"),
334
+ "end_frame": self._call(timeline, "GetEndFrame"),
335
+ })
336
+ return {"timelines": timelines, "total": count, "truncated": count > self.max_items}
337
+
338
+ def op_get_timeline(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
339
+ timeline = self._timeline(arguments)
340
+ tracks = []
341
+ for track_type in ("video", "audio", "subtitle"):
342
+ count = int(self._call(timeline, "GetTrackCount", track_type) or 0)
343
+ for index in range(1, count + 1):
344
+ tracks.append({
345
+ "type": track_type,
346
+ "index": index,
347
+ "name": self._call(timeline, "GetTrackName", track_type, index),
348
+ "enabled": self._call(timeline, "GetIsTrackEnabled", track_type, index),
349
+ "locked": self._call(timeline, "GetIsTrackLocked", track_type, index),
350
+ })
351
+ return {
352
+ "name": self._call(timeline, "GetName"),
353
+ "start_frame": self._call(timeline, "GetStartFrame"),
354
+ "end_frame": self._call(timeline, "GetEndFrame"),
355
+ "start_timecode": self._call(timeline, "GetStartTimecode"),
356
+ "tracks": tracks,
357
+ }
358
+
359
+ def op_list_media(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
360
+ pool = self._call(self._project(), "GetMediaPool")
361
+ root = self._call(pool, "GetRootFolder", false_is_error=True)
362
+ clips: List[Dict[str, Any]] = []
363
+ pending = [root]
364
+ seen = set()
365
+ while pending and len(clips) < self.max_items:
366
+ folder = pending.pop()
367
+ unique = str(self._call(folder, "GetUniqueId"))
368
+ if unique in seen:
369
+ continue
370
+ seen.add(unique)
371
+ for clip in list(self._call(folder, "GetClipList") or []):
372
+ if len(clips) >= self.max_items:
373
+ break
374
+ props = self._call(clip, "GetClipProperty") or {}
375
+ raw_path = str(props.get("File Path", "") or "")
376
+ clips.append({
377
+ "name": self._call(clip, "GetName"),
378
+ # A path outside the configured roots is reported as such
379
+ # rather than leaked: the bridge should not become a way to
380
+ # enumerate the filesystem.
381
+ "file_path": raw_path if self._path_visible(raw_path) else "<outside-allowed-roots>",
382
+ "duration": props.get("Duration"),
383
+ "fps": props.get("FPS"),
384
+ "resolution": props.get("Resolution"),
385
+ })
386
+ pending.extend(list(self._call(folder, "GetSubFolderList") or []))
387
+ return {"clips": clips, "truncated": len(clips) >= self.max_items}
388
+
389
+ def _path_visible(self, raw: str) -> bool:
390
+ if not raw:
391
+ return False
392
+ try:
393
+ resolved = os.path.realpath(os.path.expanduser(raw))
394
+ except OSError:
395
+ return False
396
+ return self.policy._under(resolved, self.policy.media_roots)
397
+
398
+ def op_get_render_formats(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
399
+ project = self._project()
400
+ formats = self._call(project, "GetRenderFormats") or {}
401
+ return {
402
+ "formats": formats,
403
+ "current": self._call(project, "GetCurrentRenderFormatAndCodec") or {},
404
+ }
405
+
406
+ # -- writes -----------------------------------------------------------
407
+
408
+ def op_save_project(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
409
+ self._project()
410
+ return {"saved": bool(self._call(self._project_manager(), "SaveProject", false_is_error=True))}
411
+
412
+ def op_set_current_timeline(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
413
+ name = arguments.get("timeline_name")
414
+ if not isinstance(name, str) or not name.strip():
415
+ raise OperationError("invalid_arguments", "timeline_name is required")
416
+ timeline = self._timeline({"timeline_name": name})
417
+ self._call(self._project(), "SetCurrentTimeline", timeline, false_is_error=True)
418
+ return {"current_timeline": self._call(timeline, "GetName")}
419
+
420
+
421
+ # ── the transparent proxy ────────────────────────────────────────────────────
422
+
423
+ #: Roots a caller may start a chain from by name. Everything else must be a
424
+ #: handle this bridge minted, and therefore already a descendant of the live
425
+ #: `resolve` object.
426
+ ROOT_NAMES = ("resolve", "project_manager", "project", "media_pool", "current_timeline")
427
+
428
+ def _mint(self, obj: Any, shape: str = "?") -> str:
429
+ self._handle_counter += 1
430
+ handle = "h:%s:%d" % (self._session, self._handle_counter)
431
+ self._handles[handle] = (obj, shape)
432
+ # Bounded, **least-recently-used** — `_resolve_target` promotes on access,
433
+ # so a handle in continuous use is never the one dropped. Measured on a
434
+ # 400-item timeline: 6000 handles minted, a handle touched each round
435
+ # still valid, an untouched one evicted and refusing with `stale_handle`.
436
+ # That is the property that matters. An unbounded table would pin every
437
+ # enumerated item for the session; a recycled id resolving to a
438
+ # *different* object would be a wrong-target edit with no error anywhere.
439
+ while len(self._handles) > self.MAX_HANDLES:
440
+ self._handles.popitem(last=False)
441
+ return handle
442
+
443
+ def _shape_of(self, target: Any) -> str:
444
+ """Provenance, used by the client as a method-set cache key.
445
+
446
+ `type(obj).__name__` cannot serve: every live Resolve object is a
447
+ `PyRemoteObject`, so caching on it makes the first object touched define
448
+ `hasattr` for every object afterwards. Provenance — "whatever
449
+ `resolve.GetProjectManager` returns" — actually discriminates, and costs
450
+ nothing to compute.
451
+
452
+ It is a *hint*, not a proof: it assumes one method always returns one
453
+ shape. The client is required to re-verify before reporting a method
454
+ **missing**, so a wrong hint can never manufacture an absence. A wrong
455
+ hint in the other direction just yields a method that fails when called,
456
+ which is what native Resolve does for every name anyway.
457
+ """
458
+ if isinstance(target, str) and target in self.ROOT_NAMES:
459
+ return target
460
+ entry = self._handles.get(target) if isinstance(target, str) else None
461
+ return entry[1] if entry else "?"
462
+
463
+ def _named_root(self, name: str) -> Any:
464
+ if name == "resolve":
465
+ return self.resolve
466
+ manager = self._project_manager()
467
+ if name == "project_manager":
468
+ return manager
469
+ project = self._call(manager, "GetCurrentProject")
470
+ if name == "project":
471
+ if project is None:
472
+ raise OperationError("no_project", "no Resolve project is currently open")
473
+ return project
474
+ if project is None:
475
+ raise OperationError("no_project", "no Resolve project is currently open")
476
+ if name == "media_pool":
477
+ return self._call(project, "GetMediaPool")
478
+ if name == "current_timeline":
479
+ timeline = self._call(project, "GetCurrentTimeline")
480
+ if timeline is None:
481
+ raise OperationError("no_timeline", "no current timeline is selected")
482
+ return timeline
483
+ raise OperationError("invalid_arguments", f"unknown root object {name!r}")
484
+
485
+ def _resolve_target(self, target: Any) -> Any:
486
+ if not isinstance(target, str) or not target:
487
+ raise OperationError("invalid_arguments", "target must be a root name or a bridge handle")
488
+ if target in self.ROOT_NAMES:
489
+ return self._named_root(target)
490
+ if target.startswith("h:"):
491
+ if target in self._handles:
492
+ self._handles.move_to_end(target)
493
+ return self._handles[target][0]
494
+ raise OperationError(
495
+ "stale_handle",
496
+ "that handle is no longer held by the bridge — re-fetch the object and retry",
497
+ hint="handles are session-scoped and bounded; a restart or heavy enumeration evicts them",
498
+ )
499
+ raise OperationError(
500
+ "invalid_arguments",
501
+ f"target must be one of {list(self.ROOT_NAMES)} or a bridge-issued handle",
502
+ )
503
+
504
+ def _encode(self, value: Any, depth: int = 0, shape: str = "?") -> Any:
505
+ """Resolve return value -> JSON-safe, minting handles for live objects.
506
+
507
+ Every object produced by one call carries the same shape, including the
508
+ elements of a returned list — a track's timeline items are homogeneous,
509
+ which is exactly the case where sharing a cached method set pays.
510
+ """
511
+ if value is None or isinstance(value, (bool, int, float, str)):
512
+ return value
513
+ if depth > 6:
514
+ return str(value)
515
+ if isinstance(value, (list, tuple)):
516
+ return [self._encode(v, depth + 1, shape) for v in list(value)[: self.max_items]]
517
+ if isinstance(value, dict):
518
+ return {str(k): self._encode(v, depth + 1, shape) for k, v in list(value.items())[: self.max_items]}
519
+ return {
520
+ "__handle__": self._mint(value, shape),
521
+ "__type__": type(value).__name__,
522
+ "__shape__": shape,
523
+ }
524
+
525
+ def _decode(self, value: Any) -> Any:
526
+ """Argument -> live object, rehydrating handles the bridge itself issued."""
527
+ if isinstance(value, dict):
528
+ handle = value.get("__handle__")
529
+ if handle is not None:
530
+ return self._resolve_target(handle)
531
+ return {k: self._decode(v) for k, v in value.items()}
532
+ if isinstance(value, list):
533
+ return [self._decode(v) for v in value]
534
+ return value
535
+
536
+ def op_call(self, arguments: Dict[str, Any]) -> Any:
537
+ """Invoke one method on a live Resolve object. See the module docstring.
538
+
539
+ Returns whatever Resolve returned, verbatim — including False. The proxy
540
+ is transparent on purpose: the MCP server's existing call sites already
541
+ interpret False correctly, and re-interpreting it here would change the
542
+ meaning of code that was written against the native API.
543
+ """
544
+ method = arguments.get("method")
545
+ if not isinstance(method, str) or not method:
546
+ raise OperationError("invalid_arguments", "method must be a non-empty string")
547
+ if method.startswith("_"):
548
+ # Closes __class__/__globals__/__reduce__ traversal off the live object.
549
+ raise OperationError("method_not_allowed", "private and dunder attributes are not reachable")
550
+ target_key = arguments.get("target", "resolve")
551
+ target = self._resolve_target(target_key)
552
+ if target is None:
553
+ raise OperationError("not_found", "the requested object is not available right now")
554
+ fn = getattr(target, method, None)
555
+ if not callable(fn):
556
+ raise OperationError(
557
+ "capability_unavailable",
558
+ f"{type(target).__name__} has no callable {method!r} in this Resolve build",
559
+ )
560
+ raw_args = arguments.get("args") or []
561
+ if not isinstance(raw_args, list):
562
+ raise OperationError("invalid_arguments", "args must be a list")
563
+ if len(raw_args) > 64:
564
+ raise OperationError("invalid_arguments", "too many arguments")
565
+ try:
566
+ result = fn(*[self._decode(a) for a in raw_args])
567
+ except OperationError:
568
+ raise
569
+ except Exception as exc:
570
+ raise OperationError(
571
+ "resolve_raised",
572
+ f"Resolve raised while running {method}: {str(exc)[:200]}",
573
+ )
574
+ return {"value": self._encode(result, shape=f"{self._shape_of(target_key)}.{method}")}
575
+
576
+ def op_list_methods(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
577
+ """The public attribute names on a target — what `hasattr` should answer.
578
+
579
+ The proxy needs this. Without it a `__getattr__`-based proxy claims every
580
+ name exists, so capability detection — `getattr(obj, "CreateMagicMask",
581
+ None)` — silently passes for methods this Resolve build does not have,
582
+ and the failure resurfaces later as an unrelated error.
583
+
584
+ **`dir()` is the whole answer; do not filter with `callable(getattr(...))`.**
585
+ Two measured reasons, both specific to Resolve's Python objects:
586
+
587
+ 1. It cannot reject anything. Resolve's bridge fabricates a callable for
588
+ *any* attribute name, so the test is true by construction — that is the
589
+ documented limitation this operation exists to work around, and `dir()`
590
+ is the only thing that tells the truth.
591
+ 2. It is ruinously slow. Each `getattr` is an IPC round trip into Resolve,
592
+ so filtering costs one round trip **per name**: measured at 5-10 ms for
593
+ a 30-88 method object against 0.85 ms for an ordinary call. Removing it
594
+ makes introspection as cheap as any other call.
595
+
596
+ `type()` is returned for diagnostics only. It is **not** an identity: every
597
+ live Resolve object is a `PyRemoteObject` regardless of what it is, so
598
+ anything that caches on it conflates a Timeline with the Resolve root.
599
+ """
600
+ target_key = arguments.get("target", "resolve")
601
+ target = self._resolve_target(target_key)
602
+ names = sorted(name for name in dir(target) if not name.startswith("_"))
603
+ return {"type": type(target).__name__, "shape": self._shape_of(target_key), "methods": names}
604
+
605
+ # -- lifecycle ---------------------------------------------------------
606
+ #
607
+ # These stop or restart the bridge, never the project. No extra gate beyond
608
+ # the token: a caller holding it can already drive Resolve through `call`, so
609
+ # refusing them here would guard nothing while removing the only way to
610
+ # recover a stale bridge without quitting Resolve.
611
+
612
+ def _lifecycle_stop(self, mode: str) -> Dict[str, Any]:
613
+ if self._lifecycle is None:
614
+ raise OperationError(
615
+ "capability_unavailable",
616
+ "this bridge was started without a lifecycle owner, so it cannot stop itself; "
617
+ "quit the script inside Resolve instead",
618
+ )
619
+ outcome = self._lifecycle(mode) or {}
620
+ # The reply has to be written before the listener goes away, so the
621
+ # handler returns now and the teardown happens in the serve loop.
622
+ return {"stopping": True, "mode": mode, **outcome}
623
+
624
+ def op_shutdown(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
625
+ """Stop the listener so Workspace ▸ Scripts can start it again."""
626
+ return self._lifecycle_stop("exit")
627
+
628
+ def op_reload(self, _arguments: Dict[str, Any]) -> Dict[str, Any]:
629
+ """Re-import the runtime from disk and serve again, in the same process.
630
+
631
+ The in-Resolve runtime is a copy taken at install time, so a repository
632
+ change leaves the bridge stale with no way back except quitting Resolve.
633
+ This closes that loop: re-run the installer, call `reload`, and the new
634
+ code is live without touching the UI.
635
+
636
+ Handles do not survive: the new `ResolveOperations` has a new session, so
637
+ a client's existing proxies get `stale_handle` and re-fetch. That is the
638
+ designed behaviour — silently resolving an old handle against a new object
639
+ graph is the wrong-target failure the session scoping exists to prevent.
640
+ """
641
+ return self._lifecycle_stop("reload")
642
+
643
+ #: Distinguishes "attribute is absent" from "attribute is present and None",
644
+ #: which `getattr(obj, name, None)` cannot.
645
+ _MISSING = object()
646
+
647
+ def op_get_attribute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
648
+ """Read a non-callable attribute — Resolve's API constants live here.
649
+
650
+ `Timeline.Export(path, resolve.EXPORT_AAF, resolve.EXPORT_AAF_NEW)` is the
651
+ documented way to export, and `resolve.AUDIO_SYNC_*` is the documented way
652
+ to configure AutoSyncAudio. Both are plain attributes, and **`dir()` does
653
+ not list them** — measured on 21.0.3.7, where `dir(resolve)` returns 34
654
+ names and not one `EXPORT_*` among them.
655
+
656
+ Without this operation the proxy answers `hasattr(resolve, "EXPORT_AAF")`
657
+ with False, and the server's own resolver then falls back to passing the
658
+ bare string `"EXPORT_AAF"` to Resolve. That is a silent degradation of the
659
+ entire conform surface, which is why reading attributes is a first-class
660
+ operation rather than something a caller works around.
661
+
662
+ `kind` is reported rather than a bare value so a caller can tell a
663
+ constant from a method from an absence, instead of inferring it from a
664
+ None that could mean any of the three.
665
+ """
666
+ name = arguments.get("name")
667
+ if not isinstance(name, str) or not name:
668
+ raise OperationError("invalid_arguments", "name must be a non-empty string")
669
+ if name.startswith("_"):
670
+ raise OperationError("method_not_allowed", "private and dunder attributes are not reachable")
671
+ target = self._resolve_target(arguments.get("target", "resolve"))
672
+ try:
673
+ value = getattr(target, name, self._MISSING)
674
+ except Exception as exc: # noqa: BLE001 - a raising getattr is an answer
675
+ raise OperationError("resolve_raised", f"reading {name} raised: {str(exc)[:200]}")
676
+ if value is self._MISSING:
677
+ return {"kind": "absent"}
678
+ if callable(value):
679
+ # Left to `call`. Reported rather than returned because Resolve
680
+ # fabricates a callable for names that do not exist, so "callable"
681
+ # is not evidence the attribute is real — `dir()` is.
682
+ return {"kind": "callable"}
683
+ if value is None:
684
+ # Measured on 21.0.3.7: `getattr(resolve, "Xyzzy_NotAThing")` returns
685
+ # None rather than raising, so the sentinel above never fires on a
686
+ # Resolve object and None cannot be told apart from absence. Reported
687
+ # as its own kind instead of as a value, because returning it would
688
+ # make `hasattr` true for every name anyone ever asks about — the
689
+ # always-true trap this whole path exists to avoid.
690
+ return {"kind": "none",
691
+ "note": "present-but-None; on Resolve objects this is indistinguishable "
692
+ "from absent, because getattr never raises"}
693
+ return {"kind": "value", "value": self._encode(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")}
694
+
695
+ def op_release_handles(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
696
+ """Drop handles a client no longer needs, or all of them."""
697
+ handles = arguments.get("handles")
698
+ if handles is None:
699
+ count = len(self._handles)
700
+ self._handles.clear()
701
+ return {"released": count, "all": True}
702
+ if not isinstance(handles, list):
703
+ raise OperationError("invalid_arguments", "handles must be a list")
704
+ released = sum(1 for h in handles if self._handles.pop(h, None) is not None)
705
+ return {"released": released, "all": False, "held": len(self._handles)}
706
+
707
+
708
+ def make_dispatch(operations: ResolveOperations) -> Callable[[str, Dict[str, Any]], Any]:
709
+ """Adapt a `ResolveOperations` into the callable `Bridge` expects."""
710
+
711
+ def dispatch(operation: str, arguments: Dict[str, Any]) -> Any:
712
+ return operations.dispatch(operation, arguments)
713
+
714
+ return dispatch