obs-mcp 0.2.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_mcp/__init__.py ADDED
File without changes
obs_mcp/error_codes.py ADDED
@@ -0,0 +1,25 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class ErrorCode(IntEnum):
5
+ # Connection errors (1000s)
6
+ CONNECTION_FAILED = 1000
7
+ CONNECTION_REFUSED = 1001
8
+ AUTH_FAILED = 1002
9
+
10
+ # Request errors (2000s)
11
+ REQUEST_FAILED = 2000
12
+ RESOURCE_NOT_FOUND = 2001
13
+
14
+ # Validation errors (3000s)
15
+ VALIDATION_FAILED = 3000
16
+ INVALID_PARAMETER = 3001
17
+ MISSING_PARAMETER = 3002
18
+ VALUE_OUT_OF_RANGE = 3003
19
+
20
+
21
+ class OBSMCPError(Exception):
22
+ def __init__(self, code: ErrorCode, message: str):
23
+ self.code = code
24
+ self.message = message
25
+ super().__init__(f"[{code.name} ({code.value})] {message}")
obs_mcp/main.py ADDED
@@ -0,0 +1,43 @@
1
+ """OBS-MCP server entry point."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from obs_mcp.obs_client import OBSClient
9
+ from obs_mcp.tool_registry import register_all_tools
10
+
11
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr)
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # obsws-python logs the OBS password in plaintext ("Connecting with
15
+ # parameters: ... password='...'") at INFO on every connect. Raise it above
16
+ # our own INFO level so that line never reaches stderr (and whatever log
17
+ # file the MCP client persists stderr to).
18
+ logging.getLogger("obsws_python").setLevel(logging.WARNING)
19
+
20
+ client = OBSClient()
21
+
22
+ mcp = FastMCP(
23
+ "obs-mcp",
24
+ instructions=(
25
+ "Control OBS Studio via obs-websocket v5. Requires OBS Studio running "
26
+ "with Tools > WebSocket Server Settings enabled. Connection defaults "
27
+ "to localhost:4455 with no password — override with OBS_HOST/OBS_PORT/"
28
+ "OBS_PASSWORD environment variables if yours differs.\n\n"
29
+ "Scene and source names are case-sensitive and must match exactly "
30
+ "what's shown in OBS. Use get_scene_list / get_input_list to see "
31
+ "exact names before acting on them."
32
+ ),
33
+ )
34
+
35
+ register_all_tools(mcp)
36
+
37
+
38
+ def main():
39
+ mcp.run()
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
obs_mcp/obs_client.py ADDED
@@ -0,0 +1,91 @@
1
+ """Async wrapper around obsws-python's synchronous ReqClient.
2
+
3
+ obsws-python's ReqClient blocks on the underlying WebSocket call; every
4
+ request here runs in a thread-pool executor so FastMCP's async tool
5
+ functions never block the event loop on OBS round-trips.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+
12
+ import obsws_python as obsws
13
+ from obsws_python.error import OBSSDKRequestError
14
+
15
+ from obs_mcp.error_codes import ErrorCode, OBSMCPError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class OBSClient:
21
+ def __init__(self):
22
+ self._client: obsws.ReqClient | None = None
23
+ self._lock = asyncio.Lock()
24
+
25
+ def _connect_sync(self) -> obsws.ReqClient:
26
+ host = os.environ.get("OBS_HOST", "localhost")
27
+ port = int(os.environ.get("OBS_PORT", "4455"))
28
+ password = os.environ.get("OBS_PASSWORD", "")
29
+ try:
30
+ return obsws.ReqClient(host=host, port=port, password=password, timeout=5)
31
+ except Exception as e:
32
+ raise OBSMCPError(
33
+ ErrorCode.CONNECTION_FAILED,
34
+ f"Could not connect to OBS at {host}:{port} — {e}. "
35
+ f"Make sure OBS Studio is running with the WebSocket server "
36
+ f"enabled (Tools > WebSocket Server Settings) and that "
37
+ f"OBS_HOST/OBS_PORT/OBS_PASSWORD match its settings.",
38
+ )
39
+
40
+ async def _ensure_connected_locked(self) -> obsws.ReqClient:
41
+ """Must be called with self._lock already held."""
42
+ if self._client is None:
43
+ loop = asyncio.get_running_loop()
44
+ self._client = await loop.run_in_executor(None, self._connect_sync)
45
+ return self._client
46
+
47
+ async def execute(self, request_type: str, **data) -> dict:
48
+ """Call any obs-websocket v5 request by name. Returns response fields as a dict.
49
+
50
+ obsws-python's req() does a bare send() then recv() with no request-ID
51
+ matching, so it isn't safe to call from two threads at once — a second
52
+ concurrent request can steal the first request's response off the
53
+ socket. Serialize every call (connect included) through one lock so
54
+ concurrent tool calls queue instead of racing.
55
+ """
56
+ loop = asyncio.get_running_loop()
57
+ async with self._lock:
58
+ client = await self._ensure_connected_locked()
59
+ try:
60
+ result = await loop.run_in_executor(
61
+ None, lambda: client.send(request_type, data if data else None, raw=True)
62
+ )
63
+ except OBSSDKRequestError as e:
64
+ # OBS replied — the request was just invalid (bad scene name,
65
+ # out-of-range value, etc). The socket is still healthy, so
66
+ # don't force a reconnect for something that isn't a
67
+ # connection problem; the AI will hit this routinely while
68
+ # exploring scene/source names.
69
+ raise OBSMCPError(
70
+ ErrorCode.REQUEST_FAILED,
71
+ f"OBS request '{request_type}' failed: {e}",
72
+ )
73
+ except Exception as e:
74
+ # No reply came back at all — the connection itself is dead.
75
+ # Reset so the next call reconnects instead of failing forever.
76
+ self._client = None
77
+ raise OBSMCPError(
78
+ ErrorCode.REQUEST_FAILED,
79
+ f"OBS request '{request_type}' failed: {e}",
80
+ )
81
+ if result is None:
82
+ return {}
83
+ return result if isinstance(result, dict) else result.__dict__
84
+
85
+ async def close(self):
86
+ if self._client is not None:
87
+ try:
88
+ self._client.disconnect()
89
+ except Exception:
90
+ pass
91
+ self._client = None
@@ -0,0 +1,61 @@
1
+ import importlib
2
+ import logging
3
+ import pkgutil
4
+ import sys
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ import obs_mcp.tools as tools_package
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Every module that should exist on disk in a healthy checkout — update this
13
+ # when adding a new obs_mcp/tools/*.py module. Lets startup fail loud if a
14
+ # module goes missing instead of silently shipping fewer tools than intended.
15
+ _EXPECTED_MODULES = frozenset({
16
+ "general_tools", "config_tools", "source_tools", "scene_tools",
17
+ "input_tools", "transition_tools", "filter_tools", "scene_item_tools",
18
+ "output_tools", "stream_record_tools", "media_tools", "ui_tools",
19
+ "pipeline_tools",
20
+ })
21
+
22
+
23
+ def register_all_tools(mcp: FastMCP):
24
+ """Discover and register every tool module in obs_mcp/tools/."""
25
+ failures: list[tuple[str, Exception]] = []
26
+ registered: list[str] = []
27
+
28
+ for _finder, name, _ispkg in pkgutil.iter_modules(tools_package.__path__):
29
+ try:
30
+ module = importlib.import_module(f"obs_mcp.tools.{name}")
31
+ except Exception as e:
32
+ logger.error("IMPORT FAILED for tool module %s: %s", name, e, exc_info=True)
33
+ sys.stderr.write(f"\n[obs-mcp] ERROR: Failed to import tool module '{name}': {e}\n")
34
+ failures.append((name, e))
35
+ continue
36
+
37
+ if not hasattr(module, "register"):
38
+ continue
39
+
40
+ try:
41
+ module.register(mcp)
42
+ registered.append(name)
43
+ except Exception as e:
44
+ logger.error("REGISTER FAILED for %s: %s", name, e, exc_info=True)
45
+ sys.stderr.write(f"\n[obs-mcp] ERROR: Tool registration failed for '{name}': {e}\n")
46
+ failures.append((name, e))
47
+
48
+ sys.stderr.write(f"[obs-mcp] Registered {len(registered)} tool module(s)\n")
49
+
50
+ missing = _EXPECTED_MODULES - set(registered) - {n for n, _ in failures}
51
+ if missing:
52
+ sys.stderr.write(
53
+ f"[obs-mcp] WARNING: expected module(s) not found on disk: {sorted(missing)}. "
54
+ f"This install is incomplete.\n"
55
+ )
56
+
57
+ if failures:
58
+ sys.stderr.write(
59
+ f"[obs-mcp] WARNING: {len(failures)} tool module(s) failed to load: "
60
+ f"{', '.join(n for n, _ in failures)}\n"
61
+ )
File without changes
@@ -0,0 +1,136 @@
1
+ """Config tools — scene collections, profiles, video settings, stream service, record directory."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from obs_mcp.error_codes import ErrorCode, OBSMCPError
6
+
7
+
8
+ def register(mcp: FastMCP):
9
+ from obs_mcp.main import client
10
+
11
+ # ── Scene collections ──────────────────────────────────────
12
+ @mcp.tool()
13
+ async def get_scene_collection_list() -> dict:
14
+ """List all scene collections and the currently active one."""
15
+ return await client.execute("GetSceneCollectionList")
16
+
17
+ @mcp.tool()
18
+ async def set_current_scene_collection(name: str) -> dict:
19
+ """Switch to a scene collection by name. Blocks until the switch completes."""
20
+ return await client.execute("SetCurrentSceneCollection", sceneCollectionName=name)
21
+
22
+ @mcp.tool()
23
+ async def create_scene_collection(name: str) -> dict:
24
+ """Create a new scene collection and switch to it."""
25
+ return await client.execute("CreateSceneCollection", sceneCollectionName=name)
26
+
27
+ # ── Profiles ──────────────────────────────────────
28
+ @mcp.tool()
29
+ async def get_profile_list() -> dict:
30
+ """List all profiles and the currently active one."""
31
+ return await client.execute("GetProfileList")
32
+
33
+ @mcp.tool()
34
+ async def set_current_profile(name: str) -> dict:
35
+ """Switch to a profile by name."""
36
+ return await client.execute("SetCurrentProfile", profileName=name)
37
+
38
+ @mcp.tool()
39
+ async def create_profile(name: str) -> dict:
40
+ """Create a new profile and switch to it."""
41
+ return await client.execute("CreateProfile", profileName=name)
42
+
43
+ @mcp.tool()
44
+ async def remove_profile(name: str) -> dict:
45
+ """Remove a profile. If it's the active one, OBS switches away first."""
46
+ return await client.execute("RemoveProfile", profileName=name)
47
+
48
+ @mcp.tool()
49
+ async def get_profile_parameter(category: str, name: str) -> dict:
50
+ """Read a raw parameter from the current profile's .ini configuration.
51
+
52
+ Args:
53
+ category: Config category, e.g. "Output".
54
+ name: Parameter name within that category.
55
+ """
56
+ return await client.execute("GetProfileParameter", parameterCategory=category, parameterName=name)
57
+
58
+ @mcp.tool()
59
+ async def set_profile_parameter(category: str, name: str, value: str | None) -> dict:
60
+ """Write a raw parameter into the current profile's .ini configuration.
61
+ Advanced/low-level — prefer a dedicated request when one exists.
62
+
63
+ Args:
64
+ category: Config category, e.g. "Output".
65
+ name: Parameter name within that category.
66
+ value: New value, or None to delete the parameter.
67
+ """
68
+ return await client.execute("SetProfileParameter", parameterCategory=category, parameterName=name, parameterValue=value)
69
+
70
+ # ── Video settings ──────────────────────────────────────
71
+ @mcp.tool()
72
+ async def get_video_settings() -> dict:
73
+ """Current canvas/output resolution and FPS (as a fraction — divide
74
+ fpsNumerator by fpsDenominator for the true FPS)."""
75
+ return await client.execute("GetVideoSettings")
76
+
77
+ @mcp.tool()
78
+ async def set_video_settings(
79
+ fps_numerator: int | None = None, fps_denominator: int | None = None,
80
+ base_width: int | None = None, base_height: int | None = None,
81
+ output_width: int | None = None, output_height: int | None = None,
82
+ ) -> dict:
83
+ """Change canvas/output resolution and/or FPS. Width/height fields must
84
+ be set in pairs (can't change baseWidth without baseHeight, etc).
85
+ Range 1-4096 for all resolution fields.
86
+ """
87
+ params = {}
88
+ if fps_numerator is not None:
89
+ params["fpsNumerator"] = fps_numerator
90
+ if fps_denominator is not None:
91
+ params["fpsDenominator"] = fps_denominator
92
+ if base_width is not None:
93
+ params["baseWidth"] = base_width
94
+ if base_height is not None:
95
+ params["baseHeight"] = base_height
96
+ if output_width is not None:
97
+ params["outputWidth"] = output_width
98
+ if output_height is not None:
99
+ params["outputHeight"] = output_height
100
+ return await client.execute("SetVideoSettings", **params)
101
+
102
+ # ── Stream service settings ──────────────────────────────────────
103
+ @mcp.tool()
104
+ async def get_stream_service_settings() -> dict:
105
+ """Current stream destination service type and settings (server/key etc)."""
106
+ return await client.execute("GetStreamServiceSettings")
107
+
108
+ @mcp.tool()
109
+ async def set_stream_service_settings(service_type: str, settings: dict) -> dict:
110
+ """Set the stream destination. For a plain custom RTMP target, use
111
+ service_type="rtmp_custom" and settings={"server": "...", "key": "..."}.
112
+
113
+ Args:
114
+ service_type: e.g. "rtmp_custom" or "rtmp_common".
115
+ settings: Service-specific settings object.
116
+ """
117
+ return await client.execute(
118
+ "SetStreamServiceSettings", streamServiceType=service_type, streamServiceSettings=settings,
119
+ )
120
+
121
+ # ── Record directory ──────────────────────────────────────
122
+ @mcp.tool()
123
+ async def get_record_directory() -> dict:
124
+ """Current output directory for recordings."""
125
+ return await client.execute("GetRecordDirectory")
126
+
127
+ @mcp.tool()
128
+ async def set_record_directory(path: str) -> dict:
129
+ """Set the output directory recordings are written to.
130
+
131
+ Args:
132
+ path: Absolute directory path.
133
+ """
134
+ if not path:
135
+ raise OBSMCPError(ErrorCode.MISSING_PARAMETER, "path is required")
136
+ return await client.execute("SetRecordDirectory", recordDirectory=path)
@@ -0,0 +1,106 @@
1
+ """Filter tools — raw CRUD on source filters (audio/video effects chains).
2
+ For a one-call noise-free mic setup, see pipeline_tools.clean_audio_input instead
3
+ of hand-assembling a filter chain with these."""
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from obs_mcp.error_codes import ErrorCode, OBSMCPError
8
+
9
+
10
+ def register(mcp: FastMCP):
11
+ from obs_mcp.main import client
12
+
13
+ @mcp.tool()
14
+ async def get_source_filter_kind_list() -> dict:
15
+ """List every filter kind this OBS install supports (noise_suppress_filter,
16
+ noise_gate_filter, compressor_filter, color_filter, chroma_key_filter, etc)."""
17
+ return await client.execute("GetSourceFilterKindList")
18
+
19
+ @mcp.tool()
20
+ async def get_source_filter_list(source_name: str) -> dict:
21
+ """List all filters on a source (input or scene), in chain order.
22
+
23
+ Args:
24
+ source_name: Name of the source.
25
+ """
26
+ return await client.execute("GetSourceFilterList", sourceName=source_name)
27
+
28
+ @mcp.tool()
29
+ async def get_source_filter_default_settings(filter_kind: str) -> dict:
30
+ """Default settings object for a filter kind."""
31
+ return await client.execute("GetSourceFilterDefaultSettings", filterKind=filter_kind)
32
+
33
+ @mcp.tool()
34
+ async def create_source_filter(
35
+ source_name: str, filter_name: str, filter_kind: str, filter_settings: dict | None = None,
36
+ ) -> dict:
37
+ """Add a new filter to a source.
38
+
39
+ Args:
40
+ source_name: Name of the source to add the filter to.
41
+ filter_name: Name for the new filter.
42
+ filter_kind: Filter kind (see get_source_filter_kind_list).
43
+ filter_settings: Optional initial settings.
44
+ """
45
+ if not source_name or not filter_name or not filter_kind:
46
+ raise OBSMCPError(ErrorCode.MISSING_PARAMETER, "source_name, filter_name, and filter_kind are required")
47
+ return await client.execute(
48
+ "CreateSourceFilter", sourceName=source_name, filterName=filter_name,
49
+ filterKind=filter_kind, filterSettings=filter_settings or {},
50
+ )
51
+
52
+ @mcp.tool()
53
+ async def remove_source_filter(source_name: str, filter_name: str) -> dict:
54
+ """Remove a filter from a source."""
55
+ if not source_name or not filter_name:
56
+ raise OBSMCPError(ErrorCode.MISSING_PARAMETER, "source_name and filter_name are required")
57
+ return await client.execute("RemoveSourceFilter", sourceName=source_name, filterName=filter_name)
58
+
59
+ @mcp.tool()
60
+ async def set_source_filter_name(source_name: str, filter_name: str, new_filter_name: str) -> dict:
61
+ """Rename a filter."""
62
+ return await client.execute(
63
+ "SetSourceFilterName", sourceName=source_name, filterName=filter_name, newFilterName=new_filter_name,
64
+ )
65
+
66
+ @mcp.tool()
67
+ async def get_source_filter(source_name: str, filter_name: str) -> dict:
68
+ """Get a filter's enabled state, chain index, kind, and settings."""
69
+ return await client.execute("GetSourceFilter", sourceName=source_name, filterName=filter_name)
70
+
71
+ @mcp.tool()
72
+ async def set_source_filter_index(source_name: str, filter_name: str, filter_index: int) -> dict:
73
+ """Reorder a filter within a source's filter chain.
74
+
75
+ Args:
76
+ source_name: Name of the source.
77
+ filter_name: Name of the filter to move.
78
+ filter_index: New position, >= 0 (0 = first in chain).
79
+ """
80
+ if filter_index < 0:
81
+ raise OBSMCPError(ErrorCode.VALUE_OUT_OF_RANGE, "filter_index must be >= 0")
82
+ return await client.execute(
83
+ "SetSourceFilterIndex", sourceName=source_name, filterName=filter_name, filterIndex=filter_index,
84
+ )
85
+
86
+ @mcp.tool()
87
+ async def set_source_filter_settings(source_name: str, filter_name: str, filter_settings: dict, overlay: bool = True) -> dict:
88
+ """Configure a filter's parameters.
89
+
90
+ Args:
91
+ source_name: Name of the source.
92
+ filter_name: Name of the filter.
93
+ filter_settings: Settings object.
94
+ overlay: True = merge on top of current settings, False = reset to defaults first.
95
+ """
96
+ return await client.execute(
97
+ "SetSourceFilterSettings", sourceName=source_name, filterName=filter_name,
98
+ filterSettings=filter_settings, overlay=overlay,
99
+ )
100
+
101
+ @mcp.tool()
102
+ async def set_source_filter_enabled(source_name: str, filter_name: str, filter_enabled: bool) -> dict:
103
+ """Enable or disable a filter without removing it."""
104
+ return await client.execute(
105
+ "SetSourceFilterEnabled", sourceName=source_name, filterName=filter_name, filterEnabled=filter_enabled,
106
+ )
@@ -0,0 +1,105 @@
1
+ """General OBS tools — version/stats, hotkeys, custom events, vendor requests."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+
6
+ def register(mcp: FastMCP):
7
+ from obs_mcp.main import client
8
+
9
+ @mcp.tool()
10
+ async def get_version() -> dict:
11
+ """OBS/obs-websocket version info, platform, and the full list of requests
12
+ available on this connection (availableRequests)."""
13
+ return await client.execute("GetVersion")
14
+
15
+ @mcp.tool()
16
+ async def get_stats() -> dict:
17
+ """Live perf stats: CPU/memory usage, active FPS, render/output skipped
18
+ frames, disk space. Use to check for dropped-frame problems mid-stream."""
19
+ return await client.execute("GetStats")
20
+
21
+ @mcp.tool()
22
+ async def broadcast_custom_event(event_data: dict) -> dict:
23
+ """Broadcast a CustomEvent to all subscribed obs-websocket clients.
24
+
25
+ Args:
26
+ event_data: Arbitrary JSON payload delivered to receivers.
27
+ """
28
+ return await client.execute("BroadcastCustomEvent", eventData=event_data)
29
+
30
+ @mcp.tool()
31
+ async def call_vendor_request(vendor_name: str, request_type: str, request_data: dict | None = None) -> dict:
32
+ """Call a request registered by a third-party OBS plugin/script vendor.
33
+
34
+ Args:
35
+ vendor_name: Unique vendor name registered by the plugin.
36
+ request_type: The vendor's request type to call.
37
+ request_data: Optional payload for the vendor request.
38
+ """
39
+ return await client.execute(
40
+ "CallVendorRequest",
41
+ vendorName=vendor_name, requestType=request_type,
42
+ requestData=request_data or {},
43
+ )
44
+
45
+ @mcp.tool()
46
+ async def get_hotkey_list() -> dict:
47
+ """List all hotkey names in OBS. Best-effort — hotkey requests are less
48
+ reliable than dedicated requests when one exists for the same action."""
49
+ return await client.execute("GetHotkeyList")
50
+
51
+ @mcp.tool()
52
+ async def trigger_hotkey_by_name(hotkey_name: str, context_name: str = "") -> dict:
53
+ """Trigger a hotkey by its name (see get_hotkey_list).
54
+
55
+ Args:
56
+ hotkey_name: Name of the hotkey to trigger.
57
+ context_name: Optional hotkey context name.
58
+ """
59
+ params = {"hotkeyName": hotkey_name}
60
+ if context_name:
61
+ params["contextName"] = context_name
62
+ return await client.execute("TriggerHotkeyByName", **params)
63
+
64
+ @mcp.tool()
65
+ async def trigger_hotkey_by_key_sequence(
66
+ key_id: str = "", shift: bool = False, control: bool = False,
67
+ alt: bool = False, command: bool = False,
68
+ ) -> dict:
69
+ """Trigger a hotkey by simulating a key press + modifiers.
70
+
71
+ Args:
72
+ key_id: OBS key ID (see obs-studio's obs-hotkeys.h for the list).
73
+ shift/control/alt/command: Modifier keys to hold.
74
+ """
75
+ params = {}
76
+ if key_id:
77
+ params["keyId"] = key_id
78
+ params["keyModifiers"] = {
79
+ "shift": shift, "control": control, "alt": alt, "command": command,
80
+ }
81
+ return await client.execute("TriggerHotkeyByKeySequence", **params)
82
+
83
+ @mcp.tool()
84
+ async def get_persistent_data(realm: str, slot_name: str) -> dict:
85
+ """Get a value from an OBS persistent-data "slot".
86
+
87
+ Args:
88
+ realm: "OBS_WEBSOCKET_DATA_REALM_GLOBAL" or "OBS_WEBSOCKET_DATA_REALM_PROFILE".
89
+ slot_name: Name of the slot to read.
90
+ """
91
+ return await client.execute("GetPersistentData", realm=realm, slotName=slot_name)
92
+
93
+ @mcp.tool()
94
+ async def set_persistent_data(realm: str, slot_name: str, slot_value) -> dict:
95
+ """Set a value in an OBS persistent-data "slot" — useful for storing state
96
+ the AI needs to remember across tool calls (e.g. giveaway winners).
97
+
98
+ Args:
99
+ realm: "OBS_WEBSOCKET_DATA_REALM_GLOBAL" or "OBS_WEBSOCKET_DATA_REALM_PROFILE".
100
+ slot_name: Name of the slot to write.
101
+ slot_value: Any JSON-serializable value.
102
+ """
103
+ return await client.execute(
104
+ "SetPersistentData", realm=realm, slotName=slot_name, slotValue=slot_value,
105
+ )