davinci-resolve-mcp 4.7.2 → 4.7.4

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,60 @@
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 v4.7.4 — the networked transport can serve a client on another machine
6
+
7
+ ### Fixed
8
+
9
+ - **`--transport streamable-http` / `sse` bound to a LAN address answered every
10
+ request with HTTP 421.** ([#241](https://github.com/samuelgursky/davinci-resolve-mcp/issues/241), reported with the diagnosis by @TeamCLP)
11
+ `src/server.py` builds `FastMCP(...)` without a host, so the SDK (1.30.0)
12
+ auto-enables DNS-rebinding protection pinned to loopback
13
+ (`allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`). `run_networked` then
14
+ set `settings.host` to `DAVINCI_MCP_HOST` but never touched
15
+ `settings.transport_security`, and the app handed that loopback-only allowlist to
16
+ the transport middleware. The 421 came after the bearer check, so a wrong token
17
+ still got 401 and the bind looked healthy — a non-loopback bind could never serve
18
+ anyone, including an instance the control panel's Start button launched.
19
+ Reproduced on v4.7.3 with the real SDK app before the fix (LAN Host → 421, wrong
20
+ token → 401, loopback → 200).
21
+ - New `transport_security_for(host, extra_hosts)` in `src/utils/mcp_transport.py`,
22
+ applied **before** the app is built (the app reads the setting once). Loopback
23
+ binds are untouched. A specific non-loopback bind keeps protection ON with an
24
+ allowlist of the bind host, loopback, and any names in the new
25
+ **`DAVINCI_MCP_ALLOWED_HOSTS`** (comma-separated, for clients that reach the box
26
+ by a DNS name); IPv6 literals are bracketed. A wildcard bind (`0.0.0.0` / `::`)
27
+ with no names listed turns the Host check off with a warning, since a client
28
+ never sends the wildcard as its Host — the bearer token remains on every request.
29
+ - `tests/test_mcp_transport_host_allowlist.py`: the policy table plus the real
30
+ streamable-http app through `run_networked` — LAN Host 200, wrong token 401,
31
+ loopback 200, foreign Host still 421. `SECURITY.md` and `docs/install.md`
32
+ describe the allowlist and the new variable.
33
+
34
+ ## What's New in v4.7.3 — a false spelling no longer grants permission on six opt-in flags
35
+
36
+ ### Fixed
37
+
38
+ - **Six opt-in permission flags were read with bare truthiness, so a client sending
39
+ `"false"` was granted the permission it was declining.** ([#240](https://github.com/samuelgursky/davinci-resolve-mcp/pull/240), @Dev-next-gen)
40
+ These open in the opposite direction from `overwrite` (v4.7.2): they default to off,
41
+ so the string does not perform the act, it *grants* it. On the previous code:
42
+ `allow_media_archive="false"` let `ArchiveProject` run with source media on (the
43
+ 21.1.0.14 crash `archive_guard` measures); `acknowledge_trap="false"` stood the crash
44
+ refusal down on both archive paths and stood the trap guard in
45
+ `destructive_hook._trap_acknowledged` down in front of `TimelineItem.CopyGrades`;
46
+ `close_current="false"` closed and deleted the open project; `allow_generate`,
47
+ `allow_render` and `allow_switch` ran `CreateSubtitlesFromAudio`,
48
+ `RenderWithQuickExport` and `SetCurrentDatabase`. Nine readings in all now go through
49
+ `coerce_bool`. Real booleans and the true spellings are unchanged; a false or
50
+ unrecognised string now yields the documented refusal with its `retry_with` payload.
51
+ The neighbouring `dry_run` reads are deliberately untouched (a stringified `"false"`
52
+ there keeps the call in preview, which fails safe), and the granular tools already
53
+ type these as booleans. Guard test: `tests/test_permission_flags_string_false.py` —
54
+ one test per flag over six false spellings, asserting the effect did not happen
55
+ before checking the envelope; 54 of 54 subtests fail on the previous code, every one
56
+ on the effect. One bare opt-in read remains by choice, `allow_timeline_mismatch` on
57
+ `apply_trace_plan`, which gates a name mismatch rather than a destructive act.
58
+
5
59
  ## What's New in v4.7.2 — `overwrite="false"` no longer unlocks an install guard
6
60
 
7
61
  ### Fixed
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-4.7.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.7.4-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-37%20(389%20full)-blue.svg)](#server-modes)
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-4.7.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.7.4-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-37%20(389%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
- > 本翻译对应 v4.7.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.7.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/SECURITY.md CHANGED
@@ -24,7 +24,11 @@ Their posture:
24
24
  - **Loopback only.** The panel refuses any bind host other than
25
25
  `127.0.0.1` / `localhost` / `::1` — the bind address is not a tool parameter
26
26
  an AI can widen. The transport defaults to loopback and logs a loud warning
27
- if `DAVINCI_MCP_HOST` points elsewhere.
27
+ if `DAVINCI_MCP_HOST` points elsewhere. On a non-loopback bind its
28
+ DNS-rebinding allowlist is the bind host plus loopback, extended by
29
+ `DAVINCI_MCP_ALLOWED_HOSTS` (comma-separated names clients will use); on a
30
+ wildcard bind (`0.0.0.0` / `::`) with that variable unset the Host check is
31
+ off and the bearer token is the only gate, and the log says so.
28
32
  - **Bearer token on every request.** Each panel launch generates a fresh
29
33
  `secrets.token_urlsafe(32)` token, passed to the child via environment (not
30
34
  argv) and delivered to the browser in the URL fragment (`#token=…`), which
package/docs/install.md CHANGED
@@ -237,6 +237,18 @@ Network scripting permits remote control of Resolve. Prefer Local mode when
237
237
  remote access is unnecessary; otherwise restrict access with host firewall and
238
238
  network controls.
239
239
 
240
+ The MCP server's own networked transport (`--transport streamable-http` or
241
+ `sse`) binds `127.0.0.1:8000` by default and requires `Authorization: Bearer
242
+ <token>` on every request (`DAVINCI_MCP_TOKEN`, or a generated one). To serve a
243
+ client on another machine, set `DAVINCI_MCP_HOST` to the address to bind and,
244
+ if clients reach the box by a DNS name rather than that address, list the names
245
+ in `DAVINCI_MCP_ALLOWED_HOSTS` (comma-separated). The transport keeps
246
+ DNS-rebinding protection on, pinned to the bind host, loopback, and those
247
+ names; a request whose `Host` header is none of them gets 421. A wildcard bind
248
+ (`0.0.0.0` / `::`) with no names listed turns the Host check off, since a
249
+ client never sends the wildcard as its Host, and the bearer token is then the
250
+ only gate. Restrict a non-loopback bind with a host firewall.
251
+
240
252
  Run the read-only doctor against Network mode explicitly:
241
253
 
242
254
  ```bash
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.7.2"
40
+ VERSION = "4.7.4"
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": "4.7.2",
3
+ "version": "4.7.4",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.7.2"
96
+ VERSION = "4.7.4"
97
97
  logger = logging.getLogger("davinci-resolve-mcp")
98
98
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
99
99
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.7.2"
14
+ VERSION = "4.7.4"
15
15
 
16
16
  import base64
17
17
  import os
@@ -9298,7 +9298,7 @@ def _transcription_capabilities(mp, p: Dict[str, Any]):
9298
9298
 
9299
9299
  def _subtitle_generation_probe(tl, p: Dict[str, Any]):
9300
9300
  settings, ignored = _normalize_auto_caption_settings(p.get("settings"), get_resolve())
9301
- if not p.get("allow_generate", False):
9301
+ if not _coerce_bool(p.get("allow_generate")):
9302
9302
  return _ok(would_generate=True, settings=settings, ignored_settings=ignored,
9303
9303
  note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
9304
9304
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
@@ -18550,10 +18550,10 @@ def _safe_project_archive(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18550
18550
  return _err(flag_err, code="INVALID_ARCHIVE_FLAG", category="invalid_input")
18551
18551
  src_media, render_cache, proxy_media = (flags["src_media"], flags["render_cache"],
18552
18552
  flags["proxy_media"])
18553
- if (src_media or render_cache or proxy_media) and not p.get("allow_media_archive", False):
18553
+ if (src_media or render_cache or proxy_media) and not _coerce_bool(p.get("allow_media_archive")):
18554
18554
  return _err("Archive media/cache/proxy flags must stay false unless allow_media_archive=True")
18555
18555
  # allow_media_archive guards size; this guards the crash. Both are required.
18556
- if not p.get("acknowledge_trap"):
18556
+ if not _coerce_bool(p.get("acknowledge_trap")):
18557
18557
  refused = archive_guard.crash_refusal("project_manager", "safe_project_archive", flags)
18558
18558
  if refused:
18559
18559
  return refused
@@ -18600,7 +18600,7 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18600
18600
  from src.utils.project_cleanup import delete_project_safely
18601
18601
  current_name = current.GetName() if current and _has_method(current, "GetName") else None
18602
18602
  if current_name == name:
18603
- if not p.get("close_current", False):
18603
+ if not _coerce_bool(p.get("close_current")):
18604
18604
  return _err("Refusing to delete the currently open project; pass close_current=True")
18605
18605
  saved = bool(pm.SaveProject()) if p.get("save_current", True) else None
18606
18606
  closed = bool(pm.CloseProject(current))
@@ -18646,7 +18646,7 @@ def _safe_set_current_database(pm, p: Dict[str, Any]) -> Dict[str, Any]:
18646
18646
  if not isinstance(db_info, dict) or not db_info.get("DbType") or not db_info.get("DbName"):
18647
18647
  return _err("db_info must include DbType and DbName")
18648
18648
  current = _ser(pm.GetCurrentDatabase()) if _has_method(pm, "GetCurrentDatabase") else None
18649
- dry_run = p.get("dry_run", True) or not p.get("allow_switch", False)
18649
+ dry_run = p.get("dry_run", True) or not _coerce_bool(p.get("allow_switch"))
18650
18650
  if dry_run:
18651
18651
  return _ok(
18652
18652
  would_switch=True,
@@ -19233,7 +19233,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19233
19233
  _open_name = _open.GetName() if _open else None
19234
19234
  except Exception:
19235
19235
  _open_name = None
19236
- if _open_name == p["name"] and not p.get("close_current", False):
19236
+ if _open_name == p["name"] and not _coerce_bool(p.get("close_current")):
19237
19237
  return _err("Refusing to delete the currently open project; pass close_current=True")
19238
19238
  from src.utils.project_cleanup import delete_project_safely
19239
19239
  deleted = delete_project_safely(pm, p["name"])
@@ -19262,7 +19262,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19262
19262
  flags, flag_err = archive_guard.read_flags(p)
19263
19263
  if flag_err:
19264
19264
  return _err(flag_err, code="INVALID_ARCHIVE_FLAG", category="invalid_input")
19265
- if not p.get("acknowledge_trap"):
19265
+ if not _coerce_bool(p.get("acknowledge_trap")):
19266
19266
  refused = archive_guard.crash_refusal("project_manager", "archive", flags)
19267
19267
  if refused:
19268
19268
  return refused
@@ -20354,7 +20354,7 @@ def _safe_quick_export(proj, p: Dict[str, Any]):
20354
20354
  return err
20355
20355
  if not validation["valid"]:
20356
20356
  return {"success": False, "validation": validation}
20357
- if p.get("dry_run") or not p.get("allow_render", False):
20357
+ if p.get("dry_run") or not _coerce_bool(p.get("allow_render")):
20358
20358
  return _ok(would_render=False, preset=preset, params=params, validation=validation)
20359
20359
  before = set()
20360
20360
  if target_dir and os.path.isdir(target_dir):
@@ -833,7 +833,7 @@ TRAP_REFUSAL_EXEMPT_ACTIONS: FrozenSet[Tuple[str, str]] = frozenset({
833
833
 
834
834
  def _trap_acknowledged(params: Optional[Dict[str, Any]]) -> bool:
835
835
  """Did the caller explicitly accept a known-destructive behaviour?"""
836
- return bool(isinstance(params, dict) and params.get("acknowledge_trap"))
836
+ return isinstance(params, dict) and coerce_bool(params.get("acknowledge_trap"))
837
837
 
838
838
 
839
839
  def _trap_block_response(
@@ -13,6 +13,9 @@ it, so a logged token would outlive the session in a file the state file's
13
13
 
14
14
  Security posture:
15
15
  - Default host is loopback; a non-loopback bind logs a loud warning.
16
+ - DNS-rebinding protection follows the bind host (see
17
+ ``transport_security_for``); a loopback-only allowlist on a LAN bind
18
+ answered every request with 421 until v4.7.4 (issue #241).
16
19
  - Every HTTP request must carry ``Authorization: Bearer <token>`` (constant-time
17
20
  compared); otherwise 401.
18
21
  - stdio (the default transport) is unaffected by anything here.
@@ -36,6 +39,75 @@ def _state_path() -> str:
36
39
  # Resolved lazily so DAVINCI_RESOLVE_MCP_STATE_DIR set by a test harness is honored.
37
40
  TRANSPORT_STATE_PATH = _state_path()
38
41
  LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
42
+ #: Binds that listen on every interface. A client never sends one of these as
43
+ #: its Host header, so an allowlist cannot be derived from the bind address.
44
+ WILDCARD_HOSTS = {"0.0.0.0", "::", ""}
45
+ ALLOWED_HOSTS_ENV = "DAVINCI_MCP_ALLOWED_HOSTS"
46
+
47
+
48
+ def _host_pattern(name: str) -> str:
49
+ """`Host`-header pattern for one name: any port, IPv6 literals bracketed."""
50
+ name = name.strip()
51
+ if ":" in name and not name.startswith("["):
52
+ name = f"[{name}]"
53
+ return f"{name}:*"
54
+
55
+
56
+ def extra_allowed_hosts(env=None):
57
+ """Names from $DAVINCI_MCP_ALLOWED_HOSTS (comma-separated), stripped, deduped."""
58
+ raw = (env if env is not None else os.environ).get(ALLOWED_HOSTS_ENV, "")
59
+ seen, out = set(), []
60
+ for name in raw.split(","):
61
+ name = name.strip()
62
+ if name and name not in seen:
63
+ seen.add(name)
64
+ out.append(name)
65
+ return out
66
+
67
+
68
+ def transport_security_for(host, extra_hosts=()):
69
+ """The DNS-rebinding allowlist the transport should run with for `host`.
70
+
71
+ Returns None for a loopback bind: the SDK already pins the allowlist to
72
+ loopback when FastMCP is built without a host, and that is correct there.
73
+
74
+ Everything else exists because of issue #241. `src/server.py` builds
75
+ `FastMCP(...)` without a host, so the SDK (1.30.0,
76
+ `mcp/server/fastmcp/server.py`) auto-enables DNS-rebinding protection with
77
+ `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`. `run_networked`
78
+ then set `settings.host` to the LAN address but never touched
79
+ `settings.transport_security`, and `streamable_http_app()` / `sse_app()`
80
+ hand that loopback-only allowlist to the transport middleware. Every request
81
+ to the LAN address therefore answered **421 Misdirected Request** — after the
82
+ bearer check, so a wrong token still got 401 and the bind looked healthy.
83
+ A non-loopback bind could never serve anyone.
84
+
85
+ - Specific non-loopback host: protection stays ON; the allowlist is the bind
86
+ host, the loopback names, and any extra names from
87
+ `$DAVINCI_MCP_ALLOWED_HOSTS` (for clients that reach the box by a DNS name
88
+ rather than the bound address).
89
+ - Wildcard bind (`0.0.0.0` / `::`): a client never sends the wildcard as its
90
+ Host, so with no extra names there is nothing to allow. Protection is then
91
+ turned OFF with a warning — the bearer token remains on every request, and
92
+ a rebinding page does not hold it. Set `$DAVINCI_MCP_ALLOWED_HOSTS` to keep
93
+ the protection on for a wildcard bind.
94
+ """
95
+ from mcp.server.transport_security import TransportSecuritySettings
96
+
97
+ if host in LOOPBACK_HOSTS:
98
+ return None
99
+ names = []
100
+ if host not in WILDCARD_HOSTS:
101
+ names.append(host)
102
+ names.extend(n for n in extra_hosts if n not in names)
103
+ if not names:
104
+ return TransportSecuritySettings(enable_dns_rebinding_protection=False)
105
+ names.extend(sorted(LOOPBACK_HOSTS))
106
+ return TransportSecuritySettings(
107
+ enable_dns_rebinding_protection=True,
108
+ allowed_hosts=[_host_pattern(n) for n in names],
109
+ allowed_origins=[f"http://{_host_pattern(n)}" for n in names],
110
+ )
39
111
 
40
112
 
41
113
  def resolve_token():
@@ -125,6 +197,24 @@ def run_networked(mcp, transport):
125
197
  mcp.settings.port = port
126
198
  token, generated = resolve_token()
127
199
 
200
+ # Must happen BEFORE the app is built: sse_app()/streamable_http_app() read
201
+ # settings.transport_security once, when they construct the middleware.
202
+ extra = extra_allowed_hosts()
203
+ security = transport_security_for(host, extra)
204
+ if security is not None:
205
+ mcp.settings.transport_security = security
206
+ if security.enable_dns_rebinding_protection:
207
+ logger.info("MCP transport Host allowlist: %s",
208
+ ", ".join(security.allowed_hosts))
209
+ else:
210
+ logger.warning(
211
+ "SECURITY: MCP transport bound to %r with no %s set — a client "
212
+ "never sends the wildcard as its Host header, so DNS-rebinding "
213
+ "protection is OFF for this bind (the bearer token still gates "
214
+ "every request). Set %s to the names clients will use to keep "
215
+ "it on.", host, ALLOWED_HOSTS_ENV, ALLOWED_HOSTS_ENV,
216
+ )
217
+
128
218
  app = mcp.sse_app() if transport == "sse" else mcp.streamable_http_app()
129
219
  app.add_middleware(_auth_middleware_cls(token))
130
220