obs-studio-mcp 0.1.0__py3-none-any.whl
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.
- obs_studio_mcp/__init__.py +1 -0
- obs_studio_mcp/__main__.py +13 -0
- obs_studio_mcp/client.py +72 -0
- obs_studio_mcp/secrets.py +96 -0
- obs_studio_mcp/server.py +22 -0
- obs_studio_mcp/tools/__init__.py +0 -0
- obs_studio_mcp/tools/filters.py +140 -0
- obs_studio_mcp/tools/output.py +104 -0
- obs_studio_mcp/tools/scenes.py +82 -0
- obs_studio_mcp/tools/sources.py +104 -0
- obs_studio_mcp/tools/system.py +80 -0
- obs_studio_mcp-0.1.0.dist-info/METADATA +88 -0
- obs_studio_mcp-0.1.0.dist-info/RECORD +16 -0
- obs_studio_mcp-0.1.0.dist-info/WHEEL +4 -0
- obs_studio_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- obs_studio_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
obs_studio_mcp/client.py
ADDED
|
@@ -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)")
|
obs_studio_mcp/server.py
ADDED
|
@@ -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
|
|
File without changes
|
|
@@ -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,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,16 @@
|
|
|
1
|
+
obs_studio_mcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
obs_studio_mcp/__main__.py,sha256=g_cKPq9jUzMVgW8lHajzlRpcnFu3u0FKg4mNuwd_vdU,192
|
|
3
|
+
obs_studio_mcp/client.py,sha256=EqSDW5mSHqoCykigPMsBStVpn4pSMnjqGgwgujhKI60,2076
|
|
4
|
+
obs_studio_mcp/secrets.py,sha256=2mNrL2MEezPoWmFK6bF1aEMRarGGkxZvcZY6Ug4-uo0,3004
|
|
5
|
+
obs_studio_mcp/server.py,sha256=PECvkS-jIiyj0-MfFR5dHpe5t3ozkLQVWdtTfDPkzpQ,674
|
|
6
|
+
obs_studio_mcp/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
obs_studio_mcp/tools/filters.py,sha256=c1b7KqogsBvfrSTAmgiI7gfO4heCyi7RVTpDFWxQmmY,5266
|
|
8
|
+
obs_studio_mcp/tools/output.py,sha256=x1Lps7Idjrm5-C4-2QWmXP_seRra-p0iSvq9xdWXKxA,3794
|
|
9
|
+
obs_studio_mcp/tools/scenes.py,sha256=sLnnmWxz-iDKEs4DngeuOxo4eDirxZcKQTnVvRf0M3k,2932
|
|
10
|
+
obs_studio_mcp/tools/sources.py,sha256=147aE1-QNdt1O1YD8uSxMQX71eYt6k3KcBx0j2DOobE,4261
|
|
11
|
+
obs_studio_mcp/tools/system.py,sha256=qTWEEkDN5qJ4lndBH7BbKxPX1xU6ssIMNCcuhKOMBhI,2940
|
|
12
|
+
obs_studio_mcp-0.1.0.dist-info/METADATA,sha256=V21ahHjiUTsgnlpFxIMg_g4-KLihSb4yvyzT76ojdkM,3225
|
|
13
|
+
obs_studio_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
obs_studio_mcp-0.1.0.dist-info/entry_points.txt,sha256=Cp-xN84lcEBgXYJiCR7vh6Yl6CMiLDfChB5HneFKS54,64
|
|
15
|
+
obs_studio_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=PWDYlDaPDchw5cb23ke1atzOWzT1pFj-rk1iVFQS-l4,1065
|
|
16
|
+
obs_studio_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|