davinci-resolve-mcp 2.204.0 → 2.205.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,60 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.205.0 — a standard operation envelope on every tool result
6
+
7
+ Adapted from the design contributed in PR #181.
8
+
9
+ ### Added
10
+
11
+ - **`_operation` on every compound tool return.** Agents orchestrating
12
+ multi-turn edits had to answer the same three questions after every call —
13
+ did it happen, was it verified, what changed — in a different vocabulary per
14
+ tool (`readback.missing`, `succeeded`/`failed`, `partial`,
15
+ `status: "confirmation_required"`). Those are now normalized into one block:
16
+ `status` (`success` / `partial` / `blocked` / `failed`), `operation`,
17
+ `execution_id`, `verification`, `changes` and `warnings`.
18
+ - **A contradiction stays its own verification status.** "Resolve reported
19
+ success and the readback disagrees" is a different thing for a caller to act
20
+ on than "the call failed", and this repo's most valuable reliability signal;
21
+ it does not collapse into a failure. `readback.as_verification_dict` renders
22
+ a `verify_by_readback` result in the same shape.
23
+ - **`setup(action="set_defaults", params={"result_envelope": ...})`** — `dual`
24
+ (default), `pure`, or `legacy`, persisted to `logs/server-preferences.json`
25
+ and restored at startup. Override per call with `params={"envelope": ...}` or
26
+ per process with `RESOLVE_MCP_RESULT_ENVELOPE`.
27
+
28
+ ### Notes on the adaptation
29
+
30
+ - **The envelope is namespaced, not flattened.** Five of its key names —
31
+ `status` (22 sites), `operation` (20), `warnings` (15), `result` (8),
32
+ `changes` (2) — are already domain keys on this server, so merging the
33
+ envelope into the top level silently rewrote them: `resolve_control`
34
+ `job_status` reported `"success"` instead of `"done"` (an agent polling a
35
+ job would never see it finish), a confirm gate's `"confirmation_required"`
36
+ became `"blocked"` — renaming the very signal the envelope exists to make
37
+ unambiguous — and a transcription's `"Transcribed"` was lost. The payload is
38
+ now passed through untouched and the envelope rides under `_operation`,
39
+ following the existing `_versioning` convention. A guard test fails the
40
+ suite if any module starts returning `_operation` as a domain key.
41
+ - **An unreported delta is absent, not zero.** `changes: {}` reads as "this
42
+ operation changed nothing", which is false about an edit that simply never
43
+ declared its deltas — the silent-lie class this codebase treats as a bug.
44
+ The key is omitted instead, and `verification: "unverified"` likewise means
45
+ "no evidence reported", not "checked and clean".
46
+ - **Status inference keys only on conventions this repo actually uses.**
47
+ `blocked` reads like a gate flag but is a domain key holding the *list of
48
+ targets that could not be resolved*; a successful `bulk_match_to_hero` dry
49
+ run carries a non-empty one. Reading it as a gate reported a confirmation
50
+ that was never requested.
51
+ - **Semantic deltas are declared by the action, not guessed from key names.**
52
+ A mapping like `properties_restored_items` → `properties_updated` turns a
53
+ ripple insert's internal bookkeeping into an edit the caller never made.
54
+ `timeline.ripple_insert` declares its own; the rest report none rather than
55
+ a fabricated zero.
56
+ - Verified through the real stdio JSON-RPC tool layer, not just at module
57
+ level: 36 tools register, the envelope arrives, the payload is intact.
58
+
5
59
  ## What's New in v2.204.0 — #179: the managed install can boot the advanced server
6
60
 
