davinci-resolve-mcp 2.33.4 → 2.33.6

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,36 @@
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.6
6
+
7
+ Internal consolidation: a declarative parameter-contract validator and centralized
8
+ subprocess hygiene.
9
+
10
+ - **Added** `src/utils/contracts.py` `validate(params, rules, invariants)` — one
11
+ validator for required/type/enum/min/max/non-empty/parent-dir-exists plus custom
12
+ invariants, returning consistent agent-friendly errors with coercion + defaults
13
+ applied. Replaces scattered, hand-written validation.
14
+ - **Changed** `export_frame_as_still` and `set_mark_in_out` (clip + timeline) now
15
+ validate through contracts. Behavior is preserved (same rejections); `mark_in`/
16
+ `mark_out` are now coerced to int.
17
+ - **Added** `src/utils/proc.py` `safe_run`/`safe_popen` — subprocess wrappers that
18
+ default `stdin` to DEVNULL so a child can't consume the MCP stdio protocol
19
+ stream. Inline Python execution now routes through `safe_run`.
20
+
21
+ ## What's New in v2.33.5
22
+
23
+ A queryable ledger of verified Resolve API behavior.
24
+
25
+ - **Added** `resolve_control(action="api_truth", query?)` returns
26
+ behaviorally-verified facts about quirky or unreliable Resolve scripting-API
27
+ behavior — methods that live on unexpected objects, return values that lie,
28
+ silently-rejected string keys, and calls that don't exist. Each fact records
29
+ the reality, a recommended approach, and the Resolve build it was verified on.
30
+ No connection required. Backed by `src/utils/api_truth.py`, seeded from
31
+ hard-won findings (AutoSyncAudio, Fusion Paste, FlowView positions,
32
+ GetTimelineByName, project render methods, transcription truncation, the
33
+ CreateProject modal, stdio subprocess hygiene) and meant to grow over time.
34
+
5
35
  ## What's New in v2.33.4
6
36
 
7
37
  Internal reliability framework.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.33.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.33.6-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -318,6 +318,8 @@ Key actions:
318
318
  - `launch` — connect to or start Resolve; call this first if any tool returns a
319
319
  "Not connected" error
320
320
  - `get_version` — returns `{product, version, version_string}`
321
+ - `api_truth(query?)` — look up behaviorally-verified facts about quirky/unreliable
322
+ Resolve API behavior (no connection needed); filter by substring
321
323
  - `get_page` / `open_page(page)` — read or switch the active page
322
324
  - `get_keyframe_mode` / `set_keyframe_mode(mode)`
