davinci-resolve-mcp 2.35.2 → 2.36.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,22 @@
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.36.0
6
+
7
+ Optional networked transport — run the MCP server over the network, safely, with
8
+ control-panel management. Local stdio remains the default and is unchanged.
9
+
10
+ - **Added** `--transport stdio|sse|streamable-http` (default `stdio`). The networked
11
+ modes bind to **loopback (127.0.0.1) by default** and **require a bearer token** on
12
+ every request (from `$DAVINCI_MCP_TOKEN`, or generated + logged at startup). A
13
+ non-loopback bind logs a loud security warning. stdio is untouched; you can run a
14
+ local stdio instance and a networked instance against the same Resolve at once.
15
+ - **Added** control-panel management (Setup → MCP): a Transport card showing the live
16
+ mode / URL / token / loopback status, with Start/Stop buttons (loopback-only). Backed
17
+ by `/api/mcp/transport/{start,stop}` and a transport field on `/api/mcp/status`.
18
+ - The page-switch lock (v2.34.1) already serializes concurrent page switches across the
19
+ local and networked instances.
20
+
5
21
  ## What's New in v2.35.2
6
22
 
7
23
  Verification observability and a validator consolidation.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.35.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.36.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.35.2"
38
+ VERSION = "2.36.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (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.35.2",
3
+ "version": "2.36.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -5495,7 +5495,36 @@ HTML = r"""<!doctype html>
5495
5495
  <span>${apiOk && libOk ? 'Auto-detected from standard install paths' : 'Install via install.py if paths cannot be detected'}</span>
5496
5496
  </div>
