diff --git a/README.md b/README.md index 37ca415..6ce87ad 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the | `OBS_WEBSOCKET_URL` | No | `ws://localhost:4455` | Address of the OBS WebSocket server | | `OBS_WEBSOCKET_PASSWORD` | Only when OBS authentication is enabled | None | Password configured in OBS | | `OBS_MCP_MAX_SCREENSHOT_BYTES` | No | `4194304` | Maximum decoded size of a screenshot returned through MCP; the hard ceiling is 6 MiB | +| `OBS_MCP_TOOLSETS` | No | all tools | Comma-separated tool groups to register: `all`, `core`, or any group listed under [Choosing tools](#choosing-tools) | +| `OBS_MCP_TOOLS` | No | none | Comma-separated tool names to register in addition to `OBS_MCP_TOOLSETS` | +| `OBS_MCP_READ_ONLY` | No | `false` | Register only read-only tools; overrides both lists | | `OBS_MCP_LOG_LEVEL` | No | `info` | Diagnostic output on stderr: `debug`, `info`, `error`, or `silent` | The server never prints the password. Connection and protocol diagnostics are written to stderr so stdout remains reserved for MCP messages. @@ -112,8 +115,33 @@ The 156 tools are organized around the OBS protocol rather than a smaller opinio - a recording preflight (`obs-preflight`) that catches the conditions under which OBS silently refuses or botches a recording - protocol description and the guarded generic request fallback +Tools that send a single OBS request declare an `outputSchema` generated from the pinned protocol, so clients receive typed `structuredContent` with the documented response fields. Fields are optional and nullable because OBS omits newer fields in older versions and returns undocumented nulls. + Tool discovery works without an OBS connection. Calls that need OBS return an MCP error until the WebSocket connection is ready. +### Choosing tools + +Every tool is registered by default. Clients load each registered tool's definition into the model's context, so a smaller set is cheaper and easier for the model to choose from. + +| Group | Covers | +|---|---| +| `general` | status, version, statistics, hotkeys, vendor requests, custom events, sleep | +| `scenes` | scenes, canvases, program and preview scene | +| `scene-items` | scene items, groups, transforms, ordering, locking, blend modes | +| `sources` | source screenshots and active state | +| `inputs` | inputs, input settings and properties, audio, deinterlacing | +| `media` | media input playback | +| `filters` | source filters | +| `transitions` | transitions, overrides, the T-Bar | +| `record` | recording, chapters, and `obs-preflight` | +| `stream` | streaming and captions | +| `outputs` | virtual camera, replay buffer, and generic outputs | +| `config` | profiles, scene collections, video settings, persistent data | +| `ui` | studio mode, dialogs, projectors | +| `protocol` | `obs-describe-request` and the generic `obs-call-request` | + +`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (79 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 38-tool inspection-only server. The server refuses to start when a group or tool name is unknown. + ## Where it fits OBS MCP is well suited to operational work that already has a clear outcome: inspecting a scene collection, creating or arranging sources, changing audio state, switching scenes, taking screenshots, checking output status, or carrying out a repeatable recording setup. diff --git a/manifest.json b/manifest.json index 9844377..d140de0 100644 --- a/manifest.json +++ b/manifest.json @@ -19,9 +19,20 @@ "support": "https://github.com/Tom-R-Main/OBS-MCP/issues", "license": "GPL-2.0-only", "tools_generated": true, - "keywords": ["obs", "streaming", "recording", "video", "broadcast", "scenes"], + "keywords": [ + "obs", + "streaming", + "recording", + "video", + "broadcast", + "scenes" + ], "compatibility": { - "platforms": ["darwin", "win32", "linux"], + "platforms": [ + "darwin", + "win32", + "linux" + ], "runtimes": { "node": ">=20.19.0" } @@ -31,10 +42,14 @@ "entry_point": "server/index.js", "mcp_config": { "command": "node", - "args": ["${__dirname}/server/index.js"], + "args": [ + "${__dirname}/server/index.js" + ], "env": { "OBS_WEBSOCKET_URL": "${user_config.obs_websocket_url}", - "OBS_WEBSOCKET_PASSWORD": "${user_config.obs_websocket_password}" + "OBS_WEBSOCKET_PASSWORD": "${user_config.obs_websocket_password}", + "OBS_MCP_TOOLSETS": "${user_config.obs_mcp_toolsets}", + "OBS_MCP_READ_ONLY": "${user_config.obs_mcp_read_only}" } } }, @@ -52,6 +67,20 @@ "description": "Password from OBS WebSocket Server Settings. Leave empty if authentication is disabled.", "required": false, "sensitive": true + }, + "obs_mcp_toolsets": { + "type": "string", + "title": "Tool groups", + "description": "Comma-separated tool groups to expose: all, core, or any of general, scenes, scene-items, sources, inputs, media, filters, transitions, record, stream, outputs, config, ui, protocol.", + "required": false, + "default": "all" + }, + "obs_mcp_read_only": { + "type": "boolean", + "title": "Read-only mode", + "description": "Expose only tools that do not change OBS.", + "required": false, + "default": false } } } diff --git a/src/server.ts b/src/server.ts index 6077da6..857d179 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "./client.js"; import * as tools from "./tools/index.js"; import { PACKAGE_VERSION } from "./version.js"; +import { ALL_TOOLS, assertKnownTools, parseToolFilter, type ToolFilter } from "./tools/toolsets.js"; import { logger } from "./logger.js"; // Create the OBS WebSocket client @@ -91,19 +92,39 @@ obsClient.on("disconnected", () => { } }); +// Parsed once at startup so a bad OBS_MCP_TOOLSETS/OBS_MCP_TOOLS value fails fast. +let toolFilter: ToolFilter | null = null; + export function createServer(): McpServer { const server = new McpServer({ name: "obs-mcp", version: PACKAGE_VERSION, }); - tools.initialize(server, obsClient); + tools.initialize(server, obsClient, toolFilter ?? ALL_TOOLS); return server; } +function loadToolFilter(): ToolFilter { + const filter = parseToolFilter(); + const registry = tools.initialize( + new McpServer({ name: "obs-mcp-validation", version: PACKAGE_VERSION }), + obsClient, + filter, + ); + assertKnownTools(filter, registry); + const count = registry.filter(({ registered }) => registered).length; + if (count === 0) throw new Error("The tool filter excludes every tool"); + if (filter.groups !== null || filter.readOnly) { + logger.log(`Registering ${count} of ${registry.length} tools${filter.readOnly ? " (read-only)" : ""}`); + } + return filter; +} + // Set up server startup logic export async function startServer(): Promise { try { + toolFilter = loadToolFilter(); stdioServer = serveStdio(createServer, { onerror: (error) => logger.error(`MCP stdio error: ${error.message}`), }); diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index d6265f3..62a0166 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -19,6 +19,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -158,6 +172,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -205,6 +233,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -228,6 +270,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -382,6 +438,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemId": { + "description": "Numeric ID of the duplicated scene item", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -396,6 +470,31 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "canvases": { + "description": "Array of canvases", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -410,6 +509,57 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "sceneName": { + "description": "Current program scene name", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sceneUuid": { + "description": "Current program scene UUID", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentProgramSceneName": { + "description": "Current program scene name (Deprecated)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentProgramSceneUuid": { + "description": "Current program scene UUID (Deprecated)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -424,6 +574,94 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionName": { + "description": "Name of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionUuid": { + "description": "UUID of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionKind": { + "description": "Kind of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionFixed": { + "description": "Whether the transition uses a fixed (unconfigurable) duration", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionDuration": { + "description": "Configured transition duration in milliseconds. `null` if transition is fixed", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "transitionConfigurable": { + "description": "Whether the transition supports being configured", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionSettings": { + "description": "Object of settings for the transition. `null` if transition is not configurable", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -447,6 +685,28 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "defaultFilterSettings": { + "description": "Object of default settings for the filter kind", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -461,6 +721,27 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "sourceFilterKinds": { + "description": "Array of source filter kinds", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -475,6 +756,27 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "groups": { + "description": "Array of group names", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -503,6 +805,31 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItems": { + "description": "Array of scene items in the group", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -517,6 +844,27 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "hotkeys": { + "description": "Array of hotkey names", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -540,6 +888,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputAudioBalance": { + "description": "Audio balance value from 0.0-1.0", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -563,6 +929,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "monitorType": { + "description": "Audio monitor type", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -586,8 +970,26 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, - "annotations": { - "readOnlyHint": true, + "outputSchema": { + "type": "object", + "properties": { + "inputAudioSyncOffset": { + "description": "Audio sync offset in milliseconds", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false @@ -610,6 +1012,28 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputAudioTracks": { + "description": "Object of audio tracks and associated enable states", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -633,6 +1057,28 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "defaultInputSettings": { + "description": "Object of default settings for the input kind", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -657,6 +1103,24 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputDeinterlaceFieldOrder": { + "description": "Deinterlace field order of the input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -681,6 +1145,24 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputDeinterlaceMode": { + "description": "Deinterlace mode of the input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -744,6 +1226,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputMuted": { + "description": "Whether the input is muted", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -775,6 +1275,31 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "propertyItems": { + "description": "Array of items in the list property", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -798,6 +1323,39 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputSettings": { + "description": "Object of settings for the input", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "inputKind": { + "description": "The kind of the input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -821,6 +1379,35 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "inputVolumeMul": { + "description": "Volume setting in mul", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "inputVolumeDb": { + "description": "Volume setting in dB", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -835,6 +1422,24 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "savedReplayPath": { + "description": "File path", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -858,6 +1463,46 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "mediaState": { + "description": "State of the media input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mediaDuration": { + "description": "Total duration of the playing media in milliseconds. `null` if not playing", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "mediaCursor": { + "description": "Position of the cursor in milliseconds. `null` if not playing", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -872,6 +1517,31 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "monitors": { + "description": "a list of detected monitors with some information", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -886,6 +1556,31 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "outputs": { + "description": "Array of outputs", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -909,6 +1604,28 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "outputSettings": { + "description": "Output settings", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -932,38 +1649,143 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "obs-get-persistent-data", - "title": "Get Persistent Data", - "inputSchema": { + "outputSchema": { "type": "object", "properties": { - "realm": { - "type": "string", - "description": "The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE" + "outputActive": { + "description": "Whether the output is active", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - "slotName": { - "type": "string", - "description": "The name of the slot to retrieve data from" - } - }, - "required": [ - "realm", - "slotName" - ], - "$schema": "https://json-schema.org/draft/2020-12/schema" - }, - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, + "outputReconnecting": { + "description": "Whether the output is reconnecting", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputTimecode": { + "description": "Current formatted timecode string for the output", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "outputDuration": { + "description": "Current duration in milliseconds for the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputCongestion": { + "description": "Congestion of the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputBytes": { + "description": "Number of bytes sent by the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputSkippedFrames": { + "description": "Number of frames skipped by the output's process", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputTotalFrames": { + "description": "Total number of frames delivered by the output's process", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "obs-get-persistent-data", + "title": "Get Persistent Data", + "inputSchema": { + "type": "object", + "properties": { + "realm": { + "type": "string", + "description": "The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE" + }, + "slotName": { + "type": "string", + "description": "The name of the slot to retrieve data from" + } + }, + "required": [ + "realm", + "slotName" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "outputSchema": { + "type": "object", + "properties": { + "slotValue": { + "description": "Value associated with the slot. `null` if not set" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false } }, @@ -974,6 +1796,57 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "sceneName": { + "description": "Current preview scene name", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sceneUuid": { + "description": "Current preview scene UUID", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentPreviewSceneName": { + "description": "Current preview scene name", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentPreviewSceneUuid": { + "description": "Current preview scene UUID", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -988,6 +1861,38 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "currentProfileName": { + "description": "The name of the current profile", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "profiles": { + "description": "Array of all available profiles", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1016,6 +1921,35 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "parameterValue": { + "description": "Value associated with the parameter. `null` if not set and no default", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "defaultParameterValue": { + "description": "Default value associated with the parameter. `null` if no default", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1030,6 +1964,24 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "recordDirectory": { + "description": "Output directory", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1044,6 +1996,68 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "outputActive": { + "description": "Whether the output is active", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputPaused": { + "description": "Whether the output is paused", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputTimecode": { + "description": "Current formatted timecode string for the output", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "outputDuration": { + "description": "Current duration in milliseconds for the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputBytes": { + "description": "Number of bytes sent by the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1058,20 +2072,70 @@ "type": "object", "properties": {} }, - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "obs-get-scene-collection-list", - "title": "Get Scene Collection List", - "inputSchema": { + "outputSchema": { + "type": "object", + "properties": { + "outputActive": { + "description": "Whether the output is active", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "obs-get-scene-collection-list", + "title": "Get Scene Collection List", + "inputSchema": { "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "currentSceneCollectionName": { + "description": "The name of the current scene collection", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sceneCollections": { + "description": "Array of all available scene collections", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1109,6 +2173,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemBlendMode": { + "description": "Current blend mode", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1146,6 +2228,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemEnabled": { + "description": "Whether the scene item is enabled. `true` for enabled, `false` for disabled", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1188,6 +2288,24 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemId": { + "description": "Numeric ID of the scene item", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1225,6 +2343,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemIndex": { + "description": "Index position of the scene item", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1262,6 +2398,24 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemLocked": { + "description": "Whether the scene item is locked. `true` for locked, `false` for unlocked", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1299,6 +2453,35 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sourceName": { + "description": "Name of the source associated with the scene item", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sourceUuid": { + "description": "UUID of the source associated with the scene item", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1336,6 +2519,28 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItemTransform": { + "description": "Object containing scene item transform info", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1364,6 +2569,31 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "sceneItems": { + "description": "Array of scene items in the scene", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1384,6 +2614,75 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "currentProgramSceneName": { + "description": "Current program scene name. Can be `null` if non-main canvas or internal state desync", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentProgramSceneUuid": { + "description": "Current program scene UUID. Can be `null` if non-main canvas or internal state desync", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentPreviewSceneName": { + "description": "Current preview scene name. `null` if not in studio mode or non-main canvas", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentPreviewSceneUuid": { + "description": "Current preview scene UUID. `null` if not in studio mode or non-main canvas", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "scenes": { + "description": "Array of scenes", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1412,6 +2711,35 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "transitionName": { + "description": "Name of the overridden scene transition, else `null`", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionDuration": { + "description": "Duration of the overridden scene transition, else `null`", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1440,6 +2768,35 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "videoActive": { + "description": "Whether the source is showing in Program", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "videoShowing": { + "description": "Whether the source is showing in the UI (Preview, Projector, Properties)", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1472,7 +2829,62 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, - "annotations": { + "outputSchema": { + "type": "object", + "properties": { + "filterEnabled": { + "description": "Whether the filter is enabled", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "filterIndex": { + "description": "Index of the filter in the list, beginning at 0", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "filterKind": { + "description": "The kind of filter", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "filterSettings": { + "description": "Settings object associated with the filter", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, @@ -1499,6 +2911,31 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "filters": { + "description": "Array of filters", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1554,6 +2991,25 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "mimeType", + "sizeBytes" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1568,6 +3024,79 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "desktop1": { + "description": "Name of the Desktop Audio input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "desktop2": { + "description": "Name of the Desktop Audio 2 input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mic1": { + "description": "Name of the Mic/Auxiliary Audio input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mic2": { + "description": "Name of the Mic/Auxiliary Audio 2 input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mic3": { + "description": "Name of the Mic/Auxiliary Audio 3 input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mic4": { + "description": "Name of the Mic/Auxiliary Audio 4 input", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1582,6 +3111,134 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "cpuUsage": { + "description": "Current CPU usage in percent", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "memoryUsage": { + "description": "Amount of memory in MB currently being used by OBS", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "availableDiskSpace": { + "description": "Available disk space on the device being used for recording storage", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "activeFps": { + "description": "Current FPS being rendered", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "averageFrameRenderTime": { + "description": "Average time in milliseconds that OBS is taking to render a frame", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "renderSkippedFrames": { + "description": "Number of frames skipped by OBS in the render thread", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "renderTotalFrames": { + "description": "Total number of frames outputted by the render thread", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputSkippedFrames": { + "description": "Number of frames skipped by OBS in the output thread", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputTotalFrames": { + "description": "Total number of frames outputted by the output thread", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "webSocketSessionIncomingMessages": { + "description": "Total number of messages received by obs-websocket from the client", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "webSocketSessionOutgoingMessages": { + "description": "Total number of messages sent by obs-websocket to the client", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1610,6 +3267,39 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "streamServiceType": { + "description": "Stream service type, like `rtmp_custom` or `rtmp_common`", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "streamServiceSettings": { + "description": "Stream service settings", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1624,23 +3314,136 @@ "type": "object", "properties": {} }, - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "obs-get-studio-mode", - "title": "Get Studio Mode", - "inputSchema": { + "outputSchema": { "type": "object", - "properties": {} - }, - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, + "properties": { + "outputActive": { + "description": "Whether the output is active", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputReconnecting": { + "description": "Whether the output is currently reconnecting", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputTimecode": { + "description": "Current formatted timecode string for the output", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "outputDuration": { + "description": "Current duration in milliseconds for the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputCongestion": { + "description": "Congestion of the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputBytes": { + "description": "Number of bytes sent by the output", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputSkippedFrames": { + "description": "Number of frames skipped by the output's process", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputTotalFrames": { + "description": "Total number of frames delivered by the output's process", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "obs-get-studio-mode", + "title": "Get Studio Mode", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "studioModeEnabled": { + "description": "Whether studio mode is enabled", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, "idempotentHint": true, "openWorldHint": false } @@ -1652,6 +3455,24 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionCursor": { + "description": "Cursor position, between 0.0 and 1.0", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1666,6 +3487,94 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionName": { + "description": "Name of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionUuid": { + "description": "UUID of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionKind": { + "description": "Kind of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionFixed": { + "description": "Whether the transition uses a fixed (unconfigurable) duration", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionDuration": { + "description": "Configured transition duration in milliseconds. `null` if transition is fixed", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "transitionConfigurable": { + "description": "Whether the transition supports being configured", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionSettings": { + "description": "Object of settings for the transition. `null` if transition is not configurable", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1680,6 +3589,94 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionName": { + "description": "Name of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionUuid": { + "description": "UUID of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionKind": { + "description": "Kind of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionFixed": { + "description": "Whether the transition uses a fixed (unconfigurable) duration", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionDuration": { + "description": "Configured transition duration in milliseconds. `null` if transition is fixed", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "transitionConfigurable": { + "description": "Whether the transition supports being configured", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionSettings": { + "description": "Object of settings for the transition. `null` if transition is not configurable", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1694,6 +3691,27 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionKinds": { + "description": "Array of transition kinds", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1708,6 +3726,64 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "currentSceneTransitionName": { + "description": "Name of the current scene transition. Can be null", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentSceneTransitionUuid": { + "description": "UUID of the current scene transition. Can be null", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "currentSceneTransitionKind": { + "description": "Kind of the current scene transition. Can be null", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitions": { + "description": "Array of transitions", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1722,6 +3798,94 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "transitionName": { + "description": "Name of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionUuid": { + "description": "UUID of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionKind": { + "description": "Kind of the transition", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transitionFixed": { + "description": "Whether the transition uses a fixed (unconfigurable) duration", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionDuration": { + "description": "Configured transition duration in milliseconds. `null` if transition is fixed", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "transitionConfigurable": { + "description": "Whether the transition supports being configured", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "transitionSettings": { + "description": "Object of settings for the transition. `null` if transition is not configurable", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1736,6 +3900,96 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "obsVersion": { + "description": "Current OBS Studio version", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "obsWebSocketVersion": { + "description": "Current obs-websocket version", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rpcVersion": { + "description": "Current latest obs-websocket RPC version", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "availableRequests": { + "description": "Array of available RPC requests for the currently negotiated RPC version", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "supportedImageFormats": { + "description": "Image formats available in `GetSourceScreenshot` and `SaveSourceScreenshot` requests.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "platform": { + "description": "Name of the platform. Usually `windows`, `macos`, or `ubuntu` (linux flavor). Not guaranteed to be any of those", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "platformDescription": { + "description": "Description of the platform, like `Windows 10 (10.0)`", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1750,6 +4004,79 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "fpsNumerator": { + "description": "Numerator of the fractional FPS value", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "fpsDenominator": { + "description": "Denominator of the fractional FPS value", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "baseWidth": { + "description": "Width of the base (canvas) resolution in pixels", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "baseHeight": { + "description": "Height of the base (canvas) resolution in pixels", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputWidth": { + "description": "Width of the output resolution in pixels", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "outputHeight": { + "description": "Height of the output resolution in pixels", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1764,6 +4091,24 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "outputActive": { + "description": "Whether the output is active", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": {} + }, "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -1792,6 +4137,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -1950,6 +4309,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2009,6 +4382,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2032,6 +4419,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2055,6 +4456,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2082,6 +4497,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2119,6 +4548,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2151,6 +4594,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2165,6 +4622,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2179,6 +4650,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2231,6 +4716,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -2254,6 +4753,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2277,6 +4790,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2300,6 +4827,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2323,6 +4864,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2346,6 +4901,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2376,6 +4945,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2404,6 +4987,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2434,6 +5031,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2487,6 +5098,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2521,6 +5146,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2562,6 +5201,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2590,6 +5243,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2618,6 +5285,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2718,6 +5399,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2750,6 +5445,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2782,6 +5491,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2805,6 +5528,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2868,6 +5605,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -2918,6 +5669,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3004,6 +5769,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3046,6 +5825,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3163,6 +5956,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3215,6 +6022,20 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3252,6 +6073,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3290,6 +6125,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3327,6 +6176,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3404,6 +6267,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3427,6 +6304,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3457,6 +6348,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3508,6 +6413,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3600,6 +6519,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3623,6 +6556,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3702,6 +6649,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -3730,6 +6691,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -3744,6 +6719,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -3758,6 +6747,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": true, @@ -3846,6 +6849,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -3993,6 +7010,20 @@ ], "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -4007,6 +7038,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, @@ -4021,6 +7066,20 @@ "type": "object", "properties": {} }, + "outputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation of the completed action" + } + }, + "required": [ + "message" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false + }, "annotations": { "readOnlyHint": false, "destructiveHint": false, diff --git a/src/tools/config.ts b/src/tools/config.ts index d5798b0..bed3ab9 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -6,367 +6,134 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; const OUTPUT_PARAMETER_CATEGORIES = new Set(["Output", "SimpleOutput", "AdvOut"]); export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetPersistentData tool - server.registerTool( - "obs-get-persistent-data", - { - title: "Get Persistent Data", - description: "Gets the value of a slot from the selected persistent data realm", - inputSchema: z.object({ - realm: z.string().describe("The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE"), - slotName: z.string().describe("The name of the slot to retrieve data from") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ realm, slotName }) => { - try { - const response = await client.sendRequest("GetPersistentData", { realm, slotName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting persistent data: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-persistent-data", + title: "Get Persistent Data", + description: "Gets the value of a slot from the selected persistent data realm", + requestType: "GetPersistentData", + inputSchema: z.object({ + realm: z.string().describe("The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE"), + slotName: z.string().describe("The name of the slot to retrieve data from") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetPersistentData tool - server.registerTool( - "obs-set-persistent-data", - { - title: "Set Persistent Data", - description: "Sets the value of a slot from the selected persistent data realm", - inputSchema: z.object({ - realm: z.string().describe("The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE"), - slotName: z.string().describe("The name of the slot to set data for"), - slotValue: z.unknown().describe("The value to apply to the slot") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ realm, slotName, slotValue }) => { - try { - await client.sendRequest("SetPersistentData", { realm, slotName, slotValue }); - return { - content: [ - { - type: "text", - text: `Successfully set value for slot '${slotName}' in realm '${realm}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting persistent data: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-persistent-data", + title: "Set Persistent Data", + description: "Sets the value of a slot from the selected persistent data realm", + requestType: "SetPersistentData", + inputSchema: z.object({ + realm: z.string().describe("The data realm to select. OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE"), + slotName: z.string().describe("The name of the slot to set data for"), + slotValue: z.unknown().describe("The value to apply to the slot") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ realm, slotName }) => `Successfully set value for slot '${slotName}' in realm '${realm}'`, + }); // GetSceneCollectionList tool - server.registerTool( - "obs-get-scene-collection-list", - { - title: "Get Scene Collection List", - description: "Gets an array of all scene collections", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetSceneCollectionList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting scene collection list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-scene-collection-list", + title: "Get Scene Collection List", + description: "Gets an array of all scene collections", + requestType: "GetSceneCollectionList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetCurrentSceneCollection tool - server.registerTool( - "obs-set-current-scene-collection", - { - title: "Set Current Scene Collection", - description: "Switches to a scene collection", - inputSchema: z.object({ - sceneCollectionName: z.string().describe("Name of the scene collection to switch to") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ sceneCollectionName }) => { - try { - await client.sendRequest("SetCurrentSceneCollection", { sceneCollectionName }); - return { - content: [ - { - type: "text", - text: `Successfully switched to scene collection: ${sceneCollectionName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error switching scene collection: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-current-scene-collection", + title: "Set Current Scene Collection", + description: "Switches to a scene collection", + requestType: "SetCurrentSceneCollection", + inputSchema: z.object({ + sceneCollectionName: z.string().describe("Name of the scene collection to switch to") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ sceneCollectionName }) => `Successfully switched to scene collection: ${sceneCollectionName}`, + }); // CreateSceneCollection tool - server.registerTool( - "obs-create-scene-collection", - { - title: "Create Scene Collection", - description: "Creates a new scene collection, switching to it in the process", - inputSchema: z.object({ - sceneCollectionName: z.string().describe("Name for the new scene collection") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ sceneCollectionName }) => { - try { - await client.sendRequest("CreateSceneCollection", { sceneCollectionName }); - return { - content: [ - { - type: "text", - text: `Successfully created and switched to scene collection: ${sceneCollectionName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating scene collection: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-create-scene-collection", + title: "Create Scene Collection", + description: "Creates a new scene collection, switching to it in the process", + requestType: "CreateSceneCollection", + inputSchema: z.object({ + sceneCollectionName: z.string().describe("Name for the new scene collection") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: ({ sceneCollectionName }) => `Successfully created and switched to scene collection: ${sceneCollectionName}`, + }); // GetProfileList tool - server.registerTool( - "obs-get-profile-list", - { - title: "Get Profile List", - description: "Gets an array of all profiles", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetProfileList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting profile list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-profile-list", + title: "Get Profile List", + description: "Gets an array of all profiles", + requestType: "GetProfileList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetCurrentProfile tool - server.registerTool( - "obs-set-current-profile", - { - title: "Set Current Profile", - description: "Switches to a profile", - inputSchema: z.object({ - profileName: z.string().describe("Name of the profile to switch to") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ profileName }) => { - try { - await client.sendRequest("SetCurrentProfile", { profileName }); - return { - content: [ - { - type: "text", - text: `Successfully switched to profile: ${profileName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error switching profile: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-current-profile", + title: "Set Current Profile", + description: "Switches to a profile", + requestType: "SetCurrentProfile", + inputSchema: z.object({ + profileName: z.string().describe("Name of the profile to switch to") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ profileName }) => `Successfully switched to profile: ${profileName}`, + }); // CreateProfile tool - server.registerTool( - "obs-create-profile", - { - title: "Create Profile", - description: "Creates a new profile, switching to it in the process", - inputSchema: z.object({ - profileName: z.string().describe("Name for the new profile") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ profileName }) => { - try { - await client.sendRequest("CreateProfile", { profileName }); - return { - content: [ - { - type: "text", - text: `Successfully created and switched to profile: ${profileName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating profile: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-create-profile", + title: "Create Profile", + description: "Creates a new profile, switching to it in the process", + requestType: "CreateProfile", + inputSchema: z.object({ + profileName: z.string().describe("Name for the new profile") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: ({ profileName }) => `Successfully created and switched to profile: ${profileName}`, + }); // RemoveProfile tool - server.registerTool( - "obs-remove-profile", - { - title: "Remove Profile", - description: "Removes a profile. If the current profile is chosen, it will change to a different profile first", - inputSchema: z.object({ - profileName: z.string().describe("Name of the profile to remove") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ profileName }) => { - try { - await client.sendRequest("RemoveProfile", { profileName }); - return { - content: [ - { - type: "text", - text: `Successfully removed profile: ${profileName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error removing profile: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-remove-profile", + title: "Remove Profile", + description: "Removes a profile. If the current profile is chosen, it will change to a different profile first", + requestType: "RemoveProfile", + inputSchema: z.object({ + profileName: z.string().describe("Name of the profile to remove") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: ({ profileName }) => `Successfully removed profile: ${profileName}`, + }); // GetProfileParameter tool - server.registerTool( - "obs-get-profile-parameter", - { - title: "Get Profile Parameter", - description: "Gets a parameter from the current profile's configuration", - inputSchema: z.object({ - parameterCategory: z.string().describe("Category of the parameter to get"), - parameterName: z.string().describe("Name of the parameter to get") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ parameterCategory, parameterName }) => { - try { - const response = await client.sendRequest("GetProfileParameter", { parameterCategory, parameterName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting profile parameter: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-profile-parameter", + title: "Get Profile Parameter", + description: "Gets a parameter from the current profile's configuration", + requestType: "GetProfileParameter", + inputSchema: z.object({ + parameterCategory: z.string().describe("Category of the parameter to get"), + parameterName: z.string().describe("Name of the parameter to get") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetProfileParameter tool server.registerTool( @@ -411,37 +178,13 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetVideoSettings tool - server.registerTool( - "obs-get-video-settings", - { - title: "Get Video Settings", - description: "Gets the current video settings", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetVideoSettings"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting video settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-video-settings", + title: "Get Video Settings", + description: "Gets the current video settings", + requestType: "GetVideoSettings", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetVideoSettings tool server.registerTool( @@ -494,141 +237,47 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetStreamServiceSettings tool - server.registerTool( - "obs-get-stream-service-settings", - { - title: "Get Stream Service Settings", - description: "Gets the current stream service settings", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetStreamServiceSettings"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting stream service settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-stream-service-settings", + title: "Get Stream Service Settings", + description: "Gets the current stream service settings", + requestType: "GetStreamServiceSettings", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetStreamServiceSettings tool - server.registerTool( - "obs-set-stream-service-settings", - { - title: "Set Stream Service Settings", - description: "Sets the current stream service settings", - inputSchema: z.object({ - streamServiceType: z.string().describe("Type of stream service to apply. Example: rtmp_common or rtmp_custom"), - streamServiceSettings: z.record(z.string(), z.unknown()).describe("Settings to apply to the service") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ streamServiceType, streamServiceSettings }) => { - try { - await client.sendRequest("SetStreamServiceSettings", { streamServiceType, streamServiceSettings }); - return { - content: [ - { - type: "text", - text: `Successfully updated stream service settings to type: ${streamServiceType}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting stream service settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-stream-service-settings", + title: "Set Stream Service Settings", + description: "Sets the current stream service settings", + requestType: "SetStreamServiceSettings", + inputSchema: z.object({ + streamServiceType: z.string().describe("Type of stream service to apply. Example: rtmp_common or rtmp_custom"), + streamServiceSettings: z.record(z.string(), z.unknown()).describe("Settings to apply to the service") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ streamServiceType }) => `Successfully updated stream service settings to type: ${streamServiceType}`, + }); // GetRecordDirectory tool - server.registerTool( - "obs-get-record-directory", - { - title: "Get Record Directory", - description: "Gets the current directory that the record output is set to", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetRecordDirectory"); - return { - content: [ - { - type: "text", - text: response.recordDirectory - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting record directory: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-record-directory", + title: "Get Record Directory", + description: "Gets the current directory that the record output is set to", + requestType: "GetRecordDirectory", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetRecordDirectory tool - server.registerTool( - "obs-set-record-directory", - { - title: "Set Record Directory", - description: "Sets the current directory that the record output writes files to", - inputSchema: z.object({ - recordDirectory: z.string().describe("Output directory") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ recordDirectory }) => { - try { - await client.sendRequest("SetRecordDirectory", { recordDirectory }); - return { - content: [ - { - type: "text", - text: `Successfully set record directory to: ${recordDirectory}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting record directory: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-record-directory", + title: "Set Record Directory", + description: "Sets the current directory that the record output writes files to", + requestType: "SetRecordDirectory", + inputSchema: z.object({ + recordDirectory: z.string().describe("Output directory") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ recordDirectory }) => `Successfully set record directory to: ${recordDirectory}`, + }); } diff --git a/src/tools/filters.ts b/src/tools/filters.ts index 95c51f1..924fe27 100644 --- a/src/tools/filters.ts +++ b/src/tools/filters.ts @@ -6,113 +6,42 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetSourceFilterKindList tool - server.registerTool( - "obs-get-filter-kind-list", - { - title: "Get Filter Kind List", - description: "Gets an array of all available source filter kinds", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetSourceFilterKindList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting filter kind list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-filter-kind-list", + title: "Get Filter Kind List", + description: "Gets an array of all available source filter kinds", + requestType: "GetSourceFilterKindList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetSourceFilterList tool - server.registerTool( - "obs-get-source-filter-list", - { - title: "Get Source Filter List", - description: "Gets an array of all of a source's filters", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName }) => { - try { - const response = await client.sendRequest("GetSourceFilterList", { canvasUuid, sourceName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting source filter list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-source-filter-list", + title: "Get Source Filter List", + description: "Gets an array of all of a source's filters", + requestType: "GetSourceFilterList", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetSourceFilterDefaultSettings tool - server.registerTool( - "obs-get-filter-default-settings", - { - title: "Get Filter Default Settings", - description: "Gets the default settings for a filter kind", - inputSchema: z.object({ - filterKind: z.string().describe("Filter kind to get the default settings for") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ filterKind }) => { - try { - const response = await client.sendRequest("GetSourceFilterDefaultSettings", { filterKind }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting filter default settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-filter-default-settings", + title: "Get Filter Default Settings", + description: "Gets the default settings for a filter kind", + requestType: "GetSourceFilterDefaultSettings", + inputSchema: z.object({ + filterKind: z.string().describe("Filter kind to get the default settings for") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // CreateSourceFilter tool server.registerTool( @@ -160,158 +89,65 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // RemoveSourceFilter tool - server.registerTool( - "obs-remove-source-filter", - { - title: "Remove Source Filter", - description: "Removes a filter from a source", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source the filter is on"), - filterName: z.string().describe("Name of the filter to remove") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, filterName }) => { - try { - await client.sendRequest("RemoveSourceFilter", { canvasUuid, sourceName, filterName }); - return { - content: [ - { - type: "text", - text: `Successfully removed filter '${filterName}' from source '${sourceName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error removing source filter: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-remove-source-filter", + title: "Remove Source Filter", + description: "Removes a filter from a source", + requestType: "RemoveSourceFilter", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source the filter is on"), + filterName: z.string().describe("Name of the filter to remove") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: ({ filterName, sourceName }) => `Successfully removed filter '${filterName}' from source '${sourceName}'`, + }); // SetSourceFilterName tool - server.registerTool( - "obs-set-source-filter-name", - { - title: "Rename Source Filter", - description: "Sets the name of a source filter (rename)", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source the filter is on"), - filterName: z.string().describe("Current name of the filter"), - newFilterName: z.string().describe("New name for the filter") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, filterName, newFilterName }) => { - try { - await client.sendRequest("SetSourceFilterName", { canvasUuid, sourceName, filterName, newFilterName }); - return { - content: [ - { - type: "text", - text: `Successfully renamed filter '${filterName}' to '${newFilterName}' on source '${sourceName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error renaming source filter: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-source-filter-name", + title: "Rename Source Filter", + description: "Sets the name of a source filter (rename)", + requestType: "SetSourceFilterName", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source the filter is on"), + filterName: z.string().describe("Current name of the filter"), + newFilterName: z.string().describe("New name for the filter") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ filterName, newFilterName, sourceName }) => `Successfully renamed filter '${filterName}' to '${newFilterName}' on source '${sourceName}'`, + }); // GetSourceFilter tool - server.registerTool( - "obs-get-source-filter", - { - title: "Get Source Filter", - description: "Gets the info for a specific source filter", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source"), - filterName: z.string().describe("Name of the filter") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, filterName }) => { - try { - const response = await client.sendRequest("GetSourceFilter", { canvasUuid, sourceName, filterName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting source filter info: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-source-filter", + title: "Get Source Filter", + description: "Gets the info for a specific source filter", + requestType: "GetSourceFilter", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source"), + filterName: z.string().describe("Name of the filter") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetSourceFilterIndex tool - server.registerTool( - "obs-set-source-filter-index", - { - title: "Set Source Filter Index", - description: "Sets the index position of a filter on a source", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source the filter is on"), - filterName: z.string().describe("Name of the filter"), - filterIndex: z.number().min(0).describe("New index position of the filter") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, filterName, filterIndex }) => { - try { - await client.sendRequest("SetSourceFilterIndex", { canvasUuid, sourceName, filterName, filterIndex }); - return { - content: [ - { - type: "text", - text: `Successfully set filter '${filterName}' to index ${filterIndex} on source '${sourceName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting source filter index: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-source-filter-index", + title: "Set Source Filter Index", + description: "Sets the index position of a filter on a source", + requestType: "SetSourceFilterIndex", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source the filter is on"), + filterName: z.string().describe("Name of the filter"), + filterIndex: z.number().min(0).describe("New index position of the filter") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ filterIndex, filterName, sourceName }) => `Successfully set filter '${filterName}' to index ${filterIndex} on source '${sourceName}'`, + }); // SetSourceFilterSettings tool server.registerTool( @@ -359,41 +195,18 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // SetSourceFilterEnabled tool - server.registerTool( - "obs-set-source-filter-enabled", - { - title: "Set Source Filter Enabled", - description: "Sets the enable state of a source filter", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().describe("Name of the source the filter is on"), - filterName: z.string().describe("Name of the filter"), - filterEnabled: z.boolean().describe("New enable state of the filter") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, filterName, filterEnabled }) => { - try { - await client.sendRequest("SetSourceFilterEnabled", { canvasUuid, sourceName, filterName, filterEnabled }); - return { - content: [ - { - type: "text", - text: `Successfully ${filterEnabled ? "enabled" : "disabled"} filter '${filterName}' on source '${sourceName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting source filter enabled state: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-source-filter-enabled", + title: "Set Source Filter Enabled", + description: "Sets the enable state of a source filter", + requestType: "SetSourceFilterEnabled", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().describe("Name of the source the filter is on"), + filterName: z.string().describe("Name of the filter"), + filterEnabled: z.boolean().describe("New enable state of the filter") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ filterEnabled, filterName, sourceName }) => `Successfully ${filterEnabled ? "enabled" : "disabled"} filter '${filterName}' on source '${sourceName}'`, + }); } diff --git a/src/tools/general.ts b/src/tools/general.ts index 66a7c92..a0adb54 100644 --- a/src/tools/general.ts +++ b/src/tools/general.ts @@ -7,6 +7,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { PACKAGE_VERSION } from "../version.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; const MAX_SLEEP_MILLIS = 50_000; @@ -50,37 +51,13 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // Get OBS version info - server.registerTool( - "obs-get-version", - { - title: "OBS Version Info", - description: "Get OBS Studio version information", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const version = await client.sendRequest("GetVersion"); - return { - content: [ - { - type: "text", - text: JSON.stringify(version, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Failed to get OBS version: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-version", + title: "OBS Version Info", + description: "Get OBS Studio version information", + requestType: "GetVersion", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // Test OBS connection server.registerTool( @@ -117,73 +94,26 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetStats tool - server.registerTool( - "obs-get-stats", - { - title: "OBS Statistics", - description: "Gets statistics about OBS, obs-websocket, and the current session", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const stats = await client.sendRequest("GetStats"); - return { - content: [ - { - type: "text", - text: JSON.stringify(stats, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting stats: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-stats", + title: "OBS Statistics", + description: "Gets statistics about OBS, obs-websocket, and the current session", + requestType: "GetStats", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // BroadcastCustomEvent tool - server.registerTool( - "obs-broadcast-custom-event", - { - title: "Broadcast Custom Event", - description: "Broadcasts a CustomEvent to all WebSocket clients", - inputSchema: z.object({ - eventData: z.record(z.string(), z.unknown()).describe("Data payload to emit to all receivers") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ eventData }) => { - try { - await client.sendRequest("BroadcastCustomEvent", { eventData }); - return { - content: [ - { - type: "text", - text: "Custom event broadcast successfully" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error broadcasting custom event: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-broadcast-custom-event", + title: "Broadcast Custom Event", + description: "Broadcasts a CustomEvent to all WebSocket clients", + requestType: "BroadcastCustomEvent", + inputSchema: z.object({ + eventData: z.record(z.string(), z.unknown()).describe("Data payload to emit to all receivers") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Custom event broadcast successfully", + }); // CallVendorRequest tool server.registerTool( @@ -233,37 +163,13 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetHotkeyList tool - server.registerTool( - "obs-get-hotkey-list", - { - title: "Get Hotkey List", - description: "Gets an array of all hotkey names in OBS", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const hotkeyList = await client.sendRequest("GetHotkeyList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(hotkeyList, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting hotkey list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-hotkey-list", + title: "Get Hotkey List", + description: "Gets an array of all hotkey names in OBS", + requestType: "GetHotkeyList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // TriggerHotkeyByName tool server.registerTool( diff --git a/src/tools/index.ts b/src/tools/index.ts index 41967dc..101e94b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -24,26 +24,42 @@ import * as protocolExtensions from "./protocol-extensions.js"; import * as protocol from "./protocol.js"; import * as preflight from "./preflight.js"; import { withStructuredToolResults } from "./results.js"; +import { ALL_TOOLS, scopedServer, type RegisteredTool, type ToolFilter, type ToolGroup } from "./toolsets.js"; -// Export the initialization function for all tools -export function initialize(server: McpServer, client: OBSWebSocketClient): void { +const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client: OBSWebSocketClient): void }]> = [ + ["general", general], + ["scenes", scenes], + ["sources", sources], + ["scene-items", sceneItems], + ["stream", streaming], + ["transitions", transitions], + ["config", config], + ["filters", filters], + ["inputs", inputs], + ["media", mediaInputs], + ["outputs", outputs], + ["record", record], + ["ui", ui], + ["protocol", protocolExtensions], // Regrouped per tool in toolsets.ts. + ["protocol", protocol], + ["record", preflight], +]; + +/** + * Registers the tools the filter includes and returns every tool with its + * group and whether it was registered. + */ +export function initialize( + server: McpServer, + client: OBSWebSocketClient, + filter: ToolFilter = ALL_TOOLS, +): RegisteredTool[] { const structuredServer = withStructuredToolResults(server); - general.initialize(structuredServer, client); - scenes.initialize(structuredServer, client); - sources.initialize(structuredServer, client); - sceneItems.initialize(structuredServer, client); - streaming.initialize(structuredServer, client); - transitions.initialize(structuredServer, client); - config.initialize(structuredServer, client); - filters.initialize(structuredServer, client); - inputs.initialize(structuredServer, client); - mediaInputs.initialize(structuredServer, client); - outputs.initialize(structuredServer, client); - record.initialize(structuredServer, client); - ui.initialize(structuredServer, client); - protocolExtensions.initialize(structuredServer, client); - protocol.initialize(structuredServer, client); - preflight.initialize(structuredServer, client); + const registry: RegisteredTool[] = []; + for (const [group, module] of MODULES) { + module.initialize(scopedServer(structuredServer, group, filter, registry), client); + } + return registry; } // Export tool modules diff --git a/src/tools/inputs.ts b/src/tools/inputs.ts index 6385438..92924b5 100644 --- a/src/tools/inputs.ts +++ b/src/tools/inputs.ts @@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetInputList tool @@ -91,37 +92,13 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetSpecialInputs tool - server.registerTool( - "obs-get-special-inputs", - { - title: "Get Special Inputs", - description: "Gets the names of all special inputs", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetSpecialInputs"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting special inputs: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-special-inputs", + title: "Get Special Inputs", + description: "Gets the names of all special inputs", + requestType: "GetSpecialInputs", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // CreateInput tool server.registerTool( @@ -173,149 +150,55 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // RemoveInput tool - server.registerTool( - "obs-remove-input", - { - title: "Remove Input", - description: "Removes an existing input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to remove") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - await client.sendRequest("RemoveInput", { inputName }); - return { - content: [ - { - type: "text", - text: `Successfully removed input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error removing input: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-remove-input", + title: "Remove Input", + description: "Removes an existing input", + requestType: "RemoveInput", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to remove") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputName }) => `Successfully removed input: ${inputName}`, + }); // SetInputName tool - server.registerTool( - "obs-set-input-name", - { - title: "Rename Input", - description: "Sets the name of an input (rename)", - inputSchema: z.object({ - inputName: z.string().describe("Current input name"), - newInputName: z.string().describe("New name for the input") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, newInputName }) => { - try { - await client.sendRequest("SetInputName", { inputName, newInputName }); - return { - content: [ - { - type: "text", - text: `Successfully renamed input '${inputName}' to '${newInputName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error renaming input: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-input-name", + title: "Rename Input", + description: "Sets the name of an input (rename)", + requestType: "SetInputName", + inputSchema: z.object({ + inputName: z.string().describe("Current input name"), + newInputName: z.string().describe("New name for the input") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputName, newInputName }) => `Successfully renamed input '${inputName}' to '${newInputName}'`, + }); // GetInputDefaultSettings tool - server.registerTool( - "obs-get-input-default-settings", - { - title: "Get Input Default Settings", - description: "Gets the default settings for an input kind", - inputSchema: z.object({ - inputKind: z.string().describe("Input kind to get the default settings for") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputKind }) => { - try { - const response = await client.sendRequest("GetInputDefaultSettings", { inputKind }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input default settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-default-settings", + title: "Get Input Default Settings", + description: "Gets the default settings for an input kind", + requestType: "GetInputDefaultSettings", + inputSchema: z.object({ + inputKind: z.string().describe("Input kind to get the default settings for") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetInputSettings tool - server.registerTool( - "obs-get-input-settings", - { - title: "Get Input Settings", - description: "Gets the settings of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to get the settings of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputSettings", { inputName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-settings", + title: "Get Input Settings", + description: "Gets the settings of an input", + requestType: "GetInputSettings", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to get the settings of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputSettings tool server.registerTool( @@ -361,77 +244,30 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetInputMute tool - server.registerTool( - "obs-get-input-mute", - { - title: "Get Input Mute State", - description: "Gets the audio mute state of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of input to get the mute state of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputMute", { inputName }); - return { - content: [ - { - type: "text", - text: `Input '${inputName}' is ${response.inputMuted ? "muted" : "unmuted"}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input mute state: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-mute", + title: "Get Input Mute State", + description: "Gets the audio mute state of an input", + requestType: "GetInputMute", + inputSchema: z.object({ + inputName: z.string().describe("Name of input to get the mute state of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputMute tool - server.registerTool( - "obs-set-input-mute", - { - title: "Set Input Mute", - description: "Sets the audio mute state of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to set the mute state of"), - inputMuted: z.boolean().describe("Whether to mute the input or not") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, inputMuted }) => { - try { - await client.sendRequest("SetInputMute", { inputName, inputMuted }); - return { - content: [ - { - type: "text", - text: `Successfully ${inputMuted ? "muted" : "unmuted"} input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting input mute state: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-input-mute", + title: "Set Input Mute", + description: "Sets the audio mute state of an input", + requestType: "SetInputMute", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to set the mute state of"), + inputMuted: z.boolean().describe("Whether to mute the input or not") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputMuted, inputName }) => `Successfully ${inputMuted ? "muted" : "unmuted"} input: ${inputName}`, + }); // ToggleInputMute tool server.registerTool( @@ -470,40 +306,16 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetInputVolume tool - server.registerTool( - "obs-get-input-volume", - { - title: "Get Input Volume", - description: "Gets the current volume setting of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to get the volume of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputVolume", { inputName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input volume: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-volume", + title: "Get Input Volume", + description: "Gets the current volume setting of an input", + requestType: "GetInputVolume", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to get the volume of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputVolume tool server.registerTool( @@ -556,221 +368,80 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetInputAudioBalance tool - server.registerTool( - "obs-get-input-audio-balance", - { - title: "Get Input Audio Balance", - description: "Gets the audio balance of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to get the audio balance of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputAudioBalance", { inputName }); - return { - content: [ - { - type: "text", - text: `Audio balance for input '${inputName}': ${response.inputAudioBalance}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input audio balance: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-audio-balance", + title: "Get Input Audio Balance", + description: "Gets the audio balance of an input", + requestType: "GetInputAudioBalance", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to get the audio balance of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputAudioBalance tool - server.registerTool( - "obs-set-input-audio-balance", - { - title: "Set Input Audio Balance", - description: "Sets the audio balance of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to set the audio balance of"), - inputAudioBalance: z.number().min(0).max(1).describe("New audio balance value (0.0-1.0)") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, inputAudioBalance }) => { - try { - await client.sendRequest("SetInputAudioBalance", { inputName, inputAudioBalance }); - return { - content: [ - { - type: "text", - text: `Successfully set audio balance to ${inputAudioBalance} for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting input audio balance: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-input-audio-balance", + title: "Set Input Audio Balance", + description: "Sets the audio balance of an input", + requestType: "SetInputAudioBalance", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to set the audio balance of"), + inputAudioBalance: z.number().min(0).max(1).describe("New audio balance value (0.0-1.0)") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputAudioBalance, inputName }) => `Successfully set audio balance to ${inputAudioBalance} for input: ${inputName}`, + }); // GetInputAudioSyncOffset tool - server.registerTool( - "obs-get-input-audio-sync-offset", - { - title: "Get Input Audio Sync Offset", - description: "Gets the audio sync offset of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to get the audio sync offset of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputAudioSyncOffset", { inputName }); - return { - content: [ - { - type: "text", - text: `Audio sync offset for input '${inputName}': ${response.inputAudioSyncOffset}ms` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input audio sync offset: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-audio-sync-offset", + title: "Get Input Audio Sync Offset", + description: "Gets the audio sync offset of an input", + requestType: "GetInputAudioSyncOffset", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to get the audio sync offset of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputAudioSyncOffset tool - server.registerTool( - "obs-set-input-audio-sync-offset", - { - title: "Set Input Audio Sync Offset", - description: "Sets the audio sync offset of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to set the audio sync offset of"), - inputAudioSyncOffset: z.number().min(-950).max(20000).describe("New audio sync offset in milliseconds") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, inputAudioSyncOffset }) => { - try { - await client.sendRequest("SetInputAudioSyncOffset", { inputName, inputAudioSyncOffset }); - return { - content: [ - { - type: "text", - text: `Successfully set audio sync offset to ${inputAudioSyncOffset}ms for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting input audio sync offset: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-input-audio-sync-offset", + title: "Set Input Audio Sync Offset", + description: "Sets the audio sync offset of an input", + requestType: "SetInputAudioSyncOffset", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to set the audio sync offset of"), + inputAudioSyncOffset: z.number().min(-950).max(20000).describe("New audio sync offset in milliseconds") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputAudioSyncOffset, inputName }) => `Successfully set audio sync offset to ${inputAudioSyncOffset}ms for input: ${inputName}`, + }); // GetInputAudioMonitorType tool - server.registerTool( - "obs-get-input-audio-monitor-type", - { - title: "Get Input Audio Monitor Type", - description: "Gets the audio monitor type of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to get the audio monitor type of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetInputAudioMonitorType", { inputName }); - return { - content: [ - { - type: "text", - text: `Audio monitor type for input '${inputName}': ${response.monitorType}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting input audio monitor type: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-input-audio-monitor-type", + title: "Get Input Audio Monitor Type", + description: "Gets the audio monitor type of an input", + requestType: "GetInputAudioMonitorType", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to get the audio monitor type of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetInputAudioMonitorType tool - server.registerTool( - "obs-set-input-audio-monitor-type", - { - title: "Set Input Audio Monitor Type", - description: "Sets the audio monitor type of an input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the input to set the audio monitor type of"), - monitorType: z.string().describe("Audio monitor type (OBS_MONITORING_TYPE_NONE, OBS_MONITORING_TYPE_MONITOR_ONLY, OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT)") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, monitorType }) => { - try { - await client.sendRequest("SetInputAudioMonitorType", { inputName, monitorType }); - return { - content: [ - { - type: "text", - text: `Successfully set audio monitor type to ${monitorType} for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting input audio monitor type: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-input-audio-monitor-type", + title: "Set Input Audio Monitor Type", + description: "Sets the audio monitor type of an input", + requestType: "SetInputAudioMonitorType", + inputSchema: z.object({ + inputName: z.string().describe("Name of the input to set the audio monitor type of"), + monitorType: z.string().describe("Audio monitor type (OBS_MONITORING_TYPE_NONE, OBS_MONITORING_TYPE_MONITOR_ONLY, OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT)") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputName, monitorType }) => `Successfully set audio monitor type to ${monitorType} for input: ${inputName}`, + }); } diff --git a/src/tools/media-inputs.ts b/src/tools/media-inputs.ts index fa2c69e..2cc9318 100644 --- a/src/tools/media-inputs.ts +++ b/src/tools/media-inputs.ts @@ -6,159 +6,67 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetMediaInputStatus tool - server.registerTool( - "obs-get-media-input-status", - { - title: "Get Media Input Status", - description: "Gets the status of a media input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the media input") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName }) => { - try { - const response = await client.sendRequest("GetMediaInputStatus", { inputName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting media input status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-media-input-status", + title: "Get Media Input Status", + description: "Gets the status of a media input", + requestType: "GetMediaInputStatus", + inputSchema: z.object({ + inputName: z.string().describe("Name of the media input") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetMediaInputCursor tool - server.registerTool( - "obs-set-media-input-cursor", - { - title: "Set Media Input Cursor", - description: "Sets the cursor position of a media input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the media input"), - mediaCursor: z.number().min(0).describe("New cursor position to set (in milliseconds)") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ inputName, mediaCursor }) => { - try { - await client.sendRequest("SetMediaInputCursor", { inputName, mediaCursor }); - return { - content: [ - { - type: "text", - text: `Successfully set media cursor position to ${mediaCursor}ms for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting media input cursor: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-media-input-cursor", + title: "Set Media Input Cursor", + description: "Sets the cursor position of a media input", + requestType: "SetMediaInputCursor", + inputSchema: z.object({ + inputName: z.string().describe("Name of the media input"), + mediaCursor: z.number().min(0).describe("New cursor position to set (in milliseconds)") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ inputName, mediaCursor }) => `Successfully set media cursor position to ${mediaCursor}ms for input: ${inputName}`, + }); // OffsetMediaInputCursor tool - server.registerTool( - "obs-offset-media-input-cursor", - { - title: "Offset Media Input Cursor", - description: "Offsets the current cursor position of a media input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the media input"), - mediaCursorOffset: z.number().describe("Value to offset the current cursor position by (in milliseconds)") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ inputName, mediaCursorOffset }) => { - try { - await client.sendRequest("OffsetMediaInputCursor", { inputName, mediaCursorOffset }); - return { - content: [ - { - type: "text", - text: `Successfully offset media cursor position by ${mediaCursorOffset}ms for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error offsetting media input cursor: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-offset-media-input-cursor", + title: "Offset Media Input Cursor", + description: "Offsets the current cursor position of a media input", + requestType: "OffsetMediaInputCursor", + inputSchema: z.object({ + inputName: z.string().describe("Name of the media input"), + mediaCursorOffset: z.number().describe("Value to offset the current cursor position by (in milliseconds)") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: ({ inputName, mediaCursorOffset }) => `Successfully offset media cursor position by ${mediaCursorOffset}ms for input: ${inputName}`, + }); // TriggerMediaInputAction tool - server.registerTool( - "obs-trigger-media-input-action", - { - title: "Trigger Media Input Action", - description: "Triggers an action on a media input", - inputSchema: z.object({ - inputName: z.string().describe("Name of the media input"), - mediaAction: z.enum([ - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY", - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE", - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP", - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART", - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT", - "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS" - ]).describe("Action to trigger (PLAY, PAUSE, STOP, RESTART, NEXT, PREVIOUS)") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ inputName, mediaAction }) => { - try { - await client.sendRequest("TriggerMediaInputAction", { inputName, mediaAction }); - return { - content: [ - { - type: "text", - text: `Successfully triggered media action '${mediaAction}' for input: ${inputName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error triggering media input action: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-trigger-media-input-action", + title: "Trigger Media Input Action", + description: "Triggers an action on a media input", + requestType: "TriggerMediaInputAction", + inputSchema: z.object({ + inputName: z.string().describe("Name of the media input"), + mediaAction: z.enum([ + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY", + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE", + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP", + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART", + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT", + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS" + ]).describe("Action to trigger (PLAY, PAUSE, STOP, RESTART, NEXT, PREVIOUS)") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: ({ inputName, mediaAction }) => `Successfully triggered media action '${mediaAction}' for input: ${inputName}`, + }); } diff --git a/src/tools/output-schema.test.ts b/src/tools/output-schema.test.ts new file mode 100644 index 0000000..4047074 --- /dev/null +++ b/src/tools/output-schema.test.ts @@ -0,0 +1,119 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { OBS_PROTOCOL_REQUESTS, type ObsProtocolField } from "../obs-protocol.js"; +import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; + +type JsonSchema = { + type?: string | string[]; + enum?: unknown[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + minimum?: number; + anyOf?: JsonSchema[]; + additionalProperties?: JsonSchema | boolean; +}; + +function sampleValue(field: ObsProtocolField): unknown { + switch (field.valueType) { + case "String": return "sample"; + case "Number": return 1; + case "Boolean": return true; + case "Array": return ["sample"]; + case "Array": return [{ sample: 1 }]; + case "Object": return { sample: 1 }; + default: return null; + } +} + +/** Builds valid tool arguments from a JSON Schema: required properties only. */ +function sampleArgument(schema: JsonSchema): unknown { + if (schema.enum) return schema.enum[0]; + if (schema.anyOf?.[0]) return sampleArgument(schema.anyOf[0]); + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + switch (type) { + case "string": return "sample"; + case "number": + case "integer": return schema.minimum ?? 1; + case "boolean": return true; + case "array": return []; + case "object": return Object.fromEntries( + (schema.required ?? []).map((key) => [key, sampleArgument( + schema.properties?.[key] + ?? (typeof schema.additionalProperties === "object" ? schema.additionalProperties : {}), + )]), + ); + default: return "sample"; + } +} + +let harness: McpHarness; + +beforeAll(async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + harness = await startMcpHarness(); + for (const request of OBS_PROTOCOL_REQUESTS) { + if (request.requestType === "GetVersion") continue; // The fake answers with its advertised requests. + const response = Object.fromEntries( + request.responseFields.map((field) => [field.valueName, sampleValue(field)]), + ); + harness.fakeObs.respondWith(request.requestType, () => response); + } + harness.fakeObs.respondWith("GetSourceScreenshot", () => ({ + imageData: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + })); +}); + +afterAll(async () => { + await harness.close(); + vi.restoreAllMocks(); +}); + +describe("declared output schemas", () => { + it("are declared by every single-request tool and accept protocol-shaped responses", async () => { + const { tools } = await harness.mcpClient.listTools(); + const withSchema = tools.filter(({ outputSchema }) => outputSchema); + expect(withSchema.length).toBeGreaterThanOrEqual(110); + + const failures: string[] = []; + for (const tool of withSchema) { + const args = sampleArgument(tool.inputSchema as JsonSchema) as Record; + try { + const result = await harness.call(tool.name, args); + if (result.isError) failures.push(`${tool.name}: ${resultText(result)}`); + } catch (error) { + failures.push(`${tool.name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + expect(failures).toEqual([]); + }); + + it("reports a response that contradicts the protocol as a tool error", async () => { + harness.fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: "yes" })); + + const result = await harness.call("obs-get-record-status"); + + expect(result.isError).toBe(true); + expect(resultText(result)).toMatch(/Output validation error.*outputActive/); + }); + + it("accepts null for fields the protocol does not document as nullable", async () => { + harness.fakeObs.respondWith("GetSpecialInputs", () => ({ desktop1: "Desktop Audio", desktop2: null })); + + const result = await harness.call("obs-get-special-inputs"); + + expect(result.isError).toBeFalsy(); + }); + + it("accepts responses from older OBS versions that omit newer fields", async () => { + harness.fakeObs.respondWith("GetSceneList", () => ({ scenes: [], currentProgramSceneName: "Main" })); + + const result = await harness.call("obs-get-scene-list"); + + expect(result.isError).toBeFalsy(); + }); +}); diff --git a/src/tools/output-schema.ts b/src/tools/output-schema.ts new file mode 100644 index 0000000..bca7920 --- /dev/null +++ b/src/tools/output-schema.ts @@ -0,0 +1,44 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import { z } from "zod"; +import { getObsProtocolRequest, type ObsProtocolField } from "../obs-protocol.js"; + +function fieldSchema(field: ObsProtocolField): z.ZodType { + let schema: z.ZodType; + switch (field.valueType) { + case "String": schema = z.string(); break; + case "Number": schema = z.number(); break; + case "Boolean": schema = z.boolean(); break; + case "Array": schema = z.array(z.string()); break; + case "Array": schema = z.array(z.record(z.string(), z.unknown())); break; + case "Object": schema = z.record(z.string(), z.unknown()); break; + default: return z.unknown().optional().describe(field.valueDescription); + } + // Nullable and optional: the protocol documents nulls only in prose and not + // always (GetSpecialInputs returns null for unset devices), and older OBS + // versions omit fields added later. A stricter schema would turn a working + // call into an output validation error. + return schema.nullable().optional().describe(field.valueDescription); +} + +/** + * The structuredContent shape of a tool that returns an OBS response as-is, + * derived from the pinned protocol. Extra fields are allowed so newer OBS + * versions that add response fields still validate. + */ +export function responseOutputSchema(requestType: string): z.ZodObject | undefined { + const request = getObsProtocolRequest(requestType); + if (!request) return undefined; + const shape = Object.fromEntries( + request.responseFields.map((field) => [field.valueName, fieldSchema(field)]), + ); + return z.looseObject(shape); +} + +/** The structuredContent shape of a tool that answers with a confirmation. */ +export const MESSAGE_OUTPUT_SCHEMA = z.object({ + message: z.string().describe("Confirmation of the completed action"), +}); diff --git a/src/tools/outputs.ts b/src/tools/outputs.ts index b95bf8b..c5f159f 100644 --- a/src/tools/outputs.ts +++ b/src/tools/outputs.ts @@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; import { REPLAY_BUFFER_OUTPUT, startOutputAndConfirm, @@ -14,37 +15,13 @@ import { export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetVirtualCamStatus tool - server.registerTool( - "obs-get-virtual-cam-status", - { - title: "Get Virtual Camera Status", - description: "Gets the status of the virtualcam output", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetVirtualCamStatus"); - return { - content: [ - { - type: "text", - text: `Virtual camera is ${response.outputActive ? "active" : "inactive"}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting virtual camera status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-virtual-cam-status", + title: "Get Virtual Camera Status", + description: "Gets the status of the virtualcam output", + requestType: "GetVirtualCamStatus", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // ToggleVirtualCam tool server.registerTool( @@ -91,70 +68,23 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // StopVirtualCam tool - server.registerTool( - "obs-stop-virtual-cam", - { - title: "Stop Virtual Camera", - description: "Stops the virtualcam output", - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("StopVirtualCam"); - return { - content: [ - { - type: "text", - text: "Virtual camera stopped" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error stopping virtual camera: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-stop-virtual-cam", + title: "Stop Virtual Camera", + description: "Stops the virtualcam output", + requestType: "StopVirtualCam", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: () => "Virtual camera stopped", + }); // GetReplayBufferStatus tool - server.registerTool( - "obs-get-replay-buffer-status", - { - title: "Get Replay Buffer Status", - description: "Gets the status of the replay buffer output", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetReplayBufferStatus"); - return { - content: [ - { - type: "text", - text: `Replay buffer is ${response.outputActive ? "active" : "inactive"}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting replay buffer status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-replay-buffer-status", + title: "Get Replay Buffer Status", + description: "Gets the status of the replay buffer output", + requestType: "GetReplayBufferStatus", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // ToggleReplayBuffer tool server.registerTool( @@ -201,172 +131,54 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // StopReplayBuffer tool - server.registerTool( - "obs-stop-replay-buffer", - { - title: "Stop Replay Buffer", - description: "Stops the replay buffer output", - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("StopReplayBuffer"); - return { - content: [ - { - type: "text", - text: "Replay buffer stopped" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error stopping replay buffer: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-stop-replay-buffer", + title: "Stop Replay Buffer", + description: "Stops the replay buffer output", + requestType: "StopReplayBuffer", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: () => "Replay buffer stopped", + }); // SaveReplayBuffer tool - server.registerTool( - "obs-save-replay-buffer", - { - title: "Save Replay Buffer", - description: "Saves the contents of the replay buffer output", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("SaveReplayBuffer"); - return { - content: [ - { - type: "text", - text: "Replay buffer saved" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error saving replay buffer: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-save-replay-buffer", + title: "Save Replay Buffer", + description: "Saves the contents of the replay buffer output", + requestType: "SaveReplayBuffer", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Replay buffer saved", + }); // GetLastReplayBufferReplay tool - server.registerTool( - "obs-get-last-replay-buffer-replay", - { - title: "Get Last Replay Buffer File", - description: "Gets the filename of the last replay buffer save file", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetLastReplayBufferReplay"); - return { - content: [ - { - type: "text", - text: `Last replay buffer save file: ${response.savedReplayPath}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting last replay buffer file: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-last-replay-buffer-replay", + title: "Get Last Replay Buffer File", + description: "Gets the filename of the last replay buffer save file", + requestType: "GetLastReplayBufferReplay", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetOutputList tool - server.registerTool( - "obs-get-output-list", - { - title: "Get Output List", - description: "Gets the list of available outputs", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetOutputList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting output list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-output-list", + title: "Get Output List", + description: "Gets the list of available outputs", + requestType: "GetOutputList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetOutputStatus tool - server.registerTool( - "obs-get-output-status", - { - title: "Get Output Status", - description: "Gets the status of an output", - inputSchema: z.object({ - outputName: z.string().describe("Output name") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ outputName }) => { - try { - const response = await client.sendRequest("GetOutputStatus", { outputName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting output status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-output-status", + title: "Get Output Status", + description: "Gets the status of an output", + requestType: "GetOutputStatus", + inputSchema: z.object({ + outputName: z.string().describe("Output name") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // ToggleOutput tool server.registerTool( @@ -405,147 +217,54 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // StartOutput tool - server.registerTool( - "obs-start-output", - { - title: "Start Output", - description: "Starts an output", - inputSchema: z.object({ - outputName: z.string().describe("Output name") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, - }, - async ({ outputName }) => { - try { - await client.sendRequest("StartOutput", { outputName }); - return { - content: [ - { - type: "text", - text: `Output '${outputName}' started` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error starting output: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-start-output", + title: "Start Output", + description: "Starts an output", + requestType: "StartOutput", + inputSchema: z.object({ + outputName: z.string().describe("Output name") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + successMessage: ({ outputName }) => `Output '${outputName}' started`, + }); // StopOutput tool - server.registerTool( - "obs-stop-output", - { - title: "Stop Output", - description: "Stops an output", - inputSchema: z.object({ - outputName: z.string().describe("Output name") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, - }, - async ({ outputName }) => { - try { - await client.sendRequest("StopOutput", { outputName }); - return { - content: [ - { - type: "text", - text: `Output '${outputName}' stopped` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error stopping output: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-stop-output", + title: "Stop Output", + description: "Stops an output", + requestType: "StopOutput", + inputSchema: z.object({ + outputName: z.string().describe("Output name") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + successMessage: ({ outputName }) => `Output '${outputName}' stopped`, + }); // GetOutputSettings tool - server.registerTool( - "obs-get-output-settings", - { - title: "Get Output Settings", - description: "Gets the settings of an output", - inputSchema: z.object({ - outputName: z.string().describe("Output name") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ outputName }) => { - try { - const response = await client.sendRequest("GetOutputSettings", { outputName }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting output settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-output-settings", + title: "Get Output Settings", + description: "Gets the settings of an output", + requestType: "GetOutputSettings", + inputSchema: z.object({ + outputName: z.string().describe("Output name") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetOutputSettings tool - server.registerTool( - "obs-set-output-settings", - { - title: "Set Output Settings", - description: "Sets the settings of an output", - inputSchema: z.object({ - outputName: z.string().describe("Output name"), - outputSettings: z.record(z.string(), z.unknown()).describe("Output settings") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ outputName, outputSettings }) => { - try { - await client.sendRequest("SetOutputSettings", { outputName, outputSettings }); - return { - content: [ - { - type: "text", - text: `Settings updated for output '${outputName}'` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting output settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-output-settings", + title: "Set Output Settings", + description: "Sets the settings of an output", + requestType: "SetOutputSettings", + inputSchema: z.object({ + outputName: z.string().describe("Output name"), + outputSettings: z.record(z.string(), z.unknown()).describe("Output settings") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ outputName }) => `Settings updated for output '${outputName}'`, + }); } diff --git a/src/tools/record.ts b/src/tools/record.ts index 06db806..abb948b 100644 --- a/src/tools/record.ts +++ b/src/tools/record.ts @@ -6,41 +6,18 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; import { RECORD_OUTPUT, startOutputAndConfirm } from "./output-start.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetRecordStatus tool - server.registerTool( - "obs-get-record-status", - { - title: "Get Record Status", - description: "Gets the status of the record output", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetRecordStatus"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting record status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-record-status", + title: "Get Record Status", + description: "Gets the status of the record output", + requestType: "GetRecordStatus", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // ToggleRecord tool server.registerTool( @@ -120,136 +97,44 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // ToggleRecordPause tool - server.registerTool( - "obs-toggle-record-pause", - { - title: "Toggle Record Pause", - description: "Toggles pause on the record output", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("ToggleRecordPause"); - return { - content: [ - { - type: "text", - text: "Recording pause toggled" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error toggling record pause: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-toggle-record-pause", + title: "Toggle Record Pause", + description: "Toggles pause on the record output", + requestType: "ToggleRecordPause", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Recording pause toggled", + }); // PauseRecord tool - server.registerTool( - "obs-pause-record", - { - title: "Pause Recording", - description: "Pauses the record output", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("PauseRecord"); - return { - content: [ - { - type: "text", - text: "Recording paused" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error pausing recording: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-pause-record", + title: "Pause Recording", + description: "Pauses the record output", + requestType: "PauseRecord", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: () => "Recording paused", + }); // ResumeRecord tool - server.registerTool( - "obs-resume-record", - { - title: "Resume Recording", - description: "Resumes the record output", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("ResumeRecord"); - return { - content: [ - { - type: "text", - text: "Recording resumed" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error resuming recording: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-resume-record", + title: "Resume Recording", + description: "Resumes the record output", + requestType: "ResumeRecord", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: () => "Recording resumed", + }); // SplitRecordFile tool - server.registerTool( - "obs-split-record-file", - { - title: "Split Record File", - description: "Splits the current file being recorded into a new file", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("SplitRecordFile"); - return { - content: [ - { - type: "text", - text: "Recording file split" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error splitting record file: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-split-record-file", + title: "Split Record File", + description: "Splits the current file being recorded into a new file", + requestType: "SplitRecordFile", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Recording file split", + }); // CreateRecordChapter tool server.registerTool( diff --git a/src/tools/request-tool.ts b/src/tools/request-tool.ts index 6407b42..9819bf8 100644 --- a/src/tools/request-tool.ts +++ b/src/tools/request-tool.ts @@ -6,6 +6,7 @@ import type { McpServer, ToolAnnotations } from "@modelcontextprotocol/server"; import type { ZodObject } from "zod"; import type { OBSWebSocketClient } from "../client.js"; +import { MESSAGE_OUTPUT_SCHEMA, responseOutputSchema } from "./output-schema.js"; export const READ_ONLY_TOOL = { readOnlyHint: true, @@ -43,16 +44,29 @@ type RequestToolDefinition = { inputSchema?: ZodObject; annotations: ToolAnnotations; responseMode?: "json" | "success"; + /** Replaces the JSON response with a confirmation built from the arguments. */ + successMessage?: (args: Record) => string; /** Returns a refusal message when the request must not reach OBS. */ guard?: (client: OBSWebSocketClient, args: Record) => Promise; }; -function formatResponse(definition: RequestToolDefinition, response: unknown): string { - if (definition.responseMode === "success") { - return `${definition.title} completed successfully`; - } +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function returnsMessage(definition: RequestToolDefinition): boolean { + return definition.successMessage !== undefined || definition.responseMode === "success"; +} - return JSON.stringify(response, null, 2); +function structuredResponse( + definition: RequestToolDefinition, + response: unknown, + requestData: Record, +): Record { + if (!returnsMessage(definition)) return isObject(response) ? response : {}; + return { + message: definition.successMessage?.(requestData) ?? `${definition.title} completed successfully`, + }; } async function executeRequest( @@ -69,8 +83,15 @@ async function executeRequest( }; } const response = await client.sendRequest(definition.requestType, requestData); + const structuredContent = structuredResponse(definition, response, requestData ?? {}); return { - content: [{ type: "text" as const, text: formatResponse(definition, response) }], + content: [{ + type: "text" as const, + text: returnsMessage(definition) + ? String(structuredContent.message) + : JSON.stringify(structuredContent, null, 2), + }], + structuredContent, }; } catch (error) { return { @@ -89,11 +110,15 @@ export function registerObsRequestTool( definition: RequestToolDefinition, ): void { const { name, title, description, inputSchema, annotations } = definition; + const outputSchema = returnsMessage(definition) + ? MESSAGE_OUTPUT_SCHEMA + : responseOutputSchema(definition.requestType); + const outputConfig = outputSchema ? { outputSchema } : {}; if (inputSchema) { server.registerTool( name, - { title, description, inputSchema, annotations }, + { title, description, inputSchema, annotations, ...outputConfig }, async (args) => executeRequest( client, definition, @@ -105,7 +130,7 @@ export function registerObsRequestTool( server.registerTool( name, - { title, description, annotations }, + { title, description, annotations, ...outputConfig }, async () => executeRequest(client, definition), ); } diff --git a/src/tools/scene-items.ts b/src/tools/scene-items.ts index 0bb7846..10f6e3a 100644 --- a/src/tools/scene-items.ts +++ b/src/tools/scene-items.ts @@ -6,45 +6,22 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetSceneItemList tool - server.registerTool( - "obs-get-scene-items", - { - title: "Get Scene Items", - description: "Get a list of all scene items in a scene", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), - sceneName: z.string().optional().describe("Name of the scene to get items from"), - sceneUuid: z.string().optional().describe("UUID of the scene to get items from") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName, sceneUuid }) => { - try { - const sceneItems = await client.sendRequest("GetSceneItemList", { canvasUuid, sceneName, sceneUuid }); - return { - content: [ - { - type: "text", - text: JSON.stringify(sceneItems, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting scene items: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-scene-items", + title: "Get Scene Items", + description: "Get a list of all scene items in a scene", + requestType: "GetSceneItemList", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), + sceneName: z.string().optional().describe("Name of the scene to get items from"), + sceneUuid: z.string().optional().describe("UUID of the scene to get items from") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // CreateSceneItem tool server.registerTool( @@ -96,44 +73,20 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // RemoveSceneItem tool - server.registerTool( - "obs-remove-scene-item", - { - title: "Remove Scene Item", - description: "Remove a scene item from a scene", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), - sceneName: z.string().optional().describe("Name of the scene to remove the item from"), - sceneUuid: z.string().optional().describe("UUID of the scene to remove the item from"), - sceneItemId: z.number().int().nonnegative().describe("The ID of the scene item to remove") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName, sceneUuid, sceneItemId }) => { - try { - await client.sendRequest("RemoveSceneItem", { canvasUuid, sceneName, sceneUuid, sceneItemId }); - - return { - content: [ - { - type: "text", - text: `Successfully removed item with ID ${sceneItemId} from ${sceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error removing scene item: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-remove-scene-item", + title: "Remove Scene Item", + description: "Remove a scene item from a scene", + requestType: "RemoveSceneItem", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), + sceneName: z.string().optional().describe("Name of the scene to remove the item from"), + sceneUuid: z.string().optional().describe("UUID of the scene to remove the item from"), + sceneItemId: z.number().int().nonnegative().describe("The ID of the scene item to remove") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: ({ sceneItemId, sceneName }) => `Successfully removed item with ID ${sceneItemId} from ${sceneName}`, + }); // SetSceneItemEnabled tool server.registerTool( @@ -183,43 +136,19 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetSceneItemTransform tool - server.registerTool( - "obs-get-scene-item-transform", - { - title: "Get Scene Item Transform", - description: "Get the position, rotation, scale, or crop of a scene item", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), - sceneName: z.string().optional().describe("Name of the scene containing the item"), - sceneUuid: z.string().optional().describe("UUID of the scene containing the item"), - sceneItemId: z.number().int().nonnegative().describe("The ID of the scene item") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName, sceneUuid, sceneItemId }) => { - try { - const response = await client.sendRequest("GetSceneItemTransform", { canvasUuid, sceneName, sceneUuid, sceneItemId }); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting scene item transform: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-scene-item-transform", + title: "Get Scene Item Transform", + description: "Get the position, rotation, scale, or crop of a scene item", + requestType: "GetSceneItemTransform", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), + sceneName: z.string().optional().describe("Name of the scene containing the item"), + sceneUuid: z.string().optional().describe("UUID of the scene containing the item"), + sceneItemId: z.number().int().nonnegative().describe("The ID of the scene item") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetSceneItemTransform tool server.registerTool( @@ -306,51 +235,19 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetSceneItemIdByName tool - server.registerTool( - "obs-get-scene-item-id", - { - title: "Get Scene Item ID", - description: "Get the ID of a scene item by its source name", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), - sceneName: z.string().optional().describe("Name of the scene to search"), - sceneUuid: z.string().optional().describe("UUID of the scene to search"), - sourceName: z.string().optional().describe("Name of the source to find"), - sourceUuid: z.string().optional().describe("UUID of the source to find"), - searchOffset: z.number().int().nonnegative().optional().describe("Match offset when the source appears multiple times") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName, sceneUuid, sourceName, sourceUuid, searchOffset }) => { - try { - const response = await client.sendRequest("GetSceneItemId", { - canvasUuid, - sceneName, - sceneUuid, - sourceName, - sourceUuid, - searchOffset - }); - - return { - content: [ - { - type: "text", - text: `Scene item ID for ${sourceName} in ${sceneName}: ${response.sceneItemId}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting scene item ID: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-scene-item-id", + title: "Get Scene Item ID", + description: "Get the ID of a scene item by its source name", + requestType: "GetSceneItemId", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), + sceneName: z.string().optional().describe("Name of the scene to search"), + sceneUuid: z.string().optional().describe("UUID of the scene to search"), + sourceName: z.string().optional().describe("Name of the source to find"), + sourceUuid: z.string().optional().describe("UUID of the source to find"), + searchOffset: z.number().int().nonnegative().optional().describe("Match offset when the source appears multiple times") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); } diff --git a/src/tools/scenes.ts b/src/tools/scenes.ts index 48abd49..16ce1e7 100644 --- a/src/tools/scenes.ts +++ b/src/tools/scenes.ts @@ -6,286 +6,100 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetSceneList tool - server.registerTool( - "obs-get-scene-list", - { - title: "Get Scene List", - description: "Get a list of scenes in OBS", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas to list scenes from") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid }) => { - try { - const sceneList = await client.sendRequest("GetSceneList", { canvasUuid }); - return { - content: [ - { - type: "text", - text: JSON.stringify(sceneList, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting scene list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-scene-list", + title: "Get Scene List", + description: "Get a list of scenes in OBS", + requestType: "GetSceneList", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas to list scenes from") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetCurrentProgramScene tool - server.registerTool( - "obs-get-current-scene", - { - title: "Get Current Scene", - description: "Get the current active scene in OBS", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const currentScene = await client.sendRequest("GetCurrentProgramScene"); - return { - content: [ - { - type: "text", - text: `Current scene: ${currentScene.currentProgramSceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting current scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-current-scene", + title: "Get Current Scene", + description: "Get the current active scene in OBS", + requestType: "GetCurrentProgramScene", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetCurrentProgramScene tool - server.registerTool( - "obs-set-current-scene", - { - title: "Set Current Scene", - description: "Set the current active scene in OBS", - inputSchema: z.object({ - sceneName: z.string().describe("The name of the scene to set as current") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ sceneName }) => { - try { - await client.sendRequest("SetCurrentProgramScene", { sceneName }); - return { - content: [ - { - type: "text", - text: `Successfully switched to scene: ${sceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting current scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-current-scene", + title: "Set Current Scene", + description: "Set the current active scene in OBS", + requestType: "SetCurrentProgramScene", + inputSchema: z.object({ + sceneName: z.string().describe("The name of the scene to set as current") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ sceneName }) => `Successfully switched to scene: ${sceneName}`, + }); // GetCurrentPreviewScene tool (Studio Mode) - server.registerTool( - "obs-get-preview-scene", - { - title: "Get Preview Scene", - description: "Get the current preview scene in OBS Studio Mode", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const previewScene = await client.sendRequest("GetCurrentPreviewScene"); - return { - content: [ - { - type: "text", - text: `Preview scene: ${previewScene.currentPreviewSceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting preview scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-preview-scene", + title: "Get Preview Scene", + description: "Get the current preview scene in OBS Studio Mode", + requestType: "GetCurrentPreviewScene", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetCurrentPreviewScene tool (Studio Mode) - server.registerTool( - "obs-set-preview-scene", - { - title: "Set Preview Scene", - description: "Set the current preview scene in OBS Studio Mode", - inputSchema: z.object({ - sceneName: z.string().describe("The name of the scene to set as preview") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ sceneName }) => { - try { - await client.sendRequest("SetCurrentPreviewScene", { sceneName }); - return { - content: [ - { - type: "text", - text: `Successfully set preview scene to: ${sceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting preview scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-preview-scene", + title: "Set Preview Scene", + description: "Set the current preview scene in OBS Studio Mode", + requestType: "SetCurrentPreviewScene", + inputSchema: z.object({ + sceneName: z.string().describe("The name of the scene to set as preview") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ sceneName }) => `Successfully set preview scene to: ${sceneName}`, + }); // CreateScene tool - server.registerTool( - "obs-create-scene", - { - title: "Create Scene", - description: "Create a new scene in OBS", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas to create the scene in"), - sceneName: z.string().describe("The name for the new scene") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName }) => { - try { - await client.sendRequest("CreateScene", { canvasUuid, sceneName }); - return { - content: [ - { - type: "text", - text: `Successfully created scene: ${sceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-create-scene", + title: "Create Scene", + description: "Create a new scene in OBS", + requestType: "CreateScene", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas to create the scene in"), + sceneName: z.string().describe("The name for the new scene") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: ({ sceneName }) => `Successfully created scene: ${sceneName}`, + }); // RemoveScene tool - server.registerTool( - "obs-remove-scene", - { - title: "Remove Scene", - description: "Remove a scene from OBS", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), - sceneName: z.string().describe("The name of the scene to remove") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sceneName }) => { - try { - await client.sendRequest("RemoveScene", { canvasUuid, sceneName }); - return { - content: [ - { - type: "text", - text: `Successfully removed scene: ${sceneName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error removing scene: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-remove-scene", + title: "Remove Scene", + description: "Remove a scene from OBS", + requestType: "RemoveScene", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), + sceneName: z.string().describe("The name of the scene to remove") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + successMessage: ({ sceneName }) => `Successfully removed scene: ${sceneName}`, + }); // TriggerStudioModeTransition tool - server.registerTool( - "obs-trigger-studio-transition", - { - title: "Trigger Studio Transition", - description: "Trigger a transition from preview to program scene in Studio Mode", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("TriggerStudioModeTransition"); - return { - content: [ - { - type: "text", - text: "Successfully triggered studio mode transition" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error triggering studio transition: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-trigger-studio-transition", + title: "Trigger Studio Transition", + description: "Trigger a transition from preview to program scene in Studio Mode", + requestType: "TriggerStudioModeTransition", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Successfully triggered studio mode transition", + }); } diff --git a/src/tools/sources.ts b/src/tools/sources.ts index 5d701f1..a11f6e6 100644 --- a/src/tools/sources.ts +++ b/src/tools/sources.ts @@ -7,50 +7,22 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { screenshotResult } from "./screenshot.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetSourceActive tool - server.registerTool( - "obs-get-source-active", - { - title: "Get Source Active State", - description: "Gets the active and show state of a source", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().optional().describe("Name of the source to get the active state of"), - sourceUuid: z.string().optional().describe("UUID of the source to get the active state of") - }), - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, sourceUuid }) => { - try { - const response = await client.sendRequest("GetSourceActive", { - canvasUuid, - sourceName, - sourceUuid - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting source active state: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-source-active", + title: "Get Source Active State", + description: "Gets the active and show state of a source", + requestType: "GetSourceActive", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().optional().describe("Name of the source to get the active state of"), + sourceUuid: z.string().optional().describe("UUID of the source to get the active state of") + }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetSourceScreenshot tool server.registerTool( @@ -101,55 +73,22 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // SaveSourceScreenshot tool - server.registerTool( - "obs-save-source-screenshot", - { - title: "Save Source Screenshot", - description: "Saves a screenshot of a source to the filesystem", - inputSchema: z.object({ - canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), - sourceName: z.string().optional().describe("Name of the source to take a screenshot of"), - sourceUuid: z.string().optional().describe("UUID of the source to take a screenshot of"), - imageFormat: z.string().describe("Image compression format to use"), - imageFilePath: z.string().describe("Path to save the screenshot file to"), - imageWidth: z.number().optional().describe("Width to scale the screenshot to"), - imageHeight: z.number().optional().describe("Height to scale the screenshot to"), - imageCompressionQuality: z.number().optional().describe("Compression quality to use (0-100, -1 for default)") - }), - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - }, - async ({ canvasUuid, sourceName, sourceUuid, imageFormat, imageFilePath, imageWidth, imageHeight, imageCompressionQuality }) => { - try { - await client.sendRequest("SaveSourceScreenshot", { - canvasUuid, - sourceName, - sourceUuid, - imageFormat, - imageFilePath, - imageWidth, - imageHeight, - imageCompressionQuality - }); - - return { - content: [ - { - type: "text", - text: `Successfully saved screenshot to: ${imageFilePath}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error saving source screenshot: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-save-source-screenshot", + title: "Save Source Screenshot", + description: "Saves a screenshot of a source to the filesystem", + requestType: "SaveSourceScreenshot", + inputSchema: z.object({ + canvasUuid: z.string().optional().describe("UUID of the canvas containing the source"), + sourceName: z.string().optional().describe("Name of the source to take a screenshot of"), + sourceUuid: z.string().optional().describe("UUID of the source to take a screenshot of"), + imageFormat: z.string().describe("Image compression format to use"), + imageFilePath: z.string().describe("Path to save the screenshot file to"), + imageWidth: z.number().optional().describe("Width to scale the screenshot to"), + imageHeight: z.number().optional().describe("Height to scale the screenshot to"), + imageCompressionQuality: z.number().optional().describe("Compression quality to use (0-100, -1 for default)") + }), + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + successMessage: ({ imageFilePath }) => `Successfully saved screenshot to: ${imageFilePath}`, + }); } diff --git a/src/tools/streaming.ts b/src/tools/streaming.ts index 7ddbfb4..6483766 100644 --- a/src/tools/streaming.ts +++ b/src/tools/streaming.ts @@ -6,41 +6,18 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; import { startOutputAndConfirm, STREAM_OUTPUT } from "./output-start.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetStreamStatus tool - server.registerTool( - "obs-get-stream-status", - { - title: "Get Stream Status", - description: "Get the current streaming status", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const status = await client.sendRequest("GetStreamStatus"); - return { - content: [ - { - type: "text", - text: JSON.stringify(status, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting stream status: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-stream-status", + title: "Get Stream Status", + description: "Get the current streaming status", + requestType: "GetStreamStatus", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // StartStream tool server.registerTool( @@ -54,37 +31,14 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // StopStream tool - server.registerTool( - "obs-stop-stream", - { - title: "Stop Stream", - description: "Stop streaming in OBS", - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, - }, - async () => { - try { - await client.sendRequest("StopStream"); - return { - content: [ - { - type: "text", - text: "Successfully stopped streaming" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error stopping stream: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-stop-stream", + title: "Stop Stream", + description: "Stop streaming in OBS", + requestType: "StopStream", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + successMessage: () => "Successfully stopped streaming", + }); // ToggleStream tool server.registerTool( @@ -120,38 +74,15 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // SendStreamCaption tool - server.registerTool( - "obs-send-stream-caption", - { - title: "Send Stream Caption", - description: "Sends CEA-608 caption text over the stream output", - inputSchema: z.object({ - captionText: z.string().describe("Caption text to send") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, - }, - async ({ captionText }) => { - try { - await client.sendRequest("SendStreamCaption", { captionText }); - return { - content: [ - { - type: "text", - text: "Successfully sent stream caption" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error sending stream caption: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-send-stream-caption", + title: "Send Stream Caption", + description: "Sends CEA-608 caption text over the stream output", + requestType: "SendStreamCaption", + inputSchema: z.object({ + captionText: z.string().describe("Caption text to send") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + successMessage: () => "Successfully sent stream caption", + }); } diff --git a/src/tools/tool-inventory.test.ts b/src/tools/tool-inventory.test.ts index 84b3de0..469ea10 100644 --- a/src/tools/tool-inventory.test.ts +++ b/src/tools/tool-inventory.test.ts @@ -16,7 +16,7 @@ import { initialize } from "./index.js"; const DESTRUCTIVE_NAME = /^obs-(remove-.+|stop-(stream|record|output|virtual-cam|replay-buffer)|toggle-(stream|record|output|virtual-cam|replay-buffer))$/; describe("tool contract snapshot", () => { - it("keeps tool names, input schemas, and annotations stable", async () => { + it("keeps tool names, input and output schemas, and annotations stable", async () => { const server = new McpServer({ name: "obs-mcp-test", version: "0.0.0" }); const client = new Client({ name: "obs-mcp-test-client", version: "0.0.0" }); initialize(server, new OBSWebSocketClient("ws://127.0.0.1:1")); @@ -26,7 +26,7 @@ describe("tool contract snapshot", () => { try { const { tools } = await client.listTools(); const contract = tools - .map(({ name, title, inputSchema, annotations }) => ({ name, title, inputSchema, annotations })) + .map(({ name, title, inputSchema, outputSchema, annotations }) => ({ name, title, inputSchema, outputSchema, annotations })) .sort((a, b) => a.name.localeCompare(b.name)); const unsafe = contract .filter(({ name, annotations }) => DESTRUCTIVE_NAME.test(name) && !annotations?.destructiveHint) diff --git a/src/tools/toolsets.test.ts b/src/tools/toolsets.test.ts new file mode 100644 index 0000000..2f0baf8 --- /dev/null +++ b/src/tools/toolsets.test.ts @@ -0,0 +1,92 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; +import { OBSWebSocketClient } from "../client.js"; +import { TOOL_COUNT } from "../../test/support/tool-count.js"; +import { initialize } from "./index.js"; +import { CORE_GROUPS, TOOL_GROUPS, assertKnownTools, parseToolFilter, type ToolFilter } from "./toolsets.js"; + +function registry(filter?: ToolFilter) { + return initialize( + new McpServer({ name: "obs-mcp-test", version: "0.0.0" }), + new OBSWebSocketClient("ws://127.0.0.1:1"), + filter, + ); +} + +function registeredNames(filter: ToolFilter): string[] { + return registry(filter).filter(({ registered }) => registered).map(({ name }) => name); +} + +describe("parseToolFilter", () => { + it("registers every tool when nothing is configured", () => { + expect(parseToolFilter({})).toEqual({ groups: null, tools: new Set(), readOnly: false }); + expect(parseToolFilter({ OBS_MCP_TOOLSETS: "all,scenes" }).groups).toBeNull(); + }); + + it("expands core and accepts individual tools", () => { + const filter = parseToolFilter({ OBS_MCP_TOOLSETS: " core , stream ", OBS_MCP_TOOLS: "obs-describe-request" }); + + expect([...filter.groups ?? []].sort()).toEqual([...CORE_GROUPS, "stream"].sort()); + expect([...filter.tools]).toEqual(["obs-describe-request"]); + }); + + it("rejects unknown groups", () => { + expect(() => parseToolFilter({ OBS_MCP_TOOLSETS: "scenez" })).toThrow(/Unknown OBS_MCP_TOOLSETS entry "scenez"/); + }); + + it("reads OBS_MCP_READ_ONLY", () => { + expect(parseToolFilter({ OBS_MCP_READ_ONLY: "true" }).readOnly).toBe(true); + expect(parseToolFilter({ OBS_MCP_READ_ONLY: "0" }).readOnly).toBe(false); + }); +}); + +describe("tool registry", () => { + it("assigns every tool to a group and registers all of them by default", () => { + const tools = registry(); + + expect(tools).toHaveLength(TOOL_COUNT); + expect(tools.every(({ registered }) => registered)).toBe(true); + expect(new Set(tools.map(({ group }) => group))).toEqual(new Set(TOOL_GROUPS)); + }); + + it("registers only the chosen groups plus named tools", () => { + const names = registeredNames(parseToolFilter({ OBS_MCP_TOOLSETS: "record", OBS_MCP_TOOLS: "obs-get-scene-list" })); + + expect(names).toEqual(expect.arrayContaining([ + "obs-start-record", + "obs-stop-record", + "obs-preflight", + "obs-get-scene-list", + ])); + expect(names).not.toContain("obs-start-stream"); + expect(names).not.toContain("obs-set-current-scene"); + }); + + it("keeps the core set small", () => { + const names = registeredNames(parseToolFilter({ OBS_MCP_TOOLSETS: "core" })); + + expect(names.length).toBeLessThan(TOOL_COUNT * 0.6); + expect(names).toEqual(expect.arrayContaining(["obs-get-status", "obs-preflight", "obs-get-source-screenshot"])); + }); + + it("registers only read-only tools in read-only mode, even when named explicitly", () => { + const tools = registry(parseToolFilter({ OBS_MCP_READ_ONLY: "1", OBS_MCP_TOOLSETS: "record", OBS_MCP_TOOLS: "obs-start-record" })); + const registered = tools.filter((tool) => tool.registered); + + expect(registered.length).toBeGreaterThan(0); + expect(registered.every(({ annotations }) => annotations?.readOnlyHint === true)).toBe(true); + expect(registered.map(({ name }) => name)).not.toContain("obs-start-record"); + expect(registered.map(({ name }) => name)).toContain("obs-get-record-status"); + }); + + it("rejects unknown tool names", () => { + const filter = parseToolFilter({ OBS_MCP_TOOLS: "obs-start-recording" }); + + expect(() => assertKnownTools(filter, registry(filter))).toThrow(/obs-start-recording/); + }); +}); diff --git a/src/tools/toolsets.ts b/src/tools/toolsets.ts new file mode 100644 index 0000000..38209b2 --- /dev/null +++ b/src/tools/toolsets.ts @@ -0,0 +1,167 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import type { McpServer, ToolAnnotations } from "@modelcontextprotocol/server"; + +export const TOOL_GROUPS = [ + "general", + "scenes", + "scene-items", + "sources", + "inputs", + "media", + "filters", + "transitions", + "record", + "stream", + "outputs", + "config", + "ui", + "protocol", +] as const; + +export type ToolGroup = typeof TOOL_GROUPS[number]; + +/** The small set recommended for recording and scene work: `OBS_MCP_TOOLSETS=core`. */ +export const CORE_GROUPS: readonly ToolGroup[] = [ + "general", + "scenes", + "scene-items", + "sources", + "inputs", + "record", +]; + +export type ToolFilter = { + /** null registers every group. */ + groups: ReadonlySet | null; + tools: ReadonlySet; + readOnly: boolean; +}; + +export const ALL_TOOLS: ToolFilter = { groups: null, tools: new Set(), readOnly: false }; + +export type RegisteredTool = { + name: string; + group: ToolGroup; + annotations: ToolAnnotations | undefined; + registered: boolean; +}; + +function splitList(value: string | undefined): string[] { + return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); +} + +function isToolGroup(value: string): value is ToolGroup { + return (TOOL_GROUPS as readonly string[]).includes(value); +} + +/** + * Reads OBS_MCP_TOOLSETS, OBS_MCP_TOOLS, and OBS_MCP_READ_ONLY. With neither + * list set every tool is registered, matching earlier releases. Unknown group + * names throw so a typo cannot silently hide tools. + */ +export function parseToolFilter(env: NodeJS.ProcessEnv = process.env): ToolFilter { + const groupNames = splitList(env.OBS_MCP_TOOLSETS); + const tools = new Set(splitList(env.OBS_MCP_TOOLS)); + const readOnly = ["1", "true", "yes"].includes((env.OBS_MCP_READ_ONLY ?? "").trim().toLowerCase()); + + if (groupNames.length === 0 && tools.size === 0) return { groups: null, tools, readOnly }; + if (groupNames.includes("all")) return { groups: null, tools, readOnly }; + + const groups = new Set(); + for (const name of groupNames) { + if (name === "core") { + CORE_GROUPS.forEach((group) => groups.add(group)); + } else if (isToolGroup(name)) { + groups.add(name); + } else { + throw new Error( + `Unknown OBS_MCP_TOOLSETS entry "${name}". Use all, core, or: ${TOOL_GROUPS.join(", ")}`, + ); + } + } + return { groups, tools, readOnly }; +} + +export function includesTool(filter: ToolFilter, tool: Omit): boolean { + // Read-only mode wins over explicit groups and tool names. + if (filter.readOnly && tool.annotations?.readOnlyHint !== true) return false; + if (filter.groups === null) return true; + return filter.groups.has(tool.group) || filter.tools.has(tool.name); +} + +/** Throws when OBS_MCP_TOOLS names a tool that does not exist. */ +export function assertKnownTools(filter: ToolFilter, tools: readonly RegisteredTool[]): void { + const known = new Set(tools.map(({ name }) => name)); + const unknown = [...filter.tools].filter((name) => !known.has(name)); + if (unknown.length > 0) { + throw new Error(`Unknown OBS_MCP_TOOLS entries: ${unknown.join(", ")}`); + } +} + +/** + * Tools whose module does not match their group. protocol-extensions holds + * requests added after the original modules, across several areas. + */ +const GROUP_OVERRIDES: Record = { + "obs-get-canvas-list": "scenes", + "obs-set-scene-name": "scenes", + "obs-get-input-audio-tracks": "inputs", + "obs-set-input-audio-tracks": "inputs", + "obs-get-input-deinterlace-field-order": "inputs", + "obs-set-input-deinterlace-field-order": "inputs", + "obs-get-input-deinterlace-mode": "inputs", + "obs-set-input-deinterlace-mode": "inputs", + "obs-get-input-property-list-items": "inputs", + "obs-press-input-property-button": "inputs", + "obs-duplicate-scene-item": "scene-items", + "obs-get-group-scene-items": "scene-items", + "obs-get-group-list": "scene-items", + "obs-get-scene-item-enabled": "scene-items", + "obs-get-scene-item-index": "scene-items", + "obs-set-scene-item-index": "scene-items", + "obs-get-scene-item-locked": "scene-items", + "obs-set-scene-item-locked": "scene-items", + "obs-get-scene-item-blend-mode": "scene-items", + "obs-set-scene-item-blend-mode": "scene-items", + "obs-get-scene-item-source": "scene-items", + "obs-get-scene-transition-override": "transitions", + "obs-set-scene-transition-override": "transitions", + "obs-get-transition-kind-list": "transitions", + "obs-get-transition-cursor": "transitions", + "obs-set-tbar-position": "transitions", +}; + +/** + * Wraps tool registration for one module: records every tool with its group + * and registers only those the filter includes. Other McpServer methods stay + * bound to the real server. + */ +export function scopedServer( + server: McpServer, + group: ToolGroup, + filter: ToolFilter, + registry: RegisteredTool[], +): McpServer { + return new Proxy(server, { + get(target, property) { + if (property === "registerTool") { + return (...registrationArgs: unknown[]) => { + const [name, config] = registrationArgs; + if (typeof name !== "string") throw new TypeError("MCP tool registration requires a name"); + const annotations = (config as { annotations?: ToolAnnotations } | undefined)?.annotations; + const tool = { name, group: GROUP_OVERRIDES[name] ?? group, annotations }; + const registered = includesTool(filter, tool); + registry.push({ ...tool, registered }); + if (!registered) return undefined; + return Reflect.apply(Reflect.get(target, property, target) as Function, target, registrationArgs); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/src/tools/transitions.ts b/src/tools/transitions.ts index c9dc7b7..c17a12e 100644 --- a/src/tools/transitions.ts +++ b/src/tools/transitions.ts @@ -6,142 +6,48 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetSceneTransitionList tool - server.registerTool( - "obs-get-transition-list", - { - title: "Get Transition List", - description: "Get a list of available transitions in OBS", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const transitions = await client.sendRequest("GetSceneTransitionList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(transitions, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting transition list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-transition-list", + title: "Get Transition List", + description: "Get a list of available transitions in OBS", + requestType: "GetSceneTransitionList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // GetCurrentTransition tool - server.registerTool( - "obs-get-current-transition", - { - title: "Get Current Transition", - description: "Get the name of the currently active transition", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const transition = await client.sendRequest("GetCurrentSceneTransition"); - return { - content: [ - { - type: "text", - text: `Current transition: ${transition.transitionName} (${transition.transitionKind}) with duration: ${transition.transitionDuration}ms` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting current transition: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-current-transition", + title: "Get Current Transition", + description: "Get the name of the currently active transition", + requestType: "GetCurrentSceneTransition", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetCurrentTransition tool - server.registerTool( - "obs-set-current-transition", - { - title: "Set Current Transition", - description: "Set the current transition in OBS", - inputSchema: z.object({ - transitionName: z.string().describe("The name of the transition to set as current") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ transitionName }) => { - try { - await client.sendRequest("SetCurrentSceneTransition", { transitionName }); - return { - content: [ - { - type: "text", - text: `Successfully set current transition to: ${transitionName}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting current transition: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-current-transition", + title: "Set Current Transition", + description: "Set the current transition in OBS", + requestType: "SetCurrentSceneTransition", + inputSchema: z.object({ + transitionName: z.string().describe("The name of the transition to set as current") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ transitionName }) => `Successfully set current transition to: ${transitionName}`, + }); // GetTransitionDuration tool - server.registerTool( - "obs-get-transition-duration", - { - title: "Get Transition Duration", - description: "Get the duration of the current transition in milliseconds", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const duration = await client.sendRequest("GetCurrentSceneTransition"); - return { - content: [ - { - type: "text", - text: `Current transition duration: ${duration.transitionDuration}ms` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting transition duration: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-transition-duration", + title: "Get Transition Duration", + description: "Get the duration of the current transition in milliseconds", + requestType: "GetCurrentSceneTransition", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetTransitionDuration tool server.registerTool( @@ -180,137 +86,43 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetTransitionKind tool - server.registerTool( - "obs-get-transition-kind", - { - title: "Get Transition Kind", - description: "Get the kind/type of the current transition", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const transition = await client.sendRequest("GetCurrentSceneTransition"); - return { - content: [ - { - type: "text", - text: `Current transition kind: ${transition.transitionKind}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting transition kind: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-transition-kind", + title: "Get Transition Kind", + description: "Get the kind/type of the current transition", + requestType: "GetCurrentSceneTransition", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetTransitionSettings tool - server.registerTool( - "obs-set-transition-settings", - { - title: "Set Transition Settings", - description: "Set the settings of the current transition", - inputSchema: z.object({ - transitionSettings: z.record(z.string(), z.unknown()).describe("The settings to apply to the transition") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ transitionSettings }) => { - try { - await client.sendRequest("SetCurrentSceneTransitionSettings", { transitionSettings }); - return { - content: [ - { - type: "text", - text: "Successfully updated current transition settings" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting transition settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-transition-settings", + title: "Set Transition Settings", + description: "Set the settings of the current transition", + requestType: "SetCurrentSceneTransitionSettings", + inputSchema: z.object({ + transitionSettings: z.record(z.string(), z.unknown()).describe("The settings to apply to the transition") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: () => "Successfully updated current transition settings", + }); // GetTransitionSettings tool - server.registerTool( - "obs-get-transition-settings", - { - title: "Get Transition Settings", - description: "Get the settings of the current transition", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const settings = await client.sendRequest("GetCurrentSceneTransition"); - return { - content: [ - { - type: "text", - text: JSON.stringify(settings, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting transition settings: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-transition-settings", + title: "Get Transition Settings", + description: "Get the settings of the current transition", + requestType: "GetCurrentSceneTransition", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // TriggerStudioModeTransition tool - server.registerTool( - "obs-trigger-transition", - { - title: "Trigger Transition", - description: "Trigger a scene transition in OBS (Studio Mode must be enabled)", - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, - }, - async () => { - try { - await client.sendRequest("TriggerStudioModeTransition"); - return { - content: [ - { - type: "text", - text: "Successfully triggered studio mode transition" - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error triggering transition: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-trigger-transition", + title: "Trigger Transition", + description: "Trigger a scene transition in OBS (Studio Mode must be enabled)", + requestType: "TriggerStudioModeTransition", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + successMessage: () => "Successfully triggered studio mode transition", + }); } diff --git a/src/tools/ui.ts b/src/tools/ui.ts index 51933d6..079c8cf 100644 --- a/src/tools/ui.ts +++ b/src/tools/ui.ts @@ -6,76 +6,30 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; +import { registerObsRequestTool } from "./request-tool.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetStudioModeEnabled tool - server.registerTool( - "obs-get-studio-mode", - { - title: "Get Studio Mode", - description: "Gets whether studio mode is enabled", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetStudioModeEnabled"); - return { - content: [ - { - type: "text", - text: `Studio Mode is ${response.studioModeEnabled ? "enabled" : "disabled"}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error checking studio mode: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-studio-mode", + title: "Get Studio Mode", + description: "Gets whether studio mode is enabled", + requestType: "GetStudioModeEnabled", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // SetStudioModeEnabled tool - server.registerTool( - "obs-set-studio-mode", - { - title: "Set Studio Mode", - description: "Enables or disables studio mode", - inputSchema: z.object({ - studioModeEnabled: z.boolean().describe("Whether to enable (true) or disable (false) Studio Mode") - }), - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async ({ studioModeEnabled }) => { - try { - await client.sendRequest("SetStudioModeEnabled", { studioModeEnabled }); - return { - content: [ - { - type: "text", - text: `Studio Mode has been ${studioModeEnabled ? "enabled" : "disabled"}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting studio mode: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-set-studio-mode", + title: "Set Studio Mode", + description: "Enables or disables studio mode", + requestType: "SetStudioModeEnabled", + inputSchema: z.object({ + studioModeEnabled: z.boolean().describe("Whether to enable (true) or disable (false) Studio Mode") + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + successMessage: ({ studioModeEnabled }) => `Studio Mode has been ${studioModeEnabled ? "enabled" : "disabled"}`, + }); // OpenInputPropertiesDialog tool server.registerTool( @@ -216,37 +170,13 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ); // GetMonitorList tool - server.registerTool( - "obs-get-monitor-list", - { - title: "Get Monitor List", - description: "Gets a list of connected monitors and information about them", - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, - async () => { - try { - const response = await client.sendRequest("GetMonitorList"); - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2) - } - ] - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting monitor list: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); + registerObsRequestTool(server, client, { + name: "obs-get-monitor-list", + title: "Get Monitor List", + description: "Gets a list of connected monitors and information about them", + requestType: "GetMonitorList", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }); // OpenVideoMixProjector tool server.registerTool( diff --git a/test/e2e/stdio.e2e.test.ts b/test/e2e/stdio.e2e.test.ts index a6272f4..ecd2218 100644 --- a/test/e2e/stdio.e2e.test.ts +++ b/test/e2e/stdio.e2e.test.ts @@ -467,6 +467,44 @@ describe("compiled stdio MCP boundary", () => { await vi.waitFor(() => expect(fake.openConnectionCount).toBe(0)); }); + it("registers only the configured tool groups in read-only mode", async () => { + const fake = await FakeOBSServer.start(); + fakeServers.push(fake); + const { client, stderr } = await connectClient(fake, "modern", { + OBS_MCP_TOOLSETS: "core", + OBS_MCP_READ_ONLY: "true", + }); + + const { tools } = await client.listTools(); + const names = tools.map(({ name }) => name); + expect(tools.length).toBeGreaterThan(0); + expect(tools.length).toBeLessThan(TOOL_COUNT); + expect(tools.every(({ annotations }) => annotations?.readOnlyHint === true)).toBe(true); + expect(names).toEqual(expect.arrayContaining(["obs-get-status", "obs-preflight"])); + expect(names).not.toContain("obs-start-record"); + expect(names).not.toContain("obs-get-stream-status"); + await vi.waitFor(() => expect(stderr.join("")).toMatch(/Registering \d+ of \d+ tools \(read-only\)/)); + }); + + it("refuses to start with an unknown tool group or tool name", async () => { + const environments: Record[] = [ + { OBS_MCP_TOOLSETS: "scenez" }, + { OBS_MCP_TOOLS: "obs-start-recording" }, + ]; + for (const environment of environments) { + const child = spawn(process.execPath, [cliPath], { + cwd: projectRoot, + env: childEnvironment("ws://127.0.0.1:1", undefined, environment), + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + const [code] = await once(child, "exit") as [number | null]; + + expect(code).toBe(1); + expect(stderr).toMatch(/scenez|obs-start-recording/); + } + }); + it("exits on stdin EOF while OBS is offline", async () => { const port = await reservePort(); const child = spawnRawChild(`ws://127.0.0.1:${port}`); diff --git a/test/live/obs-live.test.ts b/test/live/obs-live.test.ts index 7001fe2..8d0590d 100644 --- a/test/live/obs-live.test.ts +++ b/test/live/obs-live.test.ts @@ -116,6 +116,25 @@ describe.skipIf(!enabled)("live OBS through compiled MCP stdio", () => { } }); + it("returns results that match every read-only tool's declared output schema", async () => { + if (!client) throw new Error("Live MCP client is not connected"); + const { tools } = await client.listTools(); + const candidates = tools.filter(({ annotations, outputSchema, inputSchema }) => ( + annotations?.readOnlyHint === true + && outputSchema !== undefined + && (inputSchema.required ?? []).length === 0 + )); + expect(candidates.length).toBeGreaterThan(20); + + const mismatches: string[] = []; + for (const tool of candidates) { + const result = await client.callTool({ name: tool.name, arguments: {} }); + const text = textContent(result as CallToolResult); + if (/Output validation error/.test(text)) mismatches.push(`${tool.name}: ${text}`); + } + expect(mismatches).toEqual([]); + }, 60_000); + it.skipIf(!mutationsEnabled)("creates and removes one scene in the approved collection", async () => { const expectedCollection = process.env.OBS_MCP_LIVE_SCENE_COLLECTION; if (!expectedCollection) {