7
61
  ### Fixed
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-2.204.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.205.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(353%20full)-blue.svg)](#server-modes)
@@ -231,6 +231,27 @@ The open-source servers are complete and fully functional on their own.
231
231
  | Extension authoring | Fuse, DCTL, ACES DCTL, and Resolve-page Lua/Python script lifecycle helpers with safe MCP-marked install/remove |
232
232
  | Craft guidance | The bundled editorial, colour, audio, and workflow guidance served as prose over MCP — indexed, searchable, and readable by any client, not just ones with this repository on disk |
233
233
 
234
+ ### Operation envelope
235
+
236
+ Every compound tool return carries an `_operation` block beside its payload, so
237
+ an agent reads one shape instead of a different key per tool: `status`
238
+ (`success` / `partial` / `blocked` / `failed`), `verification` (with
239
+ `contradiction` kept distinct — Resolve reported success and the readback
240
+ disagreed), `changes` (the semantic delta), `warnings`, and an `execution_id`.
241
+
242
+ Two absences are meaningful and deliberate. `verification.status: "unverified"`
243
+ means *no evidence was reported*, not "checked and clean". A missing `changes`
244
+ means the action did not report a delta, not that nothing changed — an empty
245
+ `{}` there would be a confident, wrong answer about an edit that simply never
246
+ declared one.
247
+
248
+ The envelope is namespaced rather than merged into the top level because
249
+ `status`, `operation`, `warnings`, `result` and `changes` are all already domain
250
+ keys here; flattening would rewrite a background job's `status: "done"` and a
251
+ confirm gate's `status: "confirmation_required"`. `setup(action="set_defaults",
252
+ params={"result_envelope": "pure" | "legacy"})` changes the shape, per call via
253
+ `params={"envelope": ...}`, per process via `RESOLVE_MCP_RESULT_ENVELOPE`.
254
+
234
255
  ## Optional Extras
235
256
 
236
257
  The core install is deliberately small: Python, ffmpeg, and the Resolve scripting
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-2.204.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.205.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(353%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
- > 本翻译对应 v2.204.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.205.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -152,6 +152,14 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
152
152
  | 渲染与交付 | 格式/编解码矩阵探测、渲染设置校验、队列任务生命周期检查、带护栏的快速导出 |
153
153
  | 扩展开发 | Fuse、DCTL、ACES DCTL 及 Resolve 页面 Lua/Python 脚本生命周期助手,带 MCP 标记的安全安装/移除 |
154
154
 
155
+ ### 操作信封(operation envelope)
156
+
157
+ 每个复合工具的返回值都会在原有 payload 旁边带一个 `_operation` 块,这样 agent 读的是同一种结构,而不是每个工具一套 key:`status`(`success` / `partial` / `blocked` / `failed`)、`verification`(其中 `contradiction` 单独成一档——Resolve 报告成功但回读结果不一致)、`changes`(语义增量)、`warnings`,以及一个 `execution_id`。
158
+
159
+ 有两种"缺失"是刻意保留其含义的。`verification.status: "unverified"` 表示*没有报告任何证据*,不等于"已检查且没问题"。`changes` 缺失表示这个动作没有报告增量,不等于什么都没改——在那里放一个空的 `{}`,等于对一次并未声明增量的剪辑给出一个自信而错误的回答。
160
+
161
+ 信封是带命名空间的,而不是平铺到顶层,因为 `status`、`operation`、`warnings`、`result` 和 `changes` 在这里本来就都是业务 key;平铺会改写后台任务的 `status: "done"` 和确认关卡的 `status: "confirmation_required"`。用 `setup(action="set_defaults", params={"result_envelope": "pure" | "legacy"})` 改变形态,单次调用用 `params={"envelope": ...}`,进程级用 `RESOLVE_MCP_RESULT_ENVELOPE`。
162
+
155
163
  ## 可选增强
156
164
 
157
165
  核心安装刻意保持精简:Python、ffmpeg 和 Resolve 脚本 API。有些功能需要更多依赖,且**每一项都会诚实拒绝并给出自己的安装命令,而不是退化成瞎猜**——编造的节拍或虚构的电平会产出自信但错误的结果,比没有这个功能更糟。
package/docs/SKILL.md CHANGED
@@ -158,6 +158,59 @@ before mutating Resolve state.
158
158
 
159
159
  ---
160
160
 
161
+ ## Reading A Result: The Operation Envelope
162
+
163
+ Every compound tool return carries an `_operation` block alongside its normal
164
+ payload. It answers the three questions that otherwise need a different key per
165
+ tool — did it happen, was it verified, what changed:
166
+
167
+ ```json
168
+ {
169
+ "success": true,
170
+ "insert_frame_absolute": 86400,
171
+ "shift_frames": 48,
172
+
173
+ "_operation": {
174
+ "status": "success",
175
+ "operation": "timeline.ripple_insert",
176
+ "execution_id": "exec_d2c123817bee",
177
+ "verification": {
178
+ "status": "passed",
179
+ "checks": [{"check": "readback_verification", "passed": true, "missing_items": 0}],
180
+ "contradiction": false
181
+ },
182
+ "changes": {"items_added": 3, "items_moved": 17, "items_deleted": 0}
183
+ }
184
+ }
185
+ ```
186
+
187
+ - **`status`** — `success` | `partial` | `blocked` | `failed`. `blocked` means a
188
+ confirm gate is waiting; the payload still carries the `confirm_token` and
189
+ `preview` to act on.
190
+ - **`verification.status`** — `passed` | `failed` | `partial` | `contradiction`
191
+ | `unverified`. **`contradiction` is the one to stop on**: Resolve reported
192
+ success and the readback disagrees. **`unverified` means no evidence was
193
+ reported, not that the operation was checked and found clean** — if you need
194
+ certainty there, go and read the state back.
195
+ - **`changes`** — the semantic delta, present only when the action declared or
196
+ reported one. **Absent means "not reported", never "nothing changed"**, so do
197
+ not read a missing `changes` as a no-op.
198
+ - **`warnings`** — present only when there are any.
199
+ - **`execution_id`** — correlates one call across logs and transcripts.
200
+
201
+ The envelope is namespaced under `_operation` rather than merged into the top
202
+ level because `status`, `operation`, `warnings`, `result` and `changes` are all
203
+ already domain keys on this server (a background job's `status` is `"done"`, a
204
+ confirm gate's is `"confirmation_required"`). The payload is passed through
205
+ untouched; read domain values where you always read them.
206
+
207
+ Change the shape with `setup(action="set_defaults", params={"result_envelope": "pure" | "legacy" | "dual"})`,
208
+ per call with `params={"envelope": "pure"}`, or per process with
209
+ `RESOLVE_MCP_RESULT_ENVELOPE`. `pure` returns only the envelope with the payload
210
+ nested under `result`; `legacy` adds nothing.
211
+
212
+ ---
213
+
161
214
  ## Two Server Modes
162
215
 
163
216
  | Mode | Entry point | Tool count | Use when |
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.204.0"
40
+ VERSION = "2.205.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": "2.204.0",
3
+ "version": "2.205.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.204.0"
90
+ VERSION = "2.205.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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.204.0"
14
+ VERSION = "2.205.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -60,6 +60,12 @@ from src.utils.page_lock import (
60
60
  )
61
61
  from src.utils.proc import safe_run
62
62
  from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
63
+ from src.utils import operation_result as _operation_result
64
+ from src.utils.operation_result import (
65
+ build_operation_envelope as _build_operation_envelope,
66
+ get_envelope_mode as _get_envelope_mode,
67
+ set_envelope_mode as _set_envelope_mode,
68
+ )
63
69
  from src.utils.render_ids import (
64
70
  render_codec_id_from_codecs as _render_codec_id_from_codecs,
65
71
  render_format_id_from_formats as _render_format_id_from_formats,
@@ -1472,6 +1478,15 @@ def _guarded_action_name(args, kwargs) -> str:
1472
1478
  return str(args[0]) if args else "?"
1473
1479
 
1474
1480
 
1481
+ def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
1482
+ """This call's `params` dict, wherever in the signature it was passed."""
1483
+ if isinstance(kwargs.get("params"), dict):
1484
+ return kwargs["params"]
1485
+ if len(args) > 1 and isinstance(args[1], dict):
1486
+ return args[1]
1487
+ return None
1488
+
1489
+
1475
1490
  def _guard_missing_params(fn):
1476
1491
  """Tool decorator: report a missing parameter instead of leaking a KeyError.
1477
1492
 
@@ -1489,21 +1504,35 @@ def _guard_missing_params(fn):
1489
1504
  - **The signature is not (action, params).** `media_analysis` also takes
1490
1505
  `ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
