davinci-resolve-mcp 2.99.2 → 2.100.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/AGENTS.md CHANGED
@@ -66,6 +66,10 @@ semantics.
66
66
  - Public overview, current stats, and docs map: `README.md`
67
67
  - Historical release notes: `CHANGELOG.md`
68
68
  - AI assistant operating reference: `docs/SKILL.md`
69
+ - Craft guidance for non-Claude-Code clients: the `knowledge` tool serves
70
+ `.claude/skills/`, `docs/guides/`, and `docs/kernels/` as resolved prose over
71
+ MCP. When you add a skill, guide, or kernel it is indexed automatically — a
72
+ drift guard fails the suite if anything in those directories is unreachable
69
73
  - Release checklist and validation rules: `docs/process/release-process.md`
70
74
  - Kernel workflow support maps: `docs/kernels/`
71
75
  - API coverage and live-test status: `docs/reference/api-coverage.md`
package/CHANGELOG.md CHANGED
@@ -2,8 +2,128 @@
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 v2.100.0
6
+
7
+ **The craft guidance is now readable by any MCP client.** This repository carries a
8
+ real body of editorial, colour, and audio guidance — how to tighten a take without
9
+ cutting the breath out of it, what frames to look at before applying a grade, which
10
+ API calls silently lie. It lived in `.claude/skills/`, `docs/guides/`, and
11
+ `docs/kernels/`, and it was reachable only by an agent with this checkout on disk.
12
+
13
+ Over MCP there is no checkout. A skill that says "open
14
+ `docs/guides/color-decision-guide.md`" is a dead end on Codex, Cursor, or a bare SDK
15
+ loop: the pointer resolves to nothing, and the agent operates the tools without ever
16
+ seeing the reasoning that makes the operation correct.
17
+
18
+ ### Added
19
+
20
+ - **`knowledge` tool** (36th compound tool) — the corpus served as prose, with no
21
+ Resolve connection involved:
22
+ - `topics(category?)` — the index: id, summary, size, sections, related topics.
23
+ 35 topics across `workflow`, `guide`, `kernel`, `reference`, and `repo`.
24
+ - `get(topic, section?, inline?)` — resolved prose. Natural aliases (`"tighten"`,
25
+ `"dead air"`, `"grading"`) resolve to real topics, and referenced guides and
26
+ kernels arrive **inlined**, so what comes back is the manual rather than a path
27
+ to it. `section` returns one heading's subtree.
28
+ - `search(query, limit?)` — ranked topics with excerpts.
29
+ - `capabilities()` — topic counts by category and the corpus directories.
30
+ - **`knowledge://topics` MCP resource** — the same index, so hosts that consume
31
+ resources can see what guidance exists without spending a turn on it.
32
+ - `setup(action="schema")` now names the guidance, because an agent's orientation
33
+ call is where it will actually be noticed.
34
+
35
+ ### Design notes
36
+
37
+ - **Inlining stops at one level.** Following references transitively would turn a
38
+ 150-line answer into the whole `docs/` tree.
39
+ - **Oversized references are summarised, not truncated.** Over the inline budget an
40
+ agent gets the title, summary, section list, and the topic id to fetch — a
41
+ truncated prefix is the first N lines, which is rarely the part that answers the
42
+ question.
43
+ - **`reference` topics are terminal.** The 2250-line operating reference and the
44
+ generated API ledgers cross-link each other freely; inlining from them doubles a
45
+ document that was already complete.
46
+ - **An unknown section is an error that lists the real ones**, never a quiet return
47
+ of the whole document.
48
+ - **Search matches whole words.** Substring counting ranked `resolve-audio` top for
49
+ "dead air", because "air" is inside "F-air-light". Body hits are also normalised by
50
+ document length, so the longest document cannot win on mass alone.
51
+
52
+ ### Guarded against drift
53
+
54
+ A test asserts every skill, guide, and kernel in the corpus reaches the index, and
55
+ that every alias points at a topic that exists. Knowledge added to this repository
56
+ later cannot go silently unserved — the failure mode a hand-kept list has every time.
57
+
58
+ ### Validation
59
+
60
+ - Offline suite: 2849 passed, 1 skipped, 711 subtests, 0 failures.
61
+ - Three deliberate mutations (substring search, inlining disabled, unknown section
62
+ returning the whole document) were each caught by the new tests.
63
+ - No Resolve behavior changed; live test not required. A test asserts the tool never
64
+ reaches for a Resolve connection.
65
+
66
+ ## What's New in v2.99.3
67
+
68
+ **Fusion authoring now works on the free edition.** v2.99.2 documented that
69
+ `fusion_comp add_tool` could not run there, because the in-app bridge reported
70
+ `GetAttrs`/`SetAttrs` as absent on a Fusion tool and `add_tool` calls `GetAttrs`
71
+ to build its return value — which took every server-authored Fusion graph with
72
+ it. Investigating the fallback found the premise was wrong: **those methods are
73
+ present and work.** Invoked directly on free 21.0.3.7, `GetAttrs` returned
74
+ `{TOOLS_Name: "Blur1", TOOLS_RegID: "Blur"}` and `SetAttrs` renamed the tool.
75
+
76
+ The fault was our capability check.
77
+
78
+ ### The API truth underneath
79
+
80
+ `dir()` on a live Fusion Tool returns 38 names — with `Composition` listed
81
+ **twice** — and omits `GetAttrs`/`SetAttrs`. Resolve fabricates a callable for
82
+ *any* attribute name, so `dir()` is the only evidence of absence that exists,
83
+ which makes an omitted name unrecoverable by probing. The bridge's strict proxy
84
+ took that omission as authoritative and answered "has no attribute 'GetAttrs' in
85
+ this Resolve build" for a method that was right there.
86
+
87
+ Resolve's own API objects enumerate correctly — Timeline 60, TimelineItem 88,
88
+ Composition 92 — so this is specific to Fusion Tools.
89
+
90
+ ### Fixed
91
+
92
+ - The bridge client now carries a **curated exception**: names that are
93
+ documented Fusion methods but absent from the enumeration resolve normally,
94
+ and only on an object whose own method list positively identifies it as a
95
+ Fusion object (`ConnectInput`/`FindMainInput`/`GetControlPageNames` on a Tool,
96
+ `AddTool`/`FindTool`/`GetToolList` on a Composition).
97
+
98
+ This is deliberately not a global relaxation. Dropping the scoping fails five
99
+ existing tests, including the ones that keep
100
+ `getattr(item, "CreateMagicMask", None)` honest — capability detection on
101
+ Resolve API objects answers exactly as before.
102
+
103
+ - `fusion_comp add_tool` needed **no change**. The fallback proposed in v2.99.2
104
+ would have papered over a client bug while leaving every other unenumerated
105
+ Fusion method broken.
106
+
107
+ ### Live validation
108
+
109
+ Free DaVinci Resolve 21.0.3.7, whole graph through the server including a custom
110
+ tool name (which requires `SetAttrs`):
111
+
112
+ ```
113
+ add_tool -> {'tool_name': 'FreeBlur', 'tool_type': 'Blur'}
114
+ connect -> {'success': True}
115
+ connect -> {'success': True}
116
+ set_input -> {'success': True}
117
+ render -> PSNR 23.32 dB vs baseline — RENDERED
118
+ ```
119
+
5
120
  ## What's New in v2.99.2
6
121
 
