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 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
- [![Version](https://img.shields.io/badge/version-2.35.1-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/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.35.1"
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.1",
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.1"
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.1"
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
- valid_pages = ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"]
10724
- if p["page"] not in valid_pages:
10725
- return _err(f"Invalid page '{p['page']}'. Valid pages: {', '.join(valid_pages)}")
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, p["page"]))}
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()
@@ -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