obs-studio-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,33 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install -e '.[dev]'
16
+ - name: Lint
17
+ run: ruff check src tests
18
+ - name: Tests
19
+ run: pytest
20
+ - name: Forbidden terms check
21
+ env:
22
+ FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
23
+ run: |
24
+ if [ -n "$FORBIDDEN_TERMS" ] && git grep -qiE "$FORBIDDEN_TERMS" -- . ; then
25
+ echo "::error::forbidden terms found in repository"
26
+ exit 1
27
+ fi
28
+ - name: Secret shape check
29
+ run: |
30
+ if git grep -nE '(password|refresh_token|client_secret)"?\s*[:=]\s*"[A-Za-z0-9_/-]{20,}' -- ':!tests' ; then
31
+ echo "::error::possible hardcoded credential"
32
+ exit 1
33
+ fi
@@ -0,0 +1,18 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ pypi:
8
+ runs-on: ubuntu-latest
9
+ environment: pypi
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build && python -m build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ docs/superpowers/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aaronckj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: obs-studio-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for controlling OBS Studio over obs-websocket v5: scenes, sources, audio, streaming, recording, replay buffer, stats, and text overlays.
5
+ Project-URL: Homepage, https://github.com/aaronckj/obs-studio-mcp
6
+ Project-URL: Issues, https://github.com/aaronckj/obs-studio-mcp/issues
7
+ Author: aaronckj
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: mcp,model-context-protocol,obs,obs-studio,obs-websocket,streaming
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: mcp>=1.2
17
+ Requires-Dist: obsws-python>=1.7
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == 'dev'
20
+ Requires-Dist: ruff>=0.6; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # obs-studio-mcp
24
+
25
+ An MCP (Model Context Protocol) server for controlling OBS Studio over
26
+ **obs-websocket v5**: scenes, sources, audio, text overlays, streaming,
27
+ recording, replay buffer, virtual camera, and performance stats.
28
+
29
+ - Talks to the obs-websocket server built into OBS 28+ (Tools → WebSocket
30
+ Server Settings).
31
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead
32
+ of acting — useful guard rails when an agent drives your live stream.
33
+ - No secrets in this repo or in your MCP client config.
34
+
35
+ ## Quick start
36
+
37
+ 1. In OBS: **Tools → WebSocket Server Settings** → enable, note the port
38
+ (default 4455) and password.
39
+ 2. Provide connection settings via environment:
40
+
41
+ ```bash
42
+ export OBS_MCP_HOST=127.0.0.1 # default
43
+ export OBS_MCP_PORT=4455 # default
44
+ export OBS_MCP_PASSWORD=yourpass # via the default env backend
45
+ ```
46
+
47
+ 3. Add to your MCP client:
48
+
49
+ ```bash
50
+ # Claude Code
51
+ claude mcp add obs -s user -- uvx obs-studio-mcp
52
+ ```
53
+
54
+ ## Tools
55
+
56
+ | Area | Tools |
57
+ |---|---|
58
+ | Scenes | `list_scenes`, `switch_scene`, `set_preview_scene`, `trigger_transition`, `set_studio_mode`, `list_scene_items`, `set_scene_item_visibility` |
59
+ | Sources | `list_inputs`, `get_audio_levels`, `set_mute`, `set_volume`, `update_text_source`, `screenshot_source`, `media_control` |
60
+ | Output | `stream_status`, `start_stream`, `stop_stream`, `record_status`, `start_record`, `stop_record`, `pause_record`, `save_replay`, `set_replay_buffer`, `set_virtual_cam` |
61
+ | System | `get_stats`, `list_profiles`, `switch_profile`, `list_scene_collections`, `switch_scene_collection`, `trigger_hotkey`, `health_check` |
62
+
63
+ `update_text_source` is handy for driving on-stream overlays from an agent —
64
+ question-of-the-day, now-playing, countdowns — anything rendered by a Text
65
+ source.
66
+
67
+ ## Secret storage
68
+
69
+ The only secret is the websocket password.
70
+
71
+ | Backend | Select with | Reads |
72
+ |---|---|---|
73
+ | `env` (default) | — | `OBS_MCP_PASSWORD` |
74
+ | `file` | `OBS_MCP_SECRETS=file` | `~/.config/obs-studio-mcp/credentials.json` (0600) |
75
+ | `vaultproxy` | `OBS_MCP_SECRETS=vaultproxy` (+ `VAULTPROXY_URL`) | a Vaultwarden vault via a local vaultproxy HTTP API |
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ pip install -e '.[dev]'
81
+ ruff check src tests && pytest
82
+ ```
83
+
84
+ Tests are fully offline (fake obs-websocket client).
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,66 @@
1
+ # obs-studio-mcp
2
+
3
+ An MCP (Model Context Protocol) server for controlling OBS Studio over
4
+ **obs-websocket v5**: scenes, sources, audio, text overlays, streaming,
5
+ recording, replay buffer, virtual camera, and performance stats.
6
+
7
+ - Talks to the obs-websocket server built into OBS 28+ (Tools → WebSocket
8
+ Server Settings).
9
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead
10
+ of acting — useful guard rails when an agent drives your live stream.
11
+ - No secrets in this repo or in your MCP client config.
12
+
13
+ ## Quick start
14
+
15
+ 1. In OBS: **Tools → WebSocket Server Settings** → enable, note the port
16
+ (default 4455) and password.
17
+ 2. Provide connection settings via environment:
18
+
19
+ ```bash
20
+ export OBS_MCP_HOST=127.0.0.1 # default
21
+ export OBS_MCP_PORT=4455 # default
22
+ export OBS_MCP_PASSWORD=yourpass # via the default env backend
23
+ ```
24
+
25
+ 3. Add to your MCP client:
26
+
27
+ ```bash
28
+ # Claude Code
29
+ claude mcp add obs -s user -- uvx obs-studio-mcp
30
+ ```
31
+
32
+ ## Tools
33
+
34
+ | Area | Tools |
35
+ |---|---|
36
+ | Scenes | `list_scenes`, `switch_scene`, `set_preview_scene`, `trigger_transition`, `set_studio_mode`, `list_scene_items`, `set_scene_item_visibility` |
37
+ | Sources | `list_inputs`, `get_audio_levels`, `set_mute`, `set_volume`, `update_text_source`, `screenshot_source`, `media_control` |
38
+ | Output | `stream_status`, `start_stream`, `stop_stream`, `record_status`, `start_record`, `stop_record`, `pause_record`, `save_replay`, `set_replay_buffer`, `set_virtual_cam` |
39
+ | System | `get_stats`, `list_profiles`, `switch_profile`, `list_scene_collections`, `switch_scene_collection`, `trigger_hotkey`, `health_check` |
40
+
41
+ `update_text_source` is handy for driving on-stream overlays from an agent —
42
+ question-of-the-day, now-playing, countdowns — anything rendered by a Text
43
+ source.
44
+
45
+ ## Secret storage
46
+
47
+ The only secret is the websocket password.
48
+
49
+ | Backend | Select with | Reads |
50
+ |---|---|---|
51
+ | `env` (default) | — | `OBS_MCP_PASSWORD` |
52
+ | `file` | `OBS_MCP_SECRETS=file` | `~/.config/obs-studio-mcp/credentials.json` (0600) |
53
+ | `vaultproxy` | `OBS_MCP_SECRETS=vaultproxy` (+ `VAULTPROXY_URL`) | a Vaultwarden vault via a local vaultproxy HTTP API |
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ pip install -e '.[dev]'
59
+ ruff check src tests && pytest
60
+ ```
61
+
62
+ Tests are fully offline (fake obs-websocket client).
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "obs-studio-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for controlling OBS Studio over obs-websocket v5: scenes, sources, audio, streaming, recording, replay buffer, stats, and text overlays."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "aaronckj" }]
13
+ keywords = ["mcp", "obs", "obs-studio", "obs-websocket", "streaming", "model-context-protocol"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=1.2",
22
+ "obsws-python>=1.7",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8", "ruff>=0.6"]
27
+
28
+ [project.scripts]
29
+ obs-studio-mcp = "obs_studio_mcp.__main__:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/aaronckj/obs-studio-mcp"
33
+ Issues = "https://github.com/aaronckj/obs-studio-mcp/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/obs_studio_mcp"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py311"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = "-q"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ """CLI entry: run the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def main() -> None:
7
+ from .server import build_app
8
+
9
+ build_app().run()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,72 @@
1
+ """obs-websocket v5 connection wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger("obs_studio_mcp")
10
+ if not logger.handlers:
11
+ handler = logging.StreamHandler()
12
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
13
+ logger.addHandler(handler)
14
+ logger.setLevel(logging.INFO)
15
+
16
+
17
+ class ObsError(RuntimeError):
18
+ pass
19
+
20
+
21
+ class OBS:
22
+ """Lazy holder for an obsws-python ReqClient (injectable for tests)."""
23
+
24
+ def __init__(self, client: Any = None):
25
+ self._client = client
26
+
27
+ @property
28
+ def client(self) -> Any:
29
+ if self._client is None:
30
+ import obsws_python as obs
31
+
32
+ from .secrets import get_store
33
+
34
+ host = os.environ.get("OBS_MCP_HOST", "127.0.0.1")
35
+ port = int(os.environ.get("OBS_MCP_PORT", "4455"))
36
+ password = get_store().get("password") or ""
37
+ try:
38
+ self._client = obs.ReqClient(
39
+ host=host, port=port, password=password, timeout=5
40
+ )
41
+ except Exception as exc:
42
+ raise ObsError(
43
+ f"cannot connect to OBS websocket at {host}:{port} — is OBS running "
44
+ "with obs-websocket enabled (Tools → WebSocket Server Settings)? "
45
+ f"({exc})"
46
+ ) from exc
47
+ return self._client
48
+
49
+ def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
50
+ try:
51
+ result = getattr(self.client, method)(*args, **kwargs)
52
+ logger.info("obs %s ok", method)
53
+ return result
54
+ except ObsError:
55
+ raise
56
+ except Exception as exc:
57
+ logger.error("obs %s failed: %s", method, exc)
58
+ raise ObsError(f"{method} failed: {exc}") from exc
59
+
60
+
61
+ def preview(action: str, would: dict) -> dict:
62
+ return {"preview": True, "action": action, "would": would}
63
+
64
+
65
+ _obs: OBS | None = None
66
+
67
+
68
+ def get_obs() -> OBS:
69
+ global _obs
70
+ if _obs is None:
71
+ _obs = OBS()
72
+ return _obs
@@ -0,0 +1,96 @@
1
+ """Pluggable secret storage for the OBS websocket password.
2
+
3
+ Selected via OBS_MCP_SECRETS (file | env | vaultproxy, default env→file):
4
+ the websocket password is the only secret this server ever needs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from urllib.error import URLError
13
+ from urllib.request import Request, urlopen
14
+
15
+ DEFAULT_PATH = Path.home() / ".config" / "obs-studio-mcp" / "credentials.json"
16
+
17
+
18
+ class SecretStore:
19
+ def get(self, key: str) -> str | None:
20
+ raise NotImplementedError
21
+
22
+ def set(self, key: str, value: str) -> None:
23
+ raise NotImplementedError
24
+
25
+
26
+ class FileStore(SecretStore):
27
+ def __init__(self, path: Path | None = None):
28
+ self.path = Path(path or DEFAULT_PATH)
29
+
30
+ def _load(self) -> dict:
31
+ if not self.path.exists():
32
+ return {}
33
+ return json.loads(self.path.read_text())
34
+
35
+ def get(self, key: str) -> str | None:
36
+ return self._load().get(key)
37
+
38
+ def set(self, key: str, value: str) -> None:
39
+ data = self._load()
40
+ data[key] = value
41
+ self.path.parent.mkdir(parents=True, exist_ok=True)
42
+ self.path.write_text(json.dumps(data, indent=2))
43
+ self.path.chmod(0o600)
44
+
45
+
46
+ class EnvStore(SecretStore):
47
+ def get(self, key: str) -> str | None:
48
+ return os.environ.get(f"OBS_MCP_{key.upper()}")
49
+
50
+ def set(self, key: str, value: str) -> None:
51
+ raise NotImplementedError("env secret backend is read-only")
52
+
53
+
54
+ class VaultproxyStore(SecretStore):
55
+ def __init__(self, base_url: str | None = None):
56
+ self.base_url = (
57
+ base_url or os.environ.get("VAULTPROXY_URL", "http://127.0.0.1:8199")
58
+ ).rstrip("/")
59
+
60
+ def _request(self, method: str, key: str, body: dict | None = None) -> dict:
61
+ req = Request(
62
+ f"{self.base_url}/items/obs-studio-mcp%2F{key}",
63
+ method=method,
64
+ data=json.dumps(body).encode() if body is not None else None,
65
+ headers={"content-type": "application/json"},
66
+ )
67
+ try:
68
+ with urlopen(req, timeout=10) as resp:
69
+ raw = resp.read()
70
+ except URLError as exc:
71
+ raise RuntimeError(
72
+ f"vaultproxy unreachable at {self.base_url}: {exc.reason}"
73
+ ) from exc
74
+ return json.loads(raw) if raw else {}
75
+
76
+ def get(self, key: str) -> str | None:
77
+ try:
78
+ return self._request("GET", key).get("value")
79
+ except RuntimeError:
80
+ raise
81
+ except Exception:
82
+ return None
83
+
84
+ def set(self, key: str, value: str) -> None:
85
+ self._request("PUT", key, {"value": value})
86
+
87
+
88
+ def get_store() -> SecretStore:
89
+ backend = os.environ.get("OBS_MCP_SECRETS", "env")
90
+ if backend == "file":
91
+ return FileStore()
92
+ if backend == "env":
93
+ return EnvStore()
94
+ if backend == "vaultproxy":
95
+ return VaultproxyStore()
96
+ raise ValueError(f"unknown OBS_MCP_SECRETS backend: {backend!r} (file|env|vaultproxy)")
@@ -0,0 +1,22 @@
1
+ """FastMCP app assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from .tools import filters, output, scenes, sources, system
8
+
9
+
10
+ def build_app() -> FastMCP:
11
+ mcp = FastMCP(
12
+ "obs-studio",
13
+ instructions=(
14
+ "Control a local OBS Studio over obs-websocket v5: scenes, sources, "
15
+ "audio, text overlays, streaming, recording, replay buffer, virtual "
16
+ "camera, and performance stats. Every mutating tool accepts "
17
+ "dry_run=True to preview without acting."
18
+ ),
19
+ )
20
+ for module in (scenes, sources, filters, output, system):
21
+ module.register(mcp)
22
+ return mcp
@@ -0,0 +1,140 @@
1
+ """Source filter and scene-item transform tools, plus a stream health sampler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from ..client import get_obs, preview
8
+
9
+
10
+ def summarize_health(samples: list[dict]) -> dict:
11
+ """Aggregate stat samples into a verdict (pure logic, unit-tested)."""
12
+ if not samples:
13
+ return {"verdict": "no samples"}
14
+ n = len(samples)
15
+ avg_fps = sum(s["fps"] for s in samples) / n
16
+ cpu = sum(s["cpu"] for s in samples) / n
17
+ render_dropped = samples[-1]["render_skipped"] - samples[0]["render_skipped"]
18
+ output_dropped = samples[-1]["output_skipped"] - samples[0]["output_skipped"]
19
+ frames = samples[-1].get("output_total", 0) - samples[0].get("output_total", 0)
20
+ drop_pct = (output_dropped / frames * 100) if frames else 0.0
21
+ issues = []
22
+ if drop_pct > 1.0:
23
+ issues.append(f"network/encoding drops {drop_pct:.1f}% (check bitrate/encoder)")
24
+ if render_dropped > n:
25
+ issues.append(f"renderer missed {render_dropped} frames (GPU overload)")
26
+ if cpu > 80:
27
+ issues.append(f"CPU {cpu:.0f}% (encoder overload risk)")
28
+ return {
29
+ "samples": n,
30
+ "avg_fps": round(avg_fps, 1),
31
+ "avg_cpu_percent": round(cpu, 1),
32
+ "render_frames_missed": render_dropped,
33
+ "output_frames_dropped": output_dropped,
34
+ "drop_percent": round(drop_pct, 2),
35
+ "verdict": "healthy" if not issues else "; ".join(issues),
36
+ }
37
+
38
+
39
+ def register(mcp) -> None:
40
+ @mcp.tool()
41
+ def list_source_filters(source: str) -> list[dict]:
42
+ """List filters on a source with enabled state."""
43
+ obs = get_obs()
44
+ res = obs.call("get_source_filter_list", source)
45
+ return [
46
+ {
47
+ "name": f["filterName"],
48
+ "kind": f["filterKind"],
49
+ "enabled": f["filterEnabled"],
50
+ }
51
+ for f in res.filters
52
+ ]
53
+
54
+ @mcp.tool()
55
+ def set_filter_enabled(
56
+ source: str, filter_name: str, enabled: bool, dry_run: bool = False
57
+ ) -> dict:
58
+ """Enable or disable a named filter on a source."""
59
+ if dry_run:
60
+ return preview(
61
+ "set_filter_enabled",
62
+ {"source": source, "filter": filter_name, "enabled": enabled},
63
+ )
64
+ obs = get_obs()
65
+ obs.call("set_source_filter_enabled", source, filter_name, enabled)
66
+ return {"source": source, "filter": filter_name, "enabled": enabled}
67
+
68
+ @mcp.tool()
69
+ def get_scene_item_transform(scene: str, item_id: int) -> dict:
70
+ """Get a scene item's position, scale, and crop."""
71
+ obs = get_obs()
72
+ res = obs.call("get_scene_item_transform", scene, item_id)
73
+ t = res.scene_item_transform
74
+ return {
75
+ "x": t.get("positionX"),
76
+ "y": t.get("positionY"),
77
+ "scale_x": t.get("scaleX"),
78
+ "scale_y": t.get("scaleY"),
79
+ "width": t.get("width"),
80
+ "height": t.get("height"),
81
+ }
82
+
83
+ @mcp.tool()
84
+ def set_scene_item_transform(
85
+ scene: str,
86
+ item_id: int,
87
+ x: float | None = None,
88
+ y: float | None = None,
89
+ scale_x: float | None = None,
90
+ scale_y: float | None = None,
91
+ dry_run: bool = False,
92
+ ) -> dict:
93
+ """Move/scale a scene item (item_id from list_scene_items). Only supplied
94
+ fields change — e.g. scale_x/scale_y 0.5 halves a facecam."""
95
+ changes: dict = {}
96
+ if x is not None:
97
+ changes["positionX"] = x
98
+ if y is not None:
99
+ changes["positionY"] = y
100
+ if scale_x is not None:
101
+ changes["scaleX"] = scale_x
102
+ if scale_y is not None:
103
+ changes["scaleY"] = scale_y
104
+ if not changes:
105
+ return {"error": "provide at least one of x, y, scale_x, scale_y"}
106
+ if dry_run:
107
+ return preview(
108
+ "set_scene_item_transform",
109
+ {"scene": scene, "item_id": item_id, **changes},
110
+ )
111
+ obs = get_obs()
112
+ obs.call("set_scene_item_transform", scene, item_id, changes)
113
+ return {"scene": scene, "item_id": item_id, "applied": changes}
114
+
115
+ @mcp.tool()
116
+ def watch_health(seconds: int = 10) -> dict:
117
+ """Sample OBS stats for up to 60 seconds and report a stream-health
118
+ verdict: dropped-frame %, GPU misses, CPU pressure."""
119
+ seconds = max(2, min(60, seconds))
120
+ obs = get_obs()
121
+ samples = []
122
+ for _ in range(seconds):
123
+ s = obs.call("get_stats")
124
+ sample = {
125
+ "fps": s.active_fps,
126
+ "cpu": s.cpu_usage,
127
+ "render_skipped": s.render_skipped_frames,
128
+ "output_skipped": s.output_skipped_frames,
129
+ }
130
+ try:
131
+ stream = obs.call("get_stream_status")
132
+ sample["output_total"] = getattr(stream, "output_total_frames", 0)
133
+ sample["output_skipped"] = getattr(
134
+ stream, "output_skipped_frames", sample["output_skipped"]
135
+ )
136
+ except Exception: # noqa: BLE001 - not streaming; stats-only sample
137
+ sample["output_total"] = 0
138
+ samples.append(sample)
139
+ time.sleep(1)
140
+ return summarize_health(samples)
@@ -0,0 +1,104 @@
1
+ """Streaming, recording, replay buffer, and virtual camera tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..client import get_obs, preview
6
+
7
+
8
+ def register(mcp) -> None:
9
+ @mcp.tool()
10
+ def stream_status() -> dict:
11
+ """Streaming state: live, duration, bytes, dropped frames."""
12
+ obs = get_obs()
13
+ s = obs.call("get_stream_status")
14
+ return {
15
+ "streaming": s.output_active,
16
+ "reconnecting": getattr(s, "output_reconnecting", False),
17
+ "duration_ms": getattr(s, "output_duration", 0),
18
+ "total_frames": getattr(s, "output_total_frames", 0),
19
+ "skipped_frames": getattr(s, "output_skipped_frames", 0),
20
+ }
21
+
22
+ @mcp.tool()
23
+ def start_stream(dry_run: bool = False) -> dict:
24
+ """Go live: start streaming to the configured service."""
25
+ if dry_run:
26
+ return preview("start_stream", {"note": "starts the live broadcast"})
27
+ obs = get_obs()
28
+ obs.call("start_stream")
29
+ return {"streaming": True}
30
+
31
+ @mcp.tool()
32
+ def stop_stream(dry_run: bool = False) -> dict:
33
+ """End the live stream."""
34
+ if dry_run:
35
+ return preview("stop_stream", {"note": "ends the live broadcast"})
36
+ obs = get_obs()
37
+ obs.call("stop_stream")
38
+ return {"streaming": False}
39
+
40
+ @mcp.tool()
41
+ def record_status() -> dict:
42
+ """Recording state: active, paused, duration, output path."""
43
+ obs = get_obs()
44
+ r = obs.call("get_record_status")
45
+ return {
46
+ "recording": r.output_active,
47
+ "paused": getattr(r, "output_paused", False),
48
+ "duration_ms": getattr(r, "output_duration", 0),
49
+ }
50
+
51
+ @mcp.tool()
52
+ def start_record(dry_run: bool = False) -> dict:
53
+ """Start recording."""
54
+ if dry_run:
55
+ return preview("start_record", {})
56
+ obs = get_obs()
57
+ obs.call("start_record")
58
+ return {"recording": True}
59
+
60
+ @mcp.tool()
61
+ def stop_record(dry_run: bool = False) -> dict:
62
+ """Stop recording; returns the saved file path."""
63
+ if dry_run:
64
+ return preview("stop_record", {})
65
+ obs = get_obs()
66
+ res = obs.call("stop_record")
67
+ return {"recording": False, "output_path": getattr(res, "output_path", None)}
68
+
69
+ @mcp.tool()
70
+ def pause_record(paused: bool, dry_run: bool = False) -> dict:
71
+ """Pause (true) or resume (false) the recording."""
72
+ if dry_run:
73
+ return preview("pause_record", {"paused": paused})
74
+ obs = get_obs()
75
+ obs.call("pause_record" if paused else "resume_record")
76
+ return {"paused": paused}
77
+
78
+ @mcp.tool()
79
+ def save_replay(dry_run: bool = False) -> dict:
80
+ """Save the replay buffer (must be running). Returns the clip path."""
81
+ if dry_run:
82
+ return preview("save_replay", {})
83
+ obs = get_obs()
84
+ obs.call("save_replay_buffer")
85
+ res = obs.call("get_last_replay_buffer_replay")
86
+ return {"saved": getattr(res, "saved_replay_path", None)}
87
+
88
+ @mcp.tool()
89
+ def set_replay_buffer(active: bool, dry_run: bool = False) -> dict:
90
+ """Start (true) or stop (false) the replay buffer."""
91
+ if dry_run:
92
+ return preview("set_replay_buffer", {"active": active})
93
+ obs = get_obs()
94
+ obs.call("start_replay_buffer" if active else "stop_replay_buffer")
95
+ return {"replay_buffer": active}
96
+
97
+ @mcp.tool()
98
+ def set_virtual_cam(active: bool, dry_run: bool = False) -> dict:
99
+ """Start (true) or stop (false) the virtual camera."""
100
+ if dry_run:
101
+ return preview("set_virtual_cam", {"active": active})
102
+ obs = get_obs()
103
+ obs.call("start_virtual_cam" if active else "stop_virtual_cam")
104
+ return {"virtual_cam": active}
@@ -0,0 +1,82 @@
1
+ """Scene and transition tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..client import get_obs, preview
6
+
7
+
8
+ def register(mcp) -> None:
9
+ @mcp.tool()
10
+ def list_scenes() -> dict:
11
+ """List scenes and the current program/preview scene."""
12
+ obs = get_obs()
13
+ res = obs.call("get_scene_list")
14
+ return {
15
+ "current_program": res.current_program_scene_name,
16
+ "current_preview": getattr(res, "current_preview_scene_name", None),
17
+ "scenes": [s["sceneName"] for s in reversed(res.scenes)],
18
+ }
19
+
20
+ @mcp.tool()
21
+ def switch_scene(scene: str, dry_run: bool = False) -> dict:
22
+ """Switch the program output to a scene (immediate cut on program)."""
23
+ if dry_run:
24
+ return preview("switch_scene", {"scene": scene})
25
+ obs = get_obs()
26
+ obs.call("set_current_program_scene", scene)
27
+ return {"program_scene": scene}
28
+
29
+ @mcp.tool()
30
+ def set_preview_scene(scene: str, dry_run: bool = False) -> dict:
31
+ """Set the preview scene (studio mode)."""
32
+ if dry_run:
33
+ return preview("set_preview_scene", {"scene": scene})
34
+ obs = get_obs()
35
+ obs.call("set_current_preview_scene", scene)
36
+ return {"preview_scene": scene}
37
+
38
+ @mcp.tool()
39
+ def trigger_transition(dry_run: bool = False) -> dict:
40
+ """Trigger the studio-mode transition (preview → program)."""
41
+ if dry_run:
42
+ return preview("trigger_transition", {})
43
+ obs = get_obs()
44
+ obs.call("trigger_studio_mode_transition")
45
+ return {"transitioned": True}
46
+
47
+ @mcp.tool()
48
+ def set_studio_mode(enabled: bool, dry_run: bool = False) -> dict:
49
+ """Enable or disable studio mode."""
50
+ if dry_run:
51
+ return preview("set_studio_mode", {"enabled": enabled})
52
+ obs = get_obs()
53
+ obs.call("set_studio_mode_enabled", enabled)
54
+ return {"studio_mode": enabled}
55
+
56
+ @mcp.tool()
57
+ def list_scene_items(scene: str) -> list[dict]:
58
+ """List the items (sources) inside a scene with visibility state."""
59
+ obs = get_obs()
60
+ res = obs.call("get_scene_item_list", scene)
61
+ return [
62
+ {
63
+ "id": i["sceneItemId"],
64
+ "source": i["sourceName"],
65
+ "visible": i["sceneItemEnabled"],
66
+ }
67
+ for i in res.scene_items
68
+ ]
69
+
70
+ @mcp.tool()
71
+ def set_scene_item_visibility(
72
+ scene: str, item_id: int, visible: bool, dry_run: bool = False
73
+ ) -> dict:
74
+ """Show or hide a scene item (item_id from list_scene_items)."""
75
+ if dry_run:
76
+ return preview(
77
+ "set_scene_item_visibility",
78
+ {"scene": scene, "item_id": item_id, "visible": visible},
79
+ )
80
+ obs = get_obs()
81
+ obs.call("set_scene_item_enabled", scene, item_id, visible)
82
+ return {"scene": scene, "item_id": item_id, "visible": visible}
@@ -0,0 +1,104 @@
1
+ """Input/source tools: audio, text overlays, media, screenshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from pathlib import Path
7
+
8
+ from ..client import get_obs, preview
9
+
10
+
11
+ def register(mcp) -> None:
12
+ @mcp.tool()
13
+ def list_inputs() -> list[dict]:
14
+ """List all inputs (sources) with their kinds."""
15
+ obs = get_obs()
16
+ res = obs.call("get_input_list")
17
+ return [
18
+ {"name": i["inputName"], "kind": i["inputKind"]} for i in res.inputs
19
+ ]
20
+
21
+ @mcp.tool()
22
+ def get_audio_levels() -> list[dict]:
23
+ """List audio inputs with mute state and volume (dB)."""
24
+ obs = get_obs()
25
+ inputs = obs.call("get_input_list").inputs
26
+ out = []
27
+ for i in inputs:
28
+ name = i["inputName"]
29
+ try:
30
+ mute = obs.call("get_input_mute", name).input_muted
31
+ vol = obs.call("get_input_volume", name)
32
+ out.append(
33
+ {
34
+ "name": name,
35
+ "muted": mute,
36
+ "volume_db": round(vol.input_volume_db, 1),
37
+ }
38
+ )
39
+ except Exception: # noqa: BLE001 - non-audio inputs raise; skip them
40
+ continue
41
+ return out
42
+
43
+ @mcp.tool()
44
+ def set_mute(input_name: str, muted: bool, dry_run: bool = False) -> dict:
45
+ """Mute or unmute an audio input."""
46
+ if dry_run:
47
+ return preview("set_mute", {"input": input_name, "muted": muted})
48
+ obs = get_obs()
49
+ obs.call("set_input_mute", input_name, muted)
50
+ return {"input": input_name, "muted": muted}
51
+
52
+ @mcp.tool()
53
+ def set_volume(input_name: str, volume_db: float, dry_run: bool = False) -> dict:
54
+ """Set an audio input's volume in dB (0 = unity, negative = quieter)."""
55
+ if dry_run:
56
+ return preview("set_volume", {"input": input_name, "volume_db": volume_db})
57
+ obs = get_obs()
58
+ obs.call("set_input_volume", input_name, vol_db=volume_db)
59
+ return {"input": input_name, "volume_db": volume_db}
60
+
61
+ @mcp.tool()
62
+ def update_text_source(input_name: str, text: str, dry_run: bool = False) -> dict:
63
+ """Set the text of a Text (GDI+/FreeType) source — e.g. an on-stream overlay
64
+ line like a giveaway question or now-playing label."""
65
+ if dry_run:
66
+ return preview("update_text_source", {"input": input_name, "text": text})
67
+ obs = get_obs()
68
+ obs.call("set_input_settings", input_name, {"text": text}, True)
69
+ return {"input": input_name, "text": text}
70
+
71
+ @mcp.tool()
72
+ def screenshot_source(
73
+ source: str, file_path: str, width: int | None = None, dry_run: bool = False
74
+ ) -> dict:
75
+ """Save a PNG screenshot of a source (or scene) to a local file."""
76
+ if dry_run:
77
+ return preview("screenshot_source", {"source": source, "file_path": file_path})
78
+ obs = get_obs()
79
+ kwargs = {"name": source, "img_format": "png", "quality": -1}
80
+ if width:
81
+ kwargs["width"] = width
82
+ res = obs.call("get_source_screenshot", **kwargs)
83
+ data = res.image_data.split(",", 1)[-1]
84
+ Path(file_path).write_bytes(base64.b64decode(data))
85
+ return {"saved": file_path, "source": source}
86
+
87
+ @mcp.tool()
88
+ def media_control(input_name: str, action: str, dry_run: bool = False) -> dict:
89
+ """Control a media source. action: play|pause|restart|stop|next|previous."""
90
+ actions = {
91
+ "play": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY",
92
+ "pause": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE",
93
+ "restart": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART",
94
+ "stop": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP",
95
+ "next": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT",
96
+ "previous": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS",
97
+ }
98
+ if action not in actions:
99
+ return {"error": f"action must be one of {sorted(actions)}"}
100
+ if dry_run:
101
+ return preview("media_control", {"input": input_name, "action": action})
102
+ obs = get_obs()
103
+ obs.call("trigger_media_input_action", input_name, actions[action])
104
+ return {"input": input_name, "action": action}
@@ -0,0 +1,80 @@
1
+ """System tools: stats, profiles, scene collections, hotkeys, health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..client import get_obs, preview
6
+
7
+
8
+ def register(mcp) -> None:
9
+ @mcp.tool()
10
+ def get_stats() -> dict:
11
+ """OBS performance stats: CPU, memory, FPS, frame drops, disk space."""
12
+ obs = get_obs()
13
+ s = obs.call("get_stats")
14
+ return {
15
+ "cpu_percent": round(s.cpu_usage, 1),
16
+ "memory_mb": round(s.memory_usage, 1),
17
+ "fps": round(s.active_fps, 2),
18
+ "render_missed_frames": s.render_skipped_frames,
19
+ "output_skipped_frames": s.output_skipped_frames,
20
+ "free_disk_mb": round(s.available_disk_space, 0),
21
+ }
22
+
23
+ @mcp.tool()
24
+ def list_profiles() -> dict:
25
+ """List OBS profiles and the current one."""
26
+ obs = get_obs()
27
+ res = obs.call("get_profile_list")
28
+ return {"current": res.current_profile_name, "profiles": res.profiles}
29
+
30
+ @mcp.tool()
31
+ def switch_profile(profile: str, dry_run: bool = False) -> dict:
32
+ """Switch OBS profile (stream settings bundle)."""
33
+ if dry_run:
34
+ return preview("switch_profile", {"profile": profile})
35
+ obs = get_obs()
36
+ obs.call("set_current_profile", profile)
37
+ return {"profile": profile}
38
+
39
+ @mcp.tool()
40
+ def list_scene_collections() -> dict:
41
+ """List scene collections and the current one."""
42
+ obs = get_obs()
43
+ res = obs.call("get_scene_collection_list")
44
+ return {
45
+ "current": res.current_scene_collection_name,
46
+ "collections": res.scene_collections,
47
+ }
48
+
49
+ @mcp.tool()
50
+ def switch_scene_collection(name: str, dry_run: bool = False) -> dict:
51
+ """Switch scene collection (full scene layout bundle)."""
52
+ if dry_run:
53
+ return preview("switch_scene_collection", {"name": name})
54
+ obs = get_obs()
55
+ obs.call("set_current_scene_collection", name)
56
+ return {"scene_collection": name}
57
+
58
+ @mcp.tool()
59
+ def trigger_hotkey(hotkey_name: str, dry_run: bool = False) -> dict:
60
+ """Trigger an OBS hotkey by name (see get_hotkey_list in OBS docs)."""
61
+ if dry_run:
62
+ return preview("trigger_hotkey", {"hotkey_name": hotkey_name})
63
+ obs = get_obs()
64
+ obs.call("trigger_hot_key_by_name", hotkey_name)
65
+ return {"triggered": hotkey_name}
66
+
67
+ @mcp.tool()
68
+ def health_check() -> dict:
69
+ """Verify the OBS websocket connection and report versions."""
70
+ try:
71
+ obs = get_obs()
72
+ v = obs.call("get_version")
73
+ return {
74
+ "status": "ok",
75
+ "obs_version": v.obs_version,
76
+ "websocket_version": v.obs_web_socket_version,
77
+ "platform": v.platform,
78
+ }
79
+ except Exception as exc: # noqa: BLE001 - health check reports, never raises
80
+ return {"status": f"error: {exc}"}
@@ -0,0 +1,43 @@
1
+ from obs_studio_mcp.tools.filters import summarize_health
2
+
3
+
4
+ def sample(fps=60.0, cpu=20.0, render=0, out_skipped=0, out_total=0):
5
+ return {
6
+ "fps": fps,
7
+ "cpu": cpu,
8
+ "render_skipped": render,
9
+ "output_skipped": out_skipped,
10
+ "output_total": out_total,
11
+ }
12
+
13
+
14
+ def test_healthy_stream():
15
+ samples = [sample(out_total=i * 60) for i in range(10)]
16
+ out = summarize_health(samples)
17
+ assert out["verdict"] == "healthy"
18
+ assert out["avg_fps"] == 60.0
19
+
20
+
21
+ def test_network_drops_flagged():
22
+ samples = [
23
+ sample(out_skipped=i * 5, out_total=i * 60) for i in range(10)
24
+ ]
25
+ out = summarize_health(samples)
26
+ assert "drops" in out["verdict"]
27
+ assert out["drop_percent"] > 1.0
28
+
29
+
30
+ def test_gpu_overload_flagged():
31
+ samples = [sample(render=i * 10, out_total=i * 60) for i in range(10)]
32
+ out = summarize_health(samples)
33
+ assert "GPU" in out["verdict"]
34
+
35
+
36
+ def test_cpu_pressure_flagged():
37
+ samples = [sample(cpu=95.0, out_total=i * 60) for i in range(10)]
38
+ out = summarize_health(samples)
39
+ assert "CPU" in out["verdict"]
40
+
41
+
42
+ def test_empty_samples():
43
+ assert summarize_health([])["verdict"] == "no samples"
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+
3
+ from obs_studio_mcp.server import build_app
4
+
5
+ EXPECTED = {
6
+ # scenes
7
+ "list_scenes", "switch_scene", "set_preview_scene", "trigger_transition",
8
+ "set_studio_mode", "list_scene_items", "set_scene_item_visibility",
9
+ # sources
10
+ "list_inputs", "get_audio_levels", "set_mute", "set_volume",
11
+ "update_text_source", "screenshot_source", "media_control",
12
+ # output
13
+ "stream_status", "start_stream", "stop_stream", "record_status",
14
+ "start_record", "stop_record", "pause_record", "save_replay",
15
+ "set_replay_buffer", "set_virtual_cam",
16
+ # filters/transforms/health
17
+ "list_source_filters", "set_filter_enabled", "get_scene_item_transform",
18
+ "set_scene_item_transform", "watch_health",
19
+ # system
20
+ "get_stats", "list_profiles", "switch_profile", "list_scene_collections",
21
+ "switch_scene_collection", "trigger_hotkey", "health_check",
22
+ }
23
+
24
+
25
+ def test_all_tools_registered():
26
+ app = build_app()
27
+ tools = asyncio.run(app.list_tools())
28
+ assert {t.name for t in tools} == EXPECTED
@@ -0,0 +1,100 @@
1
+ import asyncio
2
+ import json
3
+ from types import SimpleNamespace
4
+
5
+ import obs_studio_mcp.client as client_mod
6
+ from obs_studio_mcp.client import OBS
7
+ from obs_studio_mcp.server import build_app
8
+
9
+
10
+ class FakeReqClient:
11
+ """Records calls; returns canned obsws-style responses."""
12
+
13
+ def __init__(self):
14
+ self.calls = []
15
+
16
+ def _record(self, name, *args, **kwargs):
17
+ self.calls.append((name, args, kwargs))
18
+
19
+ def get_scene_list(self):
20
+ self._record("get_scene_list")
21
+ return SimpleNamespace(
22
+ current_program_scene_name="Game",
23
+ current_preview_scene_name="BRB",
24
+ scenes=[{"sceneName": "BRB"}, {"sceneName": "Game"}],
25
+ )
26
+
27
+ def set_current_program_scene(self, scene):
28
+ self._record("set_current_program_scene", scene)
29
+
30
+ def get_stream_status(self):
31
+ self._record("get_stream_status")
32
+ return SimpleNamespace(
33
+ output_active=True,
34
+ output_reconnecting=False,
35
+ output_duration=61000,
36
+ output_total_frames=3660,
37
+ output_skipped_frames=2,
38
+ )
39
+
40
+ def set_input_mute(self, name, muted):
41
+ self._record("set_input_mute", name, muted)
42
+
43
+ def set_input_settings(self, name, settings, overlay):
44
+ self._record("set_input_settings", name, settings, overlay)
45
+
46
+
47
+ def call_tool(app, name, args=None):
48
+ result = asyncio.run(app.call_tool(name, args or {}))
49
+ # FastMCP returns a list of content blocks; first is the JSON text.
50
+ blocks = result[0] if isinstance(result, tuple) else result
51
+ return json.loads(blocks[0].text)
52
+
53
+
54
+ def make_app(fake):
55
+ client_mod._obs = OBS(client=fake)
56
+ return build_app()
57
+
58
+
59
+ def teardown_function():
60
+ client_mod._obs = None
61
+
62
+
63
+ def test_list_scenes_shapes_response():
64
+ fake = FakeReqClient()
65
+ app = make_app(fake)
66
+ out = call_tool(app, "list_scenes")
67
+ assert out["current_program"] == "Game"
68
+ assert out["scenes"] == ["Game", "BRB"]
69
+
70
+
71
+ def test_switch_scene_calls_client():
72
+ fake = FakeReqClient()
73
+ app = make_app(fake)
74
+ out = call_tool(app, "switch_scene", {"scene": "BRB"})
75
+ assert out == {"program_scene": "BRB"}
76
+ assert ("set_current_program_scene", ("BRB",), {}) in fake.calls
77
+
78
+
79
+ def test_switch_scene_dry_run_does_not_call():
80
+ fake = FakeReqClient()
81
+ app = make_app(fake)
82
+ out = call_tool(app, "switch_scene", {"scene": "BRB", "dry_run": True})
83
+ assert out["preview"] is True
84
+ assert fake.calls == []
85
+
86
+
87
+ def test_stream_status_shapes_response():
88
+ fake = FakeReqClient()
89
+ app = make_app(fake)
90
+ out = call_tool(app, "stream_status")
91
+ assert out["streaming"] is True
92
+ assert out["skipped_frames"] == 2
93
+
94
+
95
+ def test_update_text_source():
96
+ fake = FakeReqClient()
97
+ app = make_app(fake)
98
+ out = call_tool(app, "update_text_source", {"input_name": "Question", "text": "Q: fave boss?"})
99
+ assert out["text"] == "Q: fave boss?"
100
+ assert ("set_input_settings", ("Question", {"text": "Q: fave boss?"}, True), {}) in fake.calls