davinci-resolve-mcp 3.2.2 → 3.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
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 v3.3.0 — ask the server what the native Resolve API contains
6
+
7
+ Contributed by @legionsound (#229), the second of the two branches queued in #207.
8
+
9
+ ### Added
10
+
11
+ - **`resolve_control`: `search_api`, `describe_api`, `api_surface`**, plus
12
+ granular twins `search_resolve_api`, `describe_resolve_api` and
13
+ `get_resolve_api_surface`. `resolve_control api_truth` already answered *what
14
+ is broken*; nothing answered *what exists*. #205 shipped Blackmagic's typed
15
+ `DaVinciResolveScript.pyi` and `scripts/audit_typed_api.py` could inventory
16
+ it, but only from a shell — an agent talking to this server had no way to ask.
17
+ This is the equivalent of Blackmagic's own `search_scripting_api`. Tool count
18
+ 37/384 → 37/387.
19
+ - **All three are read-only and none needs a Resolve connection.** They parse
20
+ the stub that already ships in `docs/reference/`, so they answer with Resolve
21
+ closed, and they describe the stub checked into this repository — currently
22
+ 21.1.0.14 — not whatever build happens to be installed. A missing stub is
23
+ reported as missing rather than guessed around.
24
+ - **Every result carries `referenced_in_this_server` and the files that
25
+ reference the method**, so a lookup doubles as a parity check: does the native
26
+ API have it, and do we wrap it? The flag counts executable syntax only —
27
+ attribute access, calls, `getattr(obj, "Name")` — never docstrings or
28
+ comments, built the same way `audit_typed_api.py` builds its source
29
+ references. A method named in prose is not coverage.
30
+ - **Ambiguity is reported, not guessed.** A bare `GetName` lists its candidates
31
+ instead of picking one. An invalid regex is refused rather than raised.
32
+ Results are capped with an explicit `truncated` flag rather than silently cut.
33
+
34
+ ### Guards
35
+
36
+ - The parser independently reports **410 methods, 46 TypedDicts, 513 fields**,
37
+ and `tests/test_typed_api_search.py` asserts that equality against the
38
+ inventory recorded in `resolve-211-typed-api.md`. A future stub refresh that
39
+ changes the surface now fails the suite instead of drifting quietly. The same
40
+ file checks both interfaces agree on surface, search and describe, and that
41
+ neither needs a connection.
42
+
5
43
  ## What's New in v3.2.2 — an analysis root that is deleted is actually let go of
6
44
 
7
45
  Contributed by @Dev-next-gen (#228), generalised to the second site and to the
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#server-modes)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#server-modes)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#server-modes)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
@@ -133,7 +133,7 @@ The command starts a loopback-only server and opens the control panel in your br
133
133
  | Mode | Entry point | Tools | Best for |
134
134
  |------|-------------|-------|----------|
135
135
  | Compound | `src/server.py` | 37 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
136
- | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 384 | Power users who want one MCP tool per Resolve API method. |
136
+ | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 387 | Power users who want one MCP tool per Resolve API method. |
137
137
 
138
138
  The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
139
139
 
@@ -365,7 +365,7 @@ The default server is a local stdio process launched by your MCP client; it does
365
365
 
366
366
  | Metric | Value |
367
367
  |--------|-------|
368
- | MCP Tools | **37** compound / **384** granular (live server) |
368
+ | MCP Tools | **37** compound / **387** granular (live server) |
369
369
  | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
370
370
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
371
371
  | API Methods Covered | **361/361** (100%) |
package/README.zh-CN.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.3.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
- [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(384%20full)-blue.svg)](#服务器模式)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(387%20full)-blue.svg)](#服务器模式)
9
9
  [![Advanced](https://img.shields.io/badge/Advanced%20(offline)-18%20tools-blueviolet.svg)](#服务器模式)
10
10
  [![Tested](https://img.shields.io/badge/Live%20Tested-93.6%25-green.svg)](docs/reference/api-coverage.md#test-results)
11
11
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v3.2.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.3.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -88,7 +88,7 @@ venv/bin/python -m src.control_panel
88
88
  | 模式 | 入口 | 工具数 | 适合谁 |
89
89
  |------|------|--------|--------|
90
90
  | Compound(复合) | `src/server.py` | 37 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
91
- | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 384 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
91
+ | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 387 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
92
92
 
93
93
  除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
94
94
 
@@ -226,7 +226,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
226
226
 
227
227
  | 指标 | 数值 |
228
228
  |------|------|
229
- | MCP 工具 | **37** 复合 / **384** 细粒度(实时服务器) |
229
+ | MCP 工具 | **37** 复合 / **387** 细粒度(实时服务器) |
230
230
  | Advanced(离线)工具 | **18**——.drp/.drt/.drx + 数据库创作,无需 Resolve 运行 |
231
231
  | 内核 action | 9 个复合工具下 **136** 个带护栏的工作流 action |
232
232
  | API 方法覆盖 | **361/361**(100%) |
package/docs/SKILL.md CHANGED
@@ -353,7 +353,7 @@ to the user as verified.
353
353
  | Mode | Entry point | Tool count | Use when |
354
354
  |---|---|---|---|
355
355
  | Compound (default) | `src/server.py` | 37 tools | Most workflows — keeps context lean |
356
- | Granular (full) | `src/server.py --full` | 384 tools | Power users needing one tool per API method |
356
+ | Granular (full) | `src/server.py --full` | 387 tools | Power users needing one tool per API method |
357
357
 
358
358
  Resolve 21.1 adds [twelve read-only discovery controls](reference/resolve211-read-controls.md)
359
359
  for edition, presets, audio formats/codecs, normalization modes, speed, fades
package/docs/install.md CHANGED
@@ -144,7 +144,7 @@ The MCP server comes in two modes:
144
144
  | Mode | File | Tools | Best For |
145
145
  |------|------|-------|----------|
146
146
  | **Compound** (default) | `src/server.py` | 37 | Most users — fast, clean, low context usage |
147
- | **Full** | `src/resolve_mcp_server.py` | 384 | Power users who want one tool per API method |
147
+ | **Full** | `src/resolve_mcp_server.py` | 387 | Power users who want one tool per API method |
148
148
 
149
149
  The compound server's `timeline_item` tool includes dedicated actions for common workflows:
150
150
 
@@ -159,7 +159,7 @@ The compound server's `timeline_item` tool includes dedicated actions for common
159
159
 
160
160
  The installer uses the compound server by default. To use the full server:
161
161
  ```bash
162
- python src/server.py --full # Launch full 384-tool server
162
+ python src/server.py --full # Launch full 387-tool server
163
163
  # Or point your MCP config directly at src/resolve_mcp_server.py
164
164
  ```
165
165
 
@@ -25,7 +25,7 @@ Every non-deprecated method in the bundled legacy README is represented. This
25
25
  does not claim complete coverage of the newer Resolve 21.1 typed API. The
26
26
  default compound server exposes **37 tools** that group related operations by
27
27
  action parameter, keeping LLM context windows lean. The full granular server
28
- provides **384 individual tools** for power users. The legacy coverage spans
28
+ provides **387 individual tools** for power users. The legacy coverage spans
29
29
  13 API object classes. MCP-level kernel actions are tracked separately in
30
30
  [Kernel Action Coverage](../kernels/README.md).
31
31
 
@@ -39,6 +39,11 @@ Parity with Blackmagic's own MCP surfaced one more gap: it exposes
39
39
  node but never list, install or remove LUT files. The `lut` tool and its
40
40
  granular twins close that. See [LUT file controls](lut-file-controls.md).
41
41
 
42
+ The typed stub shipped in #205 is now queryable from the server itself, the
43
+ way Blackmagic's MCP exposes `search_scripting_api` — and each result also says
44
+ whether this server wraps the method, so a lookup doubles as a parity check.
45
+ See [Querying the typed API](typed-api-search.md).
46
+
42
47
  The 34th compound tool is `timeline_versioning` (C6) — an MCP-level workflow
43
48
  tool, not a wrapper around a Resolve API method. It surfaces the
44
49
  version-on-mutate hook that auto-archives the working timeline before any
@@ -0,0 +1,56 @@
1
+ # Querying the typed API from inside the server
2
+
3
+ `resolve_control api_truth` answers *what is broken*. Nothing answered *what
4
+ exists*. PR #205 landed Blackmagic's `DaVinciResolveScript.pyi` in
5
+ `docs/reference/`, and `scripts/audit_typed_api.py` can turn it into a full
6
+ inventory — but only from a shell. An agent talking to this server had no way
7
+ to ask what the native API contains.
8
+
9
+ Blackmagic's own MCP exposes `search_scripting_api` for this. These are the
10
+ equivalent, with one addition that matters more here.
11
+
12
+ ## The addition: coverage, not just existence
13
+
14
+ Every result carries `referenced_in_this_server` and the source files that
15
+ reference it. That turns a lookup into a parity check — *does the native API
16
+ have it, and do we wrap it?* — which is the question the whole 21.1 audit was
17
+ built to answer, and it was previously only answerable by grepping.
18
+
19
+ The flag counts **executable syntax only**: attribute access, calls, and
20
+ `getattr(obj, "Name")`. A method named in a docstring or a comment is not
21
+ coverage. Treating prose as coverage is the specific mistake this repo's audits
22
+ exist to avoid, so the flag is built the same way `scripts/audit_typed_api.py`
23
+ builds its source references.
24
+
25
+ ## Actions and tools
26
+
27
+ Compound `resolve_control`, none of which need a Resolve connection:
28
+
29
+ - `search_api(pattern, kind?, limit?)` — case-insensitive regex over
30
+ class-qualified method names, signatures, TypedDict names, field names and
31
+ descriptions. `kind` is `all`, `methods` or `options`. Results are capped and
32
+ the response declares `truncated` rather than silently cutting.
33
+ - `describe_api(symbol)` — one `Class.Method`, an unambiguous bare method name,
34
+ or a TypedDict name. An ambiguous bare name lists the candidates instead of
35
+ guessing.
36
+ - `api_surface()` — counts and the object list.
37
+
38
+ Granular twins: `search_resolve_api`, `describe_resolve_api`,
39
+ `get_resolve_api_surface`. Tool count 384 → 387. The granular tools carry the read-only annotation, since
40
+ for the granular server the MCP annotation is the signal a client reads.
41
+
42
+ ## Self-consistency
43
+
44
+ The parser reports **410 methods, 46 TypedDicts, 513 fields**, independently
45
+ matching the inventory published in `resolve-211-typed-api.md` and the
46
+ disposition ledgers built during the 21.1 audit. That agreement is asserted in
47
+ `tests/test_typed_api_search.py`, so a stub refresh that changes the surface
48
+ will fail the suite rather than drift quietly.
49
+
50
+ ## Scope
51
+
52
+ This reads the stub shipped in this repository; it does not query a running
53
+ Resolve, and it will report a missing stub rather than guessing. It therefore
54
+ describes the API of the Resolve version whose stub is checked in — 21.1.0.14 —
55
+ not whatever build happens to be installed. Refreshing the stub is the existing
56
+ documented process in `resolve-211-typed-api.md`.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.2.2"
40
+ VERSION = "3.3.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
@@ -1543,7 +1543,7 @@ def verify_resolve_connection(python_path, api_path, lib_path):
1543
1543
 
1544
1544
  def print_banner():
1545
1545
  title = f"DaVinci Resolve MCP Server — Installer v{VERSION}"
1546
- subtitle = "37 compound · 384 full · 3 platforms"
1546
+ subtitle = "37 compound · 387 full · 3 platforms"
1547
1547
  print()
1548
1548
  print(bold(" ╔══════════════════════════════════════════════════════╗"))
1549
1549
  print(bold(f" ║{title:^54}║"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.2.2"
90
+ VERSION = "3.3.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
@@ -1,6 +1,8 @@
1
1
  """Resolve control resources, inspection helpers, and app-level tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ import os
5
+ from src.utils import typed_api_search
4
6
 
5
7
  resolve = ResolveProxy()
6
8
 
@@ -719,3 +721,48 @@ def export_user_preferences_preset(preset_name: str, export_path: str) -> Dict[s
719
721
  return missing
720
722
  result = resolve.ExportUserPreferencesPreset(preset_name, export_path)
721
723
  return {"success": bool(result), "preset_name": preset_name, "export_path": export_path}
724
+
725
+
726
+ _PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
727
+
728
+
729
+ @mcp.tool(annotations=READ_ONLY_TOOL)
730
+ def search_resolve_api(pattern: str, kind: str = "all", limit: int = 50) -> Dict[str, Any]:
731
+ """Search the shipped Resolve 21.1 typed API stub. Needs no connection.
732
+
733
+ `api_truth` answers what is broken; this answers what exists. Each hit also
734
+ reports whether THIS server references the method and in which files, so a
735
+ search doubles as a parity check.
736
+
737
+ Args:
738
+ pattern: Case-insensitive regular expression, e.g. "marker" or "(Get|Set)Setting".
739
+ kind: 'all' (default), 'methods', or 'options' for TypedDicts only.
740
+ limit: Maximum results per section (capped at 200).
741
+ """
742
+ try:
743
+ return typed_api_search.search(_PROJECT_DIR, pattern, kind=kind, limit=limit)
744
+ except typed_api_search.TypedApiError as exc:
745
+ return {"error": str(exc)}
746
+
747
+
748
+ @mcp.tool(annotations=READ_ONLY_TOOL)
749
+ def describe_resolve_api(symbol: str) -> Dict[str, Any]:
750
+ """Full typed detail for one Resolve API symbol. Needs no connection.
751
+
752
+ Args:
753
+ symbol: 'Class.Method' (e.g. "Project.GetName"), a bare method name when
754
+ it is unambiguous, or a TypedDict name (e.g. "RenderSettings").
755
+ """
756
+ try:
757
+ return typed_api_search.describe(_PROJECT_DIR, symbol)
758
+ except typed_api_search.TypedApiError as exc:
759
+ return {"error": str(exc)}
760
+
761
+
762
+ @mcp.tool(annotations=READ_ONLY_TOOL)
763
+ def get_resolve_api_surface() -> Dict[str, Any]:
764
+ """Counts and object list for the shipped typed stub. Needs no connection."""
765
+ try:
766
+ return typed_api_search.summary(_PROJECT_DIR)
767
+ except typed_api_search.TypedApiError as exc:
768
+ return {"error": str(exc)}
@@ -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} (384 granular tools)")
37
+ logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (387 granular tools)")
38
38
  run_fastmcp_stdio(mcp)
39
39
  except KeyboardInterrupt:
40
40
  logger.info("Server shutdown requested")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "3.2.2"
14
+ VERSION = "3.3.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -52,6 +52,7 @@ from src.utils.resolve211_edits import validate_edit_options, validate_transitio
52
52
 
53
53
  # Platform-specific Resolve paths
54
54
  from src.utils.cdl import normalize_cdl_payload
55
+ from src.utils import typed_api_search
55
56
  from src.utils import lut_files
56
57
  from src.utils import resolve_writes as _resolve_writes
57
58
  from src.utils.mcp_stdio import run_fastmcp_stdio
@@ -16652,6 +16653,15 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16652
16653
  set_keyframe_mode(mode) -> {success}
16653
16654
  quit() -> {success}
16654
16655
  get_fairlight_presets() -> {presets}
16656
+ search_api(pattern, kind?, limit?) -> {methods, option_types, total_matches}
16657
+ — regex over the shipped Resolve 21.1 typed stub. Each hit says whether
16658
+ THIS server references the method, and where, so it doubles as a
16659
+ parity check. kind: 'all' (default) | 'methods' | 'options'.
16660
+ No connection needed.
16661
+ describe_api(symbol) -> {signatures, stub_line, description, source_files}
16662
+ — one 'Class.Method' or one TypedDict name. No connection needed.
16663
+ api_surface() -> {methods, option_types, option_fields, objects}
16664
+ — what the shipped stub contains. No connection needed.
16655
16665
  set_high_priority() -> {success}
16656
16666
  disable_background_tasks_for_current_session() -> {success} — Resolve 21+
16657
16667
  list_user_preferences_presets() -> {presets} — Resolve 21.0.4+
@@ -16706,7 +16716,24 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16706
16716
  # that property is worth keeping: it is the one call that still answers when
16707
16717
  # Resolve is down or unreachable. So the live build is used only if one is
16708
16718
  # ALREADY connected, or if the caller names it. Never connect for this.
16709
- if action == "api_truth":
16719
+ if action == "search_api":
16720
+ try:
16721
+ return typed_api_search.search(
16722
+ project_dir, p.get("pattern"),
16723
+ kind=p.get("kind", "all"), limit=p.get("limit", 50))
16724
+ except typed_api_search.TypedApiError as exc:
16725
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16726
+ elif action == "describe_api":
16727
+ try:
16728
+ return typed_api_search.describe(project_dir, p.get("symbol"))
16729
+ except typed_api_search.TypedApiError as exc:
16730
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16731
+ elif action == "api_surface":
16732
+ try:
16733
+ return typed_api_search.summary(project_dir)
16734
+ except typed_api_search.TypedApiError as exc:
16735
+ return _err(str(exc), code="TYPED_API_QUERY", category="invalid_input")
16736
+ elif action == "api_truth":
16710
16737
  facts = lookup_api_truth(p.get("query"))
16711
16738
  live_version = p.get("resolve_version")
16712
16739
  if not live_version and resolve is not None:
@@ -17139,7 +17166,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17139
17166
  if err:
17140
17167
  return _err(err)
17141
17168
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
17142
- return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17169
+ return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","search_api","describe_api","api_surface","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17143
17170
 
17144
17171
 
17145
17172
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -0,0 +1,260 @@
1
+ """Query the shipped Resolve 21.1 typed stub from inside the server.
2
+
3
+ PR #205 landed Blackmagic's `DaVinciResolveScript.pyi` in `docs/reference/`, and
4
+ `scripts/audit_typed_api.py` can turn it into a full inventory — but only from a
5
+ shell. An agent talking to this server had no way to ask what the native API
6
+ contains. `resolve_control api_truth` answers "what is broken"; nothing answered
7
+ "what exists".
8
+
9
+ The official Blackmagic MCP exposes `search_scripting_api` for this. This is the
10
+ equivalent, with one addition that matters more here: each result says whether
11
+ **this server** references the method, and where. That turns a lookup into a
12
+ parity check — "does the native API have it, and do we wrap it?" — which is the
13
+ question that drove the whole 21.1 audit.
14
+
15
+ Parsing is `ast`-based and reuses the same shapes as the audit script. Source
16
+ references are executable syntax only: a method named in a docstring or comment
17
+ is not counted as coverage.
18
+ """
19
+
20
+ import ast
21
+ import os
22
+ import re
23
+ from functools import lru_cache
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ STUB_RELATIVE = os.path.join("docs", "reference", "DaVinciResolveScript.pyi")
27
+ MAX_RESULTS = 200
28
+
29
+
30
+ class TypedApiError(ValueError):
31
+ """The stub is missing, unparseable, or the query was unusable."""
32
+
33
+
34
+ def stub_path(project_dir: str) -> str:
35
+ return os.path.join(project_dir, STUB_RELATIVE)
36
+
37
+
38
+ @lru_cache(maxsize=4)
39
+ def _inventory(path: str, mtime: float) -> Dict[str, Any]:
40
+ """Parse the stub into methods and TypedDicts. Cached on path plus mtime."""
41
+ with open(path, "r", encoding="utf-8") as handle:
42
+ text = handle.read()
43
+ methods: Dict[str, Any] = {}
44
+ option_types: Dict[str, Any] = {}
45
+ for cls in ast.parse(text).body:
46
+ if not isinstance(cls, ast.ClassDef):
47
+ continue
48
+ for node in cls.body:
49
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"):
50
+ signature = f"{node.name}({ast.unparse(node.args)})"
51
+ if node.returns is not None:
52
+ signature += f" -> {ast.unparse(node.returns)}"
53
+ row = methods.setdefault(f"{cls.name}.{node.name}", {
54
+ "object": cls.name,
55
+ "method": node.name,
56
+ "signatures": [],
57
+ "stub_line": node.lineno,
58
+ "description": ast.get_docstring(node),
59
+ })
60
+ row["signatures"].append(signature)
61
+ if any(ast.unparse(base).endswith("TypedDict") for base in cls.bases):
62
+ fields: Dict[str, Any] = {}
63
+ for index, node in enumerate(cls.body):
64
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
65
+ doc = None
66
+ if index + 1 < len(cls.body):
67
+ following = cls.body[index + 1]
68
+ if (isinstance(following, ast.Expr)
69
+ and isinstance(following.value, ast.Constant)
70
+ and isinstance(following.value.value, str)):
71
+ doc = following.value.value
72
+ fields[node.target.id] = {"type": ast.unparse(node.annotation),
73
+ "stub_line": node.lineno,
74
+ "description": doc}
75
+ option_types[cls.name] = fields
76
+ if not methods:
77
+ raise TypedApiError("The typed stub contains no public class methods; refusing an empty inventory")
78
+ return {"methods": methods, "option_types": option_types}
79
+
80
+
81
+ def load(project_dir: str) -> Dict[str, Any]:
82
+ path = stub_path(project_dir)
83
+ if not os.path.isfile(path):
84
+ raise TypedApiError(
85
+ f"Typed API stub not found at {STUB_RELATIVE}. It ships with this "
86
+ "repository; a source checkout is required for API search."
87
+ )
88
+ return _inventory(path, os.path.getmtime(path))
89
+
90
+
91
+ @lru_cache(maxsize=4)
92
+ def _wrapped_names(project_dir: str, fingerprint: str) -> Dict[str, List[str]]:
93
+ """Map native method name -> source files that actually call or read it.
94
+
95
+ Executable syntax only. A name that appears solely in a docstring or comment
96
+ is not coverage, and counting it as such is exactly the mistake this whole
97
+ audit existed to avoid.
98
+ """
99
+ found: Dict[str, set] = {}
100
+ src_root = os.path.join(project_dir, "src")
101
+ for current, _dirs, files in os.walk(src_root):
102
+ for filename in files:
103
+ if not filename.endswith(".py"):
104
+ continue
105
+ absolute = os.path.join(current, filename)
106
+ try:
107
+ with open(absolute, "r", encoding="utf-8", errors="replace") as handle:
108
+ tree = ast.parse(handle.read())
109
+ except (OSError, SyntaxError):
110
+ continue
111
+ relative = os.path.relpath(absolute, project_dir).replace(os.sep, "/")
112
+ for node in ast.walk(tree):
113
+ name = None
114
+ if isinstance(node, ast.Attribute):
115
+ name = node.attr
116
+ elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
117
+ and node.func.id == "getattr" and len(node.args) > 1
118
+ and isinstance(node.args[1], ast.Constant)
119
+ and isinstance(node.args[1].value, str)):
120
+ name = node.args[1].value
121
+ if name:
122
+ found.setdefault(name, set()).add(relative)
123
+ return {name: sorted(paths) for name, paths in found.items()}
124
+
125
+
126
+ def _source_fingerprint(project_dir: str) -> str:
127
+ """Cheap change detector for the source tree: newest mtime plus file count."""
128
+ newest = 0.0
129
+ count = 0
130
+ for current, _dirs, files in os.walk(os.path.join(project_dir, "src")):
131
+ for filename in files:
132
+ if filename.endswith(".py"):
133
+ count += 1
134
+ try:
135
+ newest = max(newest, os.path.getmtime(os.path.join(current, filename)))
136
+ except OSError:
137
+ pass
138
+ return f"{count}:{newest}"
139
+
140
+
141
+ def search(project_dir: str, pattern: str, *, kind: str = "all",
142
+ limit: int = 50) -> Dict[str, Any]:
143
+ """Case-insensitive regex over method names, signatures, types and docs."""
144
+ if not isinstance(pattern, str) or not pattern.strip():
145
+ raise TypedApiError("pattern is required")
146
+ if kind not in ("all", "methods", "options"):
147
+ raise TypedApiError(f"kind must be all, methods or options, not {kind!r}")
148
+ try:
149
+ expression = re.compile(pattern, re.IGNORECASE)
150
+ except re.error as exc:
151
+ raise TypedApiError(f"pattern is not a valid regular expression: {exc}")
152
+ try:
153
+ limit = max(1, min(int(limit), MAX_RESULTS))
154
+ except (TypeError, ValueError):
155
+ raise TypedApiError("limit must be an integer")
156
+
157
+ inventory = load(project_dir)
158
+ wrapped = _wrapped_names(project_dir, _source_fingerprint(project_dir))
159
+
160
+ methods: List[Dict[str, Any]] = []
161
+ if kind in ("all", "methods"):
162
+ for key, row in sorted(inventory["methods"].items()):
163
+ haystack = " ".join([key] + row["signatures"] + [row["description"] or ""])
164
+ if not expression.search(haystack):
165
+ continue
166
+ references = wrapped.get(row["method"], [])
167
+ methods.append({
168
+ "symbol": key,
169
+ "object": row["object"],
170
+ "signatures": row["signatures"],
171
+ "stub_line": row["stub_line"],
172
+ "description": row["description"],
173
+ "referenced_in_this_server": bool(references),
174
+ "source_files": references[:5],
175
+ })
176
+
177
+ options: List[Dict[str, Any]] = []
178
+ if kind in ("all", "options"):
179
+ for type_name, fields in sorted(inventory["option_types"].items()):
180
+ matched = {
181
+ field: detail for field, detail in fields.items()
182
+ if expression.search(" ".join([type_name, field, detail["type"],
183
+ detail["description"] or ""]))
184
+ }
185
+ if expression.search(type_name):
186
+ matched = dict(fields)
187
+ if matched:
188
+ options.append({
189
+ "type": type_name,
190
+ "field_count": len(fields),
191
+ "matched_fields": matched,
192
+ })
193
+
194
+ total = len(methods) + len(options)
195
+ return {
196
+ "pattern": pattern,
197
+ "kind": kind,
198
+ "total_matches": total,
199
+ "truncated": total > limit,
200
+ "methods": methods[:limit],
201
+ "option_types": options[:limit],
202
+ "stub": STUB_RELATIVE,
203
+ "note": ("referenced_in_this_server is executable syntax only — a name that "
204
+ "appears solely in a docstring or comment is not counted."),
205
+ }
206
+
207
+
208
+ def describe(project_dir: str, symbol: str) -> Dict[str, Any]:
209
+ """Full detail for one `Class.Method` or one TypedDict name."""
210
+ if not isinstance(symbol, str) or not symbol.strip():
211
+ raise TypedApiError("symbol is required")
212
+ inventory = load(project_dir)
213
+ key = symbol.strip()
214
+
215
+ if key in inventory["option_types"]:
216
+ fields = inventory["option_types"][key]
217
+ return {"symbol": key, "kind": "option_type", "field_count": len(fields),
218
+ "fields": fields, "stub": STUB_RELATIVE}
219
+
220
+ row = inventory["methods"].get(key)
221
+ if row is None:
222
+ candidates = sorted(k for k in inventory["methods"] if k.split(".", 1)[1] == key)
223
+ if len(candidates) == 1:
224
+ key, row = candidates[0], inventory["methods"][candidates[0]]
225
+ elif candidates:
226
+ raise TypedApiError(
227
+ f"{symbol!r} exists on more than one object: {', '.join(candidates)}. "
228
+ "Qualify it, e.g. 'Project.GetName'."
229
+ )
230
+ else:
231
+ raise TypedApiError(
232
+ f"{symbol!r} is not in the Resolve 21.1 typed stub. Use search to "
233
+ "find the right name."
234
+ )
235
+
236
+ wrapped = _wrapped_names(project_dir, _source_fingerprint(project_dir))
237
+ references = wrapped.get(row["method"], [])
238
+ return {
239
+ "symbol": key,
240
+ "kind": "method",
241
+ "object": row["object"],
242
+ "signatures": row["signatures"],
243
+ "stub_line": row["stub_line"],
244
+ "description": row["description"],
245
+ "referenced_in_this_server": bool(references),
246
+ "source_files": references,
247
+ "stub": STUB_RELATIVE,
248
+ }
249
+
250
+
251
+ def summary(project_dir: str) -> Dict[str, Any]:
252
+ inventory = load(project_dir)
253
+ field_total = sum(len(fields) for fields in inventory["option_types"].values())
254
+ return {
255
+ "stub": STUB_RELATIVE,
256
+ "methods": len(inventory["methods"]),
257
+ "option_types": len(inventory["option_types"]),
258
+ "option_fields": field_total,
259
+ "objects": sorted({row["object"] for row in inventory["methods"].values()}),
260
+ }