122
+ > **Cause corrected in v2.99.3.** The "`fusion_comp add_tool` cannot run on the
123
+ > free edition" note below has the wrong cause: `GetAttrs`/`SetAttrs` are present
124
+ > on a Fusion tool and work when called — the bridge client reported them absent
125
+ > because `dir()` on a Fusion Tool omits them. `add_tool` needed no change.
126
+
7
127
  **The Fusion comp-lock question is closed on Resolve 21.** The v2.98.5–v2.98.8
8
128
  work isolated the bug on Studio 19.1.3.7 only, and the open caveat was whether
9
129
  the "renders on 19.1.3.7, ignored on 21.0.4.5" split reported in
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-2.99.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.100.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-35%20(353%20full)-blue.svg)](#server-modes)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%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)
@@ -109,7 +109,7 @@ The command starts a loopback-only server and opens the control panel in your br
109
109
 
110
110
  | Mode | Entry point | Tools | Best for |
111
111
  |------|-------------|-------|----------|
112
- | Compound | `src/server.py` | 35 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
112
+ | Compound | `src/server.py` | 36 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
113
113
  | Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 353 | Power users who want one MCP tool per Resolve API method. |
114
114
 
115
115
  The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
@@ -218,6 +218,7 @@ The open-source servers are complete and fully functional on their own.
218
218
  | Audio and Fairlight | Track/item probes, source mapping, guarded audio property writes, voice isolation, auto-sync planning, transcription/subtitle probes |
219
219
  | Render and deliver | Format/codec matrix probing, render settings validation, queued job lifecycle checks, guarded Quick Export |
220
220
  | Extension authoring | Fuse, DCTL, ACES DCTL, and Resolve-page Lua/Python script lifecycle helpers with safe MCP-marked install/remove |
221
+ | Craft guidance | The bundled editorial, colour, audio, and workflow guidance served as prose over MCP — indexed, searchable, and readable by any client, not just ones with this repository on disk |
221
222
 
222
223
  ## Optional Extras
223
224
 
@@ -272,7 +273,7 @@ The default server is a local stdio process launched by your MCP client; it does
272
273
 
273
274
  | Metric | Value |
274
275
  |--------|-------|
275
- | MCP Tools | **35** compound / **353** granular (live server) |
276
+ | MCP Tools | **36** compound / **353** granular (live server) |
276
277
  | Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
277
278
  | Kernel Actions | **136** guarded workflow actions across 9 compound tools |
278
279
  | 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-2.99.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.100.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-35%20(353%20full)-blue.svg)](#服务器模式)
8
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%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
- > 本翻译对应 v2.99.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.100.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -81,7 +81,7 @@ venv/bin/python -m src.control_panel
81
81
 
82
82
  | 模式 | 入口 | 工具数 | 适合谁 |
83
83
  |------|------|--------|--------|
84
- | Compound(复合) | `src/server.py` | 35 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
84
+ | Compound(复合) | `src/server.py` | 36 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
85
85
  | Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 353 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
86
86
 
87
87
  除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
@@ -196,7 +196,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
196
196
 
197
197
  | 指标 | 数值 |
198
198
  |------|------|
199
- | MCP 工具 | **35** 复合 / **353** 细粒度(实时服务器) |
199
+ | MCP 工具 | **36** 复合 / **353** 细粒度(实时服务器) |
200
200
  | Advanced(离线)工具 | **18**——.drp/.drt/.drx + 数据库创作,无需 Resolve 运行 |
201
201
  | 内核 action | 9 个复合工具下 **136** 个带护栏的工作流 action |
202
202
  | API 方法覆盖 | **361/361**(100%) |
package/docs/SKILL.md CHANGED
@@ -162,7 +162,7 @@ before mutating Resolve state.
162
162
 
163
163
  | Mode | Entry point | Tool count | Use when |
164
164
  |---|---|---|---|
165
- | Compound (default) | `src/server.py` | 35 tools | Most workflows — keeps context lean |
165
+ | Compound (default) | `src/server.py` | 36 tools | Most workflows — keeps context lean |
166
166
  | Granular (full) | `src/server.py --full` | 353 tools | Power users needing one tool per API method |
167
167
 
168
168
  This skill document covers the **compound server** (the default). Each compound
@@ -494,6 +494,32 @@ you are on the correct page first.
494
494
 
495
495
  ## Tool Map
496
496
 
497
+ ### Craft Guidance
498
+
499
+ **`knowledge`** — The editorial, colour, audio, and workflow guidance bundled with
500
+ this server, served as prose. No Resolve connection required.
501
+
502
+ Read a topic **before** a creative or destructive operation, not after. The tools will
503
+ happily execute an editorially wrong decision; this is where the reasoning lives —
504
+ measured numbers, known traps, and what each move costs to undo.
505
+
506
+ Key actions:
507
+ - `topics(category?)` — the index: topic id, one-line summary, size, sections, and
508
+ related topics. Categories: `workflow` (task playbooks: tighten a recording, build a
509
+ rough cut, match a grade), `guide`, `kernel` (per-surface tool maps), `reference`
510
+ (exhaustive ledgers including this document), `repo` (contributing here)
511
+ - `get(topic, section?, inline?)` — the resolved prose. Natural aliases work
512
+ (`"tighten"`, `"dead air"`, `"grading"`, `"conform"`). Referenced guides and kernels
513
+ arrive inlined, so a client with no checkout of this repository still gets the
514
+ manual, not a path to it. `section` returns one heading's subtree
515
+ - `search(query, limit?)` — ranked topics with excerpts
516
+ - `capabilities()` — topic count by category, and the corpus directories
517
+
518
+ The same index is published as the `knowledge://topics` MCP resource, so hosts that
519
+ consume resources can see what guidance exists without spending a turn.
520
+
521
+ ---
522
+
497
523
  ### App Control
498
524
 
499
525
  **`resolve_control`** — App-level operations.
@@ -63,7 +63,7 @@ This MCP server controls DaVinci Resolve via its Scripting API. Some tools perfo
63
63
  davinci-resolve-mcp/
64
64
  ├── install.py # Universal installer (macOS/Windows/Linux)
65
65
  ├── src/
66
- │ ├── server.py # Compound MCP server — 35 tools (default)
66
+ │ ├── server.py # Compound MCP server — 36 tools (default)
67
67
  │ ├── resolve_mcp_server.py # Thin full-server entrypoint — 353 tools
68
68
  │ ├── granular/ # Modular full-server implementation
69
69
  │ └── utils/ # Platform detection, Resolve connection helpers
package/docs/install.md CHANGED
@@ -141,7 +141,7 @@ The MCP server comes in two modes:
141
141
 
142
142
  | Mode | File | Tools | Best For |
143
143
  |------|------|-------|----------|
144
- | **Compound** (default) | `src/server.py` | 35 | Most users — fast, clean, low context usage |
144
+ | **Compound** (default) | `src/server.py` | 36 | Most users — fast, clean, low context usage |
145
145
  | **Full** | `src/resolve_mcp_server.py` | 353 | Power users who want one tool per API method |
146
146
 
147
147
  The compound server's `timeline_item` tool includes dedicated actions for common workflows:
@@ -6,7 +6,7 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
6
6
 
7
7
  | Metric | Value |
8
8
  |--------|-------|
9
- | MCP Tools | **35** compound (default) / **353** granular |
9
+ | MCP Tools | **36** compound (default) / **353** granular |
10
10
  | Kernel Actions | **136** guarded MCP workflow actions across 9 compound tools |
11
11
  | API Methods Covered | **361/361** (100%) |
12
12
  | Methods Live Tested | **338/361** (93.6%) |
@@ -17,7 +17,7 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
17
17
 
18
18
  ## API Coverage
19
19
 
20
- Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **35 tools** that group related operations by action parameter, keeping LLM context windows lean. The full granular server provides **353 individual tools** for power users. Both modes cover all 13 API object classes. MCP-level kernel actions are tracked separately in [Kernel Action Coverage](../kernels/README.md).
20
+ Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **36 tools** that group related operations by action parameter, keeping LLM context windows lean. The full granular server provides **353 individual tools** for power users. Both modes cover all 13 API object classes. MCP-level kernel actions are tracked separately in [Kernel Action Coverage](../kernels/README.md).
21
21
 
22
22
  The 34th compound tool is `timeline_versioning` (C6) — an MCP-level workflow
23
23
  tool, not a wrapper around a Resolve API method. It surfaces the
@@ -355,13 +355,13 @@ values, or automation-hostile modal prompts.
355
355
  - **Workaround / current handling:** Author OTIO for Resolve by mirroring what Resolve itself exports, and give every event its media timecode origin. editorial.convert_to_interchange (target 'otio') does this and reports any event whose origin had to be assumed in `mediaOriginAssumed` — a non-empty list means the file will only import if that media really starts at 00:00:00:00. To debug a refusal, export any timeline with EXPORT_OTIO and diff your document against it; do NOT chase missing media or reach for sanitize_media, which cannot even parse a .otio (it is JSON, not XML).
356
356
  - **Tags:** timeline, import, interchange, otio, silent-failure, conform
357
357
 
358
- ### Fusion tool GetAttrs / SetAttrs (unreachable over the in-app bridge)
358
+ ### Fusion object dir() omits real methods (GetAttrs / SetAttrs)
359
359
 
360
- - **Object:** `Fusion Tool (via the free-edition bridge)`
361
- - **Signature:** `GetAttrs() -> dict / SetAttrs(dict) -> None`
362
- - **Behavior:** The in-app bridge's transparent proxy exposes SetInput, GetInput, ConnectInput and FindMainInput on a Fusion tool, but NOT GetAttrs or SetAttrs - the client raises AttributeError('... has no attribute SetAttrs in this Resolve build'). Measured 2026-08-22 on free 21.0.3.7. Because fusion_comp's add_tool calls tool.GetAttrs() unconditionally to build its return value, and SetAttrs whenever a name is passed, `fusion_comp add_tool` cannot run at all on the free edition, which takes connect/set_input with it for any graph the caller wanted to build through the server. Comp-level calls (AddFusionComp, GetFusionCompByIndex, FindTool, AddTool) work, so the graph can still be wired with raw proxy calls.
363
- - **Workaround / current handling:** On the free edition, wire Fusion graphs with raw comp.AddTool/ConnectInput and use fusion_comp only for value writes. Making add_tool tolerate a missing GetAttrs (fall back to the requested tool_type and a FindTool-based name) would restore the action there.
364
- - **Tags:** fusion, bridge, free-edition
360
+ - **Object:** `Fusion Tool / Composition`
361
+ - **Signature:** `dir(tool) -> incomplete list`
362
+ - **Behavior:** `dir()` on a live Fusion Tool returns 38 names with 'Composition' listed TWICE and omits GetAttrs and SetAttrs, which are documented Fusion Tool methods that work perfectly when called. Measured on free 21.0.3.7 over the in-app bridge: invoking GetAttrs directly returned {TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs renamed the tool. This matters because Resolve fabricates a callable for ANY attribute name, so `dir()` is the only evidence of absence that exists which makes an omitted name unrecoverable by probing. Any capability detection built on dir()/hasattr will therefore report a real Fusion method as missing. Resolve's own API objects do not have this problem: Timeline (60), TimelineItem (88) and Composition (92) all enumerate correctly.
363
+ - **Workaround / current handling:** Do not treat dir()/hasattr as authoritative for Fusion Tool objects. Keep a curated set of documented Fusion methods that the enumeration omits, and identify a Fusion object positively (ConnectInput / FindMainInput / GetControlPageNames on a Tool, AddTool / FindTool / GetToolList on a Composition) rather than relaxing the check globally, which would silently re-open capability detection on Resolve API objects.
364
+ - **Tags:** fusion, introspection, bridge, free-edition
365
365
 
366
366
  ### Composition.Lock (suppresses render invalidation for value writes)
367
367
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.99.2"
40
+ VERSION = "2.100.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.99.2",
3
+ "version": "2.100.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 = "2.99.2"
90
+ VERSION = "2.100.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()}")
package/src/server.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """
3
3
  DaVinci Resolve MCP Server (Compound Tools)
4
4
 
5
- 35 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
5
+ 36 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
6
6
  plus Fusion Fuse, DCTL, and Resolve-page Script authoring tools.
7
7
  Each tool groups related operations via an 'action' parameter.
8
8
 
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.99.2"
14
+ VERSION = "2.100.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -317,7 +317,7 @@ def davinci_resolve_workflow() -> str:
317
317
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
318
318
 
319
319
  Core pattern:
320
- - Prefer the 35 compound tools and their action names over raw scripting.
320
+ - Prefer the 36 compound tools and their action names over raw scripting.
321
321
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
322
322
  - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
323
323
  - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
@@ -14730,6 +14730,17 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
14730
14730
  if action in {"schema", "capabilities", "options"}:
14731
14731
  return {
14732
14732
  "actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"],
14733
+ # Agents commonly call this first. The craft guidance is worth nothing if
14734
+ # nobody knows it is there, so the orientation call names it.
14735
+ "craft_guidance": {
14736
+ "tool": "knowledge",
14737
+ "start_with": "knowledge(action='topics')",
14738
+ "when": (
14739
+ "Before a creative or destructive operation — cutting, grading, "
14740
+ "conforming, tightening, delivering. The tools execute; this is "
14741
+ "where the reasoning and the measured numbers live."
14742
+ ),
14743
+ },
14733
14744
  "defaults": {
14734
14745
  "media_analysis.timed_markers_default": {
14735
14746
  "description": "Default answer for writing source-time analysis notes as Media Pool clip markers.",
@@ -29471,6 +29482,83 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
29471
29482
  "execute", "run_inline", *_EXTENSION_KERNEL_ACTIONS])
29472
29483
 
29473
29484
 
29485
+ # ═══════════════════════════════════════════════════════════════════════════════
29486
+ # TOOL: knowledge
29487
+ #
29488
+ # The craft guidance in this repository — how to tighten a take without cutting
29489
+ # the breath out of it, what to look at before applying a grade, which API calls
29490
+ # silently lie — has always been readable only by an agent with this checkout on
29491
+ # disk. Over MCP there is no checkout, so a client on any other host operated the
29492
+ # tools without ever seeing the reasoning that makes the operation correct.
29493
+ #
29494
+ # This serves that corpus as content: the index, one resolved topic, or a search.
29495
+ # No Resolve connection is involved at any point.
29496
+ # ═══════════════════════════════════════════════════════════════════════════════
29497
+
29498
+ _KNOWLEDGE_ACTIONS = ["topics", "get", "search", "capabilities"]
29499
+
29500
+
29501
+ @mcp.tool()
29502
+ @_guard_missing_params
29503
+ def knowledge(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
29504
+ """Editorial, colour, audio, and workflow guidance — readable without this checkout.
29505
+
29506
+ Read a topic BEFORE a creative or destructive operation, not after. The guidance
29507
+ carries measured numbers and known traps; operating the tools without it is how a
29508
+ technically-correct call produces an editorially wrong result.
29509
+
29510
+ Actions:
29511
+ topics(category?) -> {topics} — the index: id, summary, size, sections, related.
29512
+ Categories: workflow (task playbooks), guide, kernel (per-surface tool maps),
29513
+ reference (exhaustive ledgers), repo (contributing to this project).
29514
+ get(topic, section?, inline?) -> {content} — resolved prose. Accepts natural
29515
+ aliases ("tighten", "dead air", "grading"). `section` returns one heading's
29516
+ subtree; `inline=false` skips the referenced documents.
29517
+ search(query, limit?) -> {hits} — ranked topics with excerpts.
29518
+ capabilities() -> {topic_count, categories, corpus}
29519
+
29520
+ No Resolve connection required.
29521
+ """
29522
+ p = _params(params)
29523
+ from src.utils import knowledge as _knowledge_mod
29524
+
29525
+ try:
29526
+ if action == "topics":
29527
+ category = p.get("category")
29528
+ listing = _knowledge_mod.topics(category=str(category) if category else None)
29529
+ return _ok(topics=listing, count=len(listing),
29530
+ categories=list(_knowledge_mod.CATEGORIES))
29531
+ if action == "get":
29532
+ err, _clean = _validate_params(p, {
29533
+ "topic": {"type": str, "required": True, "non_empty": True},
29534
+ })
29535
+ if err:
29536
+ return _err(err)
29537
+ section = p.get("section")
29538
+ return _ok(**_knowledge_mod.get(
29539
+ str(p["topic"]),
29540
+ section=str(section) if section else None,
29541
+ inline=bool(p.get("inline", True)),
29542
+ ))
29543
+ if action == "search":
29544
+ err, _clean = _validate_params(p, {
29545
+ "query": {"type": str, "required": True, "non_empty": True},
29546
+ })
29547
+ if err:
29548
+ return _err(err)
29549
+ hits = _knowledge_mod.search(str(p["query"]), limit=int(p.get("limit", 5)))
29550
+ return _ok(hits=hits, count=len(hits))
29551
+ if action in {"capabilities", "schema"}:
29552
+ return _ok(**_knowledge_mod.capabilities(), actions=_KNOWLEDGE_ACTIONS)
29553
+ except _knowledge_mod.KnowledgeError as exc:
29554
+ # The message already names the real topics or sections, so an agent that
29555
+ # guessed wrong can correct itself without a second round-trip.
29556
+ return _err(str(exc), code="UNKNOWN_TOPIC", category="invalid_params",
29557
+ retryable=False)
29558
+
29559
+ return _unknown(action, _KNOWLEDGE_ACTIONS)
29560
+
29561
+
29474
29562
  # ═══════════════════════════════════════════════════════════════════════════════
29475
29563
  # MCP Resources — agentic-flow improvement E1
29476
29564
  #
@@ -29515,6 +29603,32 @@ def _resource_mcp_version() -> Dict[str, Any]:
29515
29603
  }
29516
29604
 
29517
29605
 
29606
+ @mcp.resource("knowledge://topics")
29607
+ @_safe_resource
29608
+ def _resource_knowledge_topics() -> Dict[str, Any]:
29609
+ """The knowledge index — id, summary, category, size. Pure read of bundled docs.
29610
+
29611
+ A host that consumes resources learns what guidance exists without spending a turn
29612
+ on it, which is the difference between the `knowledge` tool being available and it
29613
+ being used.
29614
+ """
29615
+ from src.utils import knowledge as _knowledge_mod
29616
+
29617
+ return {
29618
+ "topics": [
29619
+ {
29620
+ "topic": item["topic"],
29621
+ "title": item["title"],
29622
+ "category": item["category"],
29623
+ "summary": item["summary"],
29624
+ "length_lines": item["resolved_length_lines"],
29625
+ }
29626
+ for item in _knowledge_mod.topics()
29627
+ ],
29628
+ "fetch_with": "knowledge(action='get', params={'topic': '<id>'})",
29629
+ }
29630
+
29631
+
29518
29632
  @mcp.resource("status://resolve_connection")
29519
29633
  @_safe_resource
29520
29634
  def _resource_resolve_connection() -> Dict[str, Any]:
@@ -29734,5 +29848,5 @@ if __name__ == "__main__":
29734
29848
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
29735
29849
  sys.exit(2)
29736
29850
 
29737
- logger.info("Starting DaVinci Resolve MCP Server (35 compound tools)")
29851
+ logger.info("Starting DaVinci Resolve MCP Server (36 compound tools)")
29738
29852
  run_fastmcp_stdio(mcp)
@@ -404,29 +404,34 @@ API_TRUTH: List[Dict[str, Any]] = [
404
404
  "submit": "bug",
405
405
  },
406
406
  {
407
- "symbol": "Fusion tool GetAttrs / SetAttrs (unreachable over the in-app bridge)",
408
- "object": "Fusion Tool (via the free-edition bridge)",
409
- "signature": "GetAttrs() -> dict / SetAttrs(dict) -> None",
410
- "reality": "The in-app bridge's transparent proxy exposes SetInput, "
411
- "GetInput, ConnectInput and FindMainInput on a Fusion tool, "
412
- "but NOT GetAttrs or SetAttrs - the client raises "
413
- "AttributeError('... has no attribute SetAttrs in this "
414
- "Resolve build'). Measured 2026-08-22 on free 21.0.3.7. "
415
- "Because fusion_comp's add_tool calls tool.GetAttrs() "
416
- "unconditionally to build its return value, and SetAttrs "
417
- "whenever a name is passed, `fusion_comp add_tool` cannot "
418
- "run at all on the free edition, which takes "
419
- "connect/set_input with it for any graph the caller wanted "
420
- "to build through the server. Comp-level calls "
421
- "(AddFusionComp, GetFusionCompByIndex, FindTool, AddTool) "
422
- "work, so the graph can still be wired with raw proxy calls.",
423
- "recommended": "On the free edition, wire Fusion graphs with raw "
424
- "comp.AddTool/ConnectInput and use fusion_comp only for "
425
- "value writes. Making add_tool tolerate a missing "
426
- "GetAttrs (fall back to the requested tool_type and a "
427
- "FindTool-based name) would restore the action there.",
428
- "tags": ["fusion", "bridge", "free-edition"],
407
+ "symbol": "Fusion object dir() omits real methods (GetAttrs / SetAttrs)",
408
+ "object": "Fusion Tool / Composition",
409
+ "signature": "dir(tool) -> incomplete list",
410
+ "reality": "`dir()` on a live Fusion Tool returns 38 names — with "
411
+ "'Composition' listed TWICE and omits GetAttrs and "
412
+ "SetAttrs, which are documented Fusion Tool methods that "
413
+ "work perfectly when called. Measured on free 21.0.3.7 over "
414
+ "the in-app bridge: invoking GetAttrs directly returned "
415
+ "{TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs "
416
+ "renamed the tool. This matters because Resolve fabricates a "
417
+ "callable for ANY attribute name, so `dir()` is the only "
418
+ "evidence of absence that exists which makes an omitted "
419
+ "name unrecoverable by probing. Any capability detection "
420
+ "built on dir()/hasattr will therefore report a real Fusion "
421
+ "method as missing. Resolve's own API objects do not have "
422
+ "this problem: Timeline (60), TimelineItem (88) and "
423
+ "Composition (92) all enumerate correctly.",
424
+ "recommended": "Do not treat dir()/hasattr as authoritative for Fusion "
425
+ "Tool objects. Keep a curated set of documented Fusion "
426
+ "methods that the enumeration omits, and identify a "
427
+ "Fusion object positively (ConnectInput / FindMainInput "
428
+ "/ GetControlPageNames on a Tool, AddTool / FindTool / "
429
+ "GetToolList on a Composition) rather than relaxing the "
430
+ "check globally, which would silently re-open capability "
431
+ "detection on Resolve API objects.",
432
+ "tags": ["fusion", "introspection", "bridge", "free-edition"],
429
433
  "submit": "bug",
434
+ "mitigation": ["_FUSION_UNENUMERATED_METHODS", "_FUSION_OBJECT_MARKERS"],
430
435
  },
431
436
  {
432
437
  "symbol": "Composition.Lock (suppresses render invalidation for value writes)",
@@ -0,0 +1,594 @@
1
+ """Editorial and operating knowledge, resolved for clients that have no filesystem.
2
+
3
+ This repository carries a real body of craft guidance: how to tighten a recording
4
+ without cutting the breath out of it, what to look at before applying a grade, which
5
+ Resolve API calls silently lie. It lives in two places — `.claude/skills/*/SKILL.md`
6
+ and `docs/guides` + `docs/kernels` — and until now both were reachable only by an
7
+ agent that could open files in this checkout.
8
+
9
+ Over MCP the client has no checkout. A skill that says "open
10
+ `docs/guides/color-decision-guide.md`" is a dead end there: the pointer resolves to
11
+ nothing, and the agent operates the tools without ever seeing the reasoning that makes
12
+ the operation correct. So this module serves **content, not pointers** — it follows the
13
+ in-repo references one level and inlines what they point at.
14
+
15
+ ## One level, deliberately
16
+
17
+ Following references transitively would turn a 150-line answer into the whole `docs/`
18
+ tree. One level is the depth at which a skill's own routing is satisfied: the skill
19
+ names the manual, and the manual arrives with it. Anything deeper is a different
20
+ question, and the agent can ask it as a separate topic.
21
+
22
+ ## Nothing in the corpus is unreachable
23
+
24
+ The index is built from the directories themselves, not from a hand-kept list, and a
25
+ drift guard asserts every skill and every guide/kernel appears in it. A knowledge file
26
+ added later cannot go quietly unserved — which is the failure mode a hand-kept list has
27
+ every time.
28
+
29
+ ## An oversized reference is summarised, not truncated
30
+
31
+ Inlining is capped. Under the cap a referenced document arrives whole; over it, what
32
+ arrives is its title, its summary and its section list, plus the topic id to fetch for
33
+ the full text. A truncated prefix would be worse than a pointer — it is the first N
34
+ lines, which is rarely the part that answers the question.
35
+
36
+ ## Reference documents are terminal
37
+
38
+ `docs/SKILL.md`, the api ledgers and the release process are exhaustive by design and
39
+ cross-link each other freely. Inlining from them doubles a document that was already
40
+ complete, so the `reference` category resolves to itself. Skills, guides and kernels —
41
+ the documents whose job is to route — still inline.
42
+
43
+ ## Sections are parsed from the primary document only
44
+
45
+ `get(topic, section=...)` slices the skill's or doc's own headings, never the inlined
46
+ material. Slicing across an inline boundary would return a section whose name belongs to
47
+ one document and whose body belongs to another. An unknown section is an error that
48
+ lists the real ones rather than quietly returning the whole file.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import difflib
54
+ import math
55
+ import re
56
+ from pathlib import Path
57
+ from typing import Any, Dict, List, Optional, Tuple
58
+
59
+ REPO_ROOT = Path(__file__).resolve().parents[2]
60
+ SKILLS_DIR = REPO_ROOT / ".claude" / "skills"
61
+ GUIDES_DIR = REPO_ROOT / "docs" / "guides"
62
+ KERNELS_DIR = REPO_ROOT / "docs" / "kernels"
63
+
64
+ # Skills about developing *this repository* rather than operating Resolve. Served, but
65
+ # categorised apart so an editor asking for craft guidance is not handed our release
66
+ # checklist.
67
+ _REPO_SKILLS = frozenset({"house-style", "release-check"})
68
+
69
+ # `docs/kernels/README.md` is a table of contents for its own directory: every entry it
70
+ # lists is already an indexed topic, so serving it adds a second, staler index. The
71
+ # drift guard skips it for the same reason.
72
+ _DOC_SKIPLIST = frozenset({"README.md"})
73
+
74
+ CATEGORIES = ("workflow", "guide", "kernel", "reference", "repo")
75
+
76
+ MAX_SUMMARY_CHARS = 320
77
+ EXCERPT_RADIUS = 160
78
+
79
+ # Dropped from search terms. Without this "cut to music" is decided by "to", and the
80
+ # longest document in the corpus wins every query that contains a preposition.
81
+ _STOPWORDS = frozenset(
82
+ "a an the to of in on for and or is are it its my me we with from at by as be "
83
+ "this that how what when do does can should".split()
84
+ )
85
+
86
+ # Referenced documents longer than this are summarised rather than inlined whole. Set
87
+ # so the everyday kernels and guides (100-250 lines) arrive complete, while the two
88
+ # outliers — the 2250-line operating reference and the generated api-limitations ledger
89
+ # — do not swallow the topic that merely pointed at them.
90
+ MAX_INLINE_LINES = 260
91
+
92
+ # Documents outside `docs/guides` and `docs/kernels` that the corpus points at. Indexed
93
+ # explicitly, with stable ids, so an over-budget reference can name a topic to fetch
94
+ # instead of dead-ending. (`docs/SKILL.md` as a topic id would read as "a skill".)
95
+ _EXTRA_DOCS: Dict[str, str] = {
96
+ "mcp-operating-reference": "docs/SKILL.md",
97
+ "release-process": "docs/process/release-process.md",
98
+ "api-limitations": "docs/reference/api-limitations.md",
99
+ "api-coverage": "docs/reference/api-coverage.md",
100
+ }
101
+
102
+ # Natural phrasings an agent is likely to reach for, mapped to real topic ids. Keys are
103
+ # matched after lowercasing and collapsing separators, so "dead air" and "dead_air" both
104
+ # land.
105
+ _ALIASES: Dict[str, str] = {
106
+ "color": "resolve-color",
107
+ "colour": "resolve-color",
108
+ "grade": "resolve-color",
109
+ "grading": "resolve-color",
110
+ "look": "resolve-color",
111
+ "looks": "resolve-color",
112
+ "lut": "resolve-color",
113
+ "luts": "resolve-color",
114
+ "edit": "resolve-edit",
115
+ "editing": "resolve-edit",
116
+ "cut": "resolve-edit",
117
+ "cutting": "resolve-edit",
118
+ "trim": "resolve-edit",
119
+ "pacing": "resolve-edit",
120
+ "timeline": "resolve-edit",
121
+ "rough cut": "resolve-rough-cut",
122
+ "roughcut": "resolve-rough-cut",
123
+ "assembly": "resolve-rough-cut",
124
+ "selects": "resolve-rough-cut",
125
+ "tighten": "resolve-tighten-recording",
126
+ "tightening": "resolve-tighten-recording",
127
+ "dead air": "resolve-tighten-recording",
128
+ "silence": "resolve-tighten-recording",
129
+ "silences": "resolve-tighten-recording",
130
+ "pauses": "resolve-tighten-recording",
131
+ "audio": "resolve-audio",
132
+ "sound": "resolve-audio",
133
+ "mix": "resolve-audio",
134
+ "mixing": "resolve-audio",
135
+ "fairlight": "resolve-audio",
136
+ "loudness": "resolve-audio",
137
+ "conform": "resolve-conform",
138
+ "conforming": "resolve-conform",
139
+ "online": "resolve-conform",
140
+ "relink": "resolve-conform",
141
+ "aaf": "resolve-conform",
142
+ "delivery": "resolve-delivery",
143
+ "deliver": "resolve-delivery",
144
+ "render": "resolve-delivery",
145
+ "export": "resolve-delivery",
146
+ "fusion": "resolve-fusion",
147
+ "vfx": "resolve-fusion",
148
+ "titles": "resolve-fusion",
149
+ "analysis": "resolve-media-analysis",
150
+ "analyze": "resolve-media-analysis",
151
+ "analyse": "resolve-media-analysis",
152
+ "transcription": "resolve-media-analysis",
153
+ "transcribe": "resolve-media-analysis",
154
+ "media pool": "resolve-media-pool",
155
+ "bins": "resolve-media-pool",
156
+ "ingest": "resolve-media-pool",
157
+ "multicam": "resolve-media-pool",
158
+ "mcp": "resolve-mcp",
159
+ "tools": "resolve-mcp",
160
+ "session": "resolve-session",
161
+ "style": "house-style",
162
+ "release": "release-check",
163
+ "api": "api-coverage",
164
+ "limitations": "api-limitations",
165
+ "api limitations": "api-limitations",
166
+ "skill": "mcp-operating-reference",
167
+ "operating reference": "mcp-operating-reference",
168
+ }
169
+
170
+
171
+ class KnowledgeError(Exception):
172
+ """A topic or section that does not exist. Carries the real options."""
173
+
174
+
175
+ # ── corpus parsing ───────────────────────────────────────────────────────────
176
+
177
+
178
+ def _split_frontmatter(text: str) -> Tuple[Dict[str, str], str]:
179
+ """Return (frontmatter fields, body). Absent frontmatter yields an empty dict."""
180
+ match = re.match(r"^---\n(.*?)\n---\n", text, re.S)
181
+ if not match:
182
+ return {}, text
183
+ fields: Dict[str, str] = {}
184
+ for line in match.group(1).splitlines():
185
+ key_value = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
186
+ if key_value:
187
+ fields[key_value.group(1)] = key_value.group(2).strip()
188
+ return fields, text[match.end():]
189
+
190
+
191
+ def _first_paragraph(body: str) -> str:
192
+ """The first real paragraph after the H1 — a doc's own one-line summary."""
193
+ lines = body.splitlines()
194
+ start = 0
195
+ for index, line in enumerate(lines):
196
+ if line.startswith("# "):
197
+ start = index + 1
198
+ break
199
+ collected: List[str] = []
200
+ for line in lines[start:]:
201
+ stripped = line.strip()
202
+ if not stripped:
203
+ if collected:
204
+ break
205
+ continue
206
+ if stripped.startswith(("#", "|", "```", "- ", "* ", ">")):
207
+ if collected:
208
+ break
209
+ continue
210
+ collected.append(stripped)
211
+ summary = " ".join(collected)
212
+ if len(summary) > MAX_SUMMARY_CHARS:
213
+ summary = summary[: MAX_SUMMARY_CHARS - 1].rstrip() + "…"
214
+ return summary
215
+
216
+
217
+ def _title_of(body: str, fallback: str) -> str:
218
+ for line in body.splitlines():
219
+ if line.startswith("# "):
220
+ # `# Resolve Tighten Recording — Claude Code Skill` is scaffolding for one
221
+ # client. The subject is everything before the dash.
222
+ title = line[2:].strip()
223
+ return re.sub(r"\s*[—-]\s*Claude Code Skill\s*$", "", title).strip()
224
+ return fallback
225
+
226
+
227
+ def _strip_client_scaffolding(body: str) -> str:
228
+ """Drop the one H1 that names a specific client. Prose is left alone."""
229
+ return re.sub(
230
+ r"^#\s+(.*?)\s*[—-]\s*Claude Code Skill\s*$",
231
+ lambda m: f"# {m.group(1)}",
232
+ body,
233
+ count=1,
234
+ flags=re.M,
235
+ )
236
+
237
+
238
+ _DOC_REF_RE = re.compile(r"`(docs/[^`\s]+\.md)`|\]\((docs/[^)\s]+\.md)\)")
239
+
240
+
241
+ def _doc_references(body: str) -> List[str]:
242
+ """In-repo doc paths a body points at, in first-appearance order."""
243
+ seen: List[str] = []
244
+ for backticked, linked in _DOC_REF_RE.findall(body):
245
+ path = backticked or linked
246
+ if path not in seen:
247
+ seen.append(path)
248
+ return seen
249
+
250
+
251
+ def _demote_headings(body: str) -> str:
252
+ """Shift every ATX heading one level down so inlined material nests correctly."""
253
+ return re.sub(r"^(#{1,5})(\s)", r"#\1\2", body, flags=re.M)
254
+
255
+
256
+ # ── index ────────────────────────────────────────────────────────────────────
257
+
258
+
259
+ def _read(path: Path) -> str:
260
+ return path.read_text(encoding="utf-8")
261
+
262
+
263
+ def _build_index() -> Dict[str, Dict[str, Any]]:
264
+ """Topic id → record. Built from the directories, never from a hand-kept list."""
265
+ index: Dict[str, Dict[str, Any]] = {}
266
+
267
+ if SKILLS_DIR.is_dir():
268
+ for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")):
269
+ topic = skill_md.parent.name
270
+ text = _read(skill_md)
271
+ fields, body = _split_frontmatter(text)
272
+ index[topic] = {
273
+ "topic": topic,
274
+ "title": _title_of(body, topic),
275
+ "summary": fields.get("description", "") or _first_paragraph(body),
276
+ "category": "repo" if topic in _REPO_SKILLS else "workflow",
277
+ "path": str(skill_md.relative_to(REPO_ROOT)),
278
+ "body": _strip_client_scaffolding(body).strip(),
279
+ }
280
+
281
+ for category, directory in (("guide", GUIDES_DIR), ("kernel", KERNELS_DIR)):
282
+ if not directory.is_dir():
283
+ continue
284
+ for doc in sorted(directory.glob("*.md")):
285
+ if doc.name in _DOC_SKIPLIST:
286
+ continue
287
+ topic = doc.stem
288
+ body = _split_frontmatter(_read(doc))[1]
289
+ index[topic] = {
290
+ "topic": topic,
291
+ "title": _title_of(body, topic),
292
+ "summary": _first_paragraph(body),
293
+ "category": category,
294
+ "path": str(doc.relative_to(REPO_ROOT)),
295
+ "body": body.strip(),
296
+ }
297
+
298
+ for topic, reference in _EXTRA_DOCS.items():
299
+ path = REPO_ROOT / reference
300
+ if not path.is_file():
301
+ continue
302
+ body = _split_frontmatter(_read(path))[1]
303
+ index[topic] = {
304
+ "topic": topic,
305
+ "title": _title_of(body, topic),
306
+ "summary": _first_paragraph(body),
307
+ "category": "reference",
308
+ "path": reference,
309
+ "body": body.strip(),
310
+ }
311
+
312
+ for record in index.values():
313
+ record["related"] = _related_topics(record, index)
314
+ return index
315
+
316
+
317
+ def _related_topics(record: Dict[str, Any], index: Dict[str, Dict[str, Any]]) -> List[str]:
318
+ """Topics this one points at: linked docs first, then topics named in the body."""
319
+ by_path = {other["path"]: other["topic"] for other in index.values()}
320
+ related: List[str] = []
321
+ for reference in _doc_references(record["body"]):
322
+ topic = by_path.get(reference)
323
+ if topic and topic != record["topic"] and topic not in related:
324
+ related.append(topic)
325
+ for topic in sorted(index):
326
+ if topic == record["topic"] or topic in related:
327
+ continue
328
+ if re.search(rf"\b{re.escape(topic)}\b", record["body"]):
329
+ related.append(topic)
330
+ return related
331
+
332
+
333
+ _INDEX_CACHE: Optional[Dict[str, Dict[str, Any]]] = None
334
+
335
+
336
+ def index(*, refresh: bool = False) -> Dict[str, Dict[str, Any]]:
337
+ global _INDEX_CACHE
338
+ if _INDEX_CACHE is None or refresh:
339
+ _INDEX_CACHE = _build_index()
340
+ return _INDEX_CACHE
341
+
342
+
343
+ def _normalize(value: str) -> str:
344
+ return re.sub(r"[\s_]+", " ", str(value or "").strip().lower())
345
+
346
+
347
+ def resolve_alias(topic: str) -> Optional[str]:
348
+ """Map a natural phrasing to a topic id, or return the id when it is already one."""
349
+ records = index()
350
+ raw = str(topic or "").strip()
351
+ if raw in records:
352
+ return raw
353
+ normalized = _normalize(raw)
354
+ for candidate in records:
355
+ if _normalize(candidate) == normalized:
356
+ return candidate
357
+ hyphenated = normalized.replace(" ", "-")
358
+ if hyphenated in records:
359
+ return hyphenated
360
+ return _ALIASES.get(normalized)
361
+
362
+
363
+ # ── public surface ───────────────────────────────────────────────────────────
364
+
365
+
366
+ def topics(*, category: Optional[str] = None) -> List[Dict[str, Any]]:
367
+ """The index an agent reads before deciding what to fetch."""
368
+ if category is not None and category not in CATEGORIES:
369
+ raise KnowledgeError(
370
+ f"unknown category '{category}'. Valid categories: {', '.join(CATEGORIES)}"
371
+ )
372
+ listing = []
373
+ for record in index().values():
374
+ if category and record["category"] != category:
375
+ continue
376
+ listing.append(
377
+ {
378
+ "topic": record["topic"],
379
+ "title": record["title"],
380
+ "summary": record["summary"],
381
+ "category": record["category"],
382
+ "source": record["path"],
383
+ "length_lines": len(record["body"].splitlines()),
384
+ "resolved_length_lines": len(_resolve_body(record).splitlines()),
385
+ "sections": [name for name, _, _ in _sections(record["body"])],
386
+ "related": record["related"],
387
+ }
388
+ )
389
+ return sorted(listing, key=lambda item: (item["category"], item["topic"]))
390
+
391
+
392
+ def _sections(body: str) -> List[Tuple[str, int, int]]:
393
+ """(heading text, start line, end line) for every `##` section, in order."""
394
+ lines = body.splitlines()
395
+ starts = [
396
+ (index_, line[3:].strip())
397
+ for index_, line in enumerate(lines)
398
+ if line.startswith("## ")
399
+ ]
400
+ out: List[Tuple[str, int, int]] = []
401
+ for position, (start, name) in enumerate(starts):
402
+ end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
403
+ out.append((name, start, end))
404
+ return out
405
+
406
+
407
+ def _resolve_body(record: Dict[str, Any]) -> str:
408
+ """The topic's body with its in-repo references inlined one level deep."""
409
+ body = record["body"]
410
+ if record["category"] == "reference":
411
+ return body
412
+ references = _doc_references(body)
413
+ if not references:
414
+ return body
415
+ by_path = {other["path"]: other["topic"] for other in index().values()}
416
+ parts = [body]
417
+ for reference in references:
418
+ path = REPO_ROOT / reference
419
+ if not path.is_file():
420
+ # A reference to a file that no longer exists is a real defect in the
421
+ # corpus, but it must not take the whole answer down with it.
422
+ parts.append(
423
+ f"\n\n---\n\n## Referenced: `{reference}` (missing from the checkout)\n"
424
+ )
425
+ continue
426
+ referenced = _split_frontmatter(_read(path))[1].strip()
427
+ title = _title_of(referenced, reference)
428
+ header = f"\n\n---\n\n## Referenced: {title}\n\n_Source: `{reference}`_\n\n"
429
+ line_count = len(referenced.splitlines())
430
+ if line_count <= MAX_INLINE_LINES:
431
+ parts.append(header + _demote_headings(referenced))
432
+ continue
433
+ target = by_path.get(reference)
434
+ fetch = (
435
+ f"get(topic=\"{target}\") for the full text, or "
436
+ f"get(topic=\"{target}\", section=...) for one section"
437
+ if target
438
+ else "read it in the repository checkout"
439
+ )
440
+ sections = [name for name, _, _ in _sections(referenced)]
441
+ parts.append(
442
+ header
443
+ + f"_{line_count} lines — summarised here rather than inlined. Call {fetch}._\n\n"
444
+ + f"{_first_paragraph(referenced)}\n\n"
445
+ + "Sections: "
446
+ + (", ".join(sections) if sections else "(none)")
447
+ + "\n"
448
+ )
449
+ return "".join(parts)
450
+
451
+
452
+ def get(topic: str, *, section: Optional[str] = None, inline: bool = True) -> Dict[str, Any]:
453
+ """Resolve one topic to prose.
454
+
455
+ `section` slices the primary document's own `##` headings and implies no inlining;
456
+ an unknown section raises rather than quietly returning everything.
457
+ """
458
+ resolved = resolve_alias(topic)
459
+ if resolved is None:
460
+ # Nearest matches, not all 35 ids: a wrong guess is usually a near miss, and a
461
+ # wall of every topic is harder to act on than three plausible ones.
462
+ near = difflib.get_close_matches(_normalize(topic), sorted(index()), n=3, cutoff=0.4)
463
+ hint = f"Did you mean: {', '.join(near)}? " if near else ""
464
+ raise KnowledgeError(
465
+ f"unknown topic '{topic}'. {hint}Call topics() for the full index."
466
+ )
467
+ record = index()[resolved]
468
+
469
+ if section is not None:
470
+ available = _sections(record["body"])
471
+ wanted = _normalize(section)
472
+ for name, start, end in available:
473
+ if _normalize(name) == wanted:
474
+ lines = record["body"].splitlines()[start:end]
475
+ return {
476
+ "topic": resolved,
477
+ "title": record["title"],
478
+ "section": name,
479
+ "category": record["category"],
480
+ "source": record["path"],
481
+ "content": "\n".join(lines).strip(),
482
+ "inlined": [],
483
+ "related": record["related"],
484
+ }
485
+ raise KnowledgeError(
486
+ f"unknown section '{section}' in topic '{resolved}'. "
487
+ f"Sections: {', '.join(name for name, _, _ in available) or '(none)'}"
488
+ )
489
+
490
+ inlining = inline and record["category"] != "reference"
491
+ content = _resolve_body(record) if inlining else record["body"]
492
+ return {
493
+ "topic": resolved,
494
+ "title": record["title"],
495
+ "category": record["category"],
496
+ "source": record["path"],
497
+ "content": content,
498
+ "inlined": _doc_references(record["body"]) if inlining else [],
499
+ "sections": [name for name, _, _ in _sections(record["body"])],
500
+ "related": record["related"],
501
+ }
502
+
503
+
504
+ def _term_count(haystack: str, term: str) -> int:
505
+ """Whole-word occurrences.
506
+
507
+ Substring counting looks equivalent and is not: searching "dead air" scored
508
+ `resolve-audio` top because "air" is inside "F-air-light". Every term here is a word
509
+ the user typed, so a word boundary is the honest match.
510
+ """
511
+ return len(re.findall(rf"(?<!\w){re.escape(term)}(?!\w)", haystack))
512
+
513
+
514
+ def search(query: str, *, limit: int = 5) -> List[Dict[str, Any]]:
515
+ """Rank topics against a query. Deterministic term scoring — no embeddings."""
516
+ text_query = str(query or "").strip()
517
+ if not text_query:
518
+ raise KnowledgeError("search requires a non-empty query")
519
+ words = [term for term in re.split(r"\W+", text_query.lower()) if term]
520
+ # A query made only of stopwords still deserves an answer, so fall back to the
521
+ # unfiltered words rather than returning nothing.
522
+ terms = [term for term in words if term not in _STOPWORDS] or words
523
+ phrase = text_query.lower()
524
+
525
+ hits: List[Dict[str, Any]] = []
526
+ for record in index().values():
527
+ body = _resolve_body(record)
528
+ haystack = body.lower()
529
+ heading = f"{record['topic']} {record['title']} {record['summary']}".lower()
530
+ # Body hits are normalised by document size. Raw counts make length the ranking
531
+ # signal: the 2250-line operating reference mentions everything at least once.
532
+ weight = math.sqrt(max(1, len(body.splitlines())))
533
+ body_score = sum(_term_count(haystack, term) for term in terms)
534
+ if phrase and phrase in haystack:
535
+ body_score += 5
536
+ score = 10 * body_score / weight
537
+ score += 3 * sum(_term_count(heading, term) for term in terms)
538
+ if phrase and phrase in heading:
539
+ score += 15
540
+ score = round(score, 3)
541
+ if score <= 0:
542
+ continue
543
+ hits.append(
544
+ {
545
+ "topic": record["topic"],
546
+ "title": record["title"],
547
+ "category": record["category"],
548
+ "score": score,
549
+ "excerpt": _excerpt(body, terms, phrase),
550
+ }
551
+ )
552
+ hits.sort(key=lambda hit: (-hit["score"], hit["topic"]))
553
+ return hits[: max(1, int(limit))]
554
+
555
+
556
+ def _excerpt(body: str, terms: List[str], phrase: str) -> str:
557
+ lowered = body.lower()
558
+ position = lowered.find(phrase) if phrase else -1
559
+ if position < 0:
560
+ for term in terms:
561
+ position = lowered.find(term)
562
+ if position >= 0:
563
+ break
564
+ if position < 0:
565
+ position = 0
566
+ start = max(0, position - EXCERPT_RADIUS)
567
+ end = min(len(body), position + EXCERPT_RADIUS)
568
+ snippet = " ".join(body[start:end].split())
569
+ prefix = "…" if start > 0 else ""
570
+ suffix = "…" if end < len(body) else ""
571
+ return f"{prefix}{snippet}{suffix}"
572
+
573
+
574
+ def capabilities() -> Dict[str, Any]:
575
+ """What this server knows, summarised for a capability probe."""
576
+ records = index()
577
+ by_category: Dict[str, int] = {}
578
+ for record in records.values():
579
+ by_category[record["category"]] = by_category.get(record["category"], 0) + 1
580
+ return {
581
+ "topic_count": len(records),
582
+ "categories": by_category,
583
+ "aliases": len(_ALIASES),
584
+ "corpus": [
585
+ str(SKILLS_DIR.relative_to(REPO_ROOT)),
586
+ str(GUIDES_DIR.relative_to(REPO_ROOT)),
587
+ str(KERNELS_DIR.relative_to(REPO_ROOT)),
588
+ ],
589
+ "note": (
590
+ "Craft and workflow guidance resolved to prose, so a client with no "
591
+ "checkout can read it. Fetch the index with topics(), then get(topic) "
592
+ "before a creative or destructive operation — not after."
593
+ ),
594
+ }
@@ -198,6 +198,34 @@ def _decode_value(transport: BridgeTransport, value: Any) -> Any:
198
198
  return value
199
199
 
200
200
 
201
+ # Fusion's own objects under-report themselves, and unlike Resolve's they cannot
202
+ # be probed. `dir()` on a Fusion Tool lists 38 names — with "Composition"
203
+ # appearing twice — and omits GetAttrs/SetAttrs, which are documented Fusion Tool
204
+ # methods that work perfectly when called: measured on free 21.0.3.7 over this
205
+ # bridge, GetAttrs returned {TOOLS_Name: "Blur1", TOOLS_RegID: "Blur"} and
206
+ # SetAttrs renamed the tool. Resolve fabricates a callable for ANY name, so
207
+ # `dir()` is the only evidence of absence that exists — which means a name it
208
+ # omits cannot be recovered by probing, only by knowing.
209
+ #
210
+ # This is a curated exception, not a relaxation of the strict proxy. It applies
211
+ # only to names that are documented Fusion methods, and only on an object whose
212
+ # own method list identifies it as a Fusion object. Resolve API capability
213
+ # detection — `getattr(item, "CreateMagicMask", None)` — answers exactly as
214
+ # before, which is what the strict proxy exists to protect.
215
+ #
216
+ # Symptom when this is missing: `fusion_comp add_tool` calls tool.GetAttrs() to
217
+ # build its return value, so the action died with "has no attribute 'GetAttrs'
218
+ # in this Resolve build" and took every server-authored Fusion graph with it.
219
+ _FUSION_UNENUMERATED_METHODS = frozenset({"GetAttrs", "SetAttrs"})
220
+
221
+ #: Names that positively identify a Fusion Tool or Composition, as opposed to a
222
+ #: Resolve API object. Drawn from what `dir()` DOES report on each.
223
+ _FUSION_OBJECT_MARKERS = frozenset({
224
+ "ConnectInput", "FindMainInput", "GetControlPageNames", # Tool
225
+ "AddTool", "FindTool", "GetToolList", # Composition
226
+ })
227
+
228
+
201
229
  class _BoundMethod:
202
230
  """One callable method on a proxied object."""
203
231
 
@@ -309,6 +337,11 @@ class BridgeProxy:
309
337
  {"target": self._handle, "name": name}) or {}
310
338
  if probe.get("kind") == "value":
311
339
  return _decode_value(self._transport, probe.get("value"))
340
+ if (name in _FUSION_UNENUMERATED_METHODS
341
+ and self._methods() & _FUSION_OBJECT_MARKERS):
342
+ # A Fusion object omitting one of its own documented
343
+ # methods — see _FUSION_UNENUMERATED_METHODS.
344
+ return _BoundMethod(self._transport, self._handle, name)
312
345
  # Matches native semantics: hasattr() is False, getattr(..., None)
313
346
  # is None, and a capability check refuses instead of guessing.
314
347
  # A `callable` answer lands here too — Resolve fabricates one for