davinci-resolve-mcp 2.97.7 → 2.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,83 @@
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.0
6
+
7
+ **The control panel could be driven by any web page you visited.** Reported
8
+ privately, with the chain worked out end to end. Fixed here; the transport
9
+ state file and panel pidfile move out of shared locations at the same time.
10
+
11
+ ### Security
12
+
13
+ - **`GET /api/mcp/status` handed out the networked-transport bearer token to
14
+ anyone who could reach the port.** It was the one privileged route with no
15
+ loopback check, and its payload included the token that authenticates the
16
+ `--transport streamable-http` MCP instance — i.e. full Resolve control.
17
+ - **No Host or Origin check, and POST bodies of any Content-Type were parsed.**
18
+ A page on any site could POST to the panel (CSRF), and a DNS-rebinding page
19
+ could read its responses. Chained: visit a page → it starts the networked
20
+ transport via `/api/mcp/transport/start` → reads the token from
21
+ `/api/mcp/status` → owns the MCP toolset.
22
+ - **The bind address was a tool parameter.** `open_control_panel(host=...)`
23
+ passed straight through, so a prompt could put the unauthenticated panel on
24
+ `0.0.0.0`.
25
+
26
+ What changed:
27
+
28
+ - **Per-launch bearer token on every route except the static shell at `/`.**
29
+ `open_control_panel` mints `secrets.token_urlsafe(32)`, passes it to the
30
+ child through the environment (not argv, which `ps` shows to every local
31
+ user), records it 0600, and returns the URL with the token in the
32
+ **fragment** (`http://127.0.0.1:8765/#token=…`) — browsers never send
33
+ fragments, so it stays out of request lines and logs. The panel JS keeps
34
+ it in `localStorage`, sends `Authorization: Bearer …` on every fetch, and
35
+ exchanges it once (`POST /api/session`) for an `HttpOnly; SameSite=Strict`
36
+ cookie so thumbnail `<img>` loads work. A bare `http://127.0.0.1:8765` now
37
+ shows a "Control panel locked" screen pointing back to `open_control_panel`.
38
+ - **A single gate at the top of `do_GET`/`do_POST`:** `Host` must be a
39
+ loopback host (defeats DNS rebinding); `Origin`, when present, must be a
40
+ loopback origin (defeats CSRF); `POST` must be
41
+ `Content-Type: application/json` (a cross-site form cannot send it, and a
42
+ cross-site `fetch()` with it needs a CORS preflight the panel never
43
+ answers). Each check fails closed with 403/415 before any route runs.
44
+ - **`host` is no longer an AI knob.** `open_control_panel` refuses anything
45
+ but `127.0.0.1` / `localhost` / `::1` with no override, and
46
+ `src.analysis_dashboard --host` does the same at argparse — matching the
47
+ free-edition bridge, which already threw on non-loopback.
48
+ - **Secrets on disk are private.** The transport state file
49
+ (`mcp_transport.json`, holds the transport token) leaves
50
+ `tempfile.gettempdir()`, and the panel pidfile (now holds the panel token)
51
+ leaves `~/Documents`; both live under `~/.davinci-resolve-mcp/` (0700),
52
+ written 0600 via the new `src/utils/private_state.py`, with a best-effort
53
+ `icacls` restriction on Windows. `DAVINCI_RESOLVE_MCP_STATE_DIR` overrides
54
+ the directory.
55
+ - **The launcher still recognises a panel it has no token for.** A panel
56
+ that survived an MCP restart from before this scheme answers 401 with a
57
+ self-identifying body; `open_control_panel` reports it as `stale_running`
58
+ (nobody can log in to it) with the `force_restart=true` remediation
59
+ instead of handing out an unusable URL.
60
+
61
+ `SECURITY.md` previously said the server "does not expose a network
62
+ listener"; it now describes both opt-in local HTTP surfaces (panel and
63
+ networked transport) and their posture. Kept as-is: the default port stays
64
+ 8765 (with Host/Origin/token in place its guessability is not the barrier)
65
+ and the transport token is still shown in the panel's MCP card, now behind
66
+ the panel's own auth.
67
+
68
+ ### Added
69
+
70
+ - `tests.test_control_panel_auth` boots the real `Handler` on an ephemeral
71
+ port and asserts each guard over HTTP: `/api/mcp/status` 401 without the
72
+ token and no transport secret in the 401 body; non-loopback `Host` rejected
73
+ even with a valid token; `localhost` / `[::1]` / bare `127.0.0.1` accepted;
74
+ cross-site `Origin` rejected; non-JSON POST → 415; no CORS preflight answer;
75
+ cookie issued only via bearer, `HttpOnly` + `SameSite=Strict`, then
76
+ sufficient on its own; wrong token / wrong cookie → 401; the static shell
77
+ never contains the token. Plus the launcher's 401-probe recognition, the
78
+ `host=0.0.0.0` refusal, and 0600 on the private state files.
79
+ `tests.test_open_control_panel` gains the token-unknown → `stale_running`
80
+ case and asserts the issued URL is the token-bearing one.
81
+
5
82
  ## What's New in v2.97.7
