davinci-resolve-mcp 3.2.1 → 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,91 @@
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
+
43
+ ## What's New in v3.2.2 — an analysis root that is deleted is actually let go of
44
+
45
+ Contributed by @Dev-next-gen (#228), generalised to the second site and to the
46
+ connection cache underneath both.
47
+
48
+ ### Fixed
49
+
50
+ - **`cleanup_artifacts(frames_only=false)` reported success whether or not it
51
+ removed anything.** The analysis root contains
52
+ `_soul/timeline_brain.sqlite`, which `timeline_brain_db` keeps open in a
53
+ process-wide cache for the life of the server. Nothing let go of it before
54
+ the `shutil.rmtree`, and the rmtree runs with `ignore_errors=True`, so both
55
+ failure modes were swallowed. On Windows the open handle makes the DB
56
+ undeletable: the root survives with `_soul/` and the brain-edit history still
57
+ in it while the tool returns `{"success": true}`. On POSIX the root is
58
+ removed but the stale connection stays cached, so the next write for that
59
+ project goes to a file with no directory entry and is lost — the dashboard,
60
+ which opens the path fresh, sees nothing. `timeline_brain_db.close()` now
61
+ releases one project's connection, and the cleanup returns `success: false`
62
+ if the root is still on disk afterwards. (#228)
63
+ - **The same bug at a second site.** A `session_only` run without
64
+ `keep_artifacts` ingests every report into the brain DB under its output root
65
+ and then deletes that root, with the connection still cached. Because each
66
+ such run gets a fresh temp root, the cache accumulated one dead connection
67
+ per run. Both sites now go through one helper, and
68
+ `artifacts_cleaned_up` reports whether the removal happened rather than that
69
+ it was attempted.
70
+ - **`close()` released nothing when the root was spelled differently.** The
71
+ connection cache keyed on the caller's spelling of the path, and callers
72
+ disagree by construction: `media_analysis` realpaths a root before using it,
73
+ while its own callers pass what the user typed. On macOS that alone was
74
+ enough — every temp root under `/var/folders` is a symlink to
75
+ `/private/var/folders` — so `close()` computed a key that was never in the
76
+ cache, popped nothing, and the fix above silently did not apply. Two
77
+ spellings of one root also opened two connections to one SQLite file. The
78
+ cache now keys on the resolved DB path.
79
+
80
+ ### Release process
81
+
82
+ - **`tests.test_release_surface_drift` is now in the documented gate list.** It
83
+ asserts the README badge, the `README.zh-CN.md` badge and translation line,
84
+ and a `CHANGELOG.md` entry all match `package.json` — and it was the one
85
+ version-surface check missing from `docs/process/release-process.md`. v3.2.1
86
+ shipped with a zh-CN badge still reading v3.2.0 because every documented gate
87
+ passed while none of them looks at a version surface. That badge is corrected
88
+ here.
89
+
5
90
  ## What's New in v3.2.1 — three correctness fixes to the LUT tool
6
91
 
7
92
  Contributed by @Dev-next-gen (#225, #226, #227), each found by reading the v3.2.0
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.1-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.0-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.1 版 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
 
@@ -75,7 +75,7 @@ venv/bin/python scripts/audit_api_parity.py
75
75
  venv/bin/python scripts/gen_api_limitations.py --check
76
76
  venv/bin/python scripts/audit_readwrite_symmetry.py --check
77
77
  node scripts/agent-rules/generate.mjs --check
78
- venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
78
+ venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift tests.test_release_surface_drift
79
79
  node bin/davinci-resolve-mcp.mjs --help
80
80
  node bin/davinci-resolve-mcp.mjs --version
81
81
  npm pack --dry-run
@@ -89,6 +89,13 @@ regeneration is in the working tree when the test reads it — that ordering is
89
89
  the check is a regeneration followed by a test, not a `git diff --exit-code`,
90
90
  which would fire on the release bump's own legitimate change.
91
91
 
92
+ `test_release_surface_drift` is the gate on the version bump itself: it asserts
93
+ the README badge, the `README.zh-CN.md` badge and its `本翻译对应 vX.Y.Z` line, and
94
+ a `CHANGELOG.md` entry all match `package.json`. It was not in this list until
95
+ v3.2.2, and v3.2.1 shipped with a zh-CN badge still reading v3.2.0 as a direct
96
+ result — every other gate passed, because none of them looks at a version
97
+ surface. Run it before tagging, not after.
98
+
92
99
  `test_duplicate_definitions` asserts no module-level name is defined twice under
93
100
  `src/`. A second `def foo` silently replaces the first, and in a module the size
94
101
  of `src/server.py` the two can be thousands of lines apart with different
@@ -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.1"
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.1",
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.1"
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.1"
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 ────────────────
@@ -2590,6 +2590,48 @@ def _read_json(path: str) -> Dict[str, Any]:
2590
2590
  return json.load(f)
2591
2591
 
2592
2592
 
2593
+ def _drop_brain_db_and_rmtree(project_root: str, cleanup_root: str) -> bool:
2594
+ """Delete `cleanup_root`, releasing `project_root`'s brain DB first.
2595
+
2596
+ `<project_root>/_soul/timeline_brain.sqlite` is held open in a
2597
+ process-wide cache for the life of the server, and nothing else lets go of
2598
+ it. Deleting the root underneath that handle fails differently on each
2599
+ platform and silently on both, because rmtree runs with
2600
+ ``ignore_errors=True``:
2601
+
2602
+ - Windows refuses to delete the open file, so the root survives with
2603
+ `_soul/` and the brain-edit history still in it while the caller is told
2604
+ it is gone.
2605
+ - POSIX unlinks it, but the cache then hands the next writer a connection
2606
+ to a file with no directory entry, so the write lands nowhere.
2607
+
2608
+ Returns whether `cleanup_root` is actually gone afterwards, so callers can
2609
+ report a removal that happened rather than one they attempted.
2610
+ """
2611
+ from src.utils import timeline_brain_db as _brain_db
2612
+
2613
+ _brain_db.close(project_root)
2614
+ shutil.rmtree(cleanup_root, ignore_errors=True)
2615
+ return not os.path.isdir(cleanup_root)
2616
+
2617
+
2618
+ def _release_session_root(manifest: Dict[str, Any], project_root: str,
2619
+ cleanup_root: str) -> bool:
2620
+ """Session-only artifact cleanup: same rule as `cleanup_artifacts`.
2621
+
2622
+ A session-only run ingests every report into the brain DB under
2623
+ `project_root` and then throws the root away, and each run gets a fresh
2624
+ temp root -- so without the release the cache accumulates one dead
2625
+ connection per run.
2626
+ """
2627
+ removed = _drop_brain_db_and_rmtree(project_root, cleanup_root)
2628
+ if not removed:
2629
+ manifest.setdefault("memory_layer_warnings", []).append(
2630
+ f"Could not fully remove the session analysis root: {cleanup_root}"
2631
+ )
2632
+ return removed
2633
+
2634
+
2593
2635
  def _ingest_report_into_db(project_root: str, report: Dict[str, Any], clip_dir: Optional[str]) -> Dict[str, Any]:
2594
2636
  """C1 — write a report into the DB-canonical store (rows in a transaction).
2595
2637
 
@@ -5971,8 +6013,9 @@ async def execute_plan_async(
5971
6013
  and _is_relative_to(output_root, candidate)
5972
6014
  ):
5973
6015
  cleanup_root = candidate
5974
- shutil.rmtree(cleanup_root, ignore_errors=True)
5975
- manifest["artifacts_cleaned_up"] = True
6016
+ manifest["artifacts_cleaned_up"] = _release_session_root(
6017
+ manifest, output_root, cleanup_root
6018
+ )
5976
6019
  manifest["artifact_cleanup_root"] = cleanup_root
5977
6020
 
5978
6021
  return manifest
@@ -7057,7 +7100,14 @@ def cleanup_artifacts(project_root: str, *, frames_only: bool = True) -> Dict[st
7057
7100
  shutil.rmtree(full, ignore_errors=True)
7058
7101
  removed.append(full)
7059
7102
  else:
7060
- shutil.rmtree(root, ignore_errors=True)
7103
+ # The whole root goes, including `_soul/timeline_brain.sqlite`.
7104
+ if not _drop_brain_db_and_rmtree(root, root):
7105
+ return {
7106
+ "success": False,
7107
+ "error": f"Could not fully remove the project analysis root: {root}",
7108
+ "removed": removed,
7109
+ "frames_only": frames_only,
7110
+ }
7061
7111
  removed.append(root)
7062
7112
  return {"success": True, "removed": removed, "frames_only": frames_only}
7063
7113
 
@@ -50,6 +50,26 @@ def db_path_for_project(project_root: str) -> str:
50
50
  return os.path.join(project_root, SOUL_DIRNAME, DB_FILENAME)
51
51
 
52
52
 
53
+ def _cache_key(project_root: str) -> str:
54
+ """The connection-cache key for a project root: its DB path, realpath'd.
55
+
56
+ Two spellings of one root must not become two cached connections to one
57
+ file. They do without this, because callers disagree about spelling by
58
+ construction -- `media_analysis` normalizes a root through `realpath`
59
+ before using it, while its own callers pass whatever the user typed. On
60
+ macOS that alone is enough: every temp root under `/var/folders/...` is a
61
+ symlink to `/private/var/folders/...`.
62
+
63
+ The damage is not just a duplicate. `close()` pops by key, so a mismatch
64
+ makes it silently no-op and leave the connection it was called to release
65
+ -- which is the whole point of calling it before deleting the root.
66
+
67
+ `realpath` on a path that does not exist yet resolves the ancestors that do
68
+ and leaves the rest literal, which is what a not-yet-created DB needs.
69
+ """
70
+ return os.path.realpath(db_path_for_project(project_root))
71
+
72
+
53
73
  def _ensure_parent_dir(path: str) -> None:
54
74
  os.makedirs(os.path.dirname(path), exist_ok=True)
55
75
 
@@ -224,7 +244,7 @@ def connect(project_root: str) -> sqlite3.Connection:
224
244
  """
225
245
  if not project_root:
226
246
  raise ValueError("project_root is required")
227
- path = db_path_for_project(project_root)
247
+ path = _cache_key(project_root)
228
248
  with _CONNECTION_LOCK:
229
249
  existing = _CONNECTIONS.get(path)
230
250
  if existing is not None:
@@ -250,6 +270,25 @@ def close_all() -> None:
250
270
  _CONNECTIONS.clear()
251
271
 
252
272
 
273
+ def close(project_root: str) -> None:
274
+ """Drop and close the cached connection for `project_root`, if any.
275
+
276
+ Callers that are about to delete or move a project's analysis root need
277
+ this. On Windows the open handle makes `_soul/timeline_brain.sqlite`
278
+ undeletable; on POSIX the cache would otherwise hand the next writer a
279
+ connection to a file that no longer has a directory entry, so the write
280
+ lands nowhere.
281
+ """
282
+ path = _cache_key(project_root)
283
+ with _CONNECTION_LOCK:
284
+ conn = _CONNECTIONS.pop(path, None)
285
+ if conn is not None:
286
+ try:
287
+ conn.close()
288
+ except sqlite3.Error:
289
+ pass
290
+
291
+
253
292
  @contextmanager
254
293
  def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
255
294
  """Context manager wrapping a write transaction.
@@ -291,14 +330,8 @@ def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
291
330
 
292
331
  def reset_for_test(project_root: str) -> None:
293
332
  """Drop + recreate every table. Tests only."""
294
- path = db_path_for_project(project_root)
295
- with _CONNECTION_LOCK:
296
- conn = _CONNECTIONS.pop(path, None)
297
- if conn is not None:
298
- try:
299
- conn.close()
300
- except sqlite3.Error:
301
- pass
333
+ path = _cache_key(project_root)
334
+ close(project_root)
302
335
  for suffix in ("", "-wal", "-shm"):
303
336
  try:
304
337
  os.remove(path + suffix)
@@ -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
+ }