davinci-resolve-mcp 2.33.5 → 2.33.7
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 +28 -0
- package/README.md +1 -1
- package/docs/SKILL.md +1 -1
- package/docs/reference/readwrite-symmetry.md +25 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/audit_readwrite_symmetry.py +80 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +38 -18
- package/src/utils/contracts.py +102 -0
- package/src/utils/proc.py +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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.33.7
|
|
6
|
+
|
|
7
|
+
Read/write symmetry audit and a gap it surfaced.
|
|
8
|
+
|
|
9
|
+
- **Added** `scripts/audit_readwrite_symmetry.py` + generated
|
|
10
|
+
`docs/reference/readwrite-symmetry.md` — scans every tool's action surface and
|
|
11
|
+
reports `set_`/`add_`/`create_` writes that lack a read counterpart, so
|
|
12
|
+
write-without-read gaps surface before users hit them. A repeatable
|
|
13
|
+
feature-discovery method.
|
|
14
|
+
- **Added** `fusion_comp(action="get_frame_range")` — reads the comp's render
|
|
15
|
+
frame range, the read counterpart to `set_frame_range` (a gap the audit found).
|
|
16
|
+
|
|
17
|
+
## What's New in v2.33.6
|
|
18
|
+
|
|
19
|
+
Internal consolidation: a declarative parameter-contract validator and centralized
|
|
20
|
+
subprocess hygiene.
|
|
21
|
+
|
|
22
|
+
- **Added** `src/utils/contracts.py` `validate(params, rules, invariants)` — one
|
|
23
|
+
validator for required/type/enum/min/max/non-empty/parent-dir-exists plus custom
|
|
24
|
+
invariants, returning consistent agent-friendly errors with coercion + defaults
|
|
25
|
+
applied. Replaces scattered, hand-written validation.
|
|
26
|
+
- **Changed** `export_frame_as_still` and `set_mark_in_out` (clip + timeline) now
|
|
27
|
+
validate through contracts. Behavior is preserved (same rejections); `mark_in`/
|
|
28
|
+
`mark_out` are now coerced to int.
|
|
29
|
+
- **Added** `src/utils/proc.py` `safe_run`/`safe_popen` — subprocess wrappers that
|
|
30
|
+
default `stdin` to DEVNULL so a child can't consume the MCP stdio protocol
|
|
31
|
+
stream. Inline Python execution now routes through `safe_run`.
|
|
32
|
+
|
|
5
33
|
## What's New in v2.33.5
|
|
6
34
|
|
|
7
35
|
A queryable ledger of verified Resolve API behavior.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -997,7 +997,7 @@ Key actions:
|
|
|
997
997
|
temp `.setting` file), optionally renaming and repositioning it
|
|
998
998
|
- `auto_arrange(tool_names?, direction?, spacing?, x?, y?)` — lay tools out in a row
|
|
999
999
|
(`direction="horizontal"`, default) or column (`"vertical"`)
|
|
1000
|
-
- `get_comp_info`, `set_frame_range(start, end)`, `render`
|
|
1000
|
+
- `get_comp_info`, `set_frame_range(start, end)`, `get_frame_range`, `render`
|
|
1001
1001
|
- `start_undo(name?)` / `end_undo(keep?)`
|
|
1002
1002
|
- `bulk_set_inputs(ops)` — batch set inputs across multiple timeline item comps in
|
|
1003
1003
|
one call; each op requires timeline scope plus `tool_name`, `input_name`, `value`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!-- Generated by scripts/audit_readwrite_symmetry.py — do not edit by hand. -->
|
|
2
|
+
|
|
3
|
+
# Read/Write Symmetry Audit
|
|
4
|
+
|
|
5
|
+
- write-style actions scanned: **106**
|
|
6
|
+
- with a matching read: **57**
|
|
7
|
+
- `set_` actions with no `get_`/`list_` (real readback gaps): **11**
|
|
8
|
+
|
|
9
|
+
## High-signal gaps — `set_` with no read counterpart
|
|
10
|
+
|
|
11
|
+
- `set_cache`
|
|
12
|
+
- `set_cdl`
|
|
13
|
+
- `set_clips_linked`
|
|
14
|
+
- `set_high_priority`
|
|
15
|
+
- `set_keyframe_interpolation`
|
|
16
|
+
- `set_mcp_update_policy`
|
|
17
|
+
- `set_name`
|
|
18
|
+
- `set_node_enabled`
|
|
19
|
+
- `set_title_text`
|
|
20
|
+
- `set_track_enable`
|
|
21
|
+
- `set_track_lock`
|
|
22
|
+
|
|
23
|
+
## Low-signal (create/add/insert/import — usually expected): 37
|
|
24
|
+
|
|
25
|
+
`add_clip_mattes`, `add_comp`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_burnin`, `import_comp`, `import_folder`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.33.
|
|
38
|
+
VERSION = "2.33.7"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit read/write symmetry across the compound server's action surface.
|
|
3
|
+
|
|
4
|
+
For every mutating action (set_/add_/clear_/enable_/...) it checks whether a
|
|
5
|
+
matching read action (get_/list_/...) exists on the same tool, and reports the
|
|
6
|
+
asymmetries. The goal is to find write-without-read gaps before users have to —
|
|
7
|
+
the repeatable feature-discovery method behind R5.
|
|
8
|
+
|
|
9
|
+
Reads the `_unknown(action, [...])` lists in src/server.py, which enumerate every
|
|
10
|
+
action a tool accepts. Prints a markdown report.
|
|
11
|
+
"""
|
|
12
|
+
import ast
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
SERVER = os.path.join(ROOT, "src", "server.py")
|
|
19
|
+
|
|
20
|
+
READ_PREFIXES = ("get_", "list_", "probe_", "is_", "has_", "find_")
|
|
21
|
+
# `set_` is the high-signal class: a set with no get is a genuine readback gap.
|
|
22
|
+
# create_/add_/insert_/import_ are inherently writes that usually have no paired
|
|
23
|
+
# read of the same noun, so they're reported separately as low-signal.
|
|
24
|
+
HIGH_SIGNAL = ("set_",)
|
|
25
|
+
LOW_SIGNAL = ("add_", "create_", "insert_", "apply_", "import_")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _action_lists(src: str):
|
|
29
|
+
"""Yield lists of action-name string literals from each _unknown(action, [...])."""
|
|
30
|
+
for m in re.finditer(r"_unknown\(action,\s*\[(.*?)\]\)", src, re.DOTALL):
|
|
31
|
+
names = re.findall(r'"([a-z][a-z0-9_]*)"', m.group(1))
|
|
32
|
+
if names:
|
|
33
|
+
yield names
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_read(stem: str, aset: set) -> bool:
|
|
37
|
+
# Match get_<stem>, list_<stem>, and plural get_<stem>s (e.g. add_keyframe -> get_keyframes).
|
|
38
|
+
candidates = {rp + stem for rp in READ_PREFIXES}
|
|
39
|
+
candidates |= {rp + stem + "s" for rp in READ_PREFIXES}
|
|
40
|
+
candidates |= {rp + stem.rstrip("s") for rp in READ_PREFIXES}
|
|
41
|
+
return bool(candidates & aset)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def audit(src: str):
|
|
45
|
+
high, low, covered, total = set(), set(), 0, 0
|
|
46
|
+
for actions in _action_lists(src):
|
|
47
|
+
aset = set(actions)
|
|
48
|
+
for a in actions:
|
|
49
|
+
for wp in HIGH_SIGNAL + LOW_SIGNAL:
|
|
50
|
+
if a.startswith(wp):
|
|
51
|
+
total += 1
|
|
52
|
+
stem = a[len(wp):]
|
|
53
|
+
if _has_read(stem, aset):
|
|
54
|
+
covered += 1
|
|
55
|
+
elif wp in HIGH_SIGNAL:
|
|
56
|
+
high.add(a)
|
|
57
|
+
else:
|
|
58
|
+
low.add(a)
|
|
59
|
+
break
|
|
60
|
+
return total, covered, sorted(high), sorted(low)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
src = open(SERVER, encoding="utf-8").read()
|
|
65
|
+
total, covered, high, low = audit(src)
|
|
66
|
+
print("# Read/Write Symmetry Audit\n")
|
|
67
|
+
print(f"- write-style actions scanned: **{total}**")
|
|
68
|
+
print(f"- with a matching read: **{covered}**")
|
|
69
|
+
print(f"- `set_` actions with no `get_`/`list_` (real readback gaps): **{len(high)}**\n")
|
|
70
|
+
if high:
|
|
71
|
+
print("## High-signal gaps — `set_` with no read counterpart\n")
|
|
72
|
+
for a in high:
|
|
73
|
+
print(f"- `{a}`")
|
|
74
|
+
print(f"\n## Low-signal (create/add/insert/import — usually expected): {len(low)}\n")
|
|
75
|
+
print(", ".join(f"`{a}`" for a in low))
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.33.
|
|
83
|
+
VERSION = "2.33.7"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.33.
|
|
14
|
+
VERSION = "2.33.7"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -44,6 +44,8 @@ for p in [current_dir, project_dir]:
|
|
|
44
44
|
from src.utils.cdl import normalize_cdl_payload
|
|
45
45
|
from src.utils.mcp_stdio import run_fastmcp_stdio
|
|
46
46
|
from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
|
|
47
|
+
from src.utils.contracts import validate as _validate_params
|
|
48
|
+
from src.utils.proc import safe_run
|
|
47
49
|
from src.utils.readback import verify_by_readback
|
|
48
50
|
from src.utils.update_check import (
|
|
49
51
|
check_for_updates,
|
|
@@ -12626,13 +12628,12 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12626
12628
|
g = proj.GetGallery()
|
|
12627
12629
|
return {"available": g is not None}
|
|
12628
12630
|
elif action == "export_frame_as_still":
|
|
12629
|
-
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
|
|
12635
|
-
return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
|
|
12631
|
+
err, clean = _validate_params(p, {
|
|
12632
|
+
"path": {"type": str, "required": True, "non_empty": True, "parent_dir_exists": True},
|
|
12633
|
+
})
|
|
12634
|
+
if err:
|
|
12635
|
+
return _err(err)
|
|
12636
|
+
return {"success": bool(proj.ExportCurrentFrameAsStill(clean["path"]))}
|
|
12636
12637
|
elif action == "project_summary":
|
|
12637
12638
|
return _project_summary(
|
|
12638
12639
|
proj,
|
|
@@ -14079,9 +14080,15 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14079
14080
|
elif action == "get_mark_in_out":
|
|
14080
14081
|
return _ser(clip.GetMarkInOut())
|
|
14081
14082
|
elif action == "set_mark_in_out":
|
|
14082
|
-
|
|
14083
|
-
|
|
14084
|
-
|
|
14083
|
+
err, clean = _validate_params(
|
|
14084
|
+
p,
|
|
14085
|
+
{"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}},
|
|
14086
|
+
invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})"
|
|
14087
|
+
if c["mark_in"] > c["mark_out"] else None],
|
|
14088
|
+
)
|
|
14089
|
+
if err:
|
|
14090
|
+
return _err(err)
|
|
14091
|
+
return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
|
|
14085
14092
|
elif action == "clear_mark_in_out":
|
|
14086
14093
|
return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
|
|
14087
14094
|
return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
|
|
@@ -15485,9 +15492,15 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15485
15492
|
elif action == "get_mark_in_out":
|
|
15486
15493
|
return _ser(tl.GetMarkInOut())
|
|
15487
15494
|
elif action == "set_mark_in_out":
|
|
15488
|
-
|
|
15489
|
-
|
|
15490
|
-
|
|
15495
|
+
err, clean = _validate_params(
|
|
15496
|
+
p,
|
|
15497
|
+
{"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}},
|
|
15498
|
+
invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})"
|
|
15499
|
+
if c["mark_in"] > c["mark_out"] else None],
|
|
15500
|
+
)
|
|
15501
|
+
if err:
|
|
15502
|
+
return _err(err)
|
|
15503
|
+
return {"success": bool(tl.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
|
|
15491
15504
|
elif action == "clear_mark_in_out":
|
|
15492
15505
|
return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
|
|
15493
15506
|
elif action == "convert_to_stereo":
|
|
@@ -18717,6 +18730,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18717
18730
|
auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count}
|
|
18718
18731
|
Lay tools out in a row (direction="horizontal", default) or column ("vertical").
|
|
18719
18732
|
set_frame_range(start, end) -> {success}
|
|
18733
|
+
get_frame_range() -> {start, end} — read the comp's render frame range
|
|
18720
18734
|
render() -> {success}
|
|
18721
18735
|
start_undo(name?) -> {success}
|
|
18722
18736
|
end_undo(keep?) -> {success}
|
|
@@ -19008,6 +19022,13 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19008
19022
|
comp.SetAttrs({"COMPN_RenderStartTime": p["start"], "COMPN_RenderEndTime": p["end"]})
|
|
19009
19023
|
return _ok()
|
|
19010
19024
|
|
|
19025
|
+
elif action == "get_frame_range":
|
|
19026
|
+
attrs = comp.GetAttrs() or {}
|
|
19027
|
+
return {
|
|
19028
|
+
"start": attrs.get("COMPN_RenderStartTime"),
|
|
19029
|
+
"end": attrs.get("COMPN_RenderEndTime"),
|
|
19030
|
+
}
|
|
19031
|
+
|
|
19011
19032
|
elif action == "render":
|
|
19012
19033
|
comp.Render()
|
|
19013
19034
|
return _ok()
|
|
@@ -19146,7 +19167,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19146
19167
|
"connect","disconnect","get_inputs","get_outputs",
|
|
19147
19168
|
"set_input","get_input","set_attrs","get_attrs",
|
|
19148
19169
|
"add_keyframe","get_keyframes","delete_keyframe",
|
|
19149
|
-
"get_comp_info","set_frame_range","render",
|
|
19170
|
+
"get_comp_info","set_frame_range","get_frame_range","render",
|
|
19150
19171
|
"start_undo","end_undo",
|
|
19151
19172
|
"get_position","set_position","copy_tool","auto_arrange",
|
|
19152
19173
|
"bulk_set_inputs",
|
|
@@ -19757,9 +19778,8 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
19757
19778
|
get_resolve()
|
|
19758
19779
|
cmd = [sys.executable, path] + [str(a) for a in args]
|
|
19759
19780
|
try:
|
|
19760
|
-
result =
|
|
19761
|
-
|
|
19762
|
-
timeout=timeout, stdin=subprocess.DEVNULL)
|
|
19781
|
+
result = safe_run(cmd, env=_python_env_for_resolve(),
|
|
19782
|
+
capture_output=True, text=True, timeout=timeout)
|
|
19763
19783
|
except subprocess.TimeoutExpired as e:
|
|
19764
19784
|
return _err(f"Script timed out after {timeout}s. "
|
|
19765
19785
|
f"Partial stdout: {(e.stdout or '')[:1000]}")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Declarative parameter validation for compound-tool actions.
|
|
2
|
+
|
|
3
|
+
Input validation in the server has historically been scattered: hand-built
|
|
4
|
+
"Must be one of" strings, ad-hoc empty/range/path checks inline in each action.
|
|
5
|
+
This module centralizes it into one declarative validator that emits consistent,
|
|
6
|
+
agent-friendly error messages, so the validation bug class is closed once rather
|
|
7
|
+
than patched action by action.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
|
|
11
|
+
err, clean = validate(params, {
|
|
12
|
+
"path": {"type": str, "required": True, "non_empty": True, "parent_dir_exists": True},
|
|
13
|
+
"mark_in": {"type": int, "required": True},
|
|
14
|
+
"mark_out": {"type": int, "required": True},
|
|
15
|
+
"kind": {"enum": ["all", "video", "audio"], "default": "all"},
|
|
16
|
+
}, invariants=[
|
|
17
|
+
lambda c: "mark_in must be <= mark_out" if c["mark_in"] > c["mark_out"] else None,
|
|
18
|
+
])
|
|
19
|
+
if err:
|
|
20
|
+
return _err(err)
|
|
21
|
+
# use clean[...] — coerced + defaulted
|
|
22
|
+
|
|
23
|
+
Rule keys: ``type`` (int/str/float/bool), ``required``, ``default``, ``enum``,
|
|
24
|
+
``min``, ``max``, ``non_empty`` (str), ``parent_dir_exists`` (str path).
|
|
25
|
+
"""
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _coerce(name: str, value: Any, t: type) -> Tuple[Any, Optional[str]]:
|
|
31
|
+
if t is bool:
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return value, None
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return value.strip().lower() in ("1", "true", "yes", "on"), None
|
|
36
|
+
return bool(value), None
|
|
37
|
+
if t is int:
|
|
38
|
+
try:
|
|
39
|
+
return int(value), None
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return None, f"'{name}' must be an integer"
|
|
42
|
+
if t is float:
|
|
43
|
+
try:
|
|
44
|
+
return float(value), None
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return None, f"'{name}' must be a number"
|
|
47
|
+
if t is str:
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
return None, f"'{name}' must be a string"
|
|
50
|
+
return value, None
|
|
51
|
+
return value, None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate(
|
|
55
|
+
params: Dict[str, Any],
|
|
56
|
+
rules: Dict[str, Dict[str, Any]],
|
|
57
|
+
invariants: Optional[List[Callable[[Dict[str, Any]], Optional[str]]]] = None,
|
|
58
|
+
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
|
59
|
+
"""Validate ``params`` against ``rules``.
|
|
60
|
+
|
|
61
|
+
Returns ``(None, cleaned)`` on success (cleaned has coercions + defaults
|
|
62
|
+
applied), or ``(error_message, None)`` on the first violation.
|
|
63
|
+
"""
|
|
64
|
+
cleaned = dict(params)
|
|
65
|
+
|
|
66
|
+
for name, rule in rules.items():
|
|
67
|
+
present = name in params and params[name] is not None
|
|
68
|
+
if not present:
|
|
69
|
+
if rule.get("required"):
|
|
70
|
+
return f"'{name}' is required", None
|
|
71
|
+
if "default" in rule:
|
|
72
|
+
cleaned[name] = rule["default"]
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
val = params[name]
|
|
76
|
+
t = rule.get("type")
|
|
77
|
+
if t is not None:
|
|
78
|
+
val, err = _coerce(name, val, t)
|
|
79
|
+
if err:
|
|
80
|
+
return err, None
|
|
81
|
+
|
|
82
|
+
if "enum" in rule and val not in rule["enum"]:
|
|
83
|
+
return f"'{name}' must be one of: {', '.join(map(str, rule['enum']))}", None
|
|
84
|
+
if rule.get("non_empty") and isinstance(val, str) and not val.strip():
|
|
85
|
+
return f"'{name}' must be non-empty", None
|
|
86
|
+
if "min" in rule and val < rule["min"]:
|
|
87
|
+
return f"'{name}' must be >= {rule['min']}", None
|
|
88
|
+
if "max" in rule and val > rule["max"]:
|
|
89
|
+
return f"'{name}' must be <= {rule['max']}", None
|
|
90
|
+
if rule.get("parent_dir_exists"):
|
|
91
|
+
parent = os.path.dirname(os.path.expanduser(str(val)))
|
|
92
|
+
if parent and not os.path.isdir(parent):
|
|
93
|
+
return f"'{name}' target directory does not exist: {parent}", None
|
|
94
|
+
|
|
95
|
+
cleaned[name] = val
|
|
96
|
+
|
|
97
|
+
for inv in (invariants or []):
|
|
98
|
+
msg = inv(cleaned)
|
|
99
|
+
if msg:
|
|
100
|
+
return msg, None
|
|
101
|
+
|
|
102
|
+
return None, cleaned
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Subprocess helpers that are safe to call while the MCP stdio server is live.
|
|
2
|
+
|
|
3
|
+
The server owns the JSON-RPC stdin/stdout while serving over stdio. A child
|
|
4
|
+
process that inherits stdin can race-read bytes off the protocol stream and
|
|
5
|
+
corrupt it; ``capture_output`` only redirects stdout/stderr. These wrappers
|
|
6
|
+
default ``stdin`` to ``DEVNULL`` so subprocess hygiene is centralized rather
|
|
7
|
+
than re-applied at every call site.
|
|
8
|
+
"""
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_run(*args: Any, **kwargs: Any) -> "subprocess.CompletedProcess":
|
|
14
|
+
"""subprocess.run with stdin defaulted to DEVNULL (override by passing stdin).
|
|
15
|
+
|
|
16
|
+
If ``input`` is given, stdin is left alone — subprocess forbids passing both.
|
|
17
|
+
"""
|
|
18
|
+
if "input" not in kwargs:
|
|
19
|
+
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
|
20
|
+
return subprocess.run(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_popen(*args: Any, **kwargs: Any) -> "subprocess.Popen":
|
|
24
|
+
"""subprocess.Popen with stdin defaulted to DEVNULL (override by passing stdin)."""
|
|
25
|
+
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
|
26
|
+
return subprocess.Popen(*args, **kwargs)
|