6
83
 
7
84
  **`grab_and_export` deleted files it never created.** Reported in
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.97.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.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
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-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 localhost server and opens the control panel in your browser. 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.
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
- [![Version](https://img.shields.io/badge/version-2.97.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.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
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
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.97.7 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.98.0 版 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
- 该命令启动一个 localhost 服务器并在浏览器中打开控制面板。想让 AI 编码助手代劳,可以说:**"Open the Resolve MCP control panel for this repo."** 除非你的 Python 环境已激活,否则 agent 应使用 `venv/bin/python -m src.control_panel`。持久化的分析任务在切片成功后会自动刷新本地搜索索引;手动的 Build Index 按钮用于从已有报告重建索引。
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 进程;它不暴露网络监听器,也没有内置多用户认证面。工具元数据包含面向 MCP 客户端的安全提示(只读、破坏性、幂等、外部资源操作)。操作边界、确认指引和漏洞报告见 [安全策略](SECURITY.md)。
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, HTTP API, remote shell, or
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. The panel is local and single-user; it is an operational surface
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 does not
9
- expose a network listener beyond loopback and does not modify source media.
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 and `resolve_control(action="close_control_panel")` stops it.
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.97.7"
40
+ VERSION = "2.98.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.97.7",
3
+ "version": "2.98.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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
- url = f"http://{args.host}:{args.port}"
15960
- print(f"DaVinci Resolve MCP: {url}")
15961
- print(f"Project analysis root: {state.project_root}")
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)
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.97.7"
90
+ VERSION = "2.98.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
@@ -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.97.7"
14
+ VERSION = "2.98.0"
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?, host?, open_browser?) -> {success, url, pid, port, status}
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
- return os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis/.control_panel.pid")
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) -> Dict[str, Any]:
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). This lets
15214
- callers distinguish an older dashboard that predates the
15215
- ``mcp_version`` surface from a non-dashboard process squatting on the
15216
- port.
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
- with urllib.request.urlopen(url, timeout=timeout) as resp:
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
- probe = _control_panel_probe(host, port)
15294
- tracked_url = (existing or {}).get("url") if existing.get("running") else None
15295
- url = tracked_url or f"http://{host}:{port}"
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
- if remote_version != live_version:
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"The running control panel reports version {reported} but the "
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
- url = f"http://{host}:{port}"
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
- os.makedirs(os.path.dirname(_control_panel_pidfile()), exist_ok=True)
15457
- with open(_control_panel_pidfile(), "w", encoding="utf-8") as handle:
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
- lets the control panel show the live connection URL + token.
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
- TRANSPORT_STATE_PATH = os.path.join(
25
- tempfile.gettempdir(), "davinci_resolve_mcp_transport.json"
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(24), True
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
- with open(TRANSPORT_STATE_PATH, "w", encoding="utf-8") as fh:
60
- json.dump({
61
- "transport": transport,
62
- "host": host,
63
- "port": port,
64
- "url": f"http://{host}:{port}",
65
- "token": token,
66
- "loopback": host in LOOPBACK_HOSTS,
67
- "pid": os.getpid(),
68
- "started_at": time.time(),
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))