davinci-resolve-mcp 4.7.3 → 4.7.5

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,54 @@
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.5 — the control panel serves on the `::1` loopback it accepts
6
+
7
+ ### Fixed
8
+
9
+ - **`open_control_panel(host="::1")` was accepted by both loopback guards and could
10
+ never start.** ([#242](https://github.com/samuelgursky/davinci-resolve-mcp/pull/242), @Dev-next-gen)
11
+ The panel's `ThreadingHTTPServer` is AF_INET, so binding `("::1", port)` raised
12
+ `socket.gaierror` and the tool reported "Control panel child exited (rc=1) before
13
+ serving" — while the panel's own `--host` refusal message listed `::1` as allowed.
14
+ Behind it, the launch URL, the pidfile URL and the `/api/boot` probe URL were all
15
+ written `http://::1:<port>/`, which neither a browser nor urllib parses.
16
+ `make_panel_server()` now uses an AF_INET6 subclass for an IPv6 literal, and the
17
+ three URLs bracket the host. The Host/Origin gate already accepted `[::1]`; IPv4
18
+ and `localhost` take the same paths as before. Guard test:
19
+ `tests/test_control_panel_ipv6_loopback.py` launches the real panel on `::1`
20
+ through `_open_control_panel`, GETs `/` over IPv6, parses the issued URL, and probes
21
+ `/api/boot` with the issued token; it skips on a host with no IPv6 loopback and ran
22
+ (did not skip) on the macOS landing machine.
23
+
24
+ ## What's New in v4.7.4 — the networked transport can serve a client on another machine
25
+
26
+ ### Fixed
27
+
28
+ - **`--transport streamable-http` / `sse` bound to a LAN address answered every
29
+ request with HTTP 421.** ([#241](https://github.com/samuelgursky/davinci-resolve-mcp/issues/241), reported with the diagnosis by @TeamCLP)
30
+ `src/server.py` builds `FastMCP(...)` without a host, so the SDK (1.30.0)
31
+ auto-enables DNS-rebinding protection pinned to loopback
32
+ (`allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`). `run_networked` then
33
+ set `settings.host` to `DAVINCI_MCP_HOST` but never touched
34
+ `settings.transport_security`, and the app handed that loopback-only allowlist to
35
+ the transport middleware. The 421 came after the bearer check, so a wrong token
36
+ still got 401 and the bind looked healthy — a non-loopback bind could never serve
37
+ anyone, including an instance the control panel's Start button launched.
38
+ Reproduced on v4.7.3 with the real SDK app before the fix (LAN Host → 421, wrong
39
+ token → 401, loopback → 200).
40
+ - New `transport_security_for(host, extra_hosts)` in `src/utils/mcp_transport.py`,
41
+ applied **before** the app is built (the app reads the setting once). Loopback
42
+ binds are untouched. A specific non-loopback bind keeps protection ON with an
43
+ allowlist of the bind host, loopback, and any names in the new
44
+ **`DAVINCI_MCP_ALLOWED_HOSTS`** (comma-separated, for clients that reach the box
45
+ by a DNS name); IPv6 literals are bracketed. A wildcard bind (`0.0.0.0` / `::`)
46
+ with no names listed turns the Host check off with a warning, since a client
47
+ never sends the wildcard as its Host — the bearer token remains on every request.
48
+ - `tests/test_mcp_transport_host_allowlist.py`: the policy table plus the real
49
+ streamable-http app through `run_networked` — LAN Host 200, wrong token 401,
50
+ loopback 200, foreign Host still 421. `SECURITY.md` and `docs/install.md`
51
+ describe the allowlist and the new variable.
52
+
5
53
  ## What's New in v4.7.3 — a false spelling no longer grants permission on six opt-in flags
6
54
 
7
55
  ### 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.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.7.5-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.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.7.5-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.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.7.5 版 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.3"
40
+ VERSION = "4.7.5"
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.3",
3
+ "version": "4.7.5",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import re
12
12
  import secrets
13
+ import socket
13
14
  import sqlite3
14
15
  import sys
15
16
  import threading
@@ -16289,6 +16290,23 @@ def _loopback_host(value: str) -> str:
16289
16290
  return value
16290
16291
 
16291
16292
 
16293
+ class _IPv6ThreadingHTTPServer(ThreadingHTTPServer):
16294
+ address_family = socket.AF_INET6
16295
+
16296
+
16297
+ def make_panel_server(host: str, port: int, handler: type) -> ThreadingHTTPServer:
16298
+ """Bind the panel. ThreadingHTTPServer is AF_INET, so `::1` needs the v6 class."""
16299
+ bind = host.strip("[]")
16300
+ server_cls = _IPv6ThreadingHTTPServer if ":" in bind else ThreadingHTTPServer
16301
+ return server_cls((bind, port), handler)
16302
+
16303
+
16304
+ def panel_url_host(host: str) -> str:
16305
+ """Host as it goes in a URL: an IPv6 literal must be bracketed."""
16306
+ bind = host.strip("[]")
16307
+ return f"[{bind}]" if ":" in bind else bind
16308
+
16309
+
16292
16310
  def parse_args() -> argparse.Namespace:
16293
16311
  parser = argparse.ArgumentParser(description="Run the local Resolve MCP control panel.")
16294
16312
  parser.add_argument("--host", default="127.0.0.1", type=_loopback_host)
@@ -16325,10 +16343,10 @@ def main() -> None:
16325
16343
  state = DashboardState(args.project_name, args.project_id, args.analysis_root)
16326
16344
  Handler.state = state
16327
16345
  Handler.token = resolve_panel_token()
16328
- server = ThreadingHTTPServer((args.host, args.port), Handler)
16346
+ server = make_panel_server(args.host, args.port, Handler)
16329
16347
  # The token rides in the URL fragment: browsers keep fragments client-side,
16330
16348
  # so it never appears in a request line, proxy log, or Referer.
16331
- url = f"http://{args.host}:{args.port}/#token={Handler.token}"
16349
+ url = f"http://{panel_url_host(args.host)}:{args.port}/#token={Handler.token}"
16332
16350
  # flush: under --no-open the URL (with its token) is the only handle the
16333
16351
  # operator gets, and a piped/redirected stdout would otherwise hold it back.
16334
16352
  print(f"DaVinci Resolve MCP: {url}", flush=True)
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.7.3"
96
+ VERSION = "4.7.5"
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.3"
14
+ VERSION = "4.7.5"
15
15
 
16
16
  import base64
17
17
  import os
@@ -17491,6 +17491,11 @@ def _control_panel_read_token() -> Optional[str]:
17491
17491
  _CONTROL_PANEL_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
17492
17492
 
17493
17493
 
17494
+ def _control_panel_url_host(host: str) -> str:
17495
+ """Host as it goes in a URL: `::1` must be written `[::1]`."""
17496
+ return f"[{host}]" if ":" in host else host
17497
+
17498
+
17494
17499
  def _control_panel_pid_alive(pid: int) -> bool:
17495
17500
  """Check whether a PID is alive on this OS without killing it."""
17496
17501
  if not pid or pid <= 0:
@@ -17578,7 +17583,7 @@ def _control_panel_probe(host: str, port: int, timeout: float = 1.5,
17578
17583
  """
17579
17584
  import urllib.error
17580
17585
  import urllib.request
17581
- url = f"http://{host}:{port}/api/boot"
17586
+ url = f"http://{_control_panel_url_host(host)}:{port}/api/boot"
17582
17587
  headers = {"Authorization": f"Bearer {token}"} if token else {}
17583
17588
  try:
17584
17589
  req = urllib.request.Request(url, headers=headers)
@@ -17838,7 +17843,7 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
17838
17843
  # Write the pidfile so subsequent calls find it. The token travels in the
17839
17844
  # URL fragment — browsers never send fragments, so it stays out of every
17840
17845
  # request line and log.
17841
- url = f"http://{host}:{port}/#token={panel_token}"
17846
+ url = f"http://{_control_panel_url_host(host)}:{port}/#token={panel_token}"
17842
17847
  state = {
17843
17848
  "pid": proc.pid,
17844
17849
  "port": port,
@@ -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