323
325
  - `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.33.4"
38
+ VERSION = "2.33.6"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.33.4",
3
+ "version": "2.33.6",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.33.4"
83
+ VERSION = "2.33.6"
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.4"
14
+ VERSION = "2.33.6"
15
15
 
16
16
  import base64
17
17
  import os
@@ -43,6 +43,9 @@ for p in [current_dir, project_dir]:
43
43
  # Platform-specific Resolve paths
44
44
  from src.utils.cdl import normalize_cdl_payload
45
45
  from src.utils.mcp_stdio import run_fastmcp_stdio
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
46
49
  from src.utils.readback import verify_by_readback
47
50
  from src.utils.update_check import (
48
51
  check_for_updates,
@@ -10575,6 +10578,8 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
10575
10578
  ignore_mcp_update() -> {success, version, update, decision}
10576
10579
  snooze_mcp_update(hours?) -> {success, version, update, decision}
10577
10580
  clear_mcp_update_preferences() -> {success, version, update, decision}
10581
+ api_truth(query?) -> {verified_on, count, facts} — look up behaviorally-verified
10582
+ facts about quirky/unreliable Resolve API behavior (no connection needed).
10578
10583
  get_page() -> {page}
10579
10584
  open_page(page) -> {success} — page: edit, cut, color, fusion, fairlight, deliver
10580
10585
  get_keyframe_mode() -> {mode}
@@ -10595,6 +10600,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
10595
10600
  """
10596
10601
  p = params or {}
10597
10602
 
10603
+ # api_truth is a static knowledge lookup — no Resolve connection needed.
10604
+ if action == "api_truth":
10605
+ facts = lookup_api_truth(p.get("query"))
10606
+ return {"verified_on": _API_TRUTH_VERIFIED_ON, "count": len(facts), "facts": facts}
10607
+
10598
10608
  # Control-panel actions don't require Resolve to be running.
10599
10609
  if action == "open_control_panel":
10600
10610
  return _open_control_panel(p)
@@ -10682,7 +10692,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
10682
10692
  return missing
10683
10693
  r.DisableBackgroundTasksForCurrentResolveSession()
10684
10694
  return _ok()
10685
- return _unknown(action, ["launch","get_version","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
10695
+ return _unknown(action, ["launch","get_version","api_truth","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
10686
10696
 
10687
10697
 
10688
10698
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -12618,13 +12628,12 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12618
12628
  g = proj.GetGallery()
12619
12629
  return {"available": g is not None}
12620
12630
  elif action == "export_frame_as_still":
12621
- still_path = p.get("path")
12622
- if not still_path:
12623
- return _err("export_frame_as_still requires a non-empty 'path'")
12624
- parent = os.path.dirname(os.path.expanduser(still_path))
12625
- if parent and not os.path.isdir(parent):
12626
- return _err(f"export_frame_as_still target directory does not exist: {parent}")
12627
- 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"]))}
12628
12637
  elif action == "project_summary":
12629
12638
  return _project_summary(
12630
12639
  proj,
@@ -14071,9 +14080,15 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14071
14080
  elif action == "get_mark_in_out":
14072
14081
  return _ser(clip.GetMarkInOut())
14073
14082
  elif action == "set_mark_in_out":
14074
- if p["mark_in"] > p["mark_out"]:
14075
- return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
14076
- return {"success": bool(clip.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
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")))}
14077
14092
  elif action == "clear_mark_in_out":
14078
14093
  return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
14079
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"])
@@ -15477,9 +15492,15 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15477
15492
  elif action == "get_mark_in_out":
15478
15493
  return _ser(tl.GetMarkInOut())
15479
15494
  elif action == "set_mark_in_out":
15480
- if p["mark_in"] > p["mark_out"]:
15481
- return _err(f"mark_in ({p['mark_in']}) must be <= mark_out ({p['mark_out']})")
15482
- return {"success": bool(tl.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
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")))}
15483
15504
  elif action == "clear_mark_in_out":
15484
15505
  return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))}
15485
15506
  elif action == "convert_to_stereo":
@@ -19749,9 +19770,8 @@ def _execute_python_script(path: str, args: List[str],
19749
19770
  get_resolve()
19750
19771
  cmd = [sys.executable, path] + [str(a) for a in args]
19751
19772
  try:
19752
- result = subprocess.run(cmd, env=_python_env_for_resolve(),
19753
- capture_output=True, text=True,
19754
- timeout=timeout, stdin=subprocess.DEVNULL)
19773
+ result = safe_run(cmd, env=_python_env_for_resolve(),
19774
+ capture_output=True, text=True, timeout=timeout)
19755
19775
  except subprocess.TimeoutExpired as e:
19756
19776
  return _err(f"Script timed out after {timeout}s. "
19757
19777
  f"Partial stdout: {(e.stdout or '')[:1000]}")
@@ -0,0 +1,132 @@
1
+ """Behaviorally-verified facts about the DaVinci Resolve scripting API.
2
+
3
+ The Resolve scripting API is under-documented and frequently behaves differently
4
+ from its apparent signature: methods live on objects you wouldn't expect, return
5
+ values lie, string keys are silently rejected, and some documented-looking calls
6
+ don't exist. This module records facts we have *verified against live Resolve* so
7
+ agents and code can look the reality up instead of rediscovering it the hard way.
8
+
9
+ Each entry is a small dict. Grow it opportunistically — every readback recipe and
10
+ every fix that uncovers a surprising behavior should add an entry. Facts are
11
+ stamped with the Resolve build they were verified on so drift is visible when a
12
+ new version ships.
13
+ """
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ VERIFIED_ON = "DaVinci Resolve Studio 21.0.0"
17
+
18
+ # Each entry: symbol, object, reality, recommended, tags. `signature` optional.
19
+ API_TRUTH: List[Dict[str, Any]] = [
20
+ {
21
+ "symbol": "MediaPool.AutoSyncAudio",
22
+ "object": "MediaPool",
23
+ "signature": "(clips, settings) -> bool",
24
+ "reality": "The boolean return does not reflect whether clips actually "
25
+ "linked, and string enum keys in `settings` are silently "
26
+ "rejected (the call returns False).",
27
+ "recommended": "Resolve the AUDIO_SYNC_* enum constants via the live "
28
+ "resolve handle, and verify by reading each clip's "
29
+ "'Synced Audio' property (see verify_by_readback).",
30
+ "tags": ["unreliable-return", "silent-failure", "audio", "enum"],
31
+ },
32
+ {
33
+ "symbol": "Composition.Paste",
34
+ "object": "Fusion Composition",
35
+ "reality": "Passing tool.SaveSettings()'s in-memory table to Paste() / "
36
+ "LoadSettings() fails across the Python bridge with an "
37
+ "OrderedDict/null-argument error and creates no node, while "
38
+ "reporting nothing useful.",
39
+ "recommended": "Duplicate via AddTool(RegID) + SaveSettings(path)/"
40
+ "LoadSettings(path) through a temp .setting FILE, which "
41
+ "round-trips reliably. Identify the new node by name diff.",
42
+ "tags": ["fusion", "bridge", "silent-failure"],
43
+ },
44
+ {
45
+ "symbol": "FlowView.SetPos / FlowView.GetPosTable",
46
+ "object": "Fusion FlowView (comp.CurrentFrame.FlowView)",
47
+ "reality": "Node positions are read/written through the FlowView, not the "
48
+ "tool. SetPos returns nothing reliable; GetPosTable returns a "
49
+ "1-indexed table (or dict/tuple depending on bridge).",
50
+ "recommended": "Use comp.CurrentFrame.FlowView.SetPos(tool, x, y); confirm "
51
+ "with GetPosTable and a liberal position parser.",
52
+ "tags": ["fusion", "unreliable-return"],
53
+ },
54
+ {
55
+ "symbol": "Timeline.GetTimelineByName",
56
+ "object": "Project",
57
+ "reality": "Does not exist. Timelines are looked up by index.",
58
+ "recommended": "Iterate GetTimelineByIndex(1..GetTimelineCount()).",
59
+ "tags": ["missing-method", "timeline"],
60
+ },
61
+ {
62
+ "symbol": "Project render methods (AddRenderJob, SetRenderSettings, ...)",
63
+ "object": "Project",
64
+ "reality": "Render methods live on the Project object, not on a separate "
65
+ "render-settings interface.",
66
+ "recommended": "Call proj.AddRenderJob(), proj.SetRenderSettings(), "
67
+ "proj.LoadRenderPreset() directly on the project.",
68
+ "tags": ["render"],
69
+ },
70
+ {
71
+ "symbol": "MediaPoolItem.GetClipProperty('Transcription')",
72
+ "object": "MediaPoolItem",
73
+ "reality": "Returns a PREVIEW of the transcription that ends in an "
74
+ "ellipsis when the full transcript is longer than the property "
75
+ "exposes.",
76
+ "recommended": "Treat a trailing ellipsis as truncation (see "
77
+ "media_pool_item get_transcription's `truncated` flag).",
78
+ "tags": ["transcription", "truncation"],
79
+ },
80
+ {
81
+ "symbol": "ProjectManager.CreateProject (with a dirty Untitled project)",
82
+ "object": "ProjectManager",
83
+ "reality": "Returns None and pops a modal 'Save Current Project' dialog "
84
+ "when the current unsaved/Untitled project blocks the switch. "
85
+ "SaveProject() on an Untitled project re-triggers the same modal.",
86
+ "recommended": "CloseProject(current) to discard the untitled project "
87
+ "without a prompt, then CreateProject; restore with "
88
+ "LoadProject afterward.",
89
+ "tags": ["project", "modal", "silent-failure"],
90
+ },
91
+ {
92
+ "symbol": "Timeline.InsertFusionCompositionIntoTimeline",
93
+ "object": "Timeline",
94
+ "reality": "Reliable way to obtain a Fusion comp on an otherwise empty "
95
+ "timeline: it inserts a Fusion composition clip whose comp is "
96
+ "then reachable via GetFusionCompByIndex(1).",
97
+ "recommended": "Use it (rather than InsertGeneratorIntoTimeline) when you "
98
+ "need a comp to operate on.",
99
+ "tags": ["fusion", "timeline"],
100
+ },
101
+ {
102
+ "symbol": "subprocess inheriting stdin under the MCP stdio server",
103
+ "object": "(server runtime)",
104
+ "reality": "A child process that inherits stdin can race-read bytes off "
105
+ "the JSON-RPC protocol stream and corrupt it; capture_output "
106
+ "redirects only stdout/stderr.",
107
+ "recommended": "Pass stdin=subprocess.DEVNULL on every subprocess that can "
108
+ "run while serving over stdio.",
109
+ "tags": ["runtime", "stdio", "subprocess"],
110
+ },
111
+ ]
112
+
113
+
114
+ def lookup_api_truth(query: Optional[str] = None) -> List[Dict[str, Any]]:
115
+ """Return verified facts matching `query`, or all facts if no query.
116
+
117
+ Matches a case-insensitive substring against the symbol, tags, and reality.
118
+ """
119
+ if not query:
120
+ return list(API_TRUTH)
121
+ q = query.lower()
122
+ out = []
123
+ for e in API_TRUTH:
124
+ hay = " ".join([
125
+ e.get("symbol", ""),
126
+ e.get("object", ""),
127
+ e.get("reality", ""),
128
+ " ".join(e.get("tags", [])),
129
+ ]).lower()
130
+ if q in hay:
131
+ out.append(e)
132
+ return out
@@ -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)