davinci-resolve-mcp 2.87.2 → 2.89.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.
@@ -192,7 +192,7 @@ def save_project() -> str:
192
192
  try:
193
193
  # Method 1: Try direct save method if available
194
194
  try:
195
- if hasattr(current_project, "SaveProject"):
195
+ if _has_method(current_project, "SaveProject"):
196
196
  result = current_project.SaveProject()
197
197
  if result:
198
198
  logger.info(f"Project '{project_name}' saved using SaveProject method")
@@ -204,7 +204,7 @@ def save_project() -> str:
204
204
  # Method 2: Try project manager save method
205
205
  if not success:
206
206
  try:
207
- if hasattr(project_manager, "SaveProject"):
207
+ if _has_method(project_manager, "SaveProject"):
208
208
  result = project_manager.SaveProject()
209
209
  if result:
210
210
  logger.info(f"Project '{project_name}' saved using ProjectManager.SaveProject method")
@@ -1640,8 +1640,9 @@ def generate_speech(text_input: str, voice_model: str = "", timecode: str = "",
1640
1640
  pm, current_project = get_current_project()
1641
1641
  if not current_project:
1642
1642
  return {"error": "No project currently open"}
1643
- if not hasattr(current_project, "GenerateSpeech"):
1644
- return {"error": "GenerateSpeech requires DaVinci Resolve 21+ and the AI Speech Generator Extra"}
1643
+ missing = _requires_method(current_project, "GenerateSpeech", "21.0")
1644
+ if missing:
1645
+ return missing
1645
1646
  if not text_input:
1646
1647
  return {"error": "text_input is required"}
1647
1648
  settings: Dict[str, Any] = {"TextInput": text_input}
@@ -1664,6 +1665,28 @@ def generate_speech(text_input: str, voice_model: str = "", timecode: str = "",
1664
1665
  if audio_track is not None:
1665
1666
  settings["AudioTrack"] = audio_track
1666
1667
  new_item = current_project.GenerateSpeech(settings, timecode or "")
1667
- if not new_item:
1668
- return {"success": False, "error": "GenerateSpeech returned no media item"}
1668
+ # Without the AI Speech Generator Extra this returns the reason as a STRING,
1669
+ # which is truthy — the old check passed it through to .GetName().
1670
+ ok, message = _ai_result(new_item)
1671
+ if not ok:
1672
+ return {"success": False,
1673
+ "error": message or "GenerateSpeech returned no media item"}
1669
1674
  return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId()}
1675
+
1676
+
1677
+ @mcp.tool()
1678
+ def get_project_attributes_in_current_folder() -> Dict[str, Any]:
1679
+ """Get per-project attributes for every project in the current folder (Resolve 21.0.4+).
1680
+
1681
+ Calls ProjectManager.GetProjectAttributesInCurrentFolder(). Returns a dict
1682
+ keyed by project name with 'lastModifiedDate', 'creationDate', 'notes' and
1683
+ 'liveCollaborationMode' — without loading any project.
1684
+ """
1685
+ project_manager = get_project_manager()
1686
+ if not project_manager:
1687
+ return {"error": "Failed to get Project Manager"}
1688
+ missing = _requires_method(project_manager, "GetProjectAttributesInCurrentFolder", "21.0.4")
1689
+ if missing:
1690
+ return missing
1691
+ attributes = project_manager.GetProjectAttributesInCurrentFolder()
1692
+ return {"projects": attributes if attributes else {}}
@@ -186,10 +186,12 @@ def inspect_custom_object(object_path: str) -> Dict[str, Any]:
186
186
  return {"error": f"Method '{method_name}' not found or not callable"}
187
187
  else:
188
188
  # It's an attribute access
189
- if hasattr(obj, part):
190
- obj = getattr(obj, part)
191
- else:
189
+ # getattr, not hasattr: on a Resolve object hasattr is a
190
+ # constant True, so the not-found branch could never run.
191
+ value = getattr(obj, part, None)
192
+ if value is None:
192
193
  return {"error": f"Attribute '{part}' not found"}
194
+ obj = value
193
195
 
194
196
  # Inspect the object we've retrieved
195
197
  return inspect_object(obj)
@@ -399,7 +401,19 @@ def get_resolve_version_fields() -> Dict[str, Any]:
399
401
  return {"error": "Not connected to DaVinci Resolve"}
400
402
  version = resolve.GetVersion()
401
403
  if version:
402
- return {"major": version[0], "minor": version[1], "patch": version[2], "build": version[3], "suffix": version[4] if len(version) > 4 else ""}
404
+ # Carry what this build is MISSING, not just what it is. An agent that
405
+ # only learns the number still has to know which surfaces that number
406
+ # rules out, and guessing that is issue #132.
407
+ missing = gates_unavailable_on(resolve.GetVersionString())
408
+ return {"major": version[0], "minor": version[1], "patch": version[2], "build": version[3],
409
+ "suffix": version[4] if len(version) > 4 else "",
410
+ "unavailable_on_this_build": missing,
411
+ "note": ("Recorded surfaces absent here — do not offer them. An absence "
412
+ "from this list is not a promise a method exists; most of the "
413
+ "API has never been version-bisected."
414
+ if missing else
415
+ "Clears every recorded version gate. Surfaces nobody has "
416
+ "bisected are still unknown — probe before offering them.")}
403
417
  return {"error": "Failed to get version"}
404
418
 
405
419
 
@@ -536,3 +550,172 @@ def quit_resolve() -> Dict[str, Any]:
536
550
  return {"error": "Not connected to DaVinci Resolve"}
537
551
  resolve.Quit()
538
552
  return {"success": True, "message": "DaVinci Resolve is quitting"}
553
+
554
+
555
+ @mcp.tool()
556
+ def get_layout_preset_list() -> Dict[str, Any]:
557
+ """Get the names of all saved UI layout presets (Resolve 21.0.4+).
558
+
559
+ Calls Resolve.GetLayoutPresetList(). These are the names accepted by
560
+ load_layout_preset_tool, update_layout_preset, export_layout_preset_tool
561
+ and delete_layout_preset_tool.
562
+ """
563
+ resolve = get_resolve()
564
+ if resolve is None:
565
+ return {"error": "Not connected to DaVinci Resolve"}
566
+ missing = _requires_method(resolve, "GetLayoutPresetList", "21.0.4")
567
+ if missing:
568
+ return missing
569
+ presets = resolve.GetLayoutPresetList()
570
+ return {"presets": presets if presets else []}
571
+
572
+
573
+ @mcp.tool()
574
+ def get_burn_in_preset_list() -> Dict[str, Any]:
575
+ """Get the names of all saved data burn-in presets (Resolve 21.0.4+).
576
+
577
+ Calls Resolve.GetBurnInPresetList(). These are the names accepted by the
578
+ 'DataBurnIn' render setting, project load_burn_in_preset, and
579
+ export_burn_in_preset.
580
+ """
581
+ resolve = get_resolve()
582
+ if resolve is None:
583
+ return {"error": "Not connected to DaVinci Resolve"}
584
+ missing = _requires_method(resolve, "GetBurnInPresetList", "21.0.4")
585
+ if missing:
586
+ return missing
587
+ presets = resolve.GetBurnInPresetList()
588
+ return {"presets": presets if presets else []}
589
+
590
+
591
+ @mcp.tool()
592
+ def delete_burn_in_preset(preset_name: str) -> Dict[str, Any]:
593
+ """Delete a data burn-in preset by name (Resolve 21.0.4+).
594
+
595
+ Args:
596
+ preset_name: Name of the burn-in preset to delete.
597
+ """
598
+ resolve = get_resolve()
599
+ if resolve is None:
600
+ return {"error": "Not connected to DaVinci Resolve"}
601
+ missing = _requires_method(resolve, "DeleteBurnInPreset", "21.0.4")
602
+ if missing:
603
+ return missing
604
+ result = resolve.DeleteBurnInPreset(preset_name)
605
+ return {"success": bool(result), "preset_name": preset_name}
606
+
607
+
608
+ @mcp.tool()
609
+ def get_user_preferences_preset_list() -> Dict[str, Any]:
610
+ """Get the names of all saved user-preferences presets (Resolve 21.0.4+).
611
+
612
+ Calls Resolve.GetUserPreferencesPresetList().
613
+ """
614
+ resolve = get_resolve()
615
+ if resolve is None:
616
+ return {"error": "Not connected to DaVinci Resolve"}
617
+ missing = _requires_method(resolve, "GetUserPreferencesPresetList", "21.0.4")
618
+ if missing:
619
+ return missing
620
+ presets = resolve.GetUserPreferencesPresetList()
621
+ return {"presets": presets if presets else []}
622
+
623
+
624
+ @mcp.tool()
625
+ def save_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
626
+ """Save the current user preferences as a named preset (Resolve 21.0.4+).
627
+
628
+ Args:
629
+ preset_name: Name for the new user-preferences preset.
630
+ """
631
+ resolve = get_resolve()
632
+ if resolve is None:
633
+ return {"error": "Not connected to DaVinci Resolve"}
634
+ missing = _requires_method(resolve, "SaveUserPreferencesPreset", "21.0.4")
635
+ if missing:
636
+ return missing
637
+ result = resolve.SaveUserPreferencesPreset(preset_name)
638
+ return {"success": bool(result), "preset_name": preset_name}
639
+
640
+
641
+ @mcp.tool()
642
+ def load_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
643
+ """Load a user-preferences preset (Resolve 21.0.4+).
644
+
645
+ SESSION-WIDE: this swaps the user's global Resolve preferences, not a
646
+ project setting. It affects every project open in this Resolve instance.
647
+ Only call when the user explicitly asked for the switch.
648
+
649
+ Args:
650
+ preset_name: Name of the user-preferences preset to load.
651
+ """
652
+ resolve = get_resolve()
653
+ if resolve is None:
654
+ return {"error": "Not connected to DaVinci Resolve"}
655
+ missing = _requires_method(resolve, "LoadUserPreferencesPreset", "21.0.4")
656
+ if missing:
657
+ return missing
658
+ result = resolve.LoadUserPreferencesPreset(preset_name)
659
+ return {"success": bool(result), "preset_name": preset_name}
660
+
661
+
662
+ @mcp.tool()
663
+ def delete_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
664
+ """Delete a user-preferences preset by name (Resolve 21.0.4+).
665
+
666
+ Args:
667
+ preset_name: Name of the user-preferences preset to delete.
668
+ """
669
+ resolve = get_resolve()
670
+ if resolve is None:
671
+ return {"error": "Not connected to DaVinci Resolve"}
672
+ missing = _requires_method(resolve, "DeleteUserPreferencesPreset", "21.0.4")
673
+ if missing:
674
+ return missing
675
+ result = resolve.DeleteUserPreferencesPreset(preset_name)
676
+ return {"success": bool(result), "preset_name": preset_name}
677
+
678
+
679
+ @mcp.tool()
680
+ def import_user_preferences_preset(import_path: str, preset_name: str = None) -> Dict[str, Any]:
681
+ """Import a user-preferences preset from a file (Resolve 21.0.4+).
682
+
683
+ The imported preset is NOT auto-loaded; it takes its name from the file
684
+ when preset_name is omitted (measured on Studio 21.0.4.5). Follow with
685
+ load_user_preferences_preset to activate it.
686
+
687
+ Args:
688
+ import_path: Absolute path to the preset file to import.
689
+ preset_name: Name to save the imported preset as (filename if None).
690
+ """
691
+ resolve = get_resolve()
692
+ if resolve is None:
693
+ return {"error": "Not connected to DaVinci Resolve"}
694
+ missing = _requires_method(resolve, "ImportUserPreferencesPreset", "21.0.4")
695
+ if missing:
696
+ return missing
697
+ if preset_name:
698
+ result = resolve.ImportUserPreferencesPreset(import_path, preset_name)
699
+ else:
700
+ result = resolve.ImportUserPreferencesPreset(import_path)
701
+ preset_name = os.path.splitext(os.path.basename(import_path))[0]
702
+ return {"success": bool(result), "preset_name": preset_name, "import_path": import_path,
703
+ "note": "The imported preset is not auto-loaded; use load_user_preferences_preset to activate it."}
704
+
705
+
706
+ @mcp.tool()
707
+ def export_user_preferences_preset(preset_name: str, export_path: str) -> Dict[str, Any]:
708
+ """Export a user-preferences preset to a file (Resolve 21.0.4+).
709
+
710
+ Args:
711
+ preset_name: Name of the user-preferences preset to export.
712
+ export_path: Absolute path where the preset file will be saved.
713
+ """
714
+ resolve = get_resolve()
715
+ if resolve is None:
716
+ return {"error": "Not connected to DaVinci Resolve"}
717
+ missing = _requires_method(resolve, "ExportUserPreferencesPreset", "21.0.4")
718
+ if missing:
719
+ return missing
720
+ result = resolve.ExportUserPreferencesPreset(preset_name, export_path)
721
+ return {"success": bool(result), "preset_name": preset_name, "export_path": export_path}
@@ -655,8 +655,8 @@ def timeline_create_compound_clip(
655
655
  return {"success": False, "error": "Failed to create compound clip"}
656
656
  return {
657
657
  "success": True,
658
- "name": result.GetName() if hasattr(result, "GetName") else None,
659
- "unique_id": result.GetUniqueId() if hasattr(result, "GetUniqueId") else None,
658
+ "name": result.GetName() if _has_method(result, "GetName") else None,
659
+ "unique_id": result.GetUniqueId() if _has_method(result, "GetUniqueId") else None,
660
660
  }
661
661
 
662
662
 
@@ -714,8 +714,11 @@ def timeline_export(file_path: str, export_type: str, export_subtype: str = "EXP
714
714
  return err
715
715
  # Map string constants to resolve constants
716
716
  try:
717
- etype = getattr(resolve, export_type) if hasattr(resolve, export_type) else export_type
718
- esub = getattr(resolve, export_subtype) if hasattr(resolve, export_subtype) else export_subtype
717
+ # Fall back on the VALUE, not on hasattr: hasattr is a constant True
718
+ # here, so a build without the constant used to hand Export a None
719
+ # instead of the string name the fallback was written to pass through.
720
+ etype = _api_constant(resolve, export_type, export_type)
721
+ esub = _api_constant(resolve, export_subtype, export_subtype)
719
722
  except Exception:
720
723
  logger.debug("Could not resolve timeline export constants", exc_info=True)
721
724
  etype = export_type
@@ -1087,3 +1090,33 @@ def set_timeline_setting(setting_name: str, setting_value: str) -> Dict[str, Any
1087
1090
  return err
1088
1091
  result = tl.SetSetting(setting_name, setting_value)
1089
1092
  return {"success": bool(result), "setting_name": setting_name, "setting_value": setting_value}
1093
+
1094
+
1095
+ @mcp.tool()
1096
+ def get_selected_timeline_items() -> Dict[str, Any]:
1097
+ """Get the timeline items currently selected in the timeline (Resolve 21.0.4+).
1098
+
1099
+ Calls Timeline.GetSelectedClips() on the current timeline. An empty list
1100
+ means nothing is selected — that is an answer, not a failure. (Distinct
1101
+ from get_selected_clips, which reads the Media Pool selection.)
1102
+ """
1103
+ _, tl, err = _get_timeline()
1104
+ if err:
1105
+ return err
1106
+ missing = _requires_method(tl, "GetSelectedClips", "21.0.4")
1107
+ if missing:
1108
+ return missing
1109
+ items = tl.GetSelectedClips() or []
1110
+ summaries = []
1111
+ for item in items:
1112
+ entry = {}
1113
+ for getter, key in (("GetName", "name"), ("GetUniqueId", "unique_id"),
1114
+ ("GetStart", "start"), ("GetEnd", "end")):
1115
+ method = getattr(item, getter, None)
1116
+ if callable(method):
1117
+ try:
1118
+ entry[key] = method()
1119
+ except Exception:
1120
+ pass
1121
+ summaries.append(entry)
1122
+ return {"count": len(summaries), "items": summaries}
@@ -2118,7 +2118,7 @@ def ti_export_lut(export_type: str, path: str, item_index: int = 0, track_type:
2118
2118
  if err:
2119
2119
  return err
2120
2120
  try:
2121
- etype = getattr(resolve, export_type) if hasattr(resolve, export_type) else export_type
2121
+ etype = _api_constant(resolve, export_type, export_type)
2122
2122
  except Exception:
2123
2123
  etype = export_type
2124
2124
  return {"success": bool(item.ExportLUT(etype, path))}
@@ -34,7 +34,7 @@ from src.utils.update_check import start_background_update_check
34
34
  if __name__ == "__main__":
35
35
  try:
36
36
  start_background_update_check(VERSION, project_dir, logger)
37
- logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (341 granular tools)")
37
+ logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (353 granular tools)")
38
38
  run_fastmcp_stdio(mcp)
39
39
  except KeyboardInterrupt:
40
40
  logger.info("Server shutdown requested")
package/src/server.py CHANGED
@@ -8,10 +8,10 @@ Each tool groups related operations via an 'action' parameter.
8
8
 
9
9
  Usage:
10
10
  python src/server.py # Start the MCP server
11
- python src/server.py --full # Start the 341-tool granular server instead
11
+ python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.87.2"
14
+ VERSION = "2.89.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -48,6 +48,7 @@ from src.utils.mcp_stdio import run_fastmcp_stdio
48
48
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
49
  from src.utils import clip_colors as _clip_colors
50
50
  from src.utils import resolve_versions as _resolve_versions
51
+ from src.utils.resolve_probe import api_constant as _api_constant, has_method as _probe_has_method
51
52
  from src.utils.contracts import validate as _validate_params
52
53
  from src.utils.cut_ir import build_cut_list as _build_cut_list
53
54
  from src.utils.page_lock import (
@@ -1032,7 +1033,7 @@ def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional
1032
1033
  except Exception:
1033
1034
  project_name = None
1034
1035
  try:
1035
- project_id = proj.GetUniqueId() if hasattr(proj, "GetUniqueId") else None
1036
+ project_id = proj.GetUniqueId() if _has_method(proj, "GetUniqueId") else None
1036
1037
  except Exception:
1037
1038
  project_id = None
1038
1039
  root = resolve_media_analysis_output_root(
@@ -1785,7 +1786,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1785
1786
  return {"sent": False, "error": f"{type(exc).__name__}: {exc}"}
1786
1787
 
1787
1788
  def _has_method(obj, method_name):
1788
- return callable(getattr(obj, method_name, None))
1789
+ # `hasattr` is a constant True on Resolve objects — see src/utils/resolve_probe.
1790
+ return _probe_has_method(obj, method_name)
1789
1791
 
1790
1792
  def _requires_method(obj, method_name, min_version):
1791
1793
  if _has_method(obj, method_name):
@@ -5901,10 +5903,11 @@ def _timeline_export_value(value, resolve_obj=None):
5901
5903
  if not raw:
5902
5904
  return "", None
5903
5905
  const_name = raw if raw.startswith("EXPORT_") else None
5904
- if const_name and resolve_obj is not None and hasattr(resolve_obj, const_name):
5905
- return getattr(resolve_obj, const_name), const_name
5906
5906
  if const_name:
5907
- return const_name, const_name
5907
+ # hasattr is a constant True on a Resolve object, so the old presence
5908
+ # test always won and handed Export a None from getattr on a build
5909
+ # without the constant. Fall back on the value instead.
5910
+ return _api_constant(resolve_obj, const_name, const_name), const_name
5908
5911
  return raw, None
5909
5912
 
5910
5913
 
@@ -7383,9 +7386,7 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
7383
7386
 
7384
7387
 
7385
7388
  def _resolve_audio_constant(resolve_obj, name: str, fallback):
7386
- if resolve_obj is not None and hasattr(resolve_obj, name):
7387
- return getattr(resolve_obj, name)
7388
- return fallback
7389
+ return _api_constant(resolve_obj, name, fallback)
7389
7390
 
7390
7391
 
7391
7392
  def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
@@ -13787,10 +13788,33 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13787
13788
  if action == "get_version":
13788
13789
  update_env = _setup_update_env()
13789
13790
  mcp_update = get_cached_update_status(project_dir, VERSION, env=update_env)
13791
+ version_string = r.GetVersionString()
13792
+ # The first call of nearly every session. Issue #132 is the report of an
13793
+ # agent describing a surface that was not on the user's build, and the
13794
+ # reason it could happen is that nothing in the session ever said which
13795
+ # build that was in terms of what is missing from it. So the answer to
13796
+ # "what am I connected to" now carries what this build does not have,
13797
+ # rather than waiting to be asked.
13798
+ missing = _resolve_versions.gates_unavailable_on(version_string)
13790
13799
  return {
13791
13800
  "product": r.GetProductName(),
13792
13801
  "version": r.GetVersion(),
13793
- "version_string": r.GetVersionString(),
13802
+ "version_string": version_string,
13803
+ "build": {
13804
+ "unavailable_on_this_build": missing,
13805
+ "known_gates": len(_resolve_versions.VERSION_GATES),
13806
+ "note": (
13807
+ f"{len(missing)} recorded surface(s) are absent on this build. "
13808
+ "Do not offer them. An absence from this list is NOT a promise "
13809
+ "the method exists — most of the scripting API has never been "
13810
+ "version-bisected, so ask check_version_support for a specific "
13811
+ "symbol and probe when it answers `unknown`."
13812
+ if missing else
13813
+ "This build clears every recorded version gate. That is not a "
13814
+ "promise about surfaces nobody has bisected — ask "
13815
+ "check_version_support for a specific symbol before offering it."
13816
+ ),
13817
+ },
13794
13818
  "mcp": {
13795
13819
  "version": VERSION,
13796
13820
  "update": mcp_update,
@@ -22792,9 +22816,7 @@ def _resolve_lut_export_type(export_type, resolve_obj=None):
22792
22816
  const_name = raw
22793
22817
  if not const_name:
22794
22818
  return None, _err(f"Unknown LUT export type: {raw}")
22795
- if resolve_obj and hasattr(resolve_obj, const_name):
22796
- return getattr(resolve_obj, const_name), None
22797
- return const_name, None
22819
+ return _api_constant(resolve_obj, const_name, const_name), None
22798
22820
 
22799
22821
 
22800
22822
  def _validate_cdl_payload(cdl):
@@ -27636,7 +27658,7 @@ def _resource_current_project() -> Dict[str, Any]:
27636
27658
  return {
27637
27659
  "open": True,
27638
27660
  "name": proj.GetName(),
27639
- "id": proj.GetUniqueId() if hasattr(proj, "GetUniqueId") else None,
27661
+ "id": proj.GetUniqueId() if _has_method(proj, "GetUniqueId") else None,
27640
27662
  }
27641
27663
 
27642
27664
 
@@ -27659,7 +27681,7 @@ def _resource_current_timeline() -> Dict[str, Any]:
27659
27681
  return {
27660
27682
  "open": True,
27661
27683
  "name": tl.GetName(),
27662
- "id": tl.GetUniqueId() if hasattr(tl, "GetUniqueId") else None,
27684
+ "id": tl.GetUniqueId() if _has_method(tl, "GetUniqueId") else None,
27663
27685
  "start_frame": tl.GetStartFrame(),
27664
27686
  "end_frame": tl.GetEndFrame(),
27665
27687
  "start_timecode": tl.GetStartTimecode(),
@@ -27796,9 +27818,9 @@ if __name__ == "__main__":
27796
27818
  start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
27797
27819
  _install_threaded_tool_dispatch(mcp)
27798
27820
 
27799
- # Support --full flag to run the 341-tool granular server instead
27821
+ # Support --full flag to run the 353-tool granular server instead
27800
27822
  if "--full" in sys.argv:
27801
- logger.info("Starting full 341-tool granular server...")
27823
+ logger.info("Starting full 353-tool granular server...")
27802
27824
  sys.argv = [arg for arg in sys.argv if arg != "--full"]
27803
27825
  from src.granular import mcp as granular_mcp
27804
27826
 
@@ -9,6 +9,8 @@ the Resolve scripting API.
9
9
  import logging
10
10
  from typing import Any, Dict, Optional
11
11
 
12
+ from src.utils.resolve_probe import has_method
13
+
12
14
  logger = logging.getLogger("davinci-resolve-mcp.cloud_operations")
13
15
 
14
16
 
@@ -63,7 +65,7 @@ def _project_manager(resolve_obj, method_name: str):
63
65
  pm = resolve_obj.GetProjectManager()
64
66
  if not pm:
65
67
  return None, {"success": False, "error": "Failed to get Project Manager"}
66
- if not hasattr(pm, method_name):
68
+ if not has_method(pm, method_name):
67
69
  return None, {
68
70
  "success": False,
69
71
  "error": f"{method_name} not available in this version of DaVinci Resolve",
@@ -98,7 +100,7 @@ def create_cloud_project(
98
100
  return {
99
101
  "success": True,
100
102
  "project_name": project.GetName(),
101
- "project_id": project.GetUniqueId() if hasattr(project, "GetUniqueId") else None,
103
+ "project_id": project.GetUniqueId() if has_method(project, "GetUniqueId") else None,
102
104
  }
103
105
 
104
106
 
@@ -132,7 +134,7 @@ def load_cloud_project(
132
134
  return {
133
135
  "success": True,
134
136
  "project_name": project.GetName(),
135
- "project_id": project.GetUniqueId() if hasattr(project, "GetUniqueId") else None,
137
+ "project_id": project.GetUniqueId() if has_method(project, "GetUniqueId") else None,
136
138
  }
137
139
 
138
140
 
@@ -14,6 +14,8 @@ import inspect
14
14
  import logging
15
15
  from typing import Any, Dict, List, Optional, Union, Callable
16
16
 
17
+ from src.utils.resolve_probe import has_method
18
+
17
19
  logger = logging.getLogger(__name__)
18
20
 
19
21
 
@@ -170,7 +172,7 @@ def get_lua_table_keys(lua_table: Any) -> List[str]:
170
172
  keys = []
171
173
 
172
174
  # Check for DaVinci-specific Lua table iteration methods
173
- if hasattr(lua_table, 'GetKeyList'):
175
+ if has_method(lua_table, 'GetKeyList'):
174
176
  try:
175
177
  # Some DaVinci Resolve objects have a GetKeyList() method
176
178
  return lua_table.GetKeyList()
@@ -210,7 +212,7 @@ def convert_lua_to_python(lua_obj: Any) -> Any:
210
212
  return lua_obj
211
213
 
212
214
  # Try to convert Lua tables to Python dicts or lists
213
- if hasattr(lua_obj, 'GetKeyList') or hasattr(lua_obj, '__iter__'):
215
+ if has_method(lua_obj, 'GetKeyList') or hasattr(lua_obj, '__iter__'):
214
216
  keys = get_lua_table_keys(lua_obj)
215
217
 
216
218
  # If we found keys, convert to dict
@@ -13,6 +13,8 @@ import logging
13
13
  import json
14
14
  from typing import Dict, List, Any, Optional, Union
15
15
 
16
+ from src.utils.resolve_probe import has_method
17
+
16
18
  # Configure logging
17
19
  logger = logging.getLogger("davinci-resolve-mcp.project_properties")
18
20
 
@@ -521,7 +523,7 @@ def get_project_metadata(project_obj) -> Dict[str, Any]:
521
523
  metadata["name"] = project_obj.GetName()
522
524
 
523
525
  # Add project path if available
524
- if hasattr(project_obj, "GetPath"):
526
+ if has_method(project_obj, "GetPath"):
525
527
  metadata["path"] = project_obj.GetPath()
526
528
 
527
529
  # Get current timeline
@@ -0,0 +1,68 @@
1
+ """Capability probes for DaVinci Resolve API objects, because `hasattr` lies.
2
+
3
+ Measured on Studio 19.1.3.7, direct connection, 42 checks across Resolve,
4
+ ProjectManager, Project, MediaPool, Timeline, Folder and TimelineItem: bare
5
+ `hasattr(obj, name)` returns **True for every name** — real, borrowed from
6
+ another object type, or entirely invented — while `getattr(obj, name)` returns
7
+ `None` for the ones that do not exist. The full record is in `api_truth` under
8
+ "hasattr() / getattr() on Resolve API objects (attribute fabrication)".
9
+
10
+ So `hasattr` on a Resolve object is a constant `True`. It carries no
11
+ information, and it fails in two directions that look nothing alike:
12
+
13
+ if not hasattr(clip, "RemoveMotionBlur"): # never taken
14
+ return {"error": "requires Resolve 21+"} # dead branch
15
+ clip.RemoveMotionBlur(...) # AttributeError on 19.x
16
+
17
+ etype = getattr(r, name) if hasattr(r, name) else name # else never taken
18
+ # -> etype is None on a build without that constant, not the string
19
+ # fallback the author wrote, and None flows on into the export call.
20
+
21
+ The second shape is the dangerous one: no exception, no refusal, just a `None`
22
+ travelling further from the mistake with every line.
23
+
24
+ Use `has_method` where the intent is "can I call this", and `api_constant`
25
+ where the intent is "does this build define this constant, else use my
26
+ fallback". Both take the form that agreed with `dir()` in all 42 checks.
27
+
28
+ One caveat worth keeping: those 42 checks were on the DIRECT connection. The
29
+ 21.0.0 record of the in-app bridge fabricating callables for any name was never
30
+ re-run, so on a bridge build `callable(getattr(...))` may still over-report.
31
+ `dir()` membership is the form unaffected either way — but it is not free on
32
+ every object, and switching to it wholesale would change behaviour on a path we
33
+ have not measured. That trade is recorded here rather than made silently.
34
+ """
35
+
36
+ from typing import Any, Optional
37
+
38
+ __all__ = ["has_method", "api_constant", "MISSING"]
39
+
40
+ #: Sentinel for "no attribute", distinct from a legitimately-None attribute.
41
+ MISSING = object()
42
+
43
+
44
+ def has_method(obj: Any, name: str) -> bool:
45
+ """True when `name` is a callable on `obj`. The replacement for `hasattr`.
46
+
47
+ Returns False for a missing object rather than raising, because every
48
+ caller here is asking "is this reachable" and a None handle is one of the
49
+ ways it is not.
50
+ """
51
+ if obj is None or not name:
52
+ return False
53
+ return callable(getattr(obj, name, None))
54
+
55
+
56
+ def api_constant(obj: Any, name: str, default: Optional[Any] = None) -> Any:
57
+ """The Resolve constant `name`, or `default` when this build lacks it.
58
+
59
+ Resolve exposes export/audio type constants as plain attributes
60
+ (`resolve.EXPORT_AAF`), and callers pass the string name through unchanged
61
+ when the constant is absent. `getattr` returning None is exactly the signal
62
+ `hasattr` destroys, so the fallback is driven off the value, not off a
63
+ presence test.
64
+ """
65
+ if obj is None or not name:
66
+ return default
67
+ value = getattr(obj, name, None)
68
+ return default if value is None else value