davinci-resolve-mcp 2.97.7 → 2.98.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/CHANGELOG.md +133 -0
- package/README.md +3 -3
- package/README.zh-CN.md +4 -4
- package/SECURITY.md +36 -1
- package/docs/SKILL.md +4 -1
- package/docs/guides/control-panel.md +16 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +206 -7
- package/src/granular/common.py +1 -1
- package/src/server.py +82 -24
- package/src/utils/mcp_transport.py +22 -17
- package/src/utils/private_state.py +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,132 @@
|
|
|
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.98.1
|
|
6
|
+
|
|
7
|
+
**The Bash guard could be walked past with a newline.** Reported and fixed in
|
|
8
|
+
[#152](https://github.com/samuelgursky/davinci-resolve-mcp/pull/152) by
|
|
9
|
+
@fitfam, who found both holes by executing payloads against the hook rather
|
|
10
|
+
than reading it.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`split_commands()` did not split on newlines.** It knew `&&`, `||`, `;` and
|
|
15
|
+
`|`, so a multi-line block was a single segment: `argv0` came from the first
|
|
16
|
+
line, and a non-mutating first line returned before anything under it was
|
|
17
|
+
looked at. `mkdir -p footage/_superseded` then a newline then
|
|
18
|
+
`mv footage/clip.mp4 footage/_superseded/` passed, while the same two commands
|
|
19
|
+
joined by `;` denied. Either answer is defensible on its own; giving two
|
|
20
|
+
different answers to what a shell treats as the same command is what made the
|
|
21
|
+
guard trainable around.
|
|
22
|
+
|
|
23
|
+
- **The ffmpeg branch exempted an output that equalled an input.** The output
|
|
24
|
+
operand was dropped when it matched a `-i` value — which is exactly
|
|
25
|
+
`ffmpeg -i master.mp4 master.mp4`, the in-place overwrite the hook's own deny
|
|
26
|
+
message calls unrecoverable. Independent of the first hole: the single-line
|
|
27
|
+
form was allowed too.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The trailing operand is no longer read as an output when it is the value of a
|
|
32
|
+
`-i` immediately before it. `ffmpeg -i master.mov` prints stream info and
|
|
33
|
+
writes nothing, and denying a read is how a guard teaches an agent to route
|
|
34
|
+
around it. `ffmpeg -i x.mp4 x.mp4` still denies — the token there is not
|
|
35
|
+
preceded by `-i`.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`tests.test_source_media_guard`** — the hook had no tests, which is why two
|
|
40
|
+
holes in it needed a payload matrix to find. It runs the hook the way the
|
|
41
|
+
harness does, a PreToolUse payload on stdin and a decision on stdout, and
|
|
42
|
+
fails on exactly the four rows this release fixes. The newline/`;` pair is
|
|
43
|
+
asserted as a pair: not that either answer is right, but that the guard cannot
|
|
44
|
+
give two.
|
|
45
|
+
|
|
46
|
+
### Note
|
|
47
|
+
|
|
48
|
+
- Splitting on newlines means multi-line text carried *inside* a command — a
|
|
49
|
+
heredoc body, a commit message — is now read as commands too, so text that
|
|
50
|
+
merely quotes `rm camera.mov` is denied. That is the right way round for a
|
|
51
|
+
tripwire; write the text to a file first. #152 was itself blocked this way on
|
|
52
|
+
its first attempt, by the fix it was proposing.
|
|
53
|
+
|
|
54
|
+
## What's New in v2.98.0
|
|
55
|
+
|
|
56
|
+
**The control panel could be driven by any web page you visited.** Reported
|
|
57
|
+
privately, with the chain worked out end to end. Fixed here; the transport
|
|
58
|
+
state file and panel pidfile move out of shared locations at the same time.
|
|
59
|
+
|
|
60
|
+
### Security
|
|
61
|
+
|
|
62
|
+
- **`GET /api/mcp/status` handed out the networked-transport bearer token to
|
|
63
|
+
anyone who could reach the port.** It was the one privileged route with no
|
|
64
|
+
loopback check, and its payload included the token that authenticates the
|
|
65
|
+
`--transport streamable-http` MCP instance — i.e. full Resolve control.
|
|
66
|
+
- **No Host or Origin check, and POST bodies of any Content-Type were parsed.**
|
|
67
|
+
A page on any site could POST to the panel (CSRF), and a DNS-rebinding page
|
|
68
|
+
could read its responses. Chained: visit a page → it starts the networked
|
|
69
|
+
transport via `/api/mcp/transport/start` → reads the token from
|
|
70
|
+
`/api/mcp/status` → owns the MCP toolset.
|
|
71
|
+
- **The bind address was a tool parameter.** `open_control_panel(host=...)`
|
|
72
|
+
passed straight through, so a prompt could put the unauthenticated panel on
|
|
73
|
+
`0.0.0.0`.
|
|
74
|
+
|
|
75
|
+
What changed:
|
|
76
|
+
|
|
77
|
+
- **Per-launch bearer token on every route except the static shell at `/`.**
|
|
78
|
+
`open_control_panel` mints `secrets.token_urlsafe(32)`, passes it to the
|
|
79
|
+
child through the environment (not argv, which `ps` shows to every local
|
|
80
|
+
user), records it 0600, and returns the URL with the token in the
|
|
81
|
+
**fragment** (`http://127.0.0.1:8765/#token=…`) — browsers never send
|
|
82
|
+
fragments, so it stays out of request lines and logs. The panel JS keeps
|
|
83
|
+
it in `localStorage`, sends `Authorization: Bearer …` on every fetch, and
|
|
84
|
+
exchanges it once (`POST /api/session`) for an `HttpOnly; SameSite=Strict`
|
|
85
|
+
cookie so thumbnail `<img>` loads work. A bare `http://127.0.0.1:8765` now
|
|
86
|
+
shows a "Control panel locked" screen pointing back to `open_control_panel`.
|
|
87
|
+
- **A single gate at the top of `do_GET`/`do_POST`:** `Host` must be a
|
|
88
|
+
loopback host (defeats DNS rebinding); `Origin`, when present, must be a
|
|
89
|
+
loopback origin (defeats CSRF); `POST` must be
|
|
90
|
+
`Content-Type: application/json` (a cross-site form cannot send it, and a
|
|
91
|
+
cross-site `fetch()` with it needs a CORS preflight the panel never
|
|
92
|
+
answers). Each check fails closed with 403/415 before any route runs.
|
|
93
|
+
- **`host` is no longer an AI knob.** `open_control_panel` refuses anything
|
|
94
|
+
but `127.0.0.1` / `localhost` / `::1` with no override, and
|
|
95
|
+
`src.analysis_dashboard --host` does the same at argparse — matching the
|
|
96
|
+
free-edition bridge, which already threw on non-loopback.
|
|
97
|
+
- **Secrets on disk are private.** The transport state file
|
|
98
|
+
(`mcp_transport.json`, holds the transport token) leaves
|
|
99
|
+
`tempfile.gettempdir()`, and the panel pidfile (now holds the panel token)
|
|
100
|
+
leaves `~/Documents`; both live under `~/.davinci-resolve-mcp/` (0700),
|
|
101
|
+
written 0600 via the new `src/utils/private_state.py`, with a best-effort
|
|
102
|
+
`icacls` restriction on Windows. `DAVINCI_RESOLVE_MCP_STATE_DIR` overrides
|
|
103
|
+
the directory.
|
|
104
|
+
- **The launcher still recognises a panel it has no token for.** A panel
|
|
105
|
+
that survived an MCP restart from before this scheme answers 401 with a
|
|
106
|
+
self-identifying body; `open_control_panel` reports it as `stale_running`
|
|
107
|
+
(nobody can log in to it) with the `force_restart=true` remediation
|
|
108
|
+
instead of handing out an unusable URL.
|
|
109
|
+
|
|
110
|
+
`SECURITY.md` previously said the server "does not expose a network
|
|
111
|
+
listener"; it now describes both opt-in local HTTP surfaces (panel and
|
|
112
|
+
networked transport) and their posture. Kept as-is: the default port stays
|
|
113
|
+
8765 (with Host/Origin/token in place its guessability is not the barrier)
|
|
114
|
+
and the transport token is still shown in the panel's MCP card, now behind
|
|
115
|
+
the panel's own auth.
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- `tests.test_control_panel_auth` boots the real `Handler` on an ephemeral
|
|
120
|
+
port and asserts each guard over HTTP: `/api/mcp/status` 401 without the
|
|
121
|
+
token and no transport secret in the 401 body; non-loopback `Host` rejected
|
|
122
|
+
even with a valid token; `localhost` / `[::1]` / bare `127.0.0.1` accepted;
|
|
123
|
+
cross-site `Origin` rejected; non-JSON POST → 415; no CORS preflight answer;
|
|
124
|
+
cookie issued only via bearer, `HttpOnly` + `SameSite=Strict`, then
|
|
125
|
+
sufficient on its own; wrong token / wrong cookie → 401; the static shell
|
|
126
|
+
never contains the token. Plus the launcher's 401-probe recognition, the
|
|
127
|
+
`host=0.0.0.0` refusal, and 0600 on the private state files.
|
|
128
|
+
`tests.test_open_control_panel` gains the token-unknown → `stale_running`
|
|
129
|
+
case and asserts the issued URL is the token-bearing one.
|
|
130
|
+
|
|
5
131
|
## What's New in v2.97.7
|
|
6
132
|
|
|
7
133
|
**`grab_and_export` deleted files it never created.** Reported in
|
|
@@ -40,6 +166,13 @@ Release history for the DaVinci Resolve MCP Server. The latest release is summar
|
|
|
40
166
|
response, so an unrelated document sitting in the export folder could reach an
|
|
41
167
|
assistant's context. Only staged files are read now.
|
|
42
168
|
|
|
169
|
+
Live-verified after release on Studio 19.1.3.7 (macOS, Color page, Gallery
|
|
170
|
+
panel open): Resolve's `ExportStills` writes into the staging subdirectory — a
|
|
171
|
+
257 KB JPEG and its 28 KB `.drx` came back inlined — a bystander file in the
|
|
172
|
+
same folder survived untouched, the folder itself was kept, and
|
|
173
|
+
`cleanup: false` moved both files up into it. That subdirectory write was the
|
|
174
|
+
one claim the offline fixtures could not make.
|
|
175
|
+
|
|
43
176
|
### Added
|
|
44
177
|
|
|
45
178
|
- `tests.test_gallery_still_export_cleanup` runs the action against a real temp
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
|
@@ -103,7 +103,7 @@ Launch the single-user local control panel from the repository root:
|
|
|
103
103
|
venv/bin/python -m src.control_panel
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
The command starts a
|
|
106
|
+
The command starts a loopback-only server and opens the control panel in your browser at a URL that carries a per-launch access token (`http://127.0.0.1:8765/#token=…`) — use that exact URL; the panel refuses requests without it. To have an AI coding agent do this, ask: **"Open the Resolve MCP control panel for this repo."** Agents should use `venv/bin/python -m src.control_panel` unless your Python environment is already active. Persisted analysis jobs refresh the local search index automatically after successful slices; the manual Build Index action is for rebuilding from existing reports.
|
|
107
107
|
|
|
108
108
|
## Server Modes
|
|
109
109
|
|
|
@@ -266,7 +266,7 @@ This project treats camera originals and source media as immutable. Analysis too
|
|
|
266
266
|
|
|
267
267
|
## Security Posture
|
|
268
268
|
|
|
269
|
-
The default server is a local stdio process launched by your MCP client; it does not expose a network listener or built-in multi-user auth surface. Tool metadata includes MCP client-safety hints for read-only, destructive, idempotent, and external-resource operations. See [Security Policy](SECURITY.md) for operational boundaries, confirmation guidance, and vulnerability reporting.
|
|
269
|
+
The default server is a local stdio process launched by your MCP client; it does not expose a network listener or built-in multi-user auth surface. The two opt-in local HTTP surfaces — the control panel and the networked MCP transport — bind loopback only and require a per-launch bearer token on every request, with Host/Origin checks against DNS rebinding and CSRF. Tool metadata includes MCP client-safety hints for read-only, destructive, idempotent, and external-resource operations. See [Security Policy](SECURITY.md) for operational boundaries, confirmation guidance, and vulnerability reporting.
|
|
270
270
|
|
|
271
271
|
## Key Stats
|
|
272
272
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.98.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -75,7 +75,7 @@ launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
|
|
|
75
75
|
venv/bin/python -m src.control_panel
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
该命令启动一个仅绑定回环地址的服务器,并在浏览器中以带每次启动令牌的 URL(`http://127.0.0.1:8765/#token=…`)打开控制面板——请使用这个完整 URL,面板会拒绝不带令牌的请求。想让 AI 编码助手代劳,可以说:**"Open the Resolve MCP control panel for this repo."** 除非你的 Python 环境已激活,否则 agent 应使用 `venv/bin/python -m src.control_panel`。持久化的分析任务在切片成功后会自动刷新本地搜索索引;手动的 Build Index 按钮用于从已有报告重建索引。
|
|
79
79
|
|
|
80
80
|
## 服务器模式
|
|
81
81
|
|
|
@@ -190,7 +190,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
190
190
|
|
|
191
191
|
## 安全态势
|
|
192
192
|
|
|
193
|
-
默认服务器是由你的 MCP 客户端启动的本地 stdio
|
|
193
|
+
默认服务器是由你的 MCP 客户端启动的本地 stdio 进程;它不暴露网络监听器,也没有内置多用户认证面。两个可选的本地 HTTP 面——控制面板与联网 MCP 传输——仅绑定回环地址,每个请求都需要每次启动生成的 bearer 令牌,并校验 Host/Origin 以防 DNS 重绑定和 CSRF。工具元数据包含面向 MCP 客户端的安全提示(只读、破坏性、幂等、外部资源操作)。操作边界、确认指引和漏洞报告见 [安全策略](SECURITY.md)。
|
|
194
194
|
|
|
195
195
|
## 关键数据
|
|
196
196
|
|
package/SECURITY.md
CHANGED
|
@@ -6,10 +6,45 @@
|
|
|
6
6
|
Resolve Studio through the official Resolve Scripting API. It is intended to run
|
|
7
7
|
under the same local user account that operates Resolve.
|
|
8
8
|
|
|
9
|
-
The default server does not expose a network listener,
|
|
9
|
+
The default stdio server does not expose a network listener, remote shell, or
|
|
10
10
|
multi-user authentication surface. Access control is delegated to the MCP client
|
|
11
11
|
that launches the stdio process and to the local operating-system user session.
|
|
12
12
|
|
|
13
|
+
Two opt-in surfaces DO open local HTTP listeners, and both are hardened the same
|
|
14
|
+
way:
|
|
15
|
+
|
|
16
|
+
- **The control panel** (`resolve_control action=open_control_panel`,
|
|
17
|
+
`python -m src.control_panel`) — a single-user browser UI on
|
|
18
|
+
`127.0.0.1:8765` by default.
|
|
19
|
+
- **The networked MCP transport** (`--transport sse|streamable-http`) — a
|
|
20
|
+
second MCP instance for remote clients, on `127.0.0.1:8000` by default.
|
|
21
|
+
|
|
22
|
+
Their posture:
|
|
23
|
+
|
|
24
|
+
- **Loopback only.** The panel refuses any bind host other than
|
|
25
|
+
`127.0.0.1` / `localhost` / `::1` — the bind address is not a tool parameter
|
|
26
|
+
an AI can widen. The transport defaults to loopback and logs a loud warning
|
|
27
|
+
if `DAVINCI_MCP_HOST` points elsewhere.
|
|
28
|
+
- **Bearer token on every request.** Each panel launch generates a fresh
|
|
29
|
+
`secrets.token_urlsafe(32)` token, passed to the child via environment (not
|
|
30
|
+
argv) and delivered to the browser in the URL fragment (`#token=…`), which
|
|
31
|
+
never reaches the server or its logs. Every route except the static shell at
|
|
32
|
+
`/` returns 401 without it (`Authorization: Bearer …`, or the HttpOnly,
|
|
33
|
+
`SameSite=Strict` session cookie the panel exchanges it for so image loads
|
|
34
|
+
work). The transport requires `Authorization: Bearer <token>` on every request.
|
|
35
|
+
- **DNS-rebinding and CSRF guards.** The panel rejects any request whose
|
|
36
|
+
`Host` header is not a loopback host, any request carrying a non-loopback
|
|
37
|
+
`Origin`, and any `POST` that is not `Content-Type: application/json`. It
|
|
38
|
+
never answers a CORS preflight, so no third-party page can call it.
|
|
39
|
+
- **Secrets on disk are private.** The panel's pidfile (token + pid + URL) and
|
|
40
|
+
the transport's state file (token + URL) live under
|
|
41
|
+
`~/.davinci-resolve-mcp/` (0700) and are written 0600 — never in a shared
|
|
42
|
+
temp directory or `~/Documents`.
|
|
43
|
+
|
|
44
|
+
If you find a route that can be reached without the token, or a way to satisfy
|
|
45
|
+
the Host/Origin checks from a non-loopback page, that is a security bug — please
|
|
46
|
+
report it (see below).
|
|
47
|
+
|
|
13
48
|
## Operational Boundaries
|
|
14
49
|
|
|
15
50
|
- Keep Resolve external scripting set to **Local** unless you have a separate,
|
package/docs/SKILL.md
CHANGED
|
@@ -327,7 +327,10 @@ venv/bin/python -m src.control_panel
|
|
|
327
327
|
|
|
328
328
|
The command starts the local control panel and opens the default browser. Use
|
|
329
329
|
`--no-open` when running in a headless context, then give the user the printed
|
|
330
|
-
localhost URL
|
|
330
|
+
localhost URL **exactly as printed** — it carries a per-launch bearer token in
|
|
331
|
+
its fragment (`#token=…`) and the panel refuses every request without it. The
|
|
332
|
+
panel binds loopback only (a non-loopback host is refused, no override) and is
|
|
333
|
+
single-user; it is an operational surface
|
|
331
334
|
for server status, Resolve clips, source-safe analysis jobs, preferences, and
|
|
332
335
|
diagnostics as those sections are added.
|
|
333
336
|
|
|
@@ -5,8 +5,17 @@ for inspecting Resolve state, running source-safe media analysis, drilling into
|
|
|
5
5
|
analyzed clips and shots, fixing analysis output inline, reviewing timeline
|
|
6
6
|
edit history, driving Resolve's local AI operations, and managing preferences.
|
|
7
7
|
|
|
8
|
-
The panel is a local HTTP server (default `http://127.0.0.1:8765`). It
|
|
9
|
-
|
|
8
|
+
The panel is a local HTTP server (default `http://127.0.0.1:8765`). It binds
|
|
9
|
+
loopback only — a non-loopback `--host` / `host` is refused, with no override —
|
|
10
|
+
and does not modify source media.
|
|
11
|
+
|
|
12
|
+
Every launch mints a per-launch bearer token. The launcher hands it to the
|
|
13
|
+
browser in the URL fragment (`http://127.0.0.1:8765/#token=…`); the panel
|
|
14
|
+
answers 401 to every request that doesn't carry it, so **use the exact URL the
|
|
15
|
+
launcher prints or `open_control_panel` returns** — a bare
|
|
16
|
+
`http://127.0.0.1:8765` shows a "Control panel locked" screen. Requests with a
|
|
17
|
+
non-loopback `Host` or `Origin`, or a non-JSON `POST`, are rejected outright
|
|
18
|
+
(DNS-rebinding / CSRF guards). See `SECURITY.md` for the full posture.
|
|
10
19
|
|
|
11
20
|
## Launching the panel
|
|
12
21
|
|
|
@@ -22,7 +31,11 @@ resolve_control(action="open_control_panel")
|
|
|
22
31
|
```
|
|
23
32
|
|
|
24
33
|
Once running, `resolve_control(action="control_panel_status")` checks the
|
|
25
|
-
pidfile
|
|
34
|
+
pidfile (`~/.davinci-resolve-mcp/control_panel.json`, 0600 — it holds the
|
|
35
|
+
token) and returns the token-bearing URL; `resolve_control(action="close_control_panel")`
|
|
36
|
+
stops it. A panel whose token is not on record (e.g. one that survived an MCP
|
|
37
|
+
restart from before this scheme) is reported as `stale_running`; re-call
|
|
38
|
+
`open_control_panel` with `force_restart=true` to relaunch it.
|
|
26
39
|
|
|
27
40
|
## Navigation and deep links
|
|
28
41
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.98.1"
|
|
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
|
@@ -9,6 +9,7 @@ import hashlib
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import secrets
|
|
12
13
|
import sqlite3
|
|
13
14
|
import sys
|
|
14
15
|
import threading
|
|
@@ -5048,6 +5049,44 @@ HTML = r"""<!doctype html>
|
|
|
5048
5049
|
<script>
|
|
5049
5050
|
/* CONTROL_PANEL_I18N */
|
|
5050
5051
|
|
|
5052
|
+
// ─── Panel auth ─────────────────────────────────────────────────────
|
|
5053
|
+
// The launcher hands the per-launch bearer token over in the URL
|
|
5054
|
+
// fragment (#token=…). Fragments never leave the browser, so the token
|
|
5055
|
+
// is not in any request line or log. It is kept in localStorage for this
|
|
5056
|
+
// origin, sent as Authorization on every fetch, and exchanged once for an
|
|
5057
|
+
// HttpOnly cookie (POST /api/session) so <img src="/api/…"> loads work.
|
|
5058
|
+
const PANEL_TOKEN_KEY = 'davinci_panel_token';
|
|
5059
|
+
function captureLaunchToken() {
|
|
5060
|
+
const m = window.location.hash.match(/^#token=([A-Za-z0-9_-]+)(?:&(.*))?$/);
|
|
5061
|
+
if (!m) return;
|
|
5062
|
+
try { localStorage.setItem(PANEL_TOKEN_KEY, m[1]); } catch (_) { /* private mode */ }
|
|
5063
|
+
const rest = m[2] ? `#${m[2]}` : window.location.pathname + window.location.search;
|
|
5064
|
+
history.replaceState(null, '', rest);
|
|
5065
|
+
}
|
|
5066
|
+
function panelToken() {
|
|
5067
|
+
try { return localStorage.getItem(PANEL_TOKEN_KEY) || ''; } catch (_) { return ''; }
|
|
5068
|
+
}
|
|
5069
|
+
function authHeaders(extra = {}) {
|
|
5070
|
+
const token = panelToken();
|
|
5071
|
+
return token ? { Authorization: `Bearer ${token}`, ...extra } : { ...extra };
|
|
5072
|
+
}
|
|
5073
|
+
// Runs before anything else in this script so no request goes out tokenless.
|
|
5074
|
+
captureLaunchToken();
|
|
5075
|
+
let panelLockShown = false;
|
|
5076
|
+
function showPanelLocked() {
|
|
5077
|
+
if (panelLockShown) return;
|
|
5078
|
+
panelLockShown = true;
|
|
5079
|
+
document.body.innerHTML = `
|
|
5080
|
+
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0f14;color:#e6edf3;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
|
5081
|
+
<div style="max-width:520px;padding:32px;border:1px solid #1f2937;border-radius:8px;background:#111827;">
|
|
5082
|
+
<div style="font-size:18px;font-weight:600;margin-bottom:8px;">Control panel locked</div>
|
|
5083
|
+
<div style="color:#9ca3af;">This panel only answers to the URL it was launched with — the one containing its
|
|
5084
|
+
per-launch token. Ask your MCP client to run
|
|
5085
|
+
<code style="color:#1E90FF;">resolve_control(action="open_control_panel")</code>
|
|
5086
|
+
and open the URL it returns.</div>
|
|
5087
|
+
</div>
|
|
5088
|
+
</div>`;
|
|
5089
|
+
}
|
|
5051
5090
|
const state = {
|
|
5052
5091
|
boot: null,
|
|
5053
5092
|
projects: null,
|
|
@@ -5228,9 +5267,13 @@ HTML = r"""<!doctype html>
|
|
|
5228
5267
|
|
|
5229
5268
|
async function api(path, options = {}) {
|
|
5230
5269
|
const res = await fetch(path, {
|
|
5231
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5232
5270
|
...options,
|
|
5271
|
+
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
|
|
5233
5272
|
});
|
|
5273
|
+
if (res.status === 401) {
|
|
5274
|
+
showPanelLocked();
|
|
5275
|
+
throw new Error('Control panel is not authorized — reopen it from your MCP client.');
|
|
5276
|
+
}
|
|
5234
5277
|
const payload = await res.json();
|
|
5235
5278
|
if (!res.ok || payload.success === false) {
|
|
5236
5279
|
throw new Error(payload.error || res.statusText);
|
|
@@ -6463,6 +6506,13 @@ HTML = r"""<!doctype html>
|
|
|
6463
6506
|
}
|
|
6464
6507
|
|
|
6465
6508
|
async function boot() {
|
|
6509
|
+
if (!panelToken()) {
|
|
6510
|
+
showPanelLocked();
|
|
6511
|
+
return;
|
|
6512
|
+
}
|
|
6513
|
+
// Exchange the bearer token for the HttpOnly session cookie first so
|
|
6514
|
+
// thumbnail <img> loads (which cannot carry headers) are authorized.
|
|
6515
|
+
await api('/api/session', { method: 'POST', body: '{}' });
|
|
6466
6516
|
state.boot = await api('/api/boot');
|
|
6467
6517
|
state.activeContext = state.boot.active_context || {
|
|
6468
6518
|
project_name: state.boot.project_name,
|
|
@@ -6627,9 +6677,10 @@ HTML = r"""<!doctype html>
|
|
|
6627
6677
|
// No `limit` param: the server applies the media_analysis.inventory_limit
|
|
6628
6678
|
// preference, and sending one here would override what the user configured.
|
|
6629
6679
|
const query = options.silent ? '?probe=0&reuse=1' : '';
|
|
6630
|
-
const headers =
|
|
6680
|
+
const headers = authHeaders();
|
|
6631
6681
|
if (state.mediaETag) headers['If-None-Match'] = state.mediaETag;
|
|
6632
6682
|
const res = await fetch(`/api/resolve/media${query}`, { headers, cache: 'no-store' });
|
|
6683
|
+
if (res.status === 401) { showPanelLocked(); return; }
|
|
6633
6684
|
state.mediaLastRefresh = new Date();
|
|
6634
6685
|
state.resolveMediaStale = false;
|
|
6635
6686
|
if (res.status === 304) return;
|
|
@@ -11593,7 +11644,7 @@ HTML = r"""<!doctype html>
|
|
|
11593
11644
|
if (!Array.isArray(clipIds) || !clipIds.length) return;
|
|
11594
11645
|
const result = await fetch('/api/clips/export', {
|
|
11595
11646
|
method: 'POST',
|
|
11596
|
-
headers: { 'Content-Type': 'application/json' },
|
|
11647
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
11597
11648
|
body: JSON.stringify({ clip_ids: clipIds, format }),
|
|
11598
11649
|
});
|
|
11599
11650
|
if (!result.ok) {
|
|
@@ -14429,6 +14480,75 @@ def _request_is_loopback(handler: BaseHTTPRequestHandler) -> bool:
|
|
|
14429
14480
|
return addr in {"127.0.0.1", "::1", "localhost"}
|
|
14430
14481
|
|
|
14431
14482
|
|
|
14483
|
+
# ─── Panel request gate ───────────────────────────────────────────────────────
|
|
14484
|
+
# Every request passes through Handler._gate() before any route runs:
|
|
14485
|
+
# 1. Host header must name a loopback host → defeats DNS rebinding.
|
|
14486
|
+
# 2. Origin header (when present) must be a loopback origin → defeats CSRF
|
|
14487
|
+
# from any web page (browsers always attach Origin to cross-site POSTs).
|
|
14488
|
+
# 3. POST bodies must be application/json → a cross-site form post can't
|
|
14489
|
+
# satisfy it, and a cross-site fetch() with that header needs a CORS
|
|
14490
|
+
# preflight, which this server never answers.
|
|
14491
|
+
# 4. Everything except the static shell at "/" needs the panel bearer token
|
|
14492
|
+
# (Authorization header, or the HttpOnly cookie set by POST /api/session
|
|
14493
|
+
# so <img> loads work). The token is generated per launch and handed to
|
|
14494
|
+
# the browser in the URL fragment, which never reaches the server.
|
|
14495
|
+
PANEL_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]"})
|
|
14496
|
+
PANEL_PUBLIC_GET_PATHS = frozenset({"/"})
|
|
14497
|
+
PANEL_COOKIE_NAME = "davinci_panel_token"
|
|
14498
|
+
PANEL_TOKEN_ENV = "DAVINCI_PANEL_TOKEN"
|
|
14499
|
+
|
|
14500
|
+
|
|
14501
|
+
def resolve_panel_token() -> str:
|
|
14502
|
+
"""Token from $DAVINCI_PANEL_TOKEN (set by the MCP launcher) or a fresh one."""
|
|
14503
|
+
return os.environ.get(PANEL_TOKEN_ENV) or secrets.token_urlsafe(32)
|
|
14504
|
+
|
|
14505
|
+
|
|
14506
|
+
def _host_header_name(value: Optional[str]) -> str:
|
|
14507
|
+
"""Return the host part of a Host header (port stripped, lowercased)."""
|
|
14508
|
+
raw = (value or "").strip().lower()
|
|
14509
|
+
if not raw:
|
|
14510
|
+
return ""
|
|
14511
|
+
if raw.startswith("["):
|
|
14512
|
+
end = raw.find("]")
|
|
14513
|
+
return raw[: end + 1] if end != -1 else raw
|
|
14514
|
+
if raw.count(":") == 1:
|
|
14515
|
+
return raw.split(":", 1)[0]
|
|
14516
|
+
return raw
|
|
14517
|
+
|
|
14518
|
+
|
|
14519
|
+
def _host_is_loopback(value: Optional[str]) -> bool:
|
|
14520
|
+
return _host_header_name(value) in PANEL_LOOPBACK_HOSTS
|
|
14521
|
+
|
|
14522
|
+
|
|
14523
|
+
def _origin_is_loopback(value: Optional[str]) -> bool:
|
|
14524
|
+
try:
|
|
14525
|
+
parsed = urlparse((value or "").strip())
|
|
14526
|
+
except ValueError:
|
|
14527
|
+
return False
|
|
14528
|
+
if parsed.scheme not in {"http", "https"}:
|
|
14529
|
+
return False
|
|
14530
|
+
host = (parsed.hostname or "").lower()
|
|
14531
|
+
return host in PANEL_LOOPBACK_HOSTS
|
|
14532
|
+
|
|
14533
|
+
|
|
14534
|
+
def _cookie_value(cookie_header: Optional[str], name: str) -> Optional[str]:
|
|
14535
|
+
for part in (cookie_header or "").split(";"):
|
|
14536
|
+
key, sep, val = part.strip().partition("=")
|
|
14537
|
+
if sep and key.strip() == name:
|
|
14538
|
+
return val.strip()
|
|
14539
|
+
return None
|
|
14540
|
+
|
|
14541
|
+
|
|
14542
|
+
def _request_presents_token(handler: BaseHTTPRequestHandler, token: str) -> bool:
|
|
14543
|
+
if not token:
|
|
14544
|
+
return False
|
|
14545
|
+
auth = handler.headers.get("Authorization") or ""
|
|
14546
|
+
if auth.startswith("Bearer ") and secrets.compare_digest(auth[len("Bearer "):].strip(), token):
|
|
14547
|
+
return True
|
|
14548
|
+
cookie = _cookie_value(handler.headers.get("Cookie"), PANEL_COOKIE_NAME)
|
|
14549
|
+
return bool(cookie) and secrets.compare_digest(cookie, token)
|
|
14550
|
+
|
|
14551
|
+
|
|
14432
14552
|
def _launch_claude_code_terminal() -> Dict[str, Any]:
|
|
14433
14553
|
"""Open a Terminal/iTerm window at the MCP server's project root running
|
|
14434
14554
|
the ``claude`` CLI. macOS only — other platforms return a clipboard-only
|
|
@@ -15155,6 +15275,7 @@ def _inventory_prefs() -> Tuple[int, Optional[set]]:
|
|
|
15155
15275
|
|
|
15156
15276
|
class Handler(BaseHTTPRequestHandler):
|
|
15157
15277
|
state: DashboardState
|
|
15278
|
+
token: str = ""
|
|
15158
15279
|
|
|
15159
15280
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
15160
15281
|
return
|
|
@@ -15243,14 +15364,77 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15243
15364
|
return {}
|
|
15244
15365
|
return payload if isinstance(payload, dict) else {}
|
|
15245
15366
|
|
|
15367
|
+
def _gate(self) -> bool:
|
|
15368
|
+
"""Run the request through the panel security gate; False = already answered."""
|
|
15369
|
+
path = urlparse(self.path).path
|
|
15370
|
+
if not _host_is_loopback(self.headers.get("Host")):
|
|
15371
|
+
self._json(
|
|
15372
|
+
{"success": False, "error": "Rejected: Host header is not a loopback host (DNS-rebinding guard)."},
|
|
15373
|
+
HTTPStatus.FORBIDDEN,
|
|
15374
|
+
)
|
|
15375
|
+
return False
|
|
15376
|
+
origin = self.headers.get("Origin")
|
|
15377
|
+
if origin is not None and not _origin_is_loopback(origin):
|
|
15378
|
+
self._json(
|
|
15379
|
+
{"success": False, "error": "Rejected: cross-site origin (CSRF guard)."},
|
|
15380
|
+
HTTPStatus.FORBIDDEN,
|
|
15381
|
+
)
|
|
15382
|
+
return False
|
|
15383
|
+
if self.command == "POST":
|
|
15384
|
+
ctype = (self.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
|
15385
|
+
if ctype != "application/json":
|
|
15386
|
+
self._json(
|
|
15387
|
+
{"success": False, "error": "POST requests must be Content-Type: application/json."},
|
|
15388
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
|
15389
|
+
)
|
|
15390
|
+
return False
|
|
15391
|
+
if self.command == "GET" and path in PANEL_PUBLIC_GET_PATHS:
|
|
15392
|
+
return True
|
|
15393
|
+
if _request_presents_token(self, getattr(self, "token", "") or ""):
|
|
15394
|
+
return True
|
|
15395
|
+
# The launcher probes this on 401 to recognise a live panel + its version
|
|
15396
|
+
# without a token, so the payload names itself.
|
|
15397
|
+
self._json(
|
|
15398
|
+
{
|
|
15399
|
+
"success": False,
|
|
15400
|
+
"error": "unauthorized: this control panel requires its launch token. "
|
|
15401
|
+
"Reopen it from your MCP client (resolve_control action=open_control_panel) "
|
|
15402
|
+
"and use the returned URL.",
|
|
15403
|
+
"panel": "davinci-resolve-mcp",
|
|
15404
|
+
"mcp_version": _mcp_version(),
|
|
15405
|
+
},
|
|
15406
|
+
HTTPStatus.UNAUTHORIZED,
|
|
15407
|
+
)
|
|
15408
|
+
return False
|
|
15409
|
+
|
|
15410
|
+
def _set_session_cookie(self) -> None:
|
|
15411
|
+
"""POST /api/session (bearer-authenticated) → HttpOnly cookie for <img> loads."""
|
|
15412
|
+
raw = json.dumps({"success": True}).encode("utf-8")
|
|
15413
|
+
self.send_response(200)
|
|
15414
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
15415
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
15416
|
+
self.send_header(
|
|
15417
|
+
"Set-Cookie",
|
|
15418
|
+
f"{PANEL_COOKIE_NAME}={self.token}; Path=/; HttpOnly; SameSite=Strict",
|
|
15419
|
+
)
|
|
15420
|
+
self.end_headers()
|
|
15421
|
+
self.wfile.write(raw)
|
|
15422
|
+
|
|
15246
15423
|
def do_GET(self) -> None:
|
|
15247
15424
|
try:
|
|
15425
|
+
if not self._gate():
|
|
15426
|
+
return
|
|
15248
15427
|
self._route_get()
|
|
15249
15428
|
except Exception as exc: # pragma: no cover - runtime safety for dashboard users
|
|
15250
15429
|
self._json({"success": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
15251
15430
|
|
|
15252
15431
|
def do_POST(self) -> None:
|
|
15253
15432
|
try:
|
|
15433
|
+
if not self._gate():
|
|
15434
|
+
return
|
|
15435
|
+
if urlparse(self.path).path == "/api/session":
|
|
15436
|
+
self._set_session_cookie()
|
|
15437
|
+
return
|
|
15254
15438
|
self._route_post()
|
|
15255
15439
|
except Exception as exc: # pragma: no cover
|
|
15256
15440
|
self._json({"success": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
@@ -15920,9 +16104,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15920
16104
|
self._json({"success": False, "error": "Not found"}, HTTPStatus.NOT_FOUND)
|
|
15921
16105
|
|
|
15922
16106
|
|
|
16107
|
+
def _loopback_host(value: str) -> str:
|
|
16108
|
+
"""argparse type: the panel binds loopback only — anything else is refused."""
|
|
16109
|
+
if value not in PANEL_LOOPBACK_HOSTS:
|
|
16110
|
+
raise argparse.ArgumentTypeError(
|
|
16111
|
+
f"control panel host must be loopback (127.0.0.1 / localhost / ::1), got {value!r} — "
|
|
16112
|
+
"the panel is a single-user local UI and is never bound to a routable interface"
|
|
16113
|
+
)
|
|
16114
|
+
return value
|
|
16115
|
+
|
|
16116
|
+
|
|
15923
16117
|
def parse_args() -> argparse.Namespace:
|
|
15924
16118
|
parser = argparse.ArgumentParser(description="Run the local Resolve MCP control panel.")
|
|
15925
|
-
parser.add_argument("--host", default="127.0.0.1")
|
|
16119
|
+
parser.add_argument("--host", default="127.0.0.1", type=_loopback_host)
|
|
15926
16120
|
parser.add_argument("--port", type=int, default=8765)
|
|
15927
16121
|
parser.add_argument("--project-name", default="Dashboard Analysis")
|
|
15928
16122
|
parser.add_argument("--project-id", default="dashboard")
|
|
@@ -15955,10 +16149,15 @@ def main() -> None:
|
|
|
15955
16149
|
args = parse_args()
|
|
15956
16150
|
state = DashboardState(args.project_name, args.project_id, args.analysis_root)
|
|
15957
16151
|
Handler.state = state
|
|
16152
|
+
Handler.token = resolve_panel_token()
|
|
15958
16153
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
15959
|
-
|
|
15960
|
-
|
|
15961
|
-
|
|
16154
|
+
# The token rides in the URL fragment: browsers keep fragments client-side,
|
|
16155
|
+
# so it never appears in a request line, proxy log, or Referer.
|
|
16156
|
+
url = f"http://{args.host}:{args.port}/#token={Handler.token}"
|
|
16157
|
+
# flush: under --no-open the URL (with its token) is the only handle the
|
|
16158
|
+
# operator gets, and a piped/redirected stdout would otherwise hold it back.
|
|
16159
|
+
print(f"DaVinci Resolve MCP: {url}", flush=True)
|
|
16160
|
+
print(f"Project analysis root: {state.project_root}", flush=True)
|
|
15962
16161
|
threading.Thread(target=_warm_inventory_cache, args=(state.project_root,), daemon=True).start()
|
|
15963
16162
|
if args.open:
|
|
15964
16163
|
webbrowser.open(url)
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.98.1"
|
|
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
|
@@ -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.
|
|
14
|
+
VERSION = "2.98.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -14452,9 +14452,13 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14452
14452
|
the preset is then named after the file, so pass name when the preset
|
|
14453
14453
|
should be called something the filename does not say.
|
|
14454
14454
|
export_user_preferences_preset(name, path) -> {success} — Resolve 21.0.4+
|
|
14455
|
-
open_control_panel(port?,
|
|
14455
|
+
open_control_panel(port?, open_browser?, force_restart?) -> {success, url, pid, port, status}
|
|
14456
14456
|
— Launches the analysis control panel (src/analysis_dashboard.py) as a background process.
|
|
14457
|
-
Idempotent: returns the existing URL if already running.
|
|
14457
|
+
Idempotent: returns the existing URL if already running. Binds
|
|
14458
|
+
127.0.0.1 ONLY — a `host` other than loopback is refused, there is no
|
|
14459
|
+
override. The returned url carries a per-launch bearer token in its
|
|
14460
|
+
fragment (#token=…); the panel refuses every request without it, so
|
|
14461
|
+
give the user that exact URL, not a bare http://127.0.0.1:8765.
|
|
14458
14462
|
control_panel_status() -> {running, pid, port, url}
|
|
14459
14463
|
close_control_panel() -> {success, was_running}
|
|
14460
14464
|
save_state() -> {state_token, page, current_timeline_id, current_timecode, selected_clip_ids}
|
|
@@ -15119,7 +15123,10 @@ def _v2_list_corrections(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]
|
|
|
15119
15123
|
# ─── V2 P12: Control panel lifecycle ──────────────────────────────────────────
|
|
15120
15124
|
|
|
15121
15125
|
def _control_panel_pidfile() -> str:
|
|
15122
|
-
|
|
15126
|
+
# Holds the panel's per-launch bearer token alongside pid/port, so it lives
|
|
15127
|
+
# in the 0700 private state dir and is written 0600 — not in ~/Documents.
|
|
15128
|
+
from src.utils.private_state import private_state_dir
|
|
15129
|
+
return os.path.join(private_state_dir(), "control_panel.json")
|
|
15123
15130
|
|
|
15124
15131
|
|
|
15125
15132
|
def _control_panel_read_state() -> Optional[Dict[str, Any]]:
|
|
@@ -15134,6 +15141,15 @@ def _control_panel_read_state() -> Optional[Dict[str, Any]]:
|
|
|
15134
15141
|
return None
|
|
15135
15142
|
|
|
15136
15143
|
|
|
15144
|
+
def _control_panel_read_token() -> Optional[str]:
|
|
15145
|
+
state = _control_panel_read_state() or {}
|
|
15146
|
+
token = state.get("token")
|
|
15147
|
+
return str(token) if token else None
|
|
15148
|
+
|
|
15149
|
+
|
|
15150
|
+
_CONTROL_PANEL_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
15151
|
+
|
|
15152
|
+
|
|
15137
15153
|
def _control_panel_pid_alive(pid: int) -> bool:
|
|
15138
15154
|
"""Check whether a PID is alive on this OS without killing it."""
|
|
15139
15155
|
if not pid or pid <= 0:
|
|
@@ -15204,24 +15220,40 @@ def _pick_dashboard_python(repo_root: str) -> Tuple[str, Optional[str]]:
|
|
|
15204
15220
|
return _sys.executable, "sys.executable"
|
|
15205
15221
|
|
|
15206
15222
|
|
|
15207
|
-
def _control_panel_probe(host: str, port: int, timeout: float = 1.5
|
|
15223
|
+
def _control_panel_probe(host: str, port: int, timeout: float = 1.5,
|
|
15224
|
+
token: Optional[str] = None) -> Dict[str, Any]:
|
|
15208
15225
|
"""Probe a port to see whether a dashboard is listening and what version.
|
|
15209
15226
|
|
|
15210
15227
|
Returns ``{"is_dashboard": bool, "version": Optional[str]}``.
|
|
15211
15228
|
|
|
15212
15229
|
- ``is_dashboard`` is True when /api/boot responds with a recognizable
|
|
15213
|
-
dashboard payload (``success: true`` plus a project field)
|
|
15214
|
-
|
|
15215
|
-
|
|
15216
|
-
|
|
15230
|
+
dashboard payload (``success: true`` plus a project field), OR answers
|
|
15231
|
+
401 with the panel's self-identifying body (a live panel whose token we
|
|
15232
|
+
do not hold). This lets callers distinguish an older dashboard that
|
|
15233
|
+
predates the ``mcp_version`` surface from a non-dashboard process
|
|
15234
|
+
squatting on the port.
|
|
15217
15235
|
- ``version`` is the reported MCP version, or None if the dashboard
|
|
15218
15236
|
predates the field.
|
|
15219
15237
|
"""
|
|
15238
|
+
import urllib.error
|
|
15220
15239
|
import urllib.request
|
|
15221
15240
|
url = f"http://{host}:{port}/api/boot"
|
|
15241
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
15222
15242
|
try:
|
|
15223
|
-
|
|
15243
|
+
req = urllib.request.Request(url, headers=headers)
|
|
15244
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
15224
15245
|
payload = json.loads(resp.read().decode("utf-8") or "{}")
|
|
15246
|
+
except urllib.error.HTTPError as exc:
|
|
15247
|
+
if exc.code != 401:
|
|
15248
|
+
return {"is_dashboard": False, "version": None}
|
|
15249
|
+
try:
|
|
15250
|
+
payload = json.loads(exc.read().decode("utf-8") or "{}")
|
|
15251
|
+
except Exception:
|
|
15252
|
+
return {"is_dashboard": False, "version": None}
|
|
15253
|
+
if not isinstance(payload, dict) or payload.get("panel") != "davinci-resolve-mcp":
|
|
15254
|
+
return {"is_dashboard": False, "version": None}
|
|
15255
|
+
version = payload.get("mcp_version")
|
|
15256
|
+
return {"is_dashboard": True, "version": str(version) if version else None}
|
|
15225
15257
|
except Exception:
|
|
15226
15258
|
return {"is_dashboard": False, "version": None}
|
|
15227
15259
|
if not isinstance(payload, dict):
|
|
@@ -15271,7 +15303,13 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15271
15303
|
import socket
|
|
15272
15304
|
import time as _t
|
|
15273
15305
|
|
|
15274
|
-
host = p.get("host") or "127.0.0.1"
|
|
15306
|
+
host = str(p.get("host") or "127.0.0.1")
|
|
15307
|
+
if host not in _CONTROL_PANEL_LOOPBACK_HOSTS:
|
|
15308
|
+
return _err(
|
|
15309
|
+
f"host={host!r} refused: the control panel binds loopback only "
|
|
15310
|
+
"(127.0.0.1 / localhost / ::1). It is a single-user local UI and is "
|
|
15311
|
+
"never exposed to the network — there is no override.",
|
|
15312
|
+
)
|
|
15275
15313
|
port = int(p.get("port") or 8765)
|
|
15276
15314
|
force_restart = _media_analysis_bool(p.get("force_restart", p.get("forceRestart")), False)
|
|
15277
15315
|
|
|
@@ -15290,27 +15328,36 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15290
15328
|
live_version = VERSION
|
|
15291
15329
|
|
|
15292
15330
|
if port_pid is not None and not force_restart:
|
|
15293
|
-
|
|
15294
|
-
|
|
15295
|
-
|
|
15331
|
+
tracked = existing if existing.get("running") else {}
|
|
15332
|
+
tracked_token = _control_panel_read_token() if tracked else None
|
|
15333
|
+
probe = _control_panel_probe(host, port, token=tracked_token)
|
|
15334
|
+
url = tracked.get("url") or f"http://{host}:{port}"
|
|
15296
15335
|
if probe["is_dashboard"]:
|
|
15297
15336
|
remote_version = probe["version"]
|
|
15298
15337
|
# Compare with explicit None handling: an older dashboard that
|
|
15299
15338
|
# predates the mcp_version field is also stale — it can't honor
|
|
15300
|
-
# newer surfaces and the caller needs to know.
|
|
15301
|
-
|
|
15339
|
+
# newer surfaces and the caller needs to know. A panel whose token
|
|
15340
|
+
# we don't hold (untracked survivor) is unusable too: nobody can
|
|
15341
|
+
# log in to it, so it must be relaunched.
|
|
15342
|
+
if remote_version != live_version or not tracked_token:
|
|
15302
15343
|
reported = remote_version or "unknown (predates the mcp_version field)"
|
|
15344
|
+
why = (
|
|
15345
|
+
f"The running control panel reports version {reported} but the "
|
|
15346
|
+
f"MCP server is at {live_version}."
|
|
15347
|
+
if remote_version != live_version else
|
|
15348
|
+
"The running control panel's launch token is not on record, so "
|
|
15349
|
+
"its URL cannot be issued."
|
|
15350
|
+
)
|
|
15303
15351
|
return {
|
|
15304
15352
|
"success": True,
|
|
15305
15353
|
"status": "stale_running",
|
|
15306
|
-
"url": url,
|
|
15354
|
+
"url": url if tracked_token else None,
|
|
15307
15355
|
"pid": port_pid,
|
|
15308
15356
|
"port": port,
|
|
15309
15357
|
"running_version": remote_version,
|
|
15310
15358
|
"live_version": live_version,
|
|
15311
15359
|
"remediation": (
|
|
15312
|
-
f"
|
|
15313
|
-
f"MCP server is at {live_version}. Re-call open_control_panel with "
|
|
15360
|
+
f"{why} Re-call open_control_panel with "
|
|
15314
15361
|
"force_restart=true to terminate the stale process and relaunch."
|
|
15315
15362
|
),
|
|
15316
15363
|
}
|
|
@@ -15377,6 +15424,14 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15377
15424
|
else:
|
|
15378
15425
|
cmd.append("--no-open")
|
|
15379
15426
|
|
|
15427
|
+
# Per-launch bearer token. Passed via the environment (never argv, which
|
|
15428
|
+
# `ps` would show to every local user) and recorded 0600 in the pidfile so
|
|
15429
|
+
# later status/already-running calls can hand out the same URL.
|
|
15430
|
+
import secrets as _secrets
|
|
15431
|
+
panel_token = _secrets.token_urlsafe(32)
|
|
15432
|
+
child_env = dict(os.environ)
|
|
15433
|
+
child_env["DAVINCI_PANEL_TOKEN"] = panel_token
|
|
15434
|
+
|
|
15380
15435
|
# Detach so the dashboard outlives this MCP call.
|
|
15381
15436
|
log_path = os.path.join(os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis"), ".control_panel.log")
|
|
15382
15437
|
try:
|
|
@@ -15389,6 +15444,7 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15389
15444
|
proc = subprocess.Popen(
|
|
15390
15445
|
cmd,
|
|
15391
15446
|
cwd=repo_root,
|
|
15447
|
+
env=child_env,
|
|
15392
15448
|
stdout=log_handle,
|
|
15393
15449
|
stderr=subprocess.STDOUT,
|
|
15394
15450
|
stdin=subprocess.DEVNULL,
|
|
@@ -15437,13 +15493,16 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15437
15493
|
f"(pid {proc.pid}). Check {log_path} for details.",
|
|
15438
15494
|
)
|
|
15439
15495
|
|
|
15440
|
-
# Write the pidfile so subsequent calls find it
|
|
15441
|
-
|
|
15496
|
+
# Write the pidfile so subsequent calls find it. The token travels in the
|
|
15497
|
+
# URL fragment — browsers never send fragments, so it stays out of every
|
|
15498
|
+
# request line and log.
|
|
15499
|
+
url = f"http://{host}:{port}/#token={panel_token}"
|
|
15442
15500
|
state = {
|
|
15443
15501
|
"pid": proc.pid,
|
|
15444
15502
|
"port": port,
|
|
15445
15503
|
"host": host,
|
|
15446
15504
|
"url": url,
|
|
15505
|
+
"token": panel_token,
|
|
15447
15506
|
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
15448
15507
|
"project_name": project_name,
|
|
15449
15508
|
"project_id": project_id,
|
|
@@ -15453,9 +15512,8 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
15453
15512
|
"python_source": python_source,
|
|
15454
15513
|
}
|
|
15455
15514
|
try:
|
|
15456
|
-
|
|
15457
|
-
|
|
15458
|
-
json.dump(state, handle, indent=2)
|
|
15515
|
+
from src.utils.private_state import write_private_json
|
|
15516
|
+
write_private_json(_control_panel_pidfile(), state)
|
|
15459
15517
|
except OSError:
|
|
15460
15518
|
pass # non-fatal; status-check will just spawn a new one next time
|
|
15461
15519
|
|
|
@@ -4,7 +4,8 @@ stdio remains the default. The `sse` and `streamable-http` modes bind to
|
|
|
4
4
|
loopback (127.0.0.1) by default and REQUIRE a bearer token on every request, so
|
|
5
5
|
turning networking on never silently exposes Resolve. The token comes from
|
|
6
6
|
``$DAVINCI_MCP_TOKEN`` or is generated and logged at startup. A small state file
|
|
7
|
-
|
|
7
|
+
(0600, under the per-user private state dir — never a shared tempdir) lets the
|
|
8
|
+
control panel show the live connection URL + token.
|
|
8
9
|
|
|
9
10
|
Security posture:
|
|
10
11
|
- Default host is loopback; a non-loopback bind logs a loud warning.
|
|
@@ -16,14 +17,19 @@ import json
|
|
|
16
17
|
import logging
|
|
17
18
|
import os
|
|
18
19
|
import secrets
|
|
19
|
-
import tempfile
|
|
20
20
|
import time
|
|
21
21
|
|
|
22
|
+
from src.utils.private_state import private_state_dir, write_private_json
|
|
23
|
+
|
|
22
24
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
26
|
+
|
|
27
|
+
def _state_path() -> str:
|
|
28
|
+
return os.path.join(private_state_dir(), "mcp_transport.json")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Resolved lazily so DAVINCI_RESOLVE_MCP_STATE_DIR set by a test harness is honored.
|
|
32
|
+
TRANSPORT_STATE_PATH = _state_path()
|
|
27
33
|
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
28
34
|
|
|
29
35
|
|
|
@@ -32,7 +38,7 @@ def resolve_token():
|
|
|
32
38
|
tok = os.environ.get("DAVINCI_MCP_TOKEN")
|
|
33
39
|
if tok:
|
|
34
40
|
return tok, False
|
|
35
|
-
return secrets.token_urlsafe(
|
|
41
|
+
return secrets.token_urlsafe(32), True
|
|
36
42
|
|
|
37
43
|
|
|
38
44
|
def _auth_middleware_cls(token):
|
|
@@ -56,17 +62,16 @@ def _auth_middleware_cls(token):
|
|
|
56
62
|
|
|
57
63
|
def write_transport_state(transport, host, port, token):
|
|
58
64
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}, fh)
|
|
65
|
+
write_private_json(TRANSPORT_STATE_PATH, {
|
|
66
|
+
"transport": transport,
|
|
67
|
+
"host": host,
|
|
68
|
+
"port": port,
|
|
69
|
+
"url": f"http://{host}:{port}",
|
|
70
|
+
"token": token,
|
|
71
|
+
"loopback": host in LOOPBACK_HOSTS,
|
|
72
|
+
"pid": os.getpid(),
|
|
73
|
+
"started_at": time.time(),
|
|
74
|
+
})
|
|
70
75
|
except OSError as exc:
|
|
71
76
|
logger.warning("could not write transport state: %s", exc)
|
|
72
77
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Per-user private state files (tokens, pidfiles) — never in a shared tempdir.
|
|
2
|
+
|
|
3
|
+
Two callers keep secrets on disk: the control-panel launcher (its bearer token
|
|
4
|
+
+ pid) and the networked MCP transport (its bearer token + URL). Both used to
|
|
5
|
+
land in world-readable locations (``~/Documents/...`` and ``tempfile.gettempdir()``).
|
|
6
|
+
This module gives them one home under the user's HOME with 0700/0600 modes.
|
|
7
|
+
|
|
8
|
+
``DAVINCI_RESOLVE_MCP_STATE_DIR`` overrides the directory (tests, sandboxes).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def private_state_dir() -> str:
|
|
20
|
+
override = os.environ.get("DAVINCI_RESOLVE_MCP_STATE_DIR")
|
|
21
|
+
path = override or os.path.join(os.path.expanduser("~"), ".davinci-resolve-mcp")
|
|
22
|
+
try:
|
|
23
|
+
os.makedirs(path, mode=0o700, exist_ok=True)
|
|
24
|
+
if sys.platform != "win32":
|
|
25
|
+
os.chmod(path, 0o700)
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _restrict_windows_acl(path: str) -> None:
|
|
32
|
+
"""Best-effort: strip inherited ACEs and grant only the current user."""
|
|
33
|
+
user = os.environ.get("USERNAME")
|
|
34
|
+
if not user:
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
subprocess.run(
|
|
38
|
+
["icacls", path, "/inheritance:r", "/grant:r", f"{user}:F"],
|
|
39
|
+
capture_output=True, timeout=5, check=False,
|
|
40
|
+
)
|
|
41
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_private_file(path: str, text: str) -> None:
|
|
46
|
+
"""Write ``text`` to ``path`` readable only by the current user (0600).
|
|
47
|
+
|
|
48
|
+
Replaces any existing file so a previously wider mode never survives.
|
|
49
|
+
"""
|
|
50
|
+
parent = os.path.dirname(path)
|
|
51
|
+
if parent:
|
|
52
|
+
os.makedirs(parent, exist_ok=True)
|
|
53
|
+
try:
|
|
54
|
+
os.remove(path)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
58
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
59
|
+
fh.write(text)
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
_restrict_windows_acl(path)
|
|
62
|
+
else:
|
|
63
|
+
os.chmod(path, 0o600)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_private_json(path: str, payload: Dict[str, Any]) -> None:
|
|
67
|
+
write_private_file(path, json.dumps(payload, indent=2))
|