davinci-resolve-mcp 3.0.0 → 3.1.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,150 @@
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 v3.1.0 — "send this as a bug": issues drafted from chat
6
+
7
+ A new `resolve_control` action, `report_issue`. While working in any MCP client,
8
+ say "send this as a bug" or "send this as a feature request" and the assistant
9
+ drafts the GitHub issue for you.
10
+
11
+ ### Added
12
+
13
+ - **`resolve_control(action="report_issue")`** drafts a bug report
14
+ (`kind="bug"`) or feature request (`kind="feature"`) from the conversation:
15
+ what happened, steps to reproduce, expected vs actual, and the failing
16
+ tool/action with its error. It attaches what a maintainer would otherwise
17
+ have to ask for in the thread: server version, Resolve build and edition,
18
+ connection mode (local scripting, network scripting or in-app bridge),
19
+ whether Resolve is running with a UI or headless, OS, and Python.
20
+ - **It files nothing.** It returns the draft plus a prefilled GitHub
21
+ `issues/new` link. You review the draft and submit it under your own
22
+ account, so no GitHub credential lives in the server and nothing is
23
+ published that you have not seen.
24
+ - **It never connects to or launches Resolve.** A report about a connection
25
+ that will not come up must not start one, so the environment is read only
26
+ from a handle the server already holds.
27
+ - **Every field is redacted before it reaches the draft:** absolute paths
28
+ (POSIX, Windows and UNC, including paths with spaces; the file extension is
29
+ kept, and the Blackmagic install locations and `~/.davinci-resolve-mcp` are
30
+ kept because they identify nothing), the local username, full name and
31
+ hostname, e-mail addresses, the control panel's `#token=` fragment, and
32
+ key-shaped secrets. Client or project names written as ordinary prose
33
+ cannot be recognised, so the result tells the assistant to have you check
34
+ the draft before submitting.
35
+ - Links over 8,000 characters shorten only the narrative. The environment
36
+ table always survives, and the full body is still returned to the chat.
37
+ - `bug_report` and `feature_request` issue templates, carrying the `bug` and
38
+ `enhancement` labels.
39
+
40
+ ### Documentation
41
+
42
+ - README: new *Reporting Bugs and Requesting Features* section (with the
43
+ matching section in `README.zh-CN.md`); `docs/SKILL.md` documents when to
44
+ call the action and that the user submits; the server instructions now
45
+ mention it, so any MCP client can find it.
46
+
47
+ ### Validation
48
+
49
+ - 27 new unit tests (`tests/test_issue_report.py`) cover redaction, layout,
50
+ link round-trip and truncation, the no-connect guarantee, and the
51
+ templates. The full suite passes: 3509 passed, 0 failed.
52
+ - Smoke-tested through the real MCP stdio protocol against `src/server.py`.
53
+ - No Resolve behaviour changed, so no live Resolve run was needed.
54
+
55
+ ## What's New in v3.0.1 — project deletion is gated, and the open project is refused by default
56
+
57
+ A security fix, published as [GHSA-gmp7-qjp9-m7gm](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-gmp7-qjp9-m7gm).
58
+ One behaviour change existing callers may notice is called out below.
59
+
60
+ ### Security
61
+
62
+ - **`project_manager delete` permanently deleted any named project — including
63
+ the one open in Resolve — without confirmation, and no gate saw it.** It runs
64
+ through `delete_project_safely`, a *reliability* helper that works around
65
+ DeleteProject's flakiness and session lock by closing the open project and
66
+ then deleting it. The `project_manager` tool carried no `@_destructive_op`,
67
+ `delete` was not registered, and the CRITICAL risk rule written for project
68
+ deletion named `delete_project`, an action no tool dispatches. So it matched
69
+ nothing: safe mode, dry-run refusal and the security audit log never saw a
70
+ project deletion.
71
+ - **Thirteen further deletes that the classifier already rated HIGH were not
72
+ enforced, for the same reason.** They are the three `render` deletes,
73
+ `render_presets.delete_burnin`, `gallery_stills.delete_stills`, `fusion_comp`
74
+ `delete_tool` and `delete_keyframe`, `timeline_item.delete_keyframe`, the three
75
+ `media_pool_item_markers` deletes, `project_settings.delete_color_group` and
76
+ `resolve_control.delete_user_preferences_preset`. Safe mode is enforced only
77
+ by the decorator, and only for registered actions, so these ratings were a
78
+ promise nothing kept.
79
+
80
+ ### Changed
81
+
82
+ - **The raw `delete` now refuses the currently open project unless
83
+ `close_current=True`**, matching `safe_project_delete`. This is the one change
84
+ existing callers may notice: a call that used to close and delete the open
85
+ project now returns an error, until it passes `close_current=True`.
86
+ - All of the actions above are registered and their tools decorated. The project
87
+ delete is CRITICAL, and `safe_project_delete` and the thirteen others are HIGH,
88
+ so all are blocked while safe mode is on. An explicit dry run is refused
89
+ unless the action honours it natively; only `safe_project_delete` does, and
90
+ keeps working. Every call is audited. None of them archives the timeline,
91
+ except the keyframe deletes on `fusion_comp` and `timeline_item`, which change
92
+ timeline items and so still do.
93
+ - **`remove_motion_blur`** (on `folder` and `media_pool_item`) is **re-rated
94
+ MEDIUM**. It renders new media and never touches the source, and was already
95
+ confirm-gated for exactly that reason, but the `remove_` name-prefix rule had
96
+ rated it HIGH on its name alone. It is now audited, and not blocked by safe
97
+ mode.
98
+ - **The name-prefix rule is now a fallback for unlisted actions**: an explicit
99
+ lower rating wins. Before, it fired ahead of the LOW and MEDIUM tables and
100
+ could not be overridden. No existing rating changed except `remove_motion_blur`
101
+ — no LOW, MEDIUM or graph-LUT entry started with `delete_` or `remove_`.
102
+
103
+ ### Fixed
104
+
105
+ - **Nine risk rules named actions that no tool dispatches, and so protected
106
+ nothing.** On `project_manager`: `delete_project`, now repointed at the real
107
+ `delete`, plus `close_project_without_saving` and `save_project_as`. On
108
+ `edit_engine`: `auto_cut_silence` and `ripple_trim`. On `timeline`: `cut_clip`,
109
+ `delete_clip_by_id`, `delete_markers` and `ripple_delete` — which is a CutList
110
+ entry kind inside `apply_cuts`, not an action. The rest were removed; since
111
+ they never matched, removing them changes nothing at runtime. An existing test
112
+ even asserted that the dead `delete_project` rule classified as CRITICAL — true
113
+ of a name no tool uses, and part of how it survived. It now tests the real
114
+ `delete`.
115
+ - **The bridge installer's Lua canary gave only the pre-21.1 diagnosis** — that
116
+ Resolve cannot find a Python 3. On free 21.1 that is wrong, because Python
117
+ scripting moved to Studio (#203). Its comments and printed output now give both
118
+ causes, the newer first (#219). The printed post-install guidance was already
119
+ corrected in v2.224.1.
120
+
121
+ ### Added
122
+
123
+ - **`tests/test_write_enforcement_ratchet.py`** fails the suite in three cases:
124
+ - an action is rated destructive but not enforced;
125
+ - a risk rule names an action no tool dispatches;
126
+ - a new write-style action appears with neither a rating nor a registry entry.
127
+
128
+ The 144 unrated write-style actions that exist today are frozen as a backlog.
129
+ Rating one forces its removal from the list, so it can only shrink. This is
130
+ the second instance of this gap in two days, and the first time it cannot
131
+ come back unnoticed.
132
+ - `tests/test_project_delete_guard.py` pins the delete guard, its rating and its
133
+ enforcement.
134
+
135
+ ### Documentation
136
+
137
+ - Removed a stale tool count from `docs/authoring/script-plugin-authoring.md`.
138
+
139
+ ### Validation
140
+
141
+ - Full suite green: 3,482 passed, 1 skipped. Every static and drift gate is clean, including
142
+ the native-dry-run scan. That scan requires the native-dry-run list to match,
143
+ exactly, the registered actions whose handlers read `dry_run`, which is how
144
+ `safe_project_delete` was confirmed as the only one.
145
+ - No live Resolve run. The gating is decorator-level and verified offline. The
146
+ delete guard is tested against a fake project manager, deliberately:
147
+ exercising it live means deleting a real project.
148
+
5
149
  ## What's New in v3.0.0 — the server no longer executes caller-supplied code, and every plugin write is gated
6
150
 
7
151
  **A breaking release.** Two public actions are removed. The rest of the change
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-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.1.0-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-36%20(377%20full)-blue.svg)](#server-modes)
@@ -410,6 +410,17 @@ Extension authoring references live in [docs/authoring](docs/authoring/). Resolv
410
410
 
411
411
  Resolve 19.1.3 remains the compatibility baseline. Resolve 20.x scripting calls are additive, version-guarded, and live-tested on 20.3.2. Resolve 21.0 scripting additions (audio classification, speaker-detection transcription, IntelliSearch, slate analysis, motion-deblur, speech generation, session background-task control) are exposed behind runtime capability detection, so they stay inert on older builds and activate automatically on Resolve 21+. They are live-tested on Studio 21.0.2.4 — see the [Resolve 21 delta](docs/reference/api-coverage.md#resolve-21-delta-detail). Note that `AnalyzeForIntellisearch`, `AnalyzeForSlate` and `GenerateSpeech` each require a separately-downloaded AI Extras pack, and Resolve reports a missing pack inconsistently (some return `False`, others an error string), so these actions report `success: false` with the Resolve-supplied reason rather than guessing.
412
412
 
413
+ ## Reporting Bugs and Requesting Features
414
+
415
+ Tell your assistant "send this as a bug" or "send this as a feature request". It
416
+ drafts a GitHub issue from the conversation, including the failing call and
417
+ its error, and attaches the server version, Resolve build, connection mode and
418
+ OS. Local paths, your username and anything that looks like a secret are
419
+ redacted. Nothing is filed for you: you get a prefilled link, review the
420
+ draft, and submit it on GitHub yourself. You can also
421
+ [open an issue](https://github.com/samuelgursky/davinci-resolve-mcp/issues/new/choose)
422
+ directly.
423
+
413
424
  ## Development
414
425
 
415
426
  ```bash
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-3.0.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.1.0-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-36%20(377%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
- > 本翻译对应 v3.0.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v3.1.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -269,6 +269,10 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
269
269
 
270
270
  Resolve 19.1.3 仍是兼容性基线。Resolve 20.x 的脚本调用是增量式的、带版本护栏的,并已在 20.3.2 上实机测试。Resolve 21.0 新增的脚本能力(音频分类、说话人检测转写、IntelliSearch、场记板分析、运动去模糊、语音生成、会话后台任务控制)通过运行时能力检测暴露,在旧构建上保持沉默,在 Resolve 21+ 上自动激活。它们已在 Studio 21.0.2.4 上实机测试——见 [Resolve 21 增量明细](docs/reference/api-coverage.md#resolve-21-delta-detail)。注意 `AnalyzeForIntellisearch`、`AnalyzeForSlate` 和 `GenerateSpeech` 各自需要单独下载的 AI Extras 包,而 Resolve 报告缺包的方式不一致(有的返回 `False`,有的返回错误字符串),所以这些 action 会带着 Resolve 给出的原因报告 `success: false`,而不是瞎猜。
271
271
 
272
+ ## 报告 Bug 与提出功能需求
273
+
274
+ 对你的助手说"把这个作为 bug 发送"或"把这个作为功能需求发送"。它会根据对话起草一个 GitHub issue,包含失败的调用及其错误,并附上服务器版本、Resolve 构建、连接方式和操作系统。本地路径、你的用户名以及任何看起来像密钥的内容都会被脱敏。不会替你直接提交:你会拿到一个预填好的链接,检查草稿后在 GitHub 上自己提交。你也可以直接[新建 issue](https://github.com/samuelgursky/davinci-resolve-mcp/issues/new/choose)。
275
+
272
276
  ## 开发
273
277
 
274
278
  ```bash
package/docs/SKILL.md CHANGED
@@ -735,6 +735,16 @@ Key actions:
735
735
  Resolve API behavior (no connection needed); filter by substring
736
736
  - `verification_stats` — readback-verification tally (verified/contradicted/
737
737
  unverified) since server start (no connection needed)
738
+ - `report_issue(kind, title, summary, …)` — when the user says "send this as a
739
+ bug" or "…as a feature request", draft a GitHub issue for this server. Fill
740
+ it from the conversation (the failing tool/action and its error verbatim,
741
+ expected vs actual, steps). Server version, Resolve build, connection mode
742
+ and OS are attached; paths, usernames, e-mails and secrets are redacted. It
743
+ **files nothing**: show the user the draft, then hand them the returned
744
+ `url` to review and submit on GitHub. Redaction cannot catch client or
745
+ project names written as prose, so ask the user to check. Never call it
746
+ unprompted; offering once after a failure that looks like a server defect
747
+ is fine. No connection needed, and it never launches Resolve
738
748
  - `get_page` / `open_page(page)` — read or switch the active page
739
749
  - `get_keyframe_mode` / `set_keyframe_mode(mode)`
740
750
  - `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
@@ -19,7 +19,7 @@ automation.
19
19
  | Custom workflow you want as a permanent menu item | `script_plugin('install', ...)`, then the user runs it from Workspace → Scripts |
20
20
  | Image-processing node for the Fusion page | `fuse_plugin` |
21
21
  | Color-page programmable transform | `dctl` |
22
- | Anything the existing 28 wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
22
+ | Anything the wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
23
23
 
24
24
  ## Two template kinds
25
25
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.0.0"
40
+ VERSION = "3.1.0"
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": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -288,7 +288,9 @@ _CANARY_NAME = "resolve_bridge_canary.lua"
288
288
 
289
289
  _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
290
290
  -- If THIS appears under Workspace > Scripts but resolve_bridge_probe does not,
291
- -- Resolve is listing Lua and silently skipping Python: it cannot find a Python 3.
291
+ -- Resolve is listing Lua and silently skipping Python. On Resolve 21.1+ FREE
292
+ -- that is expected: Python scripting moved to Studio (issue #203), and nothing
293
+ -- below will change it. On Studio, or 21.0.x and earlier, it cannot find a Python 3.
292
294
  -- It looks at PYTHON3HOME, then /usr/local/bin/python3 -- and nowhere else, which
293
295
  -- is why Homebrew, pyenv, uv and conda interpreters go unseen. Either point it at
294
296
  -- the one you have (no sudo, but does NOT survive a reboot):
@@ -299,9 +301,12 @@ _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
299
301
  -- already looks, which persists:
300
302
  -- sudo ln -s "$(command -v python3)" /usr/local/bin/python3
301
303
  -- A python.org build creates that symlink for you. Restart Resolve after.
302
- print("Resolve is enumerating scripts. If the Python probe is missing, Resolve")
303
- print("cannot find a Python 3: set PYTHON3HOME with launchctl setenv, or install")
304
- print("a python.org build. Homebrew/pyenv/uv/conda are not looked at directly.")
304
+ print("Resolve is enumerating scripts. If the Python probe is missing:")
305
+ print("- Resolve 21.1+ FREE: Python scripting moved to Studio, so .py scripts")
306
+ print(" no longer list at all (issue #203). No Python setting changes that.")
307
+ print("- Studio, or 21.0.x and earlier: Resolve cannot find a Python 3. Set")
308
+ print(" PYTHON3HOME with launchctl setenv, or install a python.org build.")
309
+ print(" Homebrew/pyenv/uv/conda are not looked at directly.")
305
310
  """
306
311
 
307
312
 
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.0.0"
90
+ VERSION = "3.1.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 = "3.0.0"
14
+ VERSION = "3.1.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -147,6 +147,7 @@ from src.utils.media_analysis_jobs import (
147
147
  from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths
148
148
  from src.utils.resolve_connection import connect_resolve
149
149
  from src.utils import resolve_runtime as _resolve_runtime
150
+ from src.utils import issue_report as _issue_report
150
151
  from src.utils.lut_paths import master_lut_dir, ensure_lut_in_master
151
152
  from src.utils import fuse_templates, dctl_templates, script_templates
152
153
  from src.utils.timeline_title_text import (
@@ -248,7 +249,10 @@ mcp = FastMCP(
248
249
  "DAVINCI_RESOLVE_BRIDGE=1 only forces it), so a connection error does NOT mean "
249
250
  "the free edition is unsupported — on Resolve 21.0.x. Resolve 21.1 moved Python "
250
251
  "scripting to Studio and free 21.1 no longer lists Python scripts in that menu "
251
- "(issue #203), so on 21.1+ a free-edition connection error may be final."
252
+ "(issue #203), so on 21.1+ a free-edition connection error may be final. "
253
+ "When the user asks to send something as a bug or feature request, draft it "
254
+ "with resolve_control(action='report_issue') — it returns a prefilled GitHub "
255
+ "issue link for the user to review and submit; nothing is filed for them."
252
256
  ),
253
257
  )
254
258
 
@@ -16575,6 +16579,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
16575
16579
 
16576
16580
  @mcp.tool()
16577
16581
  @_guard_missing_params
16582
+ @_destructive_op("resolve_control")
16578
16583
  def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16579
16584
  """App-level DaVinci Resolve operations.
16580
16585
 
@@ -16618,6 +16623,23 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16618
16623
  Omit symbol for every recorded gate this build does not clear.
16619
16624
  verification_stats() -> {stats} — readback-verification tally
16620
16625
  (verified/contradicted/unverified) since server start (no connection needed).
16626
+ report_issue(kind, title, summary, steps?, expected?, actual?, error?, tool?,
16627
+ tool_action?, use_case?, proposal?, include_environment?)
16628
+ -> {title, body, labels, url, url_truncated, redactions, submitted: false, next_step}
16629
+ — Draft a GitHub bug report (kind="bug") or feature request (kind="feature")
16630
+ for this MCP server. Call it when the user asks to send, report or file
16631
+ something as a bug or feature request ("send this as a bug"). Do not call
16632
+ it unprompted; you may OFFER once when a failure looks like a defect in
16633
+ this server rather than in the user's request. Write the fields from the
16634
+ conversation: the failing tool/action and its error verbatim, what the
16635
+ user expected, and steps that reproduce it. Server version, Resolve
16636
+ build, connection mode and OS are attached automatically
16637
+ (include_environment=false to omit); nothing connects to or launches
16638
+ Resolve. NOTHING IS FILED: show the user the title and body, then give
16639
+ them the url — the issue is created only when they open it and press
16640
+ Submit on GitHub. Paths, usernames, e-mails and secrets are redacted,
16641
+ but client or project names in plain prose are not: ask the user to
16642
+ check before submitting.
16621
16643
  job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
16622
16644
  — poll a background job started by a long op run with background=True
16623
16645
  (no connection needed). status is running, done, or error.
@@ -16750,6 +16772,48 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16750
16772
  stats = _verification_stats()
16751
16773
  return {"stats": stats, "note": "Counts since server start. A rising "
16752
16774
  "'contradicted' count means the API reported success but a readback disagreed."}
16775
+ if action == "report_issue":
16776
+ # Drafts only — never files, never connects. See utils/issue_report.py.
16777
+ kind = _issue_report.normalize_kind(p.get("kind") or p.get("type"))
16778
+ if kind is None:
16779
+ return _err(
16780
+ "report_issue requires kind: 'bug' or 'feature'",
16781
+ code="INVALID_KIND",
16782
+ category="invalid_input",
16783
+ )
16784
+ title = str(p.get("title") or "").strip()
16785
+ summary = str(p.get("summary") or p.get("description") or p.get("body") or "").strip()
16786
+ if not title or not summary:
16787
+ return _err(
16788
+ "report_issue requires title and summary",
16789
+ code="MISSING_FIELDS",
16790
+ category="invalid_input",
16791
+ remediation="Write both from the conversation: a one-line title and "
16792
+ "a summary of what happened or what the user wants.",
16793
+ )
16794
+ environment = None
16795
+ if _setup_bool(p.get("include_environment", p.get("includeEnvironment")), True):
16796
+ environment = _issue_report.collect_environment(resolve, VERSION)
16797
+ draft = _issue_report.build_issue(
16798
+ kind,
16799
+ title,
16800
+ summary,
16801
+ steps=p.get("steps"),
16802
+ expected=p.get("expected"),
16803
+ actual=p.get("actual"),
16804
+ error=p.get("error"),
16805
+ tool=p.get("tool"),
16806
+ tool_action=p.get("tool_action") or p.get("failed_action"),
16807
+ use_case=p.get("use_case"),
16808
+ proposal=p.get("proposal"),
16809
+ environment=environment,
16810
+ )
16811
+ return {
16812
+ "success": True,
16813
+ **draft,
16814
+ "submitted": False,
16815
+ "next_step": _issue_report.next_step_guidance(draft["url_truncated"]),
16816
+ }
16753
16817
 
16754
16818
  # Background-job polling is a registry read — no Resolve connection needed.
16755
16819
  if action == "job_status":
@@ -17074,7 +17138,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17074
17138
  if err:
17075
17139
  return _err(err)
17076
17140
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
17077
- return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","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","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17141
+ return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","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","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
17078
17142
 
17079
17143
 
17080
17144
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -18072,6 +18136,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
18072
18136
 
18073
18137
  @mcp.tool()
18074
18138
  @_guard_missing_params
18139
+ @_destructive_op("render_presets")
18075
18140
  def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
18076
18141
  """Import/export render and burn-in presets.
18077
18142
 
@@ -19030,6 +19095,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
19030
19095
 
19031
19096
  @mcp.tool()
19032
19097
  @_guard_missing_params
19098
+ @_destructive_op("project_manager")
19033
19099
  def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19034
19100
  """Manage DaVinci Resolve projects.
19035
19101
 
@@ -19047,7 +19113,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19047
19113
  render and wedges Resolve's pipeline until restart (stuck
19048
19114
  IsRenderingInProgress, 0% jobs, refused Quit). stop_render=true stops
19049
19115
  the render, waits for the flag to clear, then closes.
19050
- delete(name) -> {success}
19116
+ delete(name, close_current?) -> {success}
19051
19117
  import_project(path, name?) -> {success}
19052
19118
  export_project(name, path, with_stills_and_luts?) -> {success}
19053
19119
  archive(name, path, src_media?, render_cache?, proxy_media?) -> {success}
@@ -19180,6 +19246,18 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19180
19246
  elif action == "delete":
19181
19247
  if not p.get("name"):
19182
19248
  return _err("delete requires name")
19249
+ # Refuse the open project unless the caller says so, as
19250
+ # safe_project_delete does. delete_project_safely closes and deletes the
19251
+ # current project without asking — the call that loses a project someone
19252
+ # is working in. If the current project cannot be read, DeleteProject on
19253
+ # an open project fails anyway (the session holds its lock).
19254
+ try:
19255
+ _open = pm.GetCurrentProject()
19256
+ _open_name = _open.GetName() if _open else None
19257
+ except Exception:
19258
+ _open_name = None
19259
+ if _open_name == p["name"] and not p.get("close_current", False):
19260
+ return _err("Refusing to delete the currently open project; pass close_current=True")
19183
19261
  from src.utils.project_cleanup import delete_project_safely
19184
19262
  deleted = delete_project_safely(pm, p["name"])
19185
19263
  return {"success": bool(deleted.get("success")), "delete_detail": deleted}
@@ -19364,6 +19442,7 @@ def _setting_limitation(name: Any, obj: str = "Project") -> Optional[Dict[str, A
19364
19442
 
19365
19443
  @mcp.tool()
19366
19444
  @_guard_missing_params
19445
+ @_destructive_op("project_settings")
19367
19446
  def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19368
19447
  """Project metadata, settings, and color groups.
19369
19448
 
@@ -20340,6 +20419,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
20340
20419
 
20341
20420
  @mcp.tool()
20342
20421
  @_guard_missing_params
20422
+ @_destructive_op("render")
20343
20423
  def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
20344
20424
  """Render pipeline: jobs, presets, formats, codecs, and rendering.
20345
20425
 
@@ -21319,6 +21399,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
21319
21399
 
21320
21400
  @mcp.tool()
21321
21401
  @_guard_missing_params
21402
+ @_destructive_op("folder")
21322
21403
  def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21323
21404
  """Operations on Media Pool folders.
21324
21405
 
@@ -21491,6 +21572,7 @@ def _keyed_get(getter, key):
21491
21572
 
21492
21573
  @mcp.tool()
21493
21574
  @_guard_missing_params
21575
+ @_destructive_op("media_pool_item")
21494
21576
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21495
21577
  """Operations on a media pool clip. Identify clip by clip_id.
21496
21578
 
@@ -21955,6 +22037,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
21955
22037
 
21956
22038
  @mcp.tool()
21957
22039
  @_guard_missing_params
22040
+ @_destructive_op("media_pool_item_markers")
21958
22041
  def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
21959
22042
  """Markers and flags on media pool clips. Identify clip by clip_id.
21960
22043
 
@@ -28805,6 +28888,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
28805
28888
 
28806
28889
  @mcp.tool()
28807
28890
  @_guard_missing_params
28891
+ @_destructive_op("gallery_stills")
28808
28892
  def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
28809
28893
  """Manage stills in gallery albums (best results on Color page).
28810
28894
 
@@ -30261,6 +30345,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
30261
30345
 
30262
30346
  @mcp.tool()
30263
30347
  @_guard_missing_params
30348
+ @_destructive_op("fusion_comp")
30264
30349
  def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
30265
30350
  """Fusion composition node graph operations.
30266
30351
 
@@ -77,6 +77,24 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
77
77
  "safe_install_extension",
78
78
  "safe_remove_extension",
79
79
  }),
80
+ # Deletes the classifier already rated HIGH (CRITICAL for the raw project
81
+ # delete) on tools that carried no @_destructive_op, so safe mode never saw
82
+ # them. Plus the 21.0 AI deblur, rated MEDIUM: it creates media, and is
83
+ # registered so it is audited and its dry run is honest.
84
+ "folder": frozenset({"remove_motion_blur"}),
85
+ "fusion_comp": frozenset({"delete_keyframe", "delete_tool"}),
86
+ "gallery_stills": frozenset({"delete_stills"}),
87
+ "media_pool_item": frozenset({"remove_motion_blur"}),
88
+ "media_pool_item_markers": frozenset({
89
+ "delete_at_frame",
90
+ "delete_by_color",
91
+ "delete_by_custom_data",
92
+ }),
93
+ "project_manager": frozenset({"delete", "safe_project_delete"}),
94
+ "project_settings": frozenset({"delete_color_group"}),
95
+ "render": frozenset({"delete_all_jobs", "delete_job", "delete_preset"}),
96
+ "render_presets": frozenset({"delete_burnin"}),
97
+ "resolve_control": frozenset({"delete_user_preferences_preset"}),
80
98
  "media_pool": frozenset({
81
99
  "delete_clips",
82
100
  "delete_folders",
@@ -155,6 +173,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
155
173
  "create_subtitles",
156
174
  }),
157
175
  "timeline_item": frozenset({
176
+ "delete_keyframe",
158
177
  "set_output_blanking",
159
178
  "set_use_timeline_for_output_blanking",
160
179
  "set_clip_enabled",
@@ -250,7 +269,9 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
250
269
 
251
270
  # ── Non-timeline write tools ────────────────────────────────────────────────
252
271
  #
253
- # Tools whose registered actions write the filesystem, not the timeline. Every
272
+ # Tools whose registered actions do not mutate the working timeline: they write
273
+ # plugin folders, or act on projects, the render queue, presets, the gallery,
274
+ # pool items or app preferences. Every
254
275
  # gate applies to them — safe mode, dry-run refusal, the audit log — but they
255
276
  # skip version-on-mutate archiving, and skip resolving the versioning context
256
277
  # at all: that goes through the project-root provider, which reaches Resolve,
@@ -258,7 +279,12 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
258
279
  # Resolve. `media_pool` has its own branch for the same reason; these differ in
259
280
  # that there is no project state to log.
260
281
 
261
- NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({"dctl", "fuse_plugin", "script_plugin"})
282
+ NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({
283
+ "dctl", "fuse_plugin", "script_plugin",
284
+ "folder", "gallery_stills", "media_pool_item", "media_pool_item_markers",
285
+ "project_manager", "project_settings", "render", "render_presets",
286
+ "resolve_control",
287
+ })
262
288
 
263
289
 
264
290
  # ── Strict-mode allowlist ───────────────────────────────────────────────────
@@ -327,6 +353,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
327
353
  ("timeline_ai", "create_subtitles"),
328
354
  ("script_plugin", "safe_install_extension"),
329
355
  ("script_plugin", "safe_remove_extension"),
356
+ ("project_manager", "safe_project_delete"),
330
357
  })
331
358
 
332
359
 
@@ -125,8 +125,10 @@ class RiskClassificationHook(LifecycleHook):
125
125
  name = "risk_classification"
126
126
 
127
127
  _CRITICAL_ACTIONS: Set[Tuple[str, str]] = {
128
- ("project_manager", "delete_project"),
129
- ("project_manager", "close_project_without_saving"),
128
+ # Raw project delete. This rule used to name `delete_project`, which
129
+ # no tool dispatches, so it matched nothing and deleting a project
130
+ # passed every gate.
131
+ ("project_manager", "delete"),
130
132
  ("media_pool", "delete_timelines"),
131
133
  ("media_pool", "delete_clips"),
132
134
  }
@@ -140,15 +142,11 @@ class RiskClassificationHook(LifecycleHook):
140
142
  ("fuse_plugin", "remove"),
141
143
  ("script_plugin", "remove"),
142
144
  ("script_plugin", "safe_remove_extension"),
145
+ # Guarded delete: disposable `_mcp_` projects only, and the open one
146
+ # only with close_current=True — but still permanent.
147
+ ("project_manager", "safe_project_delete"),
143
148
  ("timeline", "delete_clips"),
144
- ("timeline", "delete_clip_by_id"),
145
- ("timeline", "delete_markers"),
146
- ("timeline", "ripple_delete"),
147
- ("timeline", "cut_clip"),
148
149
  ("edit_engine", "execute_selects"),
149
- ("edit_engine", "auto_cut_silence"),
150
- ("edit_engine", "ripple_trim"),
151
- ("project_manager", "save_project_as"),
152
150
  ("media_pool", "delete_folders"),
153
151
  ("timeline", "delete_track"),
154
152
  ("timeline", "lift_range"),
@@ -264,6 +262,11 @@ class RiskClassificationHook(LifecycleHook):
264
262
  ("fuse_plugin", "install"),
265
263
  ("script_plugin", "install"),
266
264
  ("script_plugin", "safe_install_extension"),
265
+ # 21.0 AI deblur renders NEW media and never touches the source (it is
266
+ # confirm-token gated for that reason). The `remove_` prefix rule rated
267
+ # it HIGH on its name alone, which would make safe mode block a create.
268
+ ("folder", "remove_motion_blur"),
269
+ ("media_pool_item", "remove_motion_blur"),
267
270
  # Additive edits that place content into an existing timeline. Nothing
268
271
  # is deleted (`overwrite_range`, which does delete, is HIGH), but the
269
272
  # timeline is no longer what it was.
@@ -378,7 +381,16 @@ class RiskClassificationHook(LifecycleHook):
378
381
  radius = BlastRadius.PROJECT if "project" in tool_name else BlastRadius.TIMELINE
379
382
  conf_required = True
380
383
  reasons.append(f"Action '{action}' is permanently destructive across {radius.value}")
381
- elif pair in cls._HIGH_RISK_ACTIONS or action.startswith("delete_") or action.startswith("remove_"):
384
+ elif pair in cls._HIGH_RISK_ACTIONS or (
385
+ # The name-prefix rule is a fallback for UNLISTED actions: an
386
+ # explicit lower rating wins. It used to fire first and so could
387
+ # not be overridden — `remove_motion_blur` creates media, yet read
388
+ # HIGH on its name.
389
+ pair not in cls._LOW_RISK_ACTIONS
390
+ and pair not in cls._MEDIUM_RISK_ACTIONS
391
+ and pair not in cls._GRAPH_LUT_ACTIONS
392
+ and (action.startswith("delete_") or action.startswith("remove_"))
393
+ ):
382
394
  level = RiskLevel.HIGH
383
395
  destructive = True
384
396
  if params.get("ripple", False):
@@ -0,0 +1,456 @@
1
+ """Draft a GitHub issue from inside a chat — bug reports and feature requests.
2
+
3
+ The user says "send this as a bug" (or "…as a feature request") and the agent
4
+ calls `resolve_control(action="report_issue")`. The server never files
5
+ anything. It writes the issue and returns a prefilled `issues/new` link; the
6
+ user reads the draft, opens the link, and presses Submit on GitHub under their
7
+ own account. That keeps three things true:
8
+
9
+ - no GitHub credential ever lives in the MCP server;
10
+ - nothing is published that the user has not seen;
11
+ - the report still carries the facts a maintainer would otherwise have to
12
+ ask for in the thread — server version, Resolve build and edition,
13
+ connection mode, OS — which is most of the value.
14
+
15
+ Redaction runs over every field before it reaches the draft: absolute paths,
16
+ the local username, full name and hostname, e-mail addresses, and anything
17
+ shaped like a secret. It is best-effort by construction — it cannot recognise
18
+ a client or project name typed as plain prose — so the result always tells
19
+ the agent to show the draft to the user before handing over the link.
20
+
21
+ Nothing here connects to Resolve. A report about a connection that will not
22
+ come up must not launch Resolve or wait on one, so the environment is read
23
+ from a handle the server already holds, or reported as "not connected".
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import getpass
29
+ import os
30
+ import platform
31
+ import re
32
+ import socket
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+ from urllib.parse import quote, urlencode
35
+
36
+ from .update_check import DEFAULT_REPO
37
+
38
+ #: GitHub answers `414 URI Too Long` somewhere past 8 KB and does not document
39
+ #: the exact limit. Stay under it; the full body is returned separately.
40
+ MAX_URL_CHARS = 8000
41
+
42
+ MAX_FIELD_CHARS = 4000
43
+
44
+ KINDS: Dict[str, Dict[str, str]] = {
45
+ "bug": {"template": "bug_report.md", "label": "bug"},
46
+ "feature": {"template": "feature_request.md", "label": "enhancement"},
47
+ }
48
+
49
+ _KIND_ALIASES = {
50
+ "bug": "bug",
51
+ "bug_report": "bug",
52
+ "defect": "bug",
53
+ "issue": "bug",
54
+ "feature": "feature",
55
+ "feature_request": "feature",
56
+ "enhancement": "feature",
57
+ "request": "feature",
58
+ "idea": "feature",
59
+ }
60
+
61
+ #: Install and support locations that say which build is installed and where.
62
+ #: They are the same on every machine, so keeping them costs no privacy and
63
+ #: saves a round-trip on installer and connection bugs.
64
+ _KEEP_PATH_PREFIXES = (
65
+ "/Library/Application Support/Blackmagic Design",
66
+ "/Applications/DaVinci Resolve",
67
+ "/opt/resolve",
68
+ "~/.davinci-resolve-mcp",
69
+ "c:\\program files\\blackmagic design",
70
+ "c:\\programdata\\blackmagic design",
71
+ )
72
+
73
+ #: Usernames that are also ordinary words. Redacting "user" or "editor" out of
74
+ #: prose mangles the report and protects nobody.
75
+ _COMMON_ACCOUNT_NAMES = {
76
+ "root", "user", "admin", "administrator", "guest", "test", "dev",
77
+ "editor", "video", "mac", "pc", "home", "owner", "localhost",
78
+ }
79
+
80
+ _TRUNCATION_NOTE = "\n\n_…truncated to fit the link — the full report is in the chat._"
81
+
82
+ _FOOTER = (
83
+ "\n\n---\n"
84
+ "<sub>Drafted in chat with the davinci-resolve-mcp `report_issue` action. "
85
+ "Local paths, the username and anything secret-shaped were redacted "
86
+ "automatically.</sub>\n"
87
+ "<!-- filed-via: davinci-resolve-mcp report_issue -->"
88
+ )
89
+
90
+ # --------------------------------------------------------------------------
91
+ # Redaction
92
+ # --------------------------------------------------------------------------
93
+
94
+ _SEGMENT = r"[^\s/\\\"'`<>|*?:]+"
95
+ # A directory segment may contain spaces ("/Volumes/My Drive/") because it is
96
+ # closed by the next slash. A final segment may only when it ends in a file
97
+ # extension ("A001 take 2.mov"); otherwise it stops at the first space, so
98
+ # "/tmp/a to /tmp/b" stays two paths. A space after a comma or before a `~`
99
+ # ends the path too — "x.log, see ~/y" is prose between two paths, not one.
100
+ _SPACED = r"(?<![,;]) (?!~)"
101
+ _DIR_SEGMENT = rf"{_SEGMENT}(?:{_SPACED}{_SEGMENT})*"
102
+ _LAST_SEGMENT = (
103
+ rf"(?:{_SEGMENT}(?:{_SPACED}{_SEGMENT})*\.[A-Za-z0-9]{{1,5}}(?![\w.])|{_SEGMENT})"
104
+ )
105
+
106
+ _POSIX_PATH = re.compile(rf"(?<![\w:/.~\\-])(?:~/|/)(?:{_DIR_SEGMENT}/)+{_LAST_SEGMENT}")
107
+ _WINDOWS_PATH = re.compile(
108
+ rf"(?<![\w])(?:[A-Za-z]:[\\/]|\\\\{_SEGMENT}\\)(?:{_DIR_SEGMENT}[\\/])*{_LAST_SEGMENT}"
109
+ )
110
+
111
+ _SECRET_PATTERNS: Tuple[Tuple[re.Pattern, str], ...] = (
112
+ (re.compile(r"#token=[^\s&\"'`]+"), "#token=<redacted>"),
113
+ (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]+=*"), "Bearer <redacted>"),
114
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"), "<redacted-key>"),
115
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), "<redacted-key>"),
116
+ (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "<redacted-key>"),
117
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "<redacted-key>"),
118
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "<redacted-key>"),
119
+ (
120
+ re.compile(
121
+ r"(?i)\b(token|api[_-]?key|secret|password|passwd|authorization)"
122
+ r"(\s*[=:]\s*)(?!<redacted)[^\s,;&\"'`]+"
123
+ ),
124
+ r"\1\2<redacted>",
125
+ ),
126
+ )
127
+
128
+ _EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
129
+
130
+
131
+ def _trailing_punctuation(match: str) -> Tuple[str, str]:
132
+ stripped = match.rstrip(".,;:)]}")
133
+ return stripped, match[len(stripped):]
134
+
135
+
136
+ def _path_replacement(path: str, home: str = "") -> Tuple[str, bool]:
137
+ """Return (replacement, redacted?) for one matched absolute path."""
138
+ if len(home) > 1 and path.lower().startswith(home.lower()):
139
+ path = "~" + path[len(home):].replace("\\", "/")
140
+ windows = re.match(r"^[A-Za-z]:|^\\\\", path)
141
+ comparable = path.lower().replace("/", "\\") if windows else path
142
+ for prefix in _KEEP_PATH_PREFIXES:
143
+ if comparable.startswith(prefix):
144
+ return path, False
145
+ last = re.split(r"[\\/]", path)[-1]
146
+ ext = re.search(r"\.([A-Za-z0-9]{1,5})$", last)
147
+ if ext and not ext.group(1).isdigit():
148
+ return f"<path>.{ext.group(1)}", True
149
+ return "<path>", True
150
+
151
+
152
+ def local_identity() -> Dict[str, Any]:
153
+ """The strings that identify this machine and its user."""
154
+ identity: Dict[str, Any] = {"home": os.path.expanduser("~"), "names": []}
155
+ names: List[str] = []
156
+ try:
157
+ names.append(getpass.getuser())
158
+ except Exception:
159
+ pass
160
+ try:
161
+ import pwd # POSIX only
162
+
163
+ gecos = pwd.getpwuid(os.getuid()).pw_gecos.split(",")[0].strip()
164
+ if gecos:
165
+ names.append(gecos)
166
+ except Exception:
167
+ pass
168
+ try:
169
+ host = socket.gethostname().split(".")[0]
170
+ if host:
171
+ names.append(host)
172
+ except Exception:
173
+ pass
174
+ identity["names"] = names
175
+ return identity
176
+
177
+
178
+ def redact(text: Any, identity: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, int]]:
179
+ """Scrub one field. Returns (text, counts by kind)."""
180
+ counts = {"secrets": 0, "emails": 0, "paths": 0, "identity": 0}
181
+ if text is None:
182
+ return "", counts
183
+ out = str(text)
184
+ identity = identity if identity is not None else local_identity()
185
+
186
+ for pattern, replacement in _SECRET_PATTERNS:
187
+ out, n = pattern.subn(replacement, out)
188
+ counts["secrets"] += n
189
+
190
+ out, n = _EMAIL.subn("<email>", out)
191
+ counts["emails"] += n
192
+
193
+ # The home directory is folded to `~` per path, not across the text first:
194
+ # on Windows that would turn C:\Users\name\… into ~\…, which no longer
195
+ # looks like an absolute path and would slip through unredacted.
196
+ home = identity.get("home") or ""
197
+
198
+ def _sub_path(match: re.Match) -> str:
199
+ path, tail = _trailing_punctuation(match.group(0))
200
+ replacement, redacted = _path_replacement(path, home)
201
+ if redacted:
202
+ counts["paths"] += 1
203
+ return replacement + tail
204
+
205
+ out = _WINDOWS_PATH.sub(_sub_path, out)
206
+ out = _POSIX_PATH.sub(_sub_path, out)
207
+ if len(home) > 1:
208
+ out = out.replace(home, "~")
209
+
210
+ # Letters-only boundaries, not \b: `\bname\b` misses "name_project" and
211
+ # "name2", because `_` and digits are word characters.
212
+ for name in sorted(set(identity.get("names") or []), key=len, reverse=True):
213
+ if len(name) < 3 or name.lower() in _COMMON_ACCOUNT_NAMES:
214
+ continue
215
+ pattern = re.compile(rf"(?<![A-Za-z]){re.escape(name)}(?![A-Za-z])", re.IGNORECASE)
216
+ out, n = pattern.subn("<user>", out)
217
+ counts["identity"] += n
218
+ return out, counts
219
+
220
+
221
+ # --------------------------------------------------------------------------
222
+ # Environment
223
+ # --------------------------------------------------------------------------
224
+
225
+
226
+ def _connection_kind(handle: Any) -> str:
227
+ try:
228
+ from . import resolve_bridge_client
229
+
230
+ if isinstance(handle, resolve_bridge_client.BridgeProxy):
231
+ return "in-app bridge"
232
+ except Exception:
233
+ pass
234
+ if os.environ.get("RESOLVE_SCRIPT_HOST"):
235
+ return "network scripting"
236
+ return "local scripting"
237
+
238
+
239
+ def _os_description() -> str:
240
+ system = platform.system()
241
+ if system == "Darwin":
242
+ release = platform.mac_ver()[0] or platform.release()
243
+ return f"macOS {release} ({platform.machine()})"
244
+ if system == "Windows":
245
+ return f"Windows {platform.release()} ({platform.version()}, {platform.machine()})"
246
+ return f"{system} {platform.release()} ({platform.machine()})"
247
+
248
+
249
+ def collect_environment(resolve_handle: Any, mcp_version: str) -> Dict[str, str]:
250
+ """What a maintainer needs to reproduce, read without connecting."""
251
+ env: Dict[str, str] = {"MCP server": mcp_version}
252
+ if resolve_handle is not None:
253
+ try:
254
+ env["DaVinci Resolve"] = (
255
+ f"{resolve_handle.GetProductName()} {resolve_handle.GetVersionString()}"
256
+ )
257
+ except Exception:
258
+ env["DaVinci Resolve"] = "connected, version unreadable"
259
+ env["Connection"] = _connection_kind(resolve_handle)
260
+ else:
261
+ env["DaVinci Resolve"] = "not connected"
262
+ env["Connection"] = "not connected"
263
+ try:
264
+ from . import resolve_runtime
265
+
266
+ mode = resolve_runtime.runtime_mode()
267
+ if not mode.get("running"):
268
+ env["Resolve process"] = "not running"
269
+ elif mode.get("headless") is True:
270
+ env["Resolve process"] = "running, headless (-nogui)"
271
+ elif mode.get("headless") is False:
272
+ env["Resolve process"] = "running, with UI"
273
+ else:
274
+ env["Resolve process"] = "running, mode unknown"
275
+ except Exception:
276
+ pass
277
+ env["OS"] = _os_description()
278
+ env["Python"] = platform.python_version()
279
+ return env
280
+
281
+
282
+ # --------------------------------------------------------------------------
283
+ # Draft
284
+ # --------------------------------------------------------------------------
285
+
286
+
287
+ def normalize_kind(kind: Any) -> Optional[str]:
288
+ return _KIND_ALIASES.get(str(kind or "").strip().lower().replace("-", "_").replace(" ", "_"))
289
+
290
+
291
+ def _clip(text: str, limit: int = MAX_FIELD_CHARS) -> str:
292
+ if len(text) <= limit:
293
+ return text
294
+ return text[:limit].rstrip() + "\n…(truncated)"
295
+
296
+
297
+ def _steps_markdown(steps: Any) -> str:
298
+ if isinstance(steps, (list, tuple)):
299
+ return "\n".join(f"{i}. {s}" for i, s in enumerate(steps, 1) if str(s).strip())
300
+ return str(steps)
301
+
302
+
303
+ def _fence(text: str) -> str:
304
+ fence = "```"
305
+ while fence in text:
306
+ fence += "`"
307
+ return f"{fence}\n{text}\n{fence}"
308
+
309
+
310
+ def _environment_markdown(environment: Dict[str, str]) -> str:
311
+ rows = "\n".join(
312
+ f"| {key} | {str(value).replace('|', '/')} |" for key, value in environment.items()
313
+ )
314
+ return f"### Environment\n\n| | |\n|---|---|\n{rows}"
315
+
316
+
317
+ def issue_url(repo: str, kind: str, title: str, body: str) -> str:
318
+ query = urlencode(
319
+ {
320
+ "template": KINDS[kind]["template"],
321
+ "labels": KINDS[kind]["label"],
322
+ "title": title,
323
+ "body": body,
324
+ },
325
+ quote_via=quote,
326
+ )
327
+ return f"https://github.com/{repo}/issues/new?{query}"
328
+
329
+
330
+ def _fit_url(repo: str, kind: str, title: str, narrative: str, tail: str) -> Tuple[str, bool]:
331
+ """Build the link, shortening only the narrative if the whole body won't fit.
332
+
333
+ The environment table and footer are the part a maintainer cannot ask the
334
+ reporter to reconstruct later, so they are never the part cut.
335
+ """
336
+ url = issue_url(repo, kind, title, narrative + tail)
337
+ if len(url) <= MAX_URL_CHARS:
338
+ return url, False
339
+ lo, hi = 0, len(narrative)
340
+ best = issue_url(repo, kind, title, _TRUNCATION_NOTE.lstrip() + tail)
341
+ while lo <= hi:
342
+ mid = (lo + hi) // 2
343
+ candidate = issue_url(repo, kind, title, narrative[:mid].rstrip() + _TRUNCATION_NOTE + tail)
344
+ if len(candidate) <= MAX_URL_CHARS:
345
+ best, lo = candidate, mid + 1
346
+ else:
347
+ hi = mid - 1
348
+ return best, True
349
+
350
+
351
+ def build_issue(
352
+ kind: str,
353
+ title: str,
354
+ summary: str,
355
+ *,
356
+ steps: Any = None,
357
+ expected: Any = None,
358
+ actual: Any = None,
359
+ error: Any = None,
360
+ tool: Any = None,
361
+ tool_action: Any = None,
362
+ use_case: Any = None,
363
+ proposal: Any = None,
364
+ environment: Optional[Dict[str, str]] = None,
365
+ identity: Optional[Dict[str, Any]] = None,
366
+ repo: str = DEFAULT_REPO,
367
+ ) -> Dict[str, Any]:
368
+ """Redact every field, lay out the issue, and build the prefilled link."""
369
+ identity = identity if identity is not None else local_identity()
370
+ totals = {"secrets": 0, "emails": 0, "paths": 0, "identity": 0}
371
+
372
+ def scrub(value: Any) -> str:
373
+ text, counts = redact(value, identity)
374
+ for key, n in counts.items():
375
+ totals[key] += n
376
+ return _clip(text.strip())
377
+
378
+ clean_title = scrub(title).replace("\n", " ")[:200]
379
+ sections: List[str] = []
380
+
381
+ def add(heading: str, value: Any, render=lambda s: s) -> None:
382
+ if value is None or (isinstance(value, (list, tuple)) and not value) or not str(value).strip():
383
+ return
384
+ if isinstance(value, (list, tuple)):
385
+ value = [scrub(v) for v in value]
386
+ sections.append(f"### {heading}\n\n{render(value)}")
387
+ else:
388
+ sections.append(f"### {heading}\n\n{render(scrub(value))}")
389
+
390
+ if kind == "bug":
391
+ add("What happened", summary)
392
+ add("Steps to reproduce", steps, _steps_markdown)
393
+ add("Expected", expected)
394
+ add("Actual", actual)
395
+ else:
396
+ add("What I'd like", summary)
397
+ add("What I was trying to do", use_case)
398
+ add("How it could work", proposal)
399
+
400
+ if tool or tool_action:
401
+ call = " → ".join(f"`{scrub(v)}`" for v in (tool, tool_action) if v)
402
+ sections.append(f"### {'Failing call' if kind == 'bug' else 'Related call'}\n\n{call}")
403
+ if error is not None and str(error).strip():
404
+ sections.append(_fence(scrub(error)))
405
+
406
+ narrative = "\n\n".join(sections)
407
+ tail = ""
408
+ if environment:
409
+ tail = "\n\n" + _environment_markdown(
410
+ {key: scrub(value) for key, value in environment.items()}
411
+ )
412
+ tail += _FOOTER
413
+
414
+ url, truncated = _fit_url(repo, kind, clean_title, narrative, tail)
415
+ return {
416
+ "kind": kind,
417
+ "repo": repo,
418
+ "title": clean_title,
419
+ "body": narrative + tail,
420
+ "labels": [KINDS[kind]["label"]],
421
+ "url": url,
422
+ "url_truncated": truncated,
423
+ "redactions": totals,
424
+ }
425
+
426
+
427
+ def next_step_guidance(truncated: bool) -> str:
428
+ text = (
429
+ "Nothing has been filed. Show the user the title and body above, then give "
430
+ "them the url: the issue is created only when they open it and press Submit "
431
+ "on GitHub under their own account (a GitHub account is required). "
432
+ "Redaction is best-effort — it removes paths, the local username, e-mail "
433
+ "addresses and secret-shaped strings, but cannot recognise a client, "
434
+ "project or person named in plain prose, so ask the user to check for "
435
+ "those before submitting."
436
+ )
437
+ if truncated:
438
+ text += (
439
+ " The body was too long for a link and the url carries a shortened "
440
+ "copy; tell the user to paste the full body from the chat into the "
441
+ "issue before submitting."
442
+ )
443
+ return text
444
+
445
+
446
+ __all__ = [
447
+ "KINDS",
448
+ "MAX_URL_CHARS",
449
+ "build_issue",
450
+ "collect_environment",
451
+ "issue_url",
452
+ "local_identity",
453
+ "next_step_guidance",
454
+ "normalize_kind",
455
+ "redact",
456
+ ]