davinci-resolve-mcp 3.0.1 → 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 +50 -0
- package/README.md +12 -1
- package/README.zh-CN.md +6 -2
- package/docs/SKILL.md +10 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +66 -3
- package/src/utils/issue_report.py +456 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
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
|
+
|
|
5
55
|
## What's New in v3.0.1 — project deletion is gated, and the open project is refused by default
|
|
6
56
|
|
|
7
57
|
A security fix, published as [GHSA-gmp7-qjp9-m7gm](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-gmp7-qjp9-m7gm).
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v3.0
|
|
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
|
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
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "3.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
|
|
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
|
|
|
@@ -16619,6 +16623,23 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16619
16623
|
Omit symbol for every recorded gate this build does not clear.
|
|
16620
16624
|
verification_stats() -> {stats} — readback-verification tally
|
|
16621
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.
|
|
16622
16643
|
job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
|
|
16623
16644
|
— poll a background job started by a long op run with background=True
|
|
16624
16645
|
(no connection needed). status is running, done, or error.
|
|
@@ -16751,6 +16772,48 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16751
16772
|
stats = _verification_stats()
|
|
16752
16773
|
return {"stats": stats, "note": "Counts since server start. A rising "
|
|
16753
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
|
+
}
|
|
16754
16817
|
|
|
16755
16818
|
# Background-job polling is a registry read — no Resolve connection needed.
|
|
16756
16819
|
if action == "job_status":
|
|
@@ -17075,7 +17138,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17075
17138
|
if err:
|
|
17076
17139
|
return _err(err)
|
|
17077
17140
|
return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
|
|
17078
|
-
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"])
|
|
17079
17142
|
|
|
17080
17143
|
|
|
17081
17144
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -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
|
+
]
|