5497
5497
  </div>`;
5498
- setHtml('diagnosticsMcpServer', serverCard + resolveCard);
5498
+ const tr = data.transport || { networked: false, mode: 'stdio (local)' };
5499
+ const trPill = tr.networked
5500
+ ? (tr.loopback ? statusPill('pill-ok', 'Networked · loopback')
5501
+ : statusPill('pill-warn', 'Networked · EXPOSED'))
5502
+ : statusPill('pill-ok', 'Local (stdio)');
5503
+ const transportCard = `
5504
+ <div class="diag-card">
5505
+ <div class="diag-card-header">
5506
+ <div class="diag-card-title">${DIAG_ICONS.connection}Transport</div>
5507
+ ${trPill}
5508
+ </div>
5509
+ <div class="diag-card-rows">
5510
+ ${diagRow('Mode', tr.mode || 'stdio (local)')}
5511
+ ${tr.networked ? diagRow('URL', tr.url || '—') : ''}
5512
+ ${tr.networked ? diagRow('Auth', tr.has_token ? 'Bearer token required' : 'NONE') : ''}
5513
+ ${tr.networked && tr.token ? diagRow('Token', tr.token) : ''}
5514
+ </div>
5515
+ <div class="diag-card-footer">
5516
+ <span>${tr.networked
5517
+ ? (tr.loopback ? 'Reachable on this machine only; every request needs the bearer token.'
5518
+ : 'WARNING: bound to a non-loopback host — exposed on the network.')
5519
+ : 'Default. Networked access is opt-in (launch with --transport sse|streamable-http).'}</span>
5520
+ <button class="secondary transport-toggle-btn" data-transport-action="${tr.networked ? 'stop' : 'start'}">
5521
+ ${tr.networked ? 'Stop networked' : 'Start networked'}</button>
5522
+ </div>
5523
+ </div>`;
5524
+ setHtml('diagnosticsMcpServer', serverCard + resolveCard + transportCard);
5525
+ serverEl.querySelectorAll('.transport-toggle-btn').forEach(btn => {
5526
+ btn.addEventListener('click', () => toggleTransport(btn.dataset.transportAction, btn).catch(alertError));
5527
+ });
5499
5528
 
5500
5529
  const clients = data.clients || [];
5501
5530
  if (!clients.length) {
@@ -5592,6 +5621,28 @@ HTML = r"""<!doctype html>
5592
5621
  }
5593
5622
  }
5594
5623
 
5624
+ async function toggleTransport(action, btn) {
5625
+ if (action === 'start') {
5626
+ const proceed = await brandedConfirm({
5627
+ kicker: 'MCP Transport',
5628
+ title: 'Start networked transport?',
5629
+ body: 'Starts a second MCP server instance over streamable-http, bound to loopback (127.0.0.1) and protected by a bearer token. Local stdio is unaffected. The connection URL + token will appear here.',
5630
+ confirmLabel: 'Start',
5631
+ cancelLabel: 'Cancel',
5632
+ });
5633
+ if (!proceed) return;
5634
+ }
5635
+ const original = btn?.textContent;
5636
+ if (btn) { btn.disabled = true; btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…'; }
5637
+ try {
5638
+ const ep = action === 'start' ? '/api/mcp/transport/start' : '/api/mcp/transport/stop';
5639
+ await api(ep, { method: 'POST', body: '{}' });
5640
+ } finally {
5641
+ if (btn) { btn.disabled = false; btn.textContent = original; }
5642
+ await refreshMcpStatus();
5643
+ }
5644
+ }
5645
+
5595
5646
  function renderControlPanels() {
5596
5647
  renderOverview();
5597
5648
  renderDiagnostics();
@@ -13483,9 +13534,73 @@ def _mcp_status_payload() -> Dict[str, Any]:
13483
13534
  "resolve_lib_detected": bool(paths.get("lib_path")),
13484
13535
  },
13485
13536
  "clients": clients_out,
13537
+ "transport": _transport_status(),
13486
13538
  }
13487
13539
 
13488
13540
 
13541
+ def _transport_status() -> Dict[str, Any]:
13542
+ """Live networked-transport status (or local-only) for the MCP diagnostics card."""
13543
+ try:
13544
+ from src.utils.mcp_transport import read_transport_state
13545
+ except Exception:
13546
+ return {"networked": False, "mode": "stdio (local)"}
13547
+ state = read_transport_state()
13548
+ if not state:
13549
+ return {"networked": False, "mode": "stdio (local)"}
13550
+ return {
13551
+ "networked": True,
13552
+ "mode": state.get("transport"),
13553
+ "url": state.get("url"),
13554
+ "loopback": state.get("loopback", True),
13555
+ "has_token": bool(state.get("token")),
13556
+ "token": state.get("token"),
13557
+ "pid": state.get("pid"),
13558
+ }
13559
+
13560
+
13561
+ def _transport_start() -> Dict[str, Any]:
13562
+ """Spawn a networked MCP instance (streamable-http, loopback + token)."""
13563
+ import subprocess as _sp
13564
+ from src.utils.mcp_transport import read_transport_state
13565
+ if read_transport_state():
13566
+ return {"success": False, "error": "A networked transport instance is already running."}
13567
+ paths = _resolve_mcp_paths()
13568
+ py, script = paths.get("python_path"), paths.get("server_path")
13569
+ if not py or not script:
13570
+ return {"success": False, "error": "Could not resolve the Python interpreter or server script path."}
13571
+ try:
13572
+ _sp.Popen(
13573
+ [py, script, "--transport", "streamable-http"],
13574
+ stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
13575
+ start_new_session=True,
13576
+ )
13577
+ except OSError as exc:
13578
+ return {"success": False, "error": f"Failed to launch: {exc}"}
13579
+ import time as _t
13580
+ for _ in range(15):
13581
+ _t.sleep(0.2)
13582
+ if read_transport_state():
13583
+ return {"success": True, "transport": _transport_status()}
13584
+ return {"success": True, "note": "Launch initiated; status will appear shortly."}
13585
+
13586
+
13587
+ def _transport_stop() -> Dict[str, Any]:
13588
+ """Stop the running networked MCP instance via its state-file PID."""
13589
+ import signal as _sig
13590
+ from src.utils.mcp_transport import read_transport_state, clear_transport_state
13591
+ state = read_transport_state()
13592
+ if not state:
13593
+ return {"success": True, "note": "No networked transport running."}
13594
+ pid = state.get("pid")
13595
+ if isinstance(pid, int):
13596
+ try:
13597
+ os.kill(pid, _sig.SIGTERM)
13598
+ except (OSError, ProcessLookupError):
13599
+ pass
13600
+ clear_transport_state()
13601
+ return {"success": True}
13602
+
13603
+
13489
13604
  def _mcp_install_payload(client_id: str) -> Dict[str, Any]:
13490
13605
  """Write the MCP entry for one client by delegating to install.write_client_config."""
13491
13606
  installer, error = _load_installer_module()
@@ -14304,6 +14419,18 @@ class Handler(BaseHTTPRequestHandler):
14304
14419
  return
14305
14420
  self._json(_mcp_uninstall_payload(client_id))
14306
14421
  return
14422
+ if path == "/api/mcp/transport/start":
14423
+ if not _request_is_loopback(self):
14424
+ self._json({"success": False, "error": "Transport management is loopback-only."}, HTTPStatus.FORBIDDEN)
14425
+ return
14426
+ self._json(_transport_start())
14427
+ return
14428
+ if path == "/api/mcp/transport/stop":
14429
+ if not _request_is_loopback(self):
14430
+ self._json({"success": False, "error": "Transport management is loopback-only."}, HTTPStatus.FORBIDDEN)
14431
+ return
14432
+ self._json(_transport_stop())
14433
+ return
14307
14434
  if path == "/api/jobs":
14308
14435
  paths = body.get("paths") or []
14309
14436
  if isinstance(paths, str):
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.35.2"
83
+ VERSION = "2.36.0"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  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 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.35.2"
14
+ VERSION = "2.36.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -21041,5 +21041,22 @@ if __name__ == "__main__":
21041
21041
  run_fastmcp_stdio(granular_mcp)
21042
21042
  sys.exit(0)
21043
21043
 
21044
+ # --transport stdio (default) | sse | streamable-http. Networked modes bind
21045
+ # loopback by default and require a bearer token (see src/utils/mcp_transport).
21046
+ transport = "stdio"
21047
+ if "--transport" in sys.argv:
21048
+ _i = sys.argv.index("--transport")
21049
+ if _i + 1 < len(sys.argv):
21050
+ transport = sys.argv[_i + 1]
21051
+ del sys.argv[_i:_i + 2]
21052
+ if transport in ("sse", "streamable-http"):
21053
+ from src.utils.mcp_transport import run_networked
21054
+ logger.info(f"Starting DaVinci Resolve MCP Server ({transport} transport)")
21055
+ run_networked(mcp, transport)
21056
+ sys.exit(0)
21057
+ if transport != "stdio":
21058
+ logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
21059
+ sys.exit(2)
21060
+
21044
21061
  logger.info(f"Starting DaVinci Resolve MCP Server (32 compound tools)")
21045
21062
  run_fastmcp_stdio(mcp)
@@ -0,0 +1,128 @@
1
+ """Networked transport for the MCP server (opt-in via --transport).
2
+
3
+ stdio remains the default. The `sse` and `streamable-http` modes bind to
4
+ loopback (127.0.0.1) by default and REQUIRE a bearer token on every request, so
5
+ turning networking on never silently exposes Resolve. The token comes from
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.
8
+
9
+ Security posture:
10
+ - Default host is loopback; a non-loopback bind logs a loud warning.
11
+ - Every HTTP request must carry ``Authorization: Bearer <token>`` (constant-time
12
+ compared); otherwise 401.
13
+ - stdio (the default transport) is unaffected by anything here.
14
+ """
15
+ import json
16
+ import logging
17
+ import os
18
+ import secrets
19
+ import tempfile
20
+ import time
21
+
22
+ logger = logging.getLogger("davinci-resolve-mcp")
23
+
24
+ TRANSPORT_STATE_PATH = os.path.join(
25
+ tempfile.gettempdir(), "davinci_resolve_mcp_transport.json"
26
+ )
27
+ LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
28
+
29
+
30
+ def resolve_token():
31
+ """Return (token, was_generated). Honors $DAVINCI_MCP_TOKEN."""
32
+ tok = os.environ.get("DAVINCI_MCP_TOKEN")
33
+ if tok:
34
+ return tok, False
35
+ return secrets.token_urlsafe(24), True
36
+
37
+
38
+ def _auth_middleware_cls(token):
39
+ from starlette.middleware.base import BaseHTTPMiddleware
40
+ from starlette.responses import JSONResponse
41
+
42
+ expected = f"Bearer {token}"
43
+
44
+ class BearerAuth(BaseHTTPMiddleware):
45
+ async def dispatch(self, request, call_next):
46
+ provided = request.headers.get("authorization", "")
47
+ if not secrets.compare_digest(provided, expected):
48
+ return JSONResponse(
49
+ {"error": "unauthorized: Authorization: Bearer <token> required"},
50
+ status_code=401,
51
+ )
52
+ return await call_next(request)
53
+
54
+ return BearerAuth
55
+
56
+
57
+ def write_transport_state(transport, host, port, token):
58
+ 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)
70
+ except OSError as exc:
71
+ logger.warning("could not write transport state: %s", exc)
72
+
73
+
74
+ def clear_transport_state():
75
+ try:
76
+ os.remove(TRANSPORT_STATE_PATH)
77
+ except OSError:
78
+ pass
79
+
80
+
81
+ def read_transport_state():
82
+ """Return the live transport state dict, or None if no networked instance.
83
+
84
+ Treats a state file whose pid is no longer alive as stale (returns None).
85
+ """
86
+ try:
87
+ with open(TRANSPORT_STATE_PATH, encoding="utf-8") as fh:
88
+ state = json.load(fh)
89
+ except (OSError, ValueError):
90
+ return None
91
+ pid = state.get("pid")
92
+ if isinstance(pid, int):
93
+ try:
94
+ os.kill(pid, 0)
95
+ except (OSError, ProcessLookupError):
96
+ return None
97
+ return state
98
+
99
+
100
+ def run_networked(mcp, transport):
101
+ """Serve `mcp` over an authenticated HTTP transport ('sse'|'streamable-http')."""
102
+ import uvicorn
103
+
104
+ host = os.environ.get("DAVINCI_MCP_HOST") or mcp.settings.host or "127.0.0.1"
105
+ port = int(os.environ.get("DAVINCI_MCP_PORT") or mcp.settings.port or 8000)
106
+ mcp.settings.host = host
107
+ mcp.settings.port = port
108
+ token, generated = resolve_token()
109
+
110
+ app = mcp.sse_app() if transport == "sse" else mcp.streamable_http_app()
111
+ app.add_middleware(_auth_middleware_cls(token))
112
+
113
+ if host not in LOOPBACK_HOSTS:
114
+ logger.warning(
115
+ "SECURITY: MCP %s transport bound to NON-loopback host %r — Resolve "
116
+ "control is exposed on the network. Ensure this is intended.",
117
+ transport, host,
118
+ )
119
+ logger.info("MCP %s transport: http://%s:%s (bearer token required)",
120
+ transport, host, port)
121
+ if generated:
122
+ logger.info("Generated bearer token (set $DAVINCI_MCP_TOKEN to pin it): %s", token)
123
+
124
+ write_transport_state(transport, host, port, token)
125
+ try:
126
+ uvicorn.run(app, host=host, port=port, log_level="warning")
127
+ finally:
128
+ clear_transport_state()