davinci-resolve-mcp 2.35.1 → 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 +29 -0
- package/README.md +1 -1
- package/docs/SKILL.md +2 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +128 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +34 -7
- package/src/utils/mcp_transport.py +128 -0
- package/src/utils/readback.py +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
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
|
+
|
|
21
|
+
## What's New in v2.35.2
|
|
22
|
+
|
|
23
|
+
Verification observability and a validator consolidation.
|
|
24
|
+
|
|
25
|
+
- **Added** `resolve_control(action="verification_stats")` returns a
|
|
26
|
+
process-level tally of readback-verification outcomes
|
|
27
|
+
(verified / contradicted / unverified) since server start. A rising
|
|
28
|
+
`contradicted` count means the Resolve API reported success but a readback
|
|
29
|
+
disagreed. No connection required.
|
|
30
|
+
- **Changed** `open_page` now validates its `page` argument through the declarative
|
|
31
|
+
contract layer (`contracts.validate`) instead of a hand-written enum check —
|
|
32
|
+
behavior unchanged; part of folding scattered validation into one place.
|
|
33
|
+
|
|
5
34
|
## What's New in v2.35.1
|
|
6
35
|
|
|
7
36
|
- **Added** `media_pool_item(action="extract_frames", clip_id, timestamps, output_dir?)`
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -320,6 +320,8 @@ Key actions:
|
|
|
320
320
|
- `get_version` — returns `{product, version, version_string}`
|
|
321
321
|
- `api_truth(query?)` — look up behaviorally-verified facts about quirky/unreliable
|
|
322
322
|
Resolve API behavior (no connection needed); filter by substring
|
|
323
|
+
- `verification_stats` — readback-verification tally (verified/contradicted/
|
|
324
|
+
unverified) since server start (no connection needed)
|
|
323
325
|
- `get_page` / `open_page(page)` — read or switch the active page
|
|
324
326
|
- `get_keyframe_mode` / `set_keyframe_mode(mode)`
|
|
325
327
|
- `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "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
|
@@ -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
|
-
|
|
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):
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "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.
|
|
14
|
+
VERSION = "2.36.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -48,7 +48,7 @@ from src.utils.contracts import validate as _validate_params
|
|
|
48
48
|
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
49
49
|
from src.utils.page_lock import open_page_serialized as _open_page_serialized
|
|
50
50
|
from src.utils.proc import safe_run
|
|
51
|
-
from src.utils.readback import verify_by_readback
|
|
51
|
+
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
52
52
|
from src.utils.update_check import (
|
|
53
53
|
check_for_updates,
|
|
54
54
|
clear_update_prompt_preferences,
|
|
@@ -10632,6 +10632,8 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10632
10632
|
clear_mcp_update_preferences() -> {success, version, update, decision}
|
|
10633
10633
|
api_truth(query?) -> {verified_on, count, facts} — look up behaviorally-verified
|
|
10634
10634
|
facts about quirky/unreliable Resolve API behavior (no connection needed).
|
|
10635
|
+
verification_stats() -> {stats} — readback-verification tally
|
|
10636
|
+
(verified/contradicted/unverified) since server start (no connection needed).
|
|
10635
10637
|
get_page() -> {page}
|
|
10636
10638
|
open_page(page) -> {success} — page: edit, cut, color, fusion, fairlight, deliver
|
|
10637
10639
|
get_keyframe_mode() -> {mode}
|
|
@@ -10656,6 +10658,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10656
10658
|
if action == "api_truth":
|
|
10657
10659
|
facts = lookup_api_truth(p.get("query"))
|
|
10658
10660
|
return {"verified_on": _API_TRUTH_VERIFIED_ON, "count": len(facts), "facts": facts}
|
|
10661
|
+
if action == "verification_stats":
|
|
10662
|
+
# Process-level readback-verification tally — no connection needed.
|
|
10663
|
+
stats = _verification_stats()
|
|
10664
|
+
return {"stats": stats, "note": "Counts since server start. A rising "
|
|
10665
|
+
"'contradicted' count means the API reported success but a readback disagreed."}
|
|
10659
10666
|
|
|
10660
10667
|
# Control-panel actions don't require Resolve to be running.
|
|
10661
10668
|
if action == "open_control_panel":
|
|
@@ -10720,12 +10727,15 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10720
10727
|
elif action == "get_page":
|
|
10721
10728
|
return {"page": r.GetCurrentPage()}
|
|
10722
10729
|
elif action == "open_page":
|
|
10723
|
-
|
|
10724
|
-
|
|
10725
|
-
|
|
10730
|
+
err, clean = _validate_params(p, {
|
|
10731
|
+
"page": {"enum": ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"],
|
|
10732
|
+
"required": True},
|
|
10733
|
+
})
|
|
10734
|
+
if err:
|
|
10735
|
+
return _err(err)
|
|
10726
10736
|
# Serialize page switches so concurrent agents can't flip the single
|
|
10727
10737
|
# globally-active page underneath each other.
|
|
10728
|
-
return {"success": bool(_open_page_serialized(r,
|
|
10738
|
+
return {"success": bool(_open_page_serialized(r, clean["page"]))}
|
|
10729
10739
|
elif action == "get_keyframe_mode":
|
|
10730
10740
|
return {"mode": r.GetKeyframeMode()}
|
|
10731
10741
|
elif action == "set_keyframe_mode":
|
|
@@ -10746,7 +10756,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10746
10756
|
return missing
|
|
10747
10757
|
r.DisableBackgroundTasksForCurrentResolveSession()
|
|
10748
10758
|
return _ok()
|
|
10749
|
-
return _unknown(action, ["launch","get_version","api_truth","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
|
|
10759
|
+
return _unknown(action, ["launch","get_version","api_truth","verification_stats","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
|
|
10750
10760
|
|
|
10751
10761
|
|
|
10752
10762
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -21031,5 +21041,22 @@ if __name__ == "__main__":
|
|
|
21031
21041
|
run_fastmcp_stdio(granular_mcp)
|
|
21032
21042
|
sys.exit(0)
|
|
21033
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
|
+
|
|
21034
21061
|
logger.info(f"Starting DaVinci Resolve MCP Server (32 compound tools)")
|
|
21035
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()
|
package/src/utils/readback.py
CHANGED
|
@@ -16,6 +16,21 @@ from typing import Any, Callable, Dict, Optional
|
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
18
18
|
|
|
19
|
+
# Process-level tally of verification outcomes — lightweight observability into
|
|
20
|
+
# how often the Resolve API's self-reported success matches reality. A rising
|
|
21
|
+
# `contradicted` count is the signal worth watching.
|
|
22
|
+
_STATS = {"total": 0, "verified": 0, "contradicted": 0, "unverified": 0}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verification_stats():
|
|
26
|
+
"""Return a copy of the process-level verification tally."""
|
|
27
|
+
return dict(_STATS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reset_verification_stats():
|
|
31
|
+
for k in _STATS:
|
|
32
|
+
_STATS[k] = 0
|
|
33
|
+
|
|
19
34
|
|
|
20
35
|
def verify_by_readback(
|
|
21
36
|
mutate: Callable[[], Any],
|
|
@@ -56,6 +71,7 @@ def verify_by_readback(
|
|
|
56
71
|
|
|
57
72
|
# A contradiction — reported success but the readback disagrees — is the
|
|
58
73
|
# signal worth surfacing loudly.
|
|
74
|
+
_STATS["total"] += 1
|
|
59
75
|
if result.get("success_raw") and not result.get("verified"):
|
|
60
76
|
logger.warning(
|
|
61
77
|
"readback contradiction%s: API reported success but post-state "
|
|
@@ -64,7 +80,12 @@ def verify_by_readback(
|
|
|
64
80
|
f" (intent={intent})" if intent else "",
|
|
65
81
|
)
|
|
66
82
|
result["contradiction"] = True
|
|
83
|
+
_STATS["contradicted"] += 1
|
|
67
84
|
else:
|
|
68
85
|
result["contradiction"] = False
|
|
86
|
+
if result.get("verified"):
|
|
87
|
+
_STATS["verified"] += 1
|
|
88
|
+
else:
|
|
89
|
+
_STATS["unverified"] += 1
|
|
69
90
|
|
|
70
91
|
return result
|