1491
1506
  than naming parameters it does not know about.
1507
+
1508
+ It is also where the operation envelope is attached, for the same reason it
1509
+ is where the missing-parameter guard lives: this is the one seam every tool
1510
+ return passes through. In the default `dual` mode the payload is untouched
1511
+ and the envelope rides under `_operation` — see `src/utils/operation_result`
1512
+ for why it is namespaced rather than flattened.
1492
1513
  """
1514
+ tool_name = getattr(fn, "__name__", "tool")
1515
+
1493
1516
  if inspect.iscoroutinefunction(fn):
1494
1517
  @functools.wraps(fn)
1495
1518
  async def wrapper(*args, **kwargs):
1519
+ action = _guarded_action_name(args, kwargs)
1496
1520
  try:
1497
- return await fn(*args, **kwargs)
1521
+ result = await fn(*args, **kwargs)
1498
1522
  except _MissingParam as exc:
1499
- return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1523
+ result = _missing_param_error(exc, action)
1524
+ return _build_operation_envelope(
1525
+ tool_name, action, _guarded_params(args, kwargs), result)
1500
1526
  else:
1501
1527
  @functools.wraps(fn)
1502
1528
  def wrapper(*args, **kwargs):
1529
+ action = _guarded_action_name(args, kwargs)
1503
1530
  try:
1504
- return fn(*args, **kwargs)
1531
+ result = fn(*args, **kwargs)
1505
1532
  except _MissingParam as exc:
1506
- return _missing_param_error(exc, _guarded_action_name(args, kwargs))
1533
+ result = _missing_param_error(exc, action)
1534
+ return _build_operation_envelope(
1535
+ tool_name, action, _guarded_params(args, kwargs), result)
1507
1536
 
1508
1537
  wrapper.__wrapped_by_missing_param_guard__ = True
1509
1538
  return wrapper
@@ -5238,6 +5267,15 @@ def _timeline_ripple_insert_impl(proj, tl, p: Dict[str, Any], *, resolve=None) -
5238
5267
  "readback": {"after_counts": after_counts, "missing": missing},
5239
5268
  "gap_frames_by_track": gap_by_track,
5240
5269
  "warnings": warnings,
5270
+ # Semantic delta for the operation envelope. Declared rather than
5271
+ # inferred: this action deletes and re-appends the tail to move it, so
5272
+ # a reader counting raw API calls would see a deletion that the edit
5273
+ # did not make.
5274
+ "_changes": {
5275
+ "items_added": len(built_inserts),
5276
+ "items_moved": len(tail_rows),
5277
+ "items_deleted": 0,
5278
+ },
5241
5279
  }
5242
5280
  if failures:
5243
5281
  result["failures"] = failures
@@ -10215,6 +10253,63 @@ def _read_json_strict(path: str) -> Dict[str, Any]:
10215
10253
  return payload if isinstance(payload, dict) else {}
10216
10254
 
10217
10255
 
10256
+ _SERVER_PREFS_ENV = "RESOLVE_MCP_SERVER_PREFS"
10257
+
10258
+
10259
+ def _server_preferences_path() -> str:
10260
+ """Where server-general defaults live.
10261
+
10262
+ Separate from the media-analysis preferences file: these are settings about
10263
+ how the server answers, not about how it analyses media, and mixing them
10264
+ would make either file's name a lie.
10265
+ """
10266
+ override = os.environ.get(_SERVER_PREFS_ENV)
10267
+ if override:
10268
+ return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
10269
+ return os.path.join(project_dir, "logs", "server-preferences.json")
10270
+
10271
+
10272
+ def _read_server_preferences() -> Dict[str, Any]:
10273
+ try:
10274
+ return _read_json_strict(_server_preferences_path())
10275
+ except ConfigParseError:
10276
+ return {}
10277
+
10278
+
10279
+ def _write_server_preferences(preferences: Dict[str, Any]) -> None:
10280
+ # Same atomic replace as the other preference stores: a crash mid-write must
10281
+ # not truncate a file whose reader falls back to {}, since the next save
10282
+ # would then persist that empty state over the user's settings.
10283
+ path = _server_preferences_path()
10284
+ os.makedirs(os.path.dirname(path), exist_ok=True)
10285
+ tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
10286
+ try:
10287
+ with open(tmp_path, "w", encoding="utf-8") as handle:
10288
+ json.dump(preferences, handle, indent=2, sort_keys=True)
10289
+ handle.write("\n")
10290
+ os.replace(tmp_path, path)
10291
+ finally:
10292
+ try:
10293
+ os.remove(tmp_path)
10294
+ except OSError:
10295
+ pass
10296
+
10297
+
10298
+ def _apply_persisted_envelope_mode() -> str:
10299
+ """Restore the saved result_envelope default at startup.
10300
+
10301
+ An explicit RESOLVE_MCP_RESULT_ENVELOPE wins: a process-level override is a
10302
+ deliberate act for this run, and a saved preference should not silently
10303
+ outrank it.
10304
+ """
10305
+ if os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE"):
10306
+ return _get_envelope_mode()
10307
+ saved = _read_server_preferences().get("result_envelope")
10308
+ if isinstance(saved, str):
10309
+ _set_envelope_mode(saved)
10310
+ return _get_envelope_mode()
10311
+
10312
+
10218
10313
  def _read_media_analysis_preferences() -> Dict[str, Any]:
10219
10314
  path = _media_analysis_preferences_path()
10220
10315
  try:
@@ -15091,13 +15186,59 @@ def _setup_updates_defaults() -> Dict[str, Any]:
15091
15186
  }
15092
15187
 
15093
15188
 
15189
+ def _setup_general_defaults() -> Dict[str, Any]:
15190
+ return {
15191
+ "result_envelope": _get_envelope_mode(),
15192
+ "options": {"result_envelope": list(_operation_result.MODES)},
15193
+ "preferences_path": _server_preferences_path(),
15194
+ }
15195
+
15196
+
15094
15197
  def _setup_defaults_snapshot() -> Dict[str, Any]:
15095
15198
  return {
15199
+ "general": _setup_general_defaults(),
15096
15200
  "media_analysis": _setup_media_analysis_defaults(),
15097
15201
  "updates": _setup_updates_defaults(),
15098
15202
  }
15099
15203
 
15100
15204
 
15205
+ def _setup_set_general_defaults(general: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
15206
+ """Persist server-general defaults. Currently just the result envelope."""
15207
+ if not general:
15208
+ return {"changed": False, "recognized": False}
15209
+
15210
+ mode = _first_param(
15211
+ general, "result_envelope", "resultEnvelope", "envelope_mode", "envelopeMode",
15212
+ default=None)
15213
+ if mode is None:
15214
+ return {"changed": False, "recognized": False}
15215
+
15216
+ cleaned = str(mode).strip().lower()
15217
+ if cleaned not in _operation_result.MODES:
15218
+ return _err(
15219
+ f"result_envelope must be one of {', '.join(_operation_result.MODES)}",
15220
+ code="INVALID_ENUM_VALUE", category="validation",
15221
+ state={"result_envelope": mode})
15222
+
15223
+ if dry_run:
15224
+ return {"changed": True, "recognized": True, "result_envelope": cleaned,
15225
+ "current": _get_envelope_mode()}
15226
+
15227
+ # Persist before applying: a saved setting that does not survive a restart
15228
+ # is a setting the caller was told they changed and did not.
15229
+ try:
15230
+ preferences = _read_json_strict(_server_preferences_path())
15231
+ except ConfigParseError as exc:
15232
+ return _err(
15233
+ f"server preferences file is unreadable: {exc}",
15234
+ code="CONFIG_UNREADABLE", category="state",
15235
+ remediation=f"Repair or delete {_server_preferences_path()}, then retry.")
15236
+ preferences["result_envelope"] = cleaned
15237
+ _write_server_preferences(preferences)
15238
+ _set_envelope_mode(cleaned)
15239
+ return {"changed": True, "recognized": True, "result_envelope": _get_envelope_mode()}
15240
+
15241
+
15101
15242
  def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
15102
15243
  if not media_defaults:
15103
15244
  return {"changed": False, "recognized": False}
@@ -15691,6 +15832,17 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15691
15832
  },
15692
15833
  "updates.check_interval_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
15693
15834
  "updates.snooze_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
15835
+ "general.result_envelope": {
15836
+ "description": (
15837
+ "Where the operation envelope goes. 'dual' (default) leaves the "
15838
+ "payload untouched and adds the envelope under '_operation'; "
15839
+ "'pure' returns only the envelope with the payload under 'result'; "
15840
+ "'legacy' adds nothing. Override per call with params={'envelope': ...}."
15841
+ ),
15842
+ "values": list(_operation_result.MODES),
15843
+ "current": _get_envelope_mode(),
15844
+ "storage": _server_preferences_path(),
15845
+ },
15694
15846
  },
15695
15847
  }
15696
15848
 
@@ -15702,12 +15854,23 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15702
15854
  if action in {"set_defaults", "set", "configure"}:
15703
15855
  defaults = p.get("defaults") if isinstance(p.get("defaults"), dict) else {}
15704
15856
  merged = {**defaults, **{k: v for k, v in p.items() if k != "defaults"}}
15857
+ # media_analysis owns every unclaimed key, so anything another setter
15858
+ # owns has to be named here or it lands in the wrong store.
15859
+ _general_keys = {
15860
+ "general", "result_envelope", "resultEnvelope",
15861
+ "envelope_mode", "envelopeMode",
15862
+ }
15863
+ general_defaults = {
15864
+ **_setup_nested(merged, "general"),
15865
+ **{k: v for k, v in merged.items() if k in _general_keys and k != "general"},
15866
+ }
15705
15867
  media_defaults = {
15706
15868
  **_setup_nested(merged, "media_analysis", "mediaAnalysis"),
15707
15869
  **{
15708
15870
  key: value
15709
15871
  for key, value in merged.items()
15710
15872
  if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
15873
+ and key not in _general_keys
15711
15874
  },
15712
15875
  **({
15713
15876
  "timed_markers_default": _first_param(
@@ -15758,19 +15921,25 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
15758
15921
  } if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
15759
15922
  }
15760
15923
 
15924
+ general_result = _setup_set_general_defaults(general_defaults, dry_run)
15925
+ if general_result.get("error"):
15926
+ return general_result
15761
15927
  media_result = _setup_set_media_analysis_defaults(media_defaults, dry_run)
15762
15928
  if media_result.get("error"):
15763
15929
  return media_result
15764
15930
  update_result = _setup_set_updates_defaults(update_defaults, dry_run)
15765
15931
  if update_result.get("error"):
15766
15932
  return update_result
15767
- recognized = bool(media_result.get("recognized")) or bool(update_result.get("recognized"))
15933
+ recognized = (bool(general_result.get("recognized"))
15934
+ or bool(media_result.get("recognized"))
15935
+ or bool(update_result.get("recognized")))
15768
15936
  if not recognized:
15769
15937
  return _err("set_defaults did not receive a recognized default to set")
15770
15938
 
15771
15939
  return _ok(
15772
15940
  dry_run=dry_run,
15773
15941
  changes={
15942
+ "general": general_result,
15774
15943
  "media_analysis": media_result,
15775
15944
  "updates": update_result,
15776
15945
  },
@@ -31310,6 +31479,9 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
31310
31479
 
31311
31480
 
31312
31481
  if __name__ == "__main__":
31482
+ # Before any tool can answer: a saved result_envelope default has to be in
31483
+ # force on the first call, not the second.
31484
+ logger.info(f"Result envelope mode: {_apply_persisted_envelope_mode()}")
31313
31485
  start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
31314
31486
  _install_threaded_tool_dispatch(mcp)
31315
31487
 
@@ -0,0 +1,377 @@
1
+ """A standard operation envelope over every compound tool's return value.
2
+
3
+ Agents orchestrating multi-turn edits have to answer the same three questions
4
+ after every call — did it actually happen, was it verified, and what changed —
5
+ and today each tool answers them in its own vocabulary: ``readback.missing``,
6
+ ``succeeded``/``failed``, ``partial``, ``status: "confirmation_required"``.
7
+ This module normalizes those into one shape so the answer is in the same place
8
+ every time.
9
+
10
+ Adapted from the design contributed in PR #181.
11
+
12
+ **Where the envelope lives.** Its keys are namespaced under a single reserved
13
+ key rather than merged into the top level, because half of them are already
14
+ domain keys here and merging silently destroys them:
15
+
16
+ status 22 sites — a background job's "done", a transcription's
17
+ "Transcribed", a confirm gate's "confirmation_required"
18
+ operation 20 sites
19
+ warnings 15 sites
20
+ result 8 sites
21
+ changes 2 sites
22
+
23
+ Flattening the envelope over those rewrites `job_status`'s "done" to "success"
24
+ (an agent polling a job then never sees it finish) and a confirm gate's
25
+ "confirmation_required" to "blocked" — renaming the very signal the envelope
26
+ exists to make unambiguous. So in the default ``dual`` mode the raw payload is
27
+ passed through **untouched** and the envelope is added under ``_operation``,
28
+ following the existing ``_versioning`` private-key convention. Nothing is
29
+ shadowed, nothing is dropped, and there is still exactly one place to look.
30
+
31
+ Modes:
32
+ ``dual`` (default) raw payload verbatim + ``_operation``.
33
+ ``pure`` only the envelope, domain payload nested under ``result``. Opt in
34
+ per call with ``params={"envelope": "pure"}``, per session with
35
+ ``setup(action="set_defaults", params={"result_envelope": "pure"})``,
36
+ or per process with ``RESOLVE_MCP_RESULT_ENVELOPE=pure``.
37
+ ``legacy`` no envelope at all.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ import os
44
+ import uuid
45
+ from typing import Any, Dict, List, Optional
46
+
47
+ logger = logging.getLogger("resolve-mcp.operation-result")
48
+
49
+ #: The reserved key the envelope hangs off in ``dual`` mode. Verified unused as
50
+ #: a domain key across ``src/`` — ``test_envelope_key_stays_reserved`` keeps it
51
+ #: that way, since the day a tool returns its own ``_operation`` is the day this
52
+ #: mode starts destroying payloads the way the flat one did.
53
+ ENVELOPE_KEY = "_operation"
54
+
55
+ MODES = ("dual", "pure", "legacy")
56
+ DEFAULT_MODE = "dual"
57
+
58
+
59
+ def _clean_mode(mode: Any) -> Optional[str]:
60
+ if not isinstance(mode, str):
61
+ return None
62
+ candidate = mode.strip().lower()
63
+ return candidate if candidate in MODES else None
64
+
65
+
66
+ _CURRENT_ENVELOPE_MODE = (
67
+ _clean_mode(os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE")) or DEFAULT_MODE
68
+ )
69
+
70
+
71
+ def get_envelope_mode() -> str:
72
+ """The envelope mode applied when a call does not name one."""
73
+ return _CURRENT_ENVELOPE_MODE
74
+
75
+
76
+ def set_envelope_mode(mode: str) -> str:
77
+ """Set the default envelope mode. Unknown values leave it unchanged."""
78
+ global _CURRENT_ENVELOPE_MODE
79
+ cleaned = _clean_mode(mode)
80
+ if cleaned:
81
+ _CURRENT_ENVELOPE_MODE = cleaned
82
+ return _CURRENT_ENVELOPE_MODE
83
+
84
+
85
+ def new_execution_id() -> str:
86
+ """A compact id for correlating one tool call across logs and transcripts."""
87
+ return f"exec_{uuid.uuid4().hex[:12]}"
88
+
89
+
90
+ # ─── Status ──────────────────────────────────────────────────────────────────
91
+
92
+ def normalize_status(raw: Any) -> str:
93
+ """Reduce a result to 'success' | 'partial' | 'blocked' | 'failed'.
94
+
95
+ Deliberately narrow. Every rule below keys on a convention this repo
96
+ actually uses, checked against the call sites — a heuristic that guesses
97
+ from a plausible-looking key name produces exactly the confident-and-wrong
98
+ status the envelope is supposed to eliminate. The clearest example is
99
+ ``blocked``: it reads like a gate flag, but here it is a domain key holding
100
+ the *list of targets that could not be resolved* (`timeline` range delete,
101
+ `timeline_item_color.bulk_match_to_hero`), and a successful dry-run carries
102
+ a non-empty one. Keying on it would report a gate that never happened.
103
+ """
104
+ if not isinstance(raw, dict):
105
+ return "success" if bool(raw) else "failed"
106
+
107
+ # The confirm gate. `_issue_confirm_token` is the single producer of these
108
+ # responses and always stamps this exact status, so the check is exact
109
+ # rather than inferred.
110
+ if raw.get("status") == "confirmation_required" or raw.get("confirmation_required") is True:
111
+ return "blocked"
112
+
113
+ if raw.get("error"):
114
+ return "failed"
115
+ if raw.get("success") is False:
116
+ return "failed"
117
+
118
+ # Bulk operations set this explicitly when some units failed.
119
+ if raw.get("partial") is True:
120
+ return "partial"
121
+ succeeded, failed = raw.get("succeeded"), raw.get("failed")
122
+ if isinstance(succeeded, int) and isinstance(failed, int) and succeeded > 0 and failed > 0:
123
+ return "partial"
124
+
125
+ return "success"
126
+
127
+
128
+ # ─── Warnings ────────────────────────────────────────────────────────────────
129
+
130
+ def extract_warnings(raw: Any) -> List[str]:
131
+ """Every advisory the payload carries, as a flat list of strings."""
132
+ if not isinstance(raw, dict):
133
+ return []
134
+
135
+ out: List[str] = []
136
+
137
+ def add(value: Any) -> None:
138
+ if isinstance(value, str) and value.strip() and value.strip() not in out:
139
+ out.append(value.strip())
140
+
141
+ plural = raw.get("warnings")
142
+ if isinstance(plural, list):
143
+ for item in plural:
144
+ add(item if isinstance(item, str) else str(item) if item else None)
145
+ else:
146
+ add(plural)
147
+ add(raw.get("warning"))
148
+
149
+ ignored = raw.get("ignored_options")
150
+ if ignored:
151
+ add(f"Ignored unsupported options: {ignored}")
152
+
153
+ return out
154
+
155
+
156
+ # ─── Verification ────────────────────────────────────────────────────────────
157
+
158
+ def extract_verification(raw: Any) -> Dict[str, Any]:
159
+ """Normalize whatever verification evidence the payload carries.
160
+
161
+ 'unverified' means *no evidence was reported*, which is not the same as
162
+ 'checked and fine' — a caller that needs certainty should treat it as an
163
+ open question rather than a pass.
164
+ """
165
+ unverified = {"status": "unverified", "checks": [], "contradiction": False}
166
+ if not isinstance(raw, dict):
167
+ return unverified
168
+
169
+ # An impl that already speaks this shape wins outright.
170
+ existing = raw.get("verification")
171
+ if isinstance(existing, dict) and existing:
172
+ merged = dict(existing)
173
+ merged.setdefault(
174
+ "status", "passed" if existing.get("verified") else "unverified")
175
+ merged.setdefault("checks", [])
176
+ merged.setdefault("contradiction", False)
177
+ return merged
178
+
179
+ checks: List[Dict[str, Any]] = []
180
+ status = "unverified"
181
+ contradiction = False
182
+
183
+ readback = raw.get("readback")
184
+ if isinstance(readback, dict):
185
+ missing = readback.get("missing")
186
+ if isinstance(missing, list):
187
+ checks.append({
188
+ "check": "readback_verification",
189
+ "passed": not missing,
190
+ "missing_items": len(missing),
191
+ })
192
+ status = "passed" if not missing else "failed"
193
+
194
+ # verify_by_readback's own shape: a mutation that reported success while the
195
+ # post-state disagrees is a contradiction, this repo's single most valuable
196
+ # reliability signal — it must not be flattened into a plain failure.
197
+ if "verified" in raw:
198
+ verified = bool(raw["verified"])
199
+ contradiction = bool(raw.get("contradiction"))
200
+ checks.append({
201
+ "check": "readback_post_state",
202
+ "passed": verified,
203
+ "contradiction": contradiction,
204
+ "observed": raw.get("observed"),
205
+ })
206
+ status = "contradiction" if contradiction else ("passed" if verified else "failed")
207
+
208
+ if "property_restore_failures" in raw:
209
+ failures = _as_int(raw.get("property_restore_failures"))
210
+ checks.append({
211
+ "check": "property_restore",
212
+ "passed": failures == 0,
213
+ "restored_items": _as_int(raw.get("properties_restored_items")),
214
+ "failures": failures,
215
+ })
216
+ if failures and status in ("passed", "unverified"):
217
+ status = "partial"
218
+
219
+ succeeded, failed = raw.get("succeeded"), raw.get("failed")
220
+ if isinstance(succeeded, int) and isinstance(failed, int):
221
+ checks.append({
222
+ "check": "bulk_operations",
223
+ "passed": failed == 0,
224
+ "succeeded": succeeded,
225
+ "failed": failed,
226
+ })
227
+ if status == "unverified":
228
+ if failed == 0 and succeeded > 0:
229
+ status = "passed"
230
+ elif succeeded > 0:
231
+ status = "partial"
232
+ elif failed > 0:
233
+ status = "failed"
234
+
235
+ if not checks:
236
+ return unverified
237
+ return {"status": status, "checks": checks, "contradiction": contradiction}
238
+
239
+
240
+ # ─── Changes ─────────────────────────────────────────────────────────────────
241
+
242
+ def _as_int(value: Any) -> int:
243
+ try:
244
+ return int(value)
245
+ except (TypeError, ValueError):
246
+ return 0
247
+
248
+
249
+ #: Domain key → semantic delta. Every entry is a key that exists in `src/` and
250
+ #: means what the delta says it means; nothing is mapped on the strength of a
251
+ #: suggestive name. `properties_restored_items`, for instance, is deliberately
252
+ #: absent: a ripple insert restores clip properties it had to re-apply after
253
+ #: moving items, which is bookkeeping, not an edit the user made.
254
+ _COUNT_ALIASES = {
255
+ "items_added": ("inserted_clips",),
256
+ "items_moved": ("tail_items_shifted",),
257
+ "items_deleted": (),
258
+ }
259
+
260
+
261
+ def extract_changes(raw: Any) -> Optional[Dict[str, Any]]:
262
+ """The operation's semantic delta, or None when it did not report one.
263
+
264
+ None, not ``{}``. An empty dict reads as "this operation changed nothing",
265
+ which is a false statement about a ripple insert that simply never declared
266
+ its deltas — the silent-lie failure this codebase treats as a bug class. A
267
+ caller that needs a number and gets None knows to go and count.
268
+ """
269
+ if not isinstance(raw, dict):
270
+ return None
271
+
272
+ changes: Dict[str, Any] = {}
273
+
274
+ # An impl that declares its own deltas is authoritative.
275
+ declared = raw.get("_changes")
276
+ if isinstance(declared, dict):
277
+ changes.update(declared)
278
+
279
+ for canonical, aliases in _COUNT_ALIASES.items():
280
+ if canonical in raw:
281
+ changes[canonical] = _as_int(raw[canonical])
282
+ continue
283
+ for alias in aliases:
284
+ if alias in raw:
285
+ changes[canonical] = _as_int(raw[alias])
286
+ break
287
+
288
+ if "shift_frames" in raw:
289
+ changes["shift_frames"] = raw["shift_frames"]
290
+
291
+ versioning = raw.get("_versioning")
292
+ if isinstance(versioning, dict):
293
+ metric = versioning.get("metric")
294
+ before, after = versioning.get("before_value"), versioning.get("after_value")
295
+ if metric and before is not None and after is not None:
296
+ changes["metric"] = metric
297
+ changes["before_value"] = before
298
+ changes["after_value"] = after
299
+ try:
300
+ changes["delta"] = round(float(after) - float(before), 4)
301
+ except (TypeError, ValueError):
302
+ pass
303
+ if versioning.get("archived"):
304
+ changes["timeline_archived"] = True
305
+ if versioning.get("archived_version"):
306
+ changes["archived_version"] = versioning["archived_version"]
307
+
308
+ return changes or None
309
+
310
+
311
+ # ─── Assembly ────────────────────────────────────────────────────────────────
312
+
313
+ def is_passthrough(obj: Any) -> bool:
314
+ """MCP content objects (Image, TextContent, EmbeddedResource) go through as-is."""
315
+ return getattr(obj.__class__, "__name__", "") in {
316
+ "Image", "TextContent", "EmbeddedResource"
317
+ }
318
+
319
+
320
+ def _requested_mode(params: Optional[Dict[str, Any]], fallback: Optional[str]) -> str:
321
+ if isinstance(params, dict):
322
+ for key in ("envelope", "_envelope"):
323
+ if key in params:
324
+ named = _clean_mode(params[key])
325
+ if named:
326
+ return named
327
+ if isinstance(params[key], bool):
328
+ return "pure" if params[key] else "legacy"
329
+ return _clean_mode(fallback) or get_envelope_mode()
330
+
331
+
332
+ def build_operation_envelope(
333
+ tool_name: str,
334
+ action: str,
335
+ params: Optional[Dict[str, Any]],
336
+ raw_result: Any,
337
+ *,
338
+ execution_id: Optional[str] = None,
339
+ mode: Optional[str] = None,
340
+ ) -> Any:
341
+ """Attach the operation envelope to one action's return value."""
342
+ if is_passthrough(raw_result):
343
+ return raw_result
344
+
345
+ selected = _requested_mode(params, mode)
346
+ if selected == "legacy":
347
+ return raw_result
348
+
349
+ envelope: Dict[str, Any] = {
350
+ "status": normalize_status(raw_result),
351
+ "operation": f"{tool_name}.{action}",
352
+ "execution_id": execution_id or new_execution_id(),
353
+ "verification": extract_verification(raw_result),
354
+ }
355
+
356
+ changes = extract_changes(raw_result)
357
+ if changes is not None:
358
+ envelope["changes"] = changes
359
+
360
+ warnings = extract_warnings(raw_result)
361
+ if warnings:
362
+ envelope["warnings"] = warnings
363
+
364
+ versioning = raw_result.get("_versioning") if isinstance(raw_result, dict) else None
365
+ if isinstance(versioning, dict) and versioning.get("analysis_run_id"):
366
+ envelope["run_id"] = versioning["analysis_run_id"]
367
+
368
+ if selected == "pure":
369
+ return {**envelope, "result": raw_result}
370
+
371
+ # dual: the payload is passed through byte-for-byte and the envelope rides
372
+ # alongside it. A non-dict return has nowhere to carry the key, so it is
373
+ # handed back unchanged rather than being boxed into a shape callers of
374
+ # that action have never seen.
375
+ if not isinstance(raw_result, dict):
376
+ return raw_result
377
+ return {**raw_result, ENVELOPE_KEY: envelope}
@@ -89,3 +89,32 @@ def verify_by_readback(
89
89
  _STATS["unverified"] += 1
90
90
 
91
91
  return result
92
+
93
+
94
+ def as_verification_dict(
95
+ readback_result: Dict[str, Any],
96
+ *,
97
+ check_name: str = "readback_verification",
98
+ ) -> Dict[str, Any]:
99
+ """Render a ``verify_by_readback`` result in the operation envelope's shape.
100
+
101
+ A contradiction stays a distinct status rather than collapsing into a plain
102
+ failure: "the API said yes and the post-state says no" is a different thing
103
+ for a caller to act on than "the call failed".
104
+ """
105
+ verified = bool(readback_result.get("verified"))
106
+ contradiction = bool(readback_result.get("contradiction"))
107
+ check: Dict[str, Any] = {
108
+ "check": check_name,
109
+ "passed": verified,
110
+ "contradiction": contradiction,
111
+ "success_raw": readback_result.get("success_raw"),
112
+ "observed": readback_result.get("observed"),
113
+ }
114
+ if "intent" in readback_result:
115
+ check["intent"] = readback_result["intent"]
116
+ return {
117
+ "status": "contradiction" if contradiction else ("passed" if verified else "failed"),
118
+ "checks": [check],
119
+ "contradiction": contradiction,
120
+ }