@xtremestuff/dsh-plugin-resolve 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xtremestuff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-resolve
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) bundle that bridges
4
+ **DaVinci Resolve** into DSH: it registers Resolve's MCP server through DSH's official
5
+ mcp-client bridge, so every DSH agent can inspect and drive Resolve through ordinary,
6
+ namespaced tools (`mcp__resolve__*`).
7
+
8
+ DaVinci Resolve Studio **21.1** ships a native MCP server (`ResolveMCP`, stdio). This bundle
9
+ registers that server automatically — and, when it is not available, falls back to the
10
+ community [`davinci-resolve-mcp`](https://github.com/samuelgursky/davinci-resolve-mcp) server
11
+ (37 compound / 390 granular tools over the official scripting API, Resolve 18.5+, free edition
12
+ reachable via its in-app bridge).
13
+
14
+ ## What you get
15
+
16
+ | Backend | Source | Requires | Tools |
17
+ |---|---|---|---|
18
+ | `native` (default when present) | `ResolveMCP` bundled with Resolve Studio 21.1+ | Resolve **Studio** 21.1+, External scripting = Local | `get_resolve_status`, `get_whats_new`, `search_scripting_api`, `get_scripting_api`, `get_scripting_docs`, `launch_resolve`, `run_script`, `run_script_unsafe` |
19
+ | `community` (fallback) | `davinci-resolve-mcp` npm bootstrapper | Node.js (`npx`) + Resolve 18.5+ | 37 compound tools (projects, media pool, timeline editing, grading, Fusion, Fairlight, render, analysis) |
20
+
21
+ With `mode: auto` (the default) the bundle picks the native server when its executable exists
22
+ and falls back to the community server otherwise. If neither is available the plugin logs a
23
+ warning and registers nothing — the rest of DSH boots normally.
24
+
25
+ ## Install
26
+
27
+ Install `@xtremestuff/dsh-plugin-resolve` from the npm registry into your profile:
28
+
29
+ ```sh
30
+ dsh plugin --profile <name> add @xtremestuff/dsh-plugin-resolve
31
+ ```
32
+
33
+ (`<name>` is the profile to extend, e.g. `web` or `desktop`.) Equivalently, use the DSH
34
+ Plugins page / `plugin_manager` tool (`install_bundle` with `@xtremestuff/dsh-plugin-resolve`),
35
+ or run `pnpm add @xtremestuff/dsh-plugin-resolve` inside `~/.dsh/profiles/<name>`.
36
+
37
+ The `dsh plugin` forwarder records the dependency and adds the bundle to the profile's
38
+ `dsh.profile.bundles` layer list automatically. After installation the bundle is enabled;
39
+ DSH reconciles the profile on the next start (or immediately in a live profile).
40
+
41
+ Verify:
42
+
43
+ ```
44
+ Plugins → @xtremestuff/dsh-plugin-resolve (enabled)
45
+ Settings → MCP servers (a "resolve" entry appears, registered by the plugin)
46
+ ```
47
+
48
+ Then ask an agent:
49
+
50
+ > Use the Resolve MCP to report whether Resolve is running and its version. Tell me the name
51
+ > of the open project and how many timelines it contains. Do not change anything yet.
52
+
53
+ ### Local or development install
54
+
55
+ To install from a checkout (e.g. while developing the bundle), pass the directory path to the
56
+ same command:
57
+
58
+ ```sh
59
+ dsh plugin --profile <name> add /path/to/dsh-plugin-resolve
60
+ ```
61
+
62
+ DSH records it as a `link:` dependency; edits to the directory are picked up without
63
+ re-publishing.
64
+
65
+ ## Usage
66
+
67
+ There is nothing to invoke: once the bundle is enabled, Resolve's tools sit in every agent's
68
+ tool list as `mcp__resolve__<tool>` and the agent picks them up whenever your task involves
69
+ Resolve. Just have a project open in Resolve (or ask the agent to launch it) and talk.
70
+
71
+ ### Native backend (Resolve Studio 21.1+)
72
+
73
+ The native server is a thin, deliberate surface: discover the scripting API, then act through
74
+ `run_script`. Typical flow per task:
75
+
76
+ 1. **Status** — *"Is Resolve running, and which project is open?"*
77
+ (`mcp__resolve__get_resolve_status`; `mcp__resolve__launch_resolve` starts it if closed)
78
+ 2. **Discover** — *"Which scripting API calls exist for adding markers to a timeline?"*
79
+ (`mcp__resolve__search_scripting_api` / `mcp__resolve__get_scripting_api`)
80
+ 3. **Act** — the agent writes and runs a script against the live session
81
+ (`mcp__resolve__run_script`)
82
+
83
+ Examples:
84
+
85
+ > Add a blue marker at the start of every clip on the timeline named `Cut_v4`, then read back
86
+ > the marker count.
87
+
88
+ > Queue a ProRes 422 HQ render of the current timeline to `~/Movies/deliver` and report the job
89
+ > id.
90
+
91
+ > Rename every clip in the `Interviews` bin to `CAMERA_SCENE_TAKE` from its file metadata.
92
+
93
+ ### Community backend
94
+
95
+ The compound server ships guarded workflow tools, so most tasks need no scripting at all — the
96
+ agent calls one named tool, and destructive operations run through a plan → review → confirm
97
+ gate:
98
+
99
+ > List all projects and open the one called `My Film`.
100
+
101
+ > Create a timeline called `Assembly Cut` from all clips in the current bin.
102
+
103
+ > Probe this timeline for gaps, overlaps, and missing media, and list what needs fixing.
104
+
105
+ > Build a ProRes 422 HQ render plan for this timeline, validate the settings, and queue the job.
106
+
107
+ > Copy the review markers from the timeline to the selected clip and export a review report.
108
+
109
+ ### Working habits that pay off
110
+
111
+ - **Name things explicitly** — "the timeline named `Cut_v4`", not "this timeline".
112
+ - **Ask for a plan before execution** on anything destructive, and work on a **duplicated**
113
+ timeline until you trust the agent.
114
+ - **Verify in Resolve** after write actions — a successful tool call should be followed by a
115
+ read-back check that the intended clips, ranges, or tracks actually changed.
116
+
117
+ ## Requirements
118
+
119
+ - **Native backend:** DaVinci Resolve **Studio** 21.1+ (the native MCP server is Studio-only),
120
+ with `Preferences → System → General → External scripting` set to **Local**.
121
+ Default executable locations:
122
+
123
+ | OS | Path |
124
+ |---|---|
125
+ | macOS | `/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Applications/ResolveMCP` |
126
+ | Windows | `C:\Program Files\Blackmagic Design\DaVinci Resolve\ResolveMCP.exe` |
127
+ | Linux | `/opt/resolve/bin/ResolveMCP` |
128
+
129
+ - **Community backend:** Node.js ≥ 20.9 on `PATH` (the bundle launches
130
+ `npx -y davinci-resolve-mcp`) and a Python 3.10+ environment the bootstrapper can set up
131
+ (it installs a managed copy under your user application-data directory on first run).
132
+
133
+ ## Configuration
134
+
135
+ All options are optional row config on the `resolve-mcp` entry in
136
+ [`cordis.patch.yml`](./cordis.patch.yml) (edit the installed copy, or overlay from your
137
+ profile's own `cordis.patch.yml`):
138
+
139
+ | Key | Default | Meaning |
140
+ |---|---|---|
141
+ | `mode` | `auto` | `auto` (native, else community), `native`, or `community` |
142
+ | `serverName` | `resolve` | Tool namespace: `mcp__<serverName>__<tool>`; must match `[A-Za-z0-9_-]{1,32}` |
143
+ | `nativeCommand` | per-OS default | Path to the `ResolveMCP` executable |
144
+ | `communityCommand` | `npx` | Bootstrapper executable (resolved against `PATH`) |
145
+ | `communityArgs` | `['-y', 'davinci-resolve-mcp']` | Bootstrapper arguments |
146
+ | `env` | — | Extra environment variables for the server process |
147
+ | `cwd` | — | Working directory for the server process |
148
+ | `toolCallTimeoutMs` | 60 000 | Per tool-call timeout |
149
+ | `startupTimeoutMs` | — | Budget for connection + initial tool discovery |
150
+ | `closeTimeoutMs` | 5 000 | Transport closure budget |
151
+
152
+ Example — force the community server with a pinned package version:
153
+
154
+ ```yaml
155
+ - id: resolve-mcp
156
+ name: '@xtremestuff/dsh-plugin-resolve'
157
+ config:
158
+ mode: community
159
+ communityArgs: ['-y', 'davinci-resolve-mcp@4.19.3']
160
+ ```
161
+
162
+ ## How it works
163
+
164
+ 1. The bundle's [`cordis.patch.yml`](./cordis.patch.yml) mounts the plugin in
165
+ [`lib/index.js`](./lib/index.js).
166
+ 2. The plugin picks the backend (native `ResolveMCP` or the community bootstrapper) and
167
+ registers its tools on the harness tool runtime, mirroring Blackmagic's own MCPB
168
+ distribution (`DaVinciResolve.mcpb`):
169
+ - **Native:** the tool catalog comes from a one-shot `--dump-tools` invocation at
170
+ activation (no persistent process); the server spawns lazily on the first tool
171
+ call, is killed after 5 minutes of idle, and respawns on demand.
172
+ - **Community:** the bootstrapper runs persistently and its tools are discovered over
173
+ a live MCP handshake at activation.
174
+ 3. Tools are registered as `mcp__resolve__<tool>` with per-call timeouts and cancellation
175
+ support.
176
+ 4. The registration is lifetime-scoped: disabling or uninstalling the bundle disposes the
177
+ transport and withdraws its tools.
178
+
179
+ ## Safety notes
180
+
181
+ - `run_script` (native server) executes Python with the scripting API's privileges;
182
+ `run_script_unsafe` adds system access. Prefer the community server's guarded workflow tools
183
+ for destructive work — they implement plan → review → confirm gates and refuse
184
+ verified-destructive operations (e.g. `TimelineItem.CopyGrades`) until you acknowledge.
185
+ - Treat project names, client names, and file paths as data leaving your machine when your LLM
186
+ is a cloud model.
187
+ - Work on a duplicated timeline until you trust an agent with write access.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,22 @@
1
+ # dsh-plugin-resolve bundle patch.
2
+ #
3
+ # Mounts the Resolve bridge plugin. It registers DaVinci Resolve's MCP server
4
+ # with DSH's MCP server manager (`ctx.mcpManager`) for this bundle's lifetime,
5
+ # so Resolve's tools appear to DSH agents as `mcp__resolve__*`.
6
+ #
7
+ # Backend selection (config `mode`):
8
+ # auto (default) native ResolveMCP when present, else the community server
9
+ # native DaVinci Resolve Studio 21.1+ bundled `ResolveMCP` (stdio)
10
+ # community `davinci-resolve-mcp` npm bootstrapper (stdio, 37 compound /
11
+ # 390 granular tools, Resolve 18.5+, free edition via bridge)
12
+ #
13
+ # Zero-config by default. Override any field by editing this row, e.g.:
14
+ #
15
+ # config:
16
+ # mode: community
17
+ # serverName: resolve
18
+ # communityArgs: ['-y', 'davinci-resolve-mcp']
19
+
20
+ - insert:
21
+ - id: resolve-mcp
22
+ name: dsh-plugin-resolve
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#4D6BFE"/>
5
+ <stop offset="1" stop-color="#7B2FF7"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="#101318"/>
9
+ <circle cx="32" cy="32" r="20" fill="none" stroke="url(#g)" stroke-width="4"/>
10
+ <path d="M28 23 L43 32 L28 41 Z" fill="url(#g)"/>
11
+ </svg>
package/lib/index.js ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * dsh-plugin-resolve — bridges DaVinci Resolve into DeepSeek Harness (DSH).
3
+ *
4
+ * This Cordis plugin registers DaVinci Resolve's MCP server with DSH's tool
5
+ * runtime, mirroring Blackmagic's own MCPB distribution for Claude
6
+ * (`DaVinciResolve.mcpb` shipped inside the Resolve app):
7
+ *
8
+ * - Native backend (Resolve Studio 21.1+ `ResolveMCP`): the tool catalog is
9
+ * obtained at boot with a one-shot `--dump-tools` invocation (no
10
+ * persistent process). The real server is spawned lazily on the first
11
+ * tool call, killed after an idle timeout, and respawned on demand.
12
+ * - Community backend (`davinci-resolve-mcp` npm bootstrapper): no
13
+ * `--dump-tools` support, so the server runs persistently and its tools
14
+ * are discovered over a live MCP handshake at activation.
15
+ *
16
+ * Tools are registered on the harness tool registry as
17
+ * `mcp__resolve__<tool>` (e.g. `mcp__resolve__get_resolve_status`,
18
+ * `mcp__resolve__run_script`). The implementation is self-contained
19
+ * (node: builtins only), so a `link:` development install works without a
20
+ * node_modules next to the checkout and a registry install adds no
21
+ * transitive packages.
22
+ *
23
+ * The registration lives for this plugin's lifetime: disabling or
24
+ * uninstalling this bundle disposes the transport and withdraws its tools.
25
+ *
26
+ * Row config (all optional):
27
+ * mode 'auto' | 'native' | 'community' (default 'auto')
28
+ * serverName tool namespace (default 'resolve')
29
+ * nativeCommand ResolveMCP executable path (default per platform)
30
+ * communityCommand bootstrapper executable (default 'npx')
31
+ * communityArgs bootstrapper arguments (default ['-y', 'davinci-resolve-mcp'])
32
+ * env extra environment for the server (object of strings)
33
+ * cwd working directory for the server (string)
34
+ * toolCallTimeoutMs per tool-call timeout (number, ms)
35
+ *
36
+ * @module dsh-plugin-resolve
37
+ */
38
+
39
+ import { spawn, spawnSync } from 'node:child_process'
40
+ import { statSync } from 'node:fs'
41
+
42
+ /** Cordis plugin name used by loader diagnostics. */
43
+ export const name = 'dsh-plugin-resolve'
44
+
45
+ /** Services required by this plugin (tool registration). */
46
+ export const inject = ['tools']
47
+
48
+ /** Default `ResolveMCP` executable locations shipped with Resolve Studio 21.1+. */
49
+ const NATIVE_DEFAULTS = {
50
+ darwin: '/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Applications/ResolveMCP',
51
+ win32: 'C:\\Program Files\\Blackmagic Design\\DaVinci Resolve\\ResolveMCP.exe',
52
+ linux: '/opt/resolve/bin/ResolveMCP',
53
+ }
54
+
55
+ /** Default community-server launch: the npm bootstrapper over stdio. */
56
+ const COMMUNITY_DEFAULT_COMMAND = 'npx'
57
+ const COMMUNITY_DEFAULT_ARGS = ['-y', 'davinci-resolve-mcp']
58
+
59
+ /** MCP `serverName` pattern enforced by DSH's tool naming. */
60
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
61
+
62
+ /** Budgets, aligned with Blackmagic's MCPB wrapper. */
63
+ const DUMP_TIMEOUT_MS = 10_000
64
+ const INIT_TIMEOUT_MS = 30_000
65
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000
66
+ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
67
+
68
+ /**
69
+ * Resolve a bare executable name against `PATH`.
70
+ * @param {string} command - absolute/relative path or bare command name.
71
+ * @returns {string | undefined} the usable path, or undefined when not found.
72
+ */
73
+ function resolveExecutable(command) {
74
+ if (command.includes('/') || command.includes('\\')) {
75
+ return isExecutable(command) ? command : undefined
76
+ }
77
+ const pathEnv = process.env.PATH ?? ''
78
+ for (const dir of pathEnv.split(':')) {
79
+ if (!dir) continue
80
+ const candidate = `${dir}/${command}`
81
+ if (isExecutable(candidate)) return candidate
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ /**
87
+ * @param {string} path
88
+ * @returns {boolean} true when the path is an existing regular file.
89
+ */
90
+ function isExecutable(path) {
91
+ try {
92
+ return statSync(path).isFile()
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Rewrite an MCP input schema into DSH's supported JSON-Schema subset.
100
+ * DSH rejects `type` arrays (e.g. `["integer","string"]` on the native
101
+ * server's thumbnail frame-offset/timecode params). A single-type array
102
+ * collapses to that type; a true union drops `type` so the parameter is
103
+ * left unconstrained for DSH while the MCP server validates it.
104
+ * @param {unknown} node
105
+ * @returns {unknown} a sanitized copy of the schema node.
106
+ */
107
+ function sanitizeSchema(node) {
108
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return node
109
+ const out = { ...node }
110
+ if (Array.isArray(out.type)) {
111
+ const supported = out.type.filter((t) => typeof t === 'string')
112
+ if (supported.length === 1) out.type = supported[0]
113
+ else delete out.type
114
+ }
115
+ if (out.properties && typeof out.properties === 'object' && !Array.isArray(out.properties)) {
116
+ out.properties = Object.fromEntries(
117
+ Object.entries(out.properties).map(([key, value]) => [key, sanitizeSchema(value)]),
118
+ )
119
+ }
120
+ if (out.items !== undefined) out.items = sanitizeSchema(out.items)
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Pick the backend and its stdio launch spec for the configured mode.
126
+ * @param {object} config - resolved row config.
127
+ * @returns {{kind: 'native'|'community', command: string, args: string[]} | undefined}
128
+ */
129
+ function pickBackend(config) {
130
+ const mode = config.mode ?? 'auto'
131
+ if (mode !== 'auto' && mode !== 'native' && mode !== 'community') {
132
+ throw new Error(`dsh-plugin-resolve: unknown mode "${mode}" (expected 'auto', 'native', or 'community')`)
133
+ }
134
+
135
+ if (mode === 'native' || mode === 'auto') {
136
+ const command = config.nativeCommand ?? NATIVE_DEFAULTS[process.platform]
137
+ const resolved = command ? resolveExecutable(command) : undefined
138
+ if (resolved) return { kind: 'native', command: resolved, args: [] }
139
+ if (mode === 'native') return undefined
140
+ }
141
+
142
+ if (mode === 'community' || mode === 'auto') {
143
+ const command = config.communityCommand ?? COMMUNITY_DEFAULT_COMMAND
144
+ const resolved = command ? resolveExecutable(command) : undefined
145
+ if (resolved) {
146
+ const args = Array.isArray(config.communityArgs) ? config.communityArgs : COMMUNITY_DEFAULT_ARGS
147
+ return { kind: 'community', command: resolved, args }
148
+ }
149
+ }
150
+
151
+ return undefined
152
+ }
153
+
154
+ /**
155
+ * Shared stdio JSON-RPC machinery for one server child process.
156
+ * Newline-delimited JSON-RPC over stdin/stdout; pending responses are
157
+ * tracked by id, and client lines may be queued until the child finishes
158
+ * its internal handshake.
159
+ */
160
+ class StdioJsonRpc {
161
+ /**
162
+ * @param {object} opts
163
+ * @param {string} opts.command
164
+ * @param {string[]} opts.args
165
+ * @param {Record<string, string>} [opts.env]
166
+ * @param {string} [opts.cwd]
167
+ * @param {(message: string) => void} [opts.onStderr]
168
+ * @param {(code: number|null, signal: string) => void} [opts.onExit]
169
+ */
170
+ constructor(opts) {
171
+ this.child = spawn(opts.command, opts.args, {
172
+ stdio: ['pipe', 'pipe', 'pipe'],
173
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
174
+ cwd: opts.cwd,
175
+ })
176
+ this.nextId = 1
177
+ /** @type {Map<number, {resolve: (v: any) => void, reject: (e: Error) => void}>} */
178
+ this.pending = new Map()
179
+ this.queued = []
180
+ this.buffer = ''
181
+ this.initialized = false
182
+ this.closed = false
183
+ this.child.on('exit', (code, signal) => {
184
+ this.#settleAll(`server exited (code=${code} signal=${signal})`)
185
+ if (opts.onExit) opts.onExit(code, signal)
186
+ })
187
+ this.child.on('error', (error) => {
188
+ this.#settleAll(`server process error: ${String(error)}`)
189
+ })
190
+ this.child.stdout.on('data', (chunk) => this.#onData(chunk))
191
+ if (opts.onStderr) this.child.stderr.on('data', (chunk) => opts.onStderr(String(chunk)))
192
+ else this.child.stderr.on('data', () => {}) // drain: a full stderr pipe deadlocks the server
193
+ }
194
+
195
+ /** @param {Buffer|String} chunk */
196
+ #onData(chunk) {
197
+ this.buffer += chunk.toString()
198
+ let newline
199
+ while ((newline = this.buffer.indexOf('\n')) >= 0) {
200
+ const line = this.buffer.slice(0, newline).trim()
201
+ this.buffer = this.buffer.slice(newline + 1)
202
+ if (!line) continue
203
+ let message
204
+ try {
205
+ message = JSON.parse(line)
206
+ } catch {
207
+ continue
208
+ }
209
+ if (message.id === '__wrapper_init__') {
210
+ this.initialized = true
211
+ this.#flushQueue()
212
+ continue
213
+ }
214
+ if (message.id !== undefined && this.pending.has(message.id)) {
215
+ const waiter = this.pending.get(message.id)
216
+ this.pending.delete(message.id)
217
+ if (message.error !== undefined) waiter.reject(new Error(message.error.message ?? 'MCP RPC error'))
218
+ else waiter.resolve(message.result)
219
+ }
220
+ }
221
+ }
222
+
223
+ /** Send a raw JSON-RPC line, queueing it until the child is initialized. */
224
+ sendLine(line) {
225
+ if (this.closed) return
226
+ if (this.initialized) this.child.stdin.write(line + '\n')
227
+ else this.queued.push(line)
228
+ }
229
+
230
+ /** Perform the internal handshake (Blackmagic `__wrapper_init__` convention). */
231
+ start() {
232
+ // Written directly (not queued): the init response is what unlocks the queue.
233
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: '__wrapper_init__', method: 'initialize', params: {} }) + '\n')
234
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n')
235
+ }
236
+
237
+ #flushQueue() {
238
+ const lines = this.queued
239
+ this.queued = []
240
+ for (const line of lines) this.child.stdin.write(line + '\n')
241
+ }
242
+
243
+ /**
244
+ * Send a JSON-RPC request and await its response.
245
+ * @param {string} method
246
+ * @param {object} [params]
247
+ * @param {number} [timeoutMs]
248
+ */
249
+ request(method, params, timeoutMs = 15_000) {
250
+ if (this.closed) return Promise.reject(new Error('MCP transport is closed'))
251
+ const id = this.nextId++
252
+ this.sendLine(JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} }))
253
+ return new Promise((resolve, reject) => {
254
+ const timer = setTimeout(() => {
255
+ if (this.pending.delete(id)) reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`))
256
+ }, timeoutMs)
257
+ this.pending.set(id, {
258
+ resolve: (value) => { clearTimeout(timer); resolve(value) },
259
+ reject: (error) => { clearTimeout(timer); reject(error) },
260
+ })
261
+ })
262
+ }
263
+
264
+ /** @param {string} reason */
265
+ #settleAll(reason) {
266
+ if (this.closed && this.pending.size === 0) return
267
+ for (const [id, waiter] of this.pending) {
268
+ this.pending.delete(id)
269
+ waiter.reject(new Error(reason))
270
+ }
271
+ }
272
+
273
+ /** Kill the child process and settle every pending request. */
274
+ dispose() {
275
+ this.closed = true
276
+ this.#settleAll('MCP transport disposed')
277
+ try { this.child.stdin.end() } catch { /* already gone */ }
278
+ this.child.kill()
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Lazy stdio server following Blackmagic's MCPB wrapper: the process is
284
+ * spawned on first use, killed after an idle timeout, and respawned on
285
+ * demand. Callers get a stable `callTool` surface.
286
+ */
287
+ class LazyStdioServer {
288
+ /**
289
+ * @param {object} opts
290
+ * @param {string} opts.command
291
+ * @param {string[]} opts.args
292
+ * @param {Record<string, string>} [opts.env]
293
+ * @param {string} [opts.cwd]
294
+ * @param {(message: string) => void} [opts.onLog]
295
+ * @param {number} [opts.idleTimeoutMs]
296
+ * @param {number} [opts.initTimeoutMs]
297
+ */
298
+ constructor(opts) {
299
+ this.opts = opts
300
+ this.rpc = null
301
+ this.idleTimer = null
302
+ this.initTimer = null
303
+ }
304
+
305
+ #resetIdleTimer() {
306
+ if (this.idleTimer) clearTimeout(this.idleTimer)
307
+ this.idleTimer = setTimeout(() => {
308
+ if (this.rpc) {
309
+ this.opts.onLog?.('ResolveMCP: idle timeout, stopping server')
310
+ this.#stopChild()
311
+ }
312
+ }, this.opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS)
313
+ }
314
+
315
+ #stopChild() {
316
+ this.rpc?.dispose()
317
+ this.rpc = null
318
+ if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null }
319
+ if (this.initTimer) { clearTimeout(this.initTimer); this.initTimer = null }
320
+ }
321
+
322
+ /** Spawn (if needed) and initialize the child, waiting for readiness. */
323
+ async #ensureStarted() {
324
+ if (this.rpc && this.rpc.initialized) {
325
+ this.#resetIdleTimer()
326
+ return
327
+ }
328
+ if (this.rpc && !this.rpc.initialized) {
329
+ // Already spawning; wait for the in-flight handshake.
330
+ this.#resetIdleTimer()
331
+ await new Promise((resolve) => {
332
+ const check = () => (this.rpc?.initialized ? resolve() : setTimeout(check, 50))
333
+ check()
334
+ })
335
+ return
336
+ }
337
+ const rpc = new StdioJsonRpc({
338
+ command: this.opts.command,
339
+ args: this.opts.args,
340
+ env: { ...(this.opts.env ?? {}), BMD_IS_MCPB: '1' },
341
+ cwd: this.opts.cwd,
342
+ onStderr: (message) => this.opts.onLog?.(message.trimEnd()),
343
+ onExit: () => {
344
+ if (this.rpc === rpc) {
345
+ this.rpc = null
346
+ if (this.initTimer) { clearTimeout(this.initTimer); this.initTimer = null }
347
+ if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null }
348
+ }
349
+ },
350
+ })
351
+ this.rpc = rpc
352
+ rpc.start()
353
+ this.#resetIdleTimer()
354
+ await new Promise((resolve, reject) => {
355
+ this.initTimer = setTimeout(() => {
356
+ this.initTimer = null
357
+ reject(new Error(`MCP server did not initialize within ${INIT_TIMEOUT_MS / 1000}s`))
358
+ this.#stopChild()
359
+ }, this.opts.initTimeoutMs ?? INIT_TIMEOUT_MS)
360
+ const check = () => {
361
+ if (this.rpc !== rpc) { reject(new Error('MCP server process died during startup')); return }
362
+ if (rpc.initialized) {
363
+ if (this.initTimer) { clearTimeout(this.initTimer); this.initTimer = null }
364
+ resolve()
365
+ } else setTimeout(check, 25)
366
+ }
367
+ check()
368
+ })
369
+ }
370
+
371
+ /**
372
+ * Call one tool, spawning the server lazily.
373
+ * @param {string} toolName
374
+ * @param {object} [args]
375
+ * @param {number} [timeoutMs]
376
+ * @param {AbortSignal} [signal]
377
+ */
378
+ async callTool(toolName, args, timeoutMs = DEFAULT_TOOL_CALL_TIMEOUT_MS, signal) {
379
+ if (signal?.aborted) throw new Error('aborted')
380
+ await this.#ensureStarted()
381
+ const rpc = this.rpc
382
+ if (!rpc) throw new Error('MCP server unavailable')
383
+ this.#resetIdleTimer()
384
+ const promise = rpc.request('tools/call', { name: toolName, arguments: args ?? {} }, timeoutMs)
385
+ if (signal) signal.addEventListener('abort', () => {
386
+ // JSON-RPC has no cancel; drop the waiter so the caller's signal wins.
387
+ for (const [id, waiter] of rpc.pending) {
388
+ rpc.pending.delete(id)
389
+ waiter.reject(new Error('aborted'))
390
+ }
391
+ }, { once: true })
392
+ return promise
393
+ }
394
+
395
+ /** Stop the child (if running) and release timers. */
396
+ dispose() {
397
+ this.#stopChild()
398
+ }
399
+ }
400
+
401
+ /**
402
+ * One-shot `--dump-tools` probe of the native ResolveMCP binary
403
+ * (Blackmagic MCPB convention). Returns the live tool definitions without
404
+ * keeping a server process alive.
405
+ * @param {string} command
406
+ * @returns {{tools: any[], instructions?: string} | undefined}
407
+ */
408
+ function dumpTools(command) {
409
+ try {
410
+ const result = spawnSync(command, ['--dump-tools'], {
411
+ env: { ...process.env, BMD_IS_MCPB: '1' },
412
+ timeout: DUMP_TIMEOUT_MS,
413
+ encoding: 'utf-8',
414
+ windowsHide: true,
415
+ })
416
+ if (result.status === 0 && result.stdout) {
417
+ const dump = JSON.parse(result.stdout)
418
+ return { tools: dump.tools ?? [], instructions: dump.instructions }
419
+ }
420
+ } catch {
421
+ // fall through: the caller decides how to recover
422
+ }
423
+ return undefined
424
+ }
425
+
426
+ /**
427
+ * Extract the user-facing text from an MCP content array.
428
+ * @param {any} content
429
+ * @returns {string}
430
+ */
431
+ function extractContentText(content) {
432
+ if (!Array.isArray(content)) return ''
433
+ return content
434
+ .map((part) => (part && part.type === 'text' && typeof part.text === 'string' ? part.text : ''))
435
+ .join('\n')
436
+ }
437
+
438
+ /**
439
+ * Register DaVinci Resolve's MCP server with DSH.
440
+ *
441
+ * Fails safe: when no backend is available (Resolve not installed, free
442
+ * edition without the community server, no `npx`) the plugin logs a warning
443
+ * and registers nothing, so the rest of the harness boots normally.
444
+ *
445
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
446
+ * @param {object} [config] - resolved row config.
447
+ */
448
+ export async function apply(ctx, config = {}) {
449
+ const log = ctx.logger
450
+
451
+ const serverName = String(config.serverName ?? 'resolve')
452
+ if (!SERVER_NAME_PATTERN.test(serverName)) {
453
+ throw new Error(`dsh-plugin-resolve: serverName "${serverName}" must match [A-Za-z0-9_-]{1,32}`)
454
+ }
455
+
456
+ const backend = pickBackend(config)
457
+ if (!backend) {
458
+ const hint =
459
+ config.mode === 'native'
460
+ ? 'Install DaVinci Resolve Studio 21.1+ (the native MCP server is Studio-only) and set config.nativeCommand if it lives elsewhere.'
461
+ : config.mode === 'community'
462
+ ? 'Install Node.js (npx) or set config.communityCommand to the davinci-resolve-mcp bootstrapper.'
463
+ : 'Install DaVinci Resolve Studio 21.1+ (native ResolveMCP) or Node.js (community server via npx).'
464
+ log.warn(`dsh-plugin-resolve: no Resolve MCP backend found on this machine. ${hint} No server registered.`)
465
+ return
466
+ }
467
+
468
+ const toolCallTimeoutMs = typeof config.toolCallTimeoutMs === 'number' ? config.toolCallTimeoutMs : DEFAULT_TOOL_CALL_TIMEOUT_MS
469
+ const env = config.env && typeof config.env === 'object' ? config.env : undefined
470
+ const cwd = typeof config.cwd === 'string' && config.cwd ? config.cwd : undefined
471
+ const onLog = (message) => log.debug(`resolve-mcp: ${message}`)
472
+
473
+ /** @type {LazyStdioServer|StdioJsonRpc|null} */
474
+ let server = null
475
+ /** @type {Array<{name: string}>} */
476
+ let tools = []
477
+
478
+ try {
479
+ if (backend.kind === 'native') {
480
+ // Blackmagic pattern: one-shot dump for the catalog, lazy process for calls.
481
+ const dump = dumpTools(backend.command)
482
+ if (dump && dump.tools.length > 0) {
483
+ tools = dump.tools
484
+ server = new LazyStdioServer({ command: backend.command, args: backend.args, env, cwd, onLog })
485
+ } else {
486
+ log.warn('dsh-plugin-resolve: --dump-tools unavailable; falling back to a persistent server for tool discovery.')
487
+ }
488
+ }
489
+
490
+ if (server === null) {
491
+ // Community backend (or native dump failure): persistent server + live discovery.
492
+ const rpc = new StdioJsonRpc({ command: backend.command, args: backend.args, env, cwd, onStderr: onLog })
493
+ rpc.start()
494
+ const initOk = await new Promise((resolve) => {
495
+ const timer = setTimeout(() => resolve(false), INIT_TIMEOUT_MS)
496
+ const check = () => (rpc.initialized ? (clearTimeout(timer), resolve(true)) : setTimeout(check, 25))
497
+ check()
498
+ })
499
+ if (!initOk) throw new Error('MCP server did not initialize within 30s')
500
+ const listed = await rpc.request('tools/list', {}, INIT_TIMEOUT_MS)
501
+ tools = listed?.tools ?? []
502
+ server = rpc
503
+ }
504
+
505
+ /** @type {Array<() => void>} */
506
+ const disposers = []
507
+ for (const tool of tools) {
508
+ const publicName = `mcp__${serverName}__${tool.name}`
509
+ const definition = {
510
+ name: publicName,
511
+ description: tool.description ?? '',
512
+ parameters: sanitizeSchema(tool.inputSchema) ?? { type: 'object', properties: {} },
513
+ output: {
514
+ schema: {
515
+ type: 'object',
516
+ properties: {
517
+ content: { type: 'array', items: {} },
518
+ structuredContent: {},
519
+ },
520
+ required: ['content'],
521
+ additionalProperties: false,
522
+ },
523
+ render: (_args, value) => [{ type: 'text', text: extractContentText(value?.content) }],
524
+ },
525
+ execute: async (args, exec) => {
526
+ const result = await server.callTool(tool.name, args ?? {}, toolCallTimeoutMs, exec?.signal)
527
+ if (result?.isError === true) {
528
+ const text = Array.isArray(result.content)
529
+ ? result.content.map((part) => (part?.type === 'text' ? part.text : '')).join('\n')
530
+ : 'tool reported an error'
531
+ throw new Error(text || 'tool reported an error')
532
+ }
533
+ return {
534
+ content: result?.content ?? [],
535
+ ...(result?.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
536
+ }
537
+ },
538
+ }
539
+ disposers.push(ctx.tools.register(definition))
540
+ }
541
+
542
+ // Lifetime scope: uninstalling the bundle disposes the transport and
543
+ // withdraws every registered tool. `ctx.effect(execute, label)` runs
544
+ // `execute` at registration and collects whatever it *returns* as the
545
+ // teardown hook — so return the disposer from a thunk.
546
+ ctx.effect(() => () => {
547
+ for (const dispose of disposers.reverse()) {
548
+ try { dispose() } catch { /* already withdrawn */ }
549
+ }
550
+ server.dispose()
551
+ }, 'dsh-plugin-resolve.mcp')
552
+
553
+ log.info(
554
+ `dsh-plugin-resolve: registered ${tools.length} Resolve tools as mcp__${serverName}__* via ${backend.kind} backend ` +
555
+ `(${backend.command} ${backend.args.join(' ')}${backend.kind === 'native' ? ', lazy spawn on first call, idle kill after 5 min' : ''}).`,
556
+ )
557
+ } catch (error) {
558
+ server?.dispose()
559
+ log.warn(`dsh-plugin-resolve: could not start the Resolve MCP server (${backend.kind} backend at ${backend.command}): ${String(error)}. No tools registered.`)
560
+ }
561
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@xtremestuff/dsh-plugin-resolve",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DSH bundle that bridges DaVinci Resolve into DSH: registers Resolve 21.1+ native ResolveMCP (stdio) or the davinci-resolve-mcp community server with DSH's mcp-client bridge, exposing Resolve's tools as first-class agent tools.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./cordis.patch.yml": "./cordis.patch.yml",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib/",
14
+ "cordis.patch.yml",
15
+ "icon.svg",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "deepseek-harness",
22
+ "bundle",
23
+ "plugin",
24
+ "davinci-resolve",
25
+ "mcp",
26
+ "video-editing",
27
+ "post-production"
28
+ ],
29
+ "license": "MIT",
30
+ "author": "xtremestuff",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/xtremestuff/dsh-plugin-resolve.git"
34
+ },
35
+ "homepage": "https://github.com/xtremestuff/dsh-plugin-resolve#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/xtremestuff/dsh-plugin-resolve/issues"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=20",
44
+ "dsh": ">=0.2.0-rc.2"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "^4.1.0"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ }
53
+ }
54
+ }