From c1c7a32c5bbca88655a2a6c2c4897f12a66fd505 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Tue, 22 Sep 2026 17:55:15 -0400 Subject: [PATCH 1/2] Return the resulting state from input and transform changes obs-set-input-settings and obs-create-input read the input back: its settings, its size wherever it appears in the program scene, and a warning when it still renders at 0x0 after capture sources have had ~750 ms to produce a frame. An optional includeScreenshot adds a bounded 960px JPEG of the input. obs-set-scene-item-transform returns the transform OBS applied. Read-back failures are reported as warnings, never as a failed change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- src/tools/__snapshots__/tool-contract.json | 8 ++ src/tools/after-change.test.ts | 138 ++++++++++++++++++++ src/tools/after-change.ts | 142 +++++++++++++++++++++ src/tools/inputs.ts | 41 +++--- src/tools/scene-items.ts | 21 ++- 5 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 src/tools/after-change.test.ts create mode 100644 src/tools/after-change.ts diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index 62a0166..18167a8 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -140,6 +140,10 @@ "sceneItemEnabled": { "description": "Whether to set the created scene item to enabled or disabled", "type": "boolean" + }, + "includeScreenshot": { + "description": "Also return a screenshot of the input after creating it", + "type": "boolean" } }, "required": [ @@ -5327,6 +5331,10 @@ "overlay": { "description": "True to apply settings on top of existing ones, False to reset to defaults first", "type": "boolean" + }, + "includeScreenshot": { + "description": "Also return a screenshot of the input after the change", + "type": "boolean" } }, "required": [ diff --git a/src/tools/after-change.test.ts b/src/tools/after-change.test.ts new file mode 100644 index 0000000..41df8e8 --- /dev/null +++ b/src/tools/after-change.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FakeOBSRequestError } from "../../test/support/fake-obs-server.js"; +import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; + +const PNG_1X1 = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z"; + +let harness: McpHarness; +let size: { sourceWidth: number; sourceHeight: number }; +let settings: Record; + +beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + harness = await startMcpHarness(); + size = { sourceWidth: 1512, sourceHeight: 949 }; + settings = { type: 1, window: 31875 }; + const { fakeObs } = harness; + fakeObs.respondWith("SetInputSettings", (data) => { + settings = { ...settings, ...(data.inputSettings as Record) }; + return {}; + }); + fakeObs.respondWith("CreateInput", () => ({ inputUuid: "uuid", sceneItemId: 7 })); + fakeObs.respondWith("GetInputSettings", () => ({ inputKind: "screen_capture", inputSettings: settings })); + fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); + fakeObs.respondWith("GetSceneItemList", () => ({ + sceneItems: [ + { sceneItemId: 1, sourceName: "Background", sceneItemEnabled: true, sceneItemTransform: { sourceWidth: 1920, sourceHeight: 1080 } }, + { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sceneItemTransform: { ...size } }, + ], + })); + fakeObs.respondWith("GetSourceScreenshot", () => ({ imageData: PNG_1X1 })); + fakeObs.respondWith("SetSceneItemTransform", () => ({})); + fakeObs.respondWith("GetSceneItemTransform", () => ({ + sceneItemTransform: { ...size, positionX: 10, cropTop: 372 }, + })); +}); + +afterEach(async () => { + await harness.close(); + vi.restoreAllMocks(); +}); + +describe("obs-set-input-settings", () => { + it("returns the resulting settings and the input's size in the program scene", async () => { + const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { show_cursor: false } }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + inputSettings: { type: 1, window: 31875, show_cursor: false }, + appearances: [{ sceneName: "Demo", sceneItemId: 2, sourceWidth: 1512, sourceHeight: 949 }], + warnings: [], + }); + expect(result.content.some(({ type }) => type === "image")).toBe(false); + }); + + it("waits for a capture to produce its first frame before reporting its size", async () => { + size = { sourceWidth: 0, sourceHeight: 0 }; + setTimeout(() => { size = { sourceWidth: 1512, sourceHeight: 949 }; }, 300); + + const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { type: 2 } }); + + expect(result.structuredContent).toMatchObject({ appearances: [{ sourceWidth: 1512 }], warnings: [] }); + }); + + it("warns when the input still renders at 0×0", async () => { + size = { sourceWidth: 0, sourceHeight: 0 }; + + const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { type: 2 } }); + + expect(result.isError).toBeFalsy(); + expect(resultText(result)).toContain("Chrome renders at 0×0"); + expect(resultText(result)).toContain("display_uuid"); + }); + + it("adds a bounded screenshot on request", async () => { + const result = await harness.call("obs-set-input-settings", { + inputName: "Chrome", + inputSettings: {}, + includeScreenshot: true, + }); + + expect(result.content).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "image", mimeType: "image/jpeg" }), + ])); + const request = harness.fakeObs.history().find(({ frame }) => frame.d.requestType === "GetSourceScreenshot"); + expect(request?.frame.d.requestData).toMatchObject({ sourceName: "Chrome", imageFormat: "jpeg", imageWidth: 960 }); + }); + + it("still reports success when the screenshot fails", async () => { + harness.fakeObs.respondWith("GetSourceScreenshot", () => { + throw new FakeOBSRequestError(702, "Failed to render screenshot"); + }); + + const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: {}, includeScreenshot: true }); + + expect(result.isError).toBeFalsy(); + expect(resultText(result)).toMatch(/No screenshot: .*Failed to render screenshot/); + }); +}); + +describe("obs-create-input", () => { + it("returns the new scene item ID with the input's state", async () => { + const result = await harness.call("obs-create-input", { + sceneName: "Demo", + inputName: "Chrome", + inputKind: "screen_capture", + }); + + expect(resultText(result)).toContain("with ID 7"); + expect(result.structuredContent).toMatchObject({ appearances: [{ sceneItemId: 2 }] }); + }); +}); + +describe("obs-set-scene-item-transform", () => { + it("returns the transform OBS applied", async () => { + const result = await harness.call("obs-set-scene-item-transform", { sceneName: "Demo", sceneItemId: 2, cropTop: 372 }); + + expect(result.structuredContent).toMatchObject({ + sceneItemTransform: { cropTop: 372, sourceWidth: 1512 }, + }); + expect(result.structuredContent).not.toHaveProperty("warnings"); + }); + + it("reports success with a warning when the transform cannot be read back", async () => { + harness.fakeObs.respondWith("GetSceneItemTransform", () => { + throw new FakeOBSRequestError(600, "No scene item was found"); + }); + + const result = await harness.call("obs-set-scene-item-transform", { sceneName: "Demo", sceneItemId: 2, cropTop: 372 }); + + expect(result.isError).toBeFalsy(); + expect(resultText(result)).toContain("Could not read the applied transform back"); + }); +}); diff --git a/src/tools/after-change.ts b/src/tools/after-change.ts new file mode 100644 index 0000000..92f9d8a --- /dev/null +++ b/src/tools/after-change.ts @@ -0,0 +1,142 @@ +/* + * 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 { CallToolResult } from "@modelcontextprotocol/server"; +import type { OBSWebSocketClient } from "../client.js"; +import { screenshotResult } from "./screenshot.js"; + +type JsonObject = Record; + +export type InputAppearance = { + sceneName: string; + sceneItemId: number; + sceneItemEnabled: boolean; + sourceWidth: number; + sourceHeight: number; +}; + +export type InputState = { + inputSettings: JsonObject; + appearances: InputAppearance[]; + warnings: string[]; +}; + +// Capture sources (ScreenCaptureKit, window capture) report 0×0 until their +// first frame arrives, so a zero size is re-read briefly before warning. +const SIZE_SETTLE_ATTEMPTS = 4; +const SIZE_SETTLE_DELAY_MS = 250; +const SCREENSHOT_WIDTH = 960; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function programSceneAppearances( + client: OBSWebSocketClient, + inputName: string, +): Promise { + const program: unknown = await client.sendRequest("GetCurrentProgramScene"); + const sceneName = isObject(program) ? program.currentProgramSceneName : undefined; + if (typeof sceneName !== "string") return []; + const list: unknown = await client.sendRequest("GetSceneItemList", { sceneName }); + const items = isObject(list) && Array.isArray(list.sceneItems) ? list.sceneItems.filter(isObject) : []; + + return items + .filter((item) => item.sourceName === inputName && typeof item.sceneItemId === "number") + .map((item) => { + const transform = isObject(item.sceneItemTransform) ? item.sceneItemTransform : {}; + return { + sceneName, + sceneItemId: item.sceneItemId as number, + sceneItemEnabled: item.sceneItemEnabled !== false, + sourceWidth: typeof transform.sourceWidth === "number" ? transform.sourceWidth : 0, + sourceHeight: typeof transform.sourceHeight === "number" ? transform.sourceHeight : 0, + }; + }); +} + +function isBlank(appearance: InputAppearance): boolean { + return appearance.sceneItemEnabled && (appearance.sourceWidth === 0 || appearance.sourceHeight === 0); +} + +/** + * Reads an input back after a change: its settings, its size wherever it + * appears in the program scene, and warnings for problems the change caused. + */ +export async function readInputState( + client: OBSWebSocketClient, + inputName: string, + settleDelayMs = SIZE_SETTLE_DELAY_MS, +): Promise { + const warnings: string[] = []; + let inputSettings: JsonObject = {}; + try { + const response: unknown = await client.sendRequest("GetInputSettings", { inputName }); + if (isObject(response) && isObject(response.inputSettings)) inputSettings = response.inputSettings; + } catch (error) { + warnings.push(`Could not read the input back: ${errorMessage(error)}`); + } + + let appearances: InputAppearance[] = []; + try { + for (let attempt = 0; attempt < SIZE_SETTLE_ATTEMPTS; attempt += 1) { + appearances = await programSceneAppearances(client, inputName); + if (!appearances.some(isBlank)) break; + if (attempt < SIZE_SETTLE_ATTEMPTS - 1) await new Promise((resolve) => setTimeout(resolve, settleDelayMs)); + } + } catch (error) { + warnings.push(`Could not read the program scene: ${errorMessage(error)}`); + } + + if (appearances.some(isBlank)) { + warnings.push( + `${inputName} renders at 0×0 in the program scene. A capture source usually needs a display, ` + + "window, or application selected (macOS screen_capture application capture also needs " + + "display_uuid) and screen recording permission for OBS", + ); + } + return { inputSettings, appearances, warnings }; +} + +/** Appends a bounded screenshot of the input, or a note explaining why there is none. */ +export async function appendInputScreenshot( + client: OBSWebSocketClient, + inputName: string, + result: CallToolResult, +): Promise { + try { + const response: unknown = await client.sendRequest("GetSourceScreenshot", { + sourceName: inputName, + imageFormat: "jpeg", + imageWidth: SCREENSHOT_WIDTH, + }); + const screenshot = screenshotResult(response); + return { ...result, content: [...result.content, ...screenshot.content] }; + } catch (error) { + return { + ...result, + content: [...result.content, { type: "text", text: `No screenshot: ${errorMessage(error)}` }], + }; + } +} + +export function inputStateResult(message: string, state: InputState): CallToolResult { + const sizes = state.appearances.map(({ sceneName, sceneItemId, sourceWidth, sourceHeight }) => ( + `${sceneName} item ${sceneItemId}: ${sourceWidth}×${sourceHeight}` + )); + const lines = [ + message, + sizes.length > 0 ? `Size in program scene: ${sizes.join("; ")}` : "Not in the program scene", + ...state.warnings.map((warning) => `Warning: ${warning}`), + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { message, ...state }, + }; +} diff --git a/src/tools/inputs.ts b/src/tools/inputs.ts index 92924b5..0e8abac 100644 --- a/src/tools/inputs.ts +++ b/src/tools/inputs.ts @@ -7,6 +7,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { OBSWebSocketClient } from "../client.js"; import { z } from "zod"; import { registerObsRequestTool } from "./request-tool.js"; +import { appendInputScreenshot, inputStateResult, readInputState } from "./after-change.js"; export function initialize(server: McpServer, client: OBSWebSocketClient): void { // GetInputList tool @@ -105,18 +106,19 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void "obs-create-input", { title: "Create Input", - description: "Creates a new input, adding it as a scene item to the specified scene", + description: "Creates a new input, adding it as a scene item to the specified scene, then returns its settings and size in the program scene, warning when it renders at 0×0", inputSchema: z.object({ canvasUuid: z.string().optional().describe("UUID of the canvas containing the scene"), sceneName: z.string().describe("Name of the scene to add the input to as a scene item"), inputName: z.string().describe("Name of the new input to created"), inputKind: z.string().describe("The kind of input to be created"), inputSettings: z.record(z.string(), z.unknown()).optional().describe("Settings object to initialize the input with"), - sceneItemEnabled: z.boolean().optional().describe("Whether to set the created scene item to enabled or disabled") + sceneItemEnabled: z.boolean().optional().describe("Whether to set the created scene item to enabled or disabled"), + includeScreenshot: z.boolean().optional().describe("Also return a screenshot of the input after creating it") }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ canvasUuid, sceneName, inputName, inputKind, inputSettings, sceneItemEnabled }) => { + async ({ canvasUuid, sceneName, inputName, inputKind, inputSettings, sceneItemEnabled, includeScreenshot }) => { try { const requestParams: Record = { canvasUuid, sceneName, inputName, inputKind }; if (inputSettings !== undefined) { @@ -127,14 +129,11 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void } const response = await client.sendRequest("CreateInput", requestParams); - return { - content: [ - { - type: "text", - text: `Successfully created input '${inputName}' of kind '${inputKind}' with ID ${response.sceneItemId}` - } - ] - }; + const result = inputStateResult( + `Successfully created input '${inputName}' of kind '${inputKind}' with ID ${response.sceneItemId}`, + await readInputState(client, inputName), + ); + return includeScreenshot ? await appendInputScreenshot(client, inputName, result) : result; } catch (error) { return { content: [ @@ -205,15 +204,16 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void "obs-set-input-settings", { title: "Set Input Settings", - description: "Sets the settings of an input", + description: "Sets the settings of an input, then returns the resulting settings and the input's size in the program scene, warning when it renders at 0×0", inputSchema: z.object({ inputName: z.string().describe("Name of the input to set the settings of"), inputSettings: z.record(z.string(), z.unknown()).describe("Object of settings to apply"), - overlay: z.boolean().optional().describe("True to apply settings on top of existing ones, False to reset to defaults first") + overlay: z.boolean().optional().describe("True to apply settings on top of existing ones, False to reset to defaults first"), + includeScreenshot: z.boolean().optional().describe("Also return a screenshot of the input after the change") }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, - async ({ inputName, inputSettings, overlay }) => { + async ({ inputName, inputSettings, overlay, includeScreenshot }) => { try { const requestParams: Record = { inputName, inputSettings }; if (overlay !== undefined) { @@ -221,14 +221,11 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void } await client.sendRequest("SetInputSettings", requestParams); - return { - content: [ - { - type: "text", - text: `Successfully updated settings for input: ${inputName}` - } - ] - }; + const result = inputStateResult( + `Successfully updated settings for input: ${inputName}`, + await readInputState(client, inputName), + ); + return includeScreenshot ? await appendInputScreenshot(client, inputName, result) : result; } catch (error) { return { content: [ diff --git a/src/tools/scene-items.ts b/src/tools/scene-items.ts index 10f6e3a..fb23d1f 100644 --- a/src/tools/scene-items.ts +++ b/src/tools/scene-items.ts @@ -155,7 +155,7 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void "obs-set-scene-item-transform", { title: "Set Scene Item Transform", - description: "Set the position, rotation, scale, or crop of a scene item", + description: "Set the position, rotation, scale, or crop of a scene item, then return the transform OBS applied", 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"), @@ -212,13 +212,28 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void sceneItemTransform }); + // Return the transform OBS actually applied (bounds and alignment can + // change the effective size), so callers do not need a second request. + const message = `Successfully updated transform for item with ID ${sceneItemId} in ${sceneName ?? sceneUuid}`; + let transform: Record = {}; + let warning: string | undefined; + try { + const applied = await client.sendRequest("GetSceneItemTransform", { canvasUuid, sceneName, sceneUuid, sceneItemId }); + transform = applied?.sceneItemTransform ?? {}; + if (transform.sourceWidth === 0 || transform.sourceHeight === 0) { + warning = "The source renders at 0×0, so the item is invisible whatever its transform"; + } + } catch (error) { + warning = `Could not read the applied transform back: ${error instanceof Error ? error.message : String(error)}`; + } return { content: [ { type: "text", - text: `Successfully updated transform for item with ID ${sceneItemId} in ${sceneName}` + text: [message, `Applied transform: ${JSON.stringify(transform)}`, ...(warning ? [`Warning: ${warning}`] : [])].join("\n") } - ] + ], + structuredContent: { message, sceneItemTransform: transform, ...(warning ? { warnings: [warning] } : {}) } }; } catch (error) { return { From 3495062cf3ab857aec31e53ae6cf88fa181dd773 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Tue, 22 Sep 2026 17:58:35 -0400 Subject: [PATCH 2/2] Add obs-record-clip and obs-capture-window obs-record-clip runs the preflight, starts recording and waits for OBS to confirm it, adds chapters at the requested offsets, always stops, then checks the file with ffprobe (length, audio streams) and ffmpeg volumedetect when silence is expected. Clips are capped at 50 s because many clients time tool calls out at 60 s. obs-capture-window (macOS) creates or reuses a screen_capture input, selects a display before listing windows or apps (listing apps without one crashed OBS), matches the target by name or ID, mutes it and clears its audio tracks, and fits it to the canvas, then returns its size like obs-set-input-settings. The preflight fake state moves to a shared fixture, and the fake OBS takes a platform option. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 15 +- manifest.json | 2 +- src/tools/__snapshots__/tool-contract.json | 109 +++++++ src/tools/index.ts | 5 +- src/tools/preflight.test.ts | 84 +----- src/tools/toolsets.ts | 1 + src/tools/workflows.test.ts | 287 ++++++++++++++++++ src/tools/workflows.ts | 323 +++++++++++++++++++++ test/support/fake-obs-server.ts | 5 +- test/support/fake-obs-state.ts | 85 ++++++ test/support/tool-count.ts | 2 +- 11 files changed, 829 insertions(+), 89 deletions(-) create mode 100644 src/tools/workflows.test.ts create mode 100644 src/tools/workflows.ts create mode 100644 test/support/fake-obs-state.ts diff --git a/README.md b/README.md index 6ce87ad..4080f2d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/Tom-R-Main/OBS-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/Tom-R-Main/OBS-MCP/actions/workflows/ci.yml) -OBS MCP gives MCP clients a current, inspectable interface to OBS Studio. It exposes 156 tools for scenes, sources, audio, transitions, filters, recording, streaming, canvases, and the rest of the OBS WebSocket v5 request surface. +OBS MCP gives MCP clients a current, inspectable interface to OBS Studio. It exposes 158 tools for scenes, sources, audio, transitions, filters, recording, streaming, canvases, and the rest of the OBS WebSocket v5 request surface. The server is built against MCP 2026-07-28 and still accepts legacy 2025-era clients. MCP discovery stays available when OBS is closed, and the server reconnects in the background when OBS returns. @@ -104,7 +104,7 @@ The server never prints the password. Connection and protocol diagnostics are wr ## Tool surface -The 156 tools are organized around the OBS protocol rather than a smaller opinionated workflow: +The 158 tools are organized around the OBS protocol rather than a smaller opinionated workflow: - server status, version information, statistics, hotkeys, and studio mode - scenes, groups, sources, filters, and scene items @@ -113,6 +113,9 @@ The 156 tools are organized around the OBS protocol rather than a smaller opinio - transitions, transition overrides, the transition cursor, and the T-Bar - canvases, screenshots, profiles, scene collections, and persistent data - a recording preflight (`obs-preflight`) that catches the conditions under which OBS silently refuses or botches a recording +- `obs-record-clip`, which preflights, records up to 50 seconds with chapter markers, stops, and checks the file's length and audio with ffprobe/ffmpeg when they are installed +- `obs-capture-window` (macOS), which points a `screen_capture` input at a window or app by name, silences it, and fits it to the canvas +- input and transform changes that return the resulting settings, size, and an optional screenshot, warning when a source renders at 0×0 - 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. @@ -129,18 +132,18 @@ Every tool is registered by default. Clients load each registered tool's definit | `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 | +| `inputs` | inputs, input settings and properties, audio, deinterlacing, and `obs-capture-window` | | `media` | media input playback | | `filters` | source filters | | `transitions` | transitions, overrides, the T-Bar | -| `record` | recording, chapters, and `obs-preflight` | +| `record` | recording, chapters, `obs-preflight`, and `obs-record-clip` | | `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. +`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (81 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 @@ -191,7 +194,7 @@ Test the file users will actually install with: npm run test:package ``` -That command creates `dist/obs-studio.mcpb`, extracts it into a temporary directory, validates its manifest and source contents, checks all 156 tool definitions, and calls the extracted server through MCP against fake OBS. +That command creates `dist/obs-studio.mcpb`, extracts it into a temporary directory, validates its manifest and source contents, checks all 158 tool definitions, and calls the extracted server through MCP against fake OBS. ### Live OBS tests diff --git a/manifest.json b/manifest.json index d140de0..3e762f7 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "obs-mcp", "version": "1.1.1", "display_name": "OBS MCP", - "description": "Control OBS Studio through 156 current, annotated MCP tools", + "description": "Control OBS Studio through 158 current, annotated MCP tools", "long_description": "OBS MCP connects compatible MCP clients to OBS Studio through the built-in WebSocket v5 server. It covers the synchronized OBS request surface for scenes, sources, audio, filters, transitions, recording, streaming, canvases, and configuration. Tool discovery remains available while OBS is offline, and the server reconnects in the background.\n\nRequirements:\n- Node.js 20.19 or newer\n- OBS Studio 28 or newer with WebSocket Server enabled under Tools > WebSocket Server Settings", "icon": "icon.png", "author": { diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index 18167a8..e027b77 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -107,6 +107,61 @@ "openWorldHint": true } }, + { + "name": "obs-capture-window", + "title": "Capture Window (macOS)", + "inputSchema": { + "type": "object", + "properties": { + "sceneName": { + "type": "string", + "description": "Scene to add the input to if it does not exist" + }, + "inputName": { + "type": "string", + "description": "Name of the screen_capture input to create or reuse" + }, + "window": { + "description": "Case-insensitive part of \"[App] Title\", or the window ID", + "type": "string" + }, + "application": { + "description": "Case-insensitive part of the app name, or its bundle ID", + "type": "string" + }, + "displayUuid": { + "description": "Display UUID; defaults to the first display OBS lists", + "type": "string" + }, + "silent": { + "default": true, + "description": "Mute the capture and remove it from every audio track", + "type": "boolean" + }, + "fit": { + "default": true, + "description": "Scale the capture to fit the canvas, centered", + "type": "boolean" + }, + "includeScreenshot": { + "default": false, + "description": "Also return a screenshot of the capture", + "type": "boolean" + } + }, + "required": [ + "sceneName", + "inputName" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "obs-create-input", "title": "Create Input", @@ -4407,6 +4462,60 @@ "openWorldHint": false } }, + { + "name": "obs-record-clip", + "title": "Record Clip", + "inputSchema": { + "type": "object", + "properties": { + "durationSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 50, + "description": "Clip length in seconds" + }, + "chapters": { + "default": [], + "description": "Chapter markers; need the Hybrid MP4 or Hybrid MOV recording format", + "type": "array", + "items": { + "type": "object", + "properties": { + "atSeconds": { + "type": "number", + "minimum": 0, + "description": "Seconds after the start" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Chapter name" + } + }, + "required": [ + "atSeconds", + "name" + ] + } + }, + "expectSilent": { + "default": false, + "description": "Refuse to record with audible inputs, and check the file's audio is silent", + "type": "boolean" + } + }, + "required": [ + "durationSeconds" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + } + }, { "name": "obs-remove-input", "title": "Remove Input", diff --git a/src/tools/index.ts b/src/tools/index.ts index 101e94b..e247bb3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -23,6 +23,7 @@ import * as ui from "./ui.js"; import * as protocolExtensions from "./protocol-extensions.js"; import * as protocol from "./protocol.js"; import * as preflight from "./preflight.js"; +import * as workflows from "./workflows.js"; import { withStructuredToolResults } from "./results.js"; import { ALL_TOOLS, scopedServer, type RegisteredTool, type ToolFilter, type ToolGroup } from "./toolsets.js"; @@ -43,6 +44,7 @@ const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client: ["protocol", protocolExtensions], // Regrouped per tool in toolsets.ts. ["protocol", protocol], ["record", preflight], + ["record", workflows], ]; /** @@ -79,5 +81,6 @@ export { ui, protocolExtensions, protocol, - preflight + preflight, + workflows }; diff --git a/src/tools/preflight.test.ts b/src/tools/preflight.test.ts index 79c3a91..fa99777 100644 --- a/src/tools/preflight.test.ts +++ b/src/tools/preflight.test.ts @@ -10,89 +10,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FakeOBSRequestError } from "../../test/support/fake-obs-server.js"; import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; import type { PreflightCheck } from "./preflight.js"; - -type FakeInput = { - inputName: string; - inputKind: string; - muted?: boolean; // undefined means the input has no audio - tracks?: Record; -}; - -type FakeSceneItem = { - sceneItemId: number; - sourceName: string; - sceneItemEnabled: boolean; - sourceWidth: number; - sourceHeight: number; -}; - -/** A healthy OBS that preflight should pass; each test breaks one thing. */ -type FakeState = { - recording: boolean; - recordDirectory: string; - availableDiskSpace: number; - profile: Record; - inputs: FakeInput[]; - sceneItems: FakeSceneItem[]; -}; +import { healthyObsState, servePreflightState, type FakeObsState } from "../../test/support/fake-obs-state.js"; let harness: McpHarness; let recordDirectory: string; -let state: FakeState; - -function healthyState(): FakeState { - return { - recording: false, - recordDirectory, - availableDiskSpace: 50_000, - profile: { - "Output/Mode": "Simple", - "SimpleOutput/RecQuality": "Small", - "SimpleOutput/RecEncoder": "apple_h264", - }, - inputs: [ - { inputName: "Chrome", inputKind: "screen_capture", muted: true, tracks: { 1: false } }, - { inputName: "Background", inputKind: "color_source_v3" }, - ], - sceneItems: [ - { sceneItemId: 1, sourceName: "Background", sceneItemEnabled: true, sourceWidth: 1920, sourceHeight: 1080 }, - { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sourceWidth: 1512, sourceHeight: 949 }, - ], - }; -} - -function findInput(requestData: Record): FakeInput { - const input = state.inputs.find(({ inputName }) => inputName === requestData.inputName); - if (!input) throw new FakeOBSRequestError(600, "No source was found"); - return input; -} +let state: FakeObsState; function wireFakeObs(): void { - const { fakeObs } = harness; - fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: state.recording })); - fakeObs.respondWith("GetRecordDirectory", () => ({ recordDirectory: state.recordDirectory })); - fakeObs.respondWith("GetStats", () => ({ availableDiskSpace: state.availableDiskSpace })); - fakeObs.respondWith("GetProfileParameter", ({ parameterCategory, parameterName }) => ({ - parameterValue: state.profile[`${String(parameterCategory)}/${String(parameterName)}`] ?? null, - defaultParameterValue: null, - })); - fakeObs.respondWith("GetInputList", () => ({ - inputs: state.inputs.map(({ inputName, inputKind }) => ({ inputName, inputKind })), - })); - fakeObs.respondWith("GetInputMute", (data) => { - const input = findInput(data); - if (input.muted === undefined) throw new FakeOBSRequestError(604, "The specified input does not support audio"); - return { inputMuted: input.muted }; - }); - fakeObs.respondWith("GetInputAudioTracks", (data) => ({ inputAudioTracks: findInput(data).tracks ?? {} })); - fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); - fakeObs.respondWith("GetSceneItemList", () => ({ - sceneItems: state.sceneItems.map(({ sourceWidth, sourceHeight, ...item }) => ({ - ...item, - sceneItemTransform: { sourceWidth, sourceHeight }, - })), - })); - fakeObs.respondWith("SetProfileParameter", () => ({})); + servePreflightState(harness.fakeObs, () => state); + harness.fakeObs.respondWith("SetProfileParameter", () => ({})); } async function preflight(args: Record = {}) { @@ -106,7 +32,7 @@ async function preflight(args: Record = {}) { beforeEach(async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); recordDirectory = mkdtempSync(join(tmpdir(), "obs-mcp-preflight-")); - state = healthyState(); + state = healthyObsState(recordDirectory); harness = await startMcpHarness({ obsStudioVersion: "32.2.2" }); wireFakeObs(); }); diff --git a/src/tools/toolsets.ts b/src/tools/toolsets.ts index 38209b2..af6b462 100644 --- a/src/tools/toolsets.ts +++ b/src/tools/toolsets.ts @@ -133,6 +133,7 @@ const GROUP_OVERRIDES: Record = { "obs-get-transition-kind-list": "transitions", "obs-get-transition-cursor": "transitions", "obs-set-tbar-position": "transitions", + "obs-capture-window": "inputs", }; /** diff --git a/src/tools/workflows.test.ts b/src/tools/workflows.test.ts new file mode 100644 index 0000000..1625e80 --- /dev/null +++ b/src/tools/workflows.test.ts @@ -0,0 +1,287 @@ +/* + * 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 { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FakeOBSRequestError } from "../../test/support/fake-obs-server.js"; +import { healthyObsState, servePreflightState, type FakeObsState } from "../../test/support/fake-obs-state.js"; +import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; + +function hasFfmpeg(): boolean { + try { + execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); + execFileSync("ffprobe", ["-version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const ffmpegAvailable = hasFfmpeg(); + +let harness: McpHarness; +let recordDirectory: string; +let state: FakeObsState; +let clipPath: string; + +function requestTypes(): unknown[] { + return harness.fakeObs.history().map(({ frame }) => frame.d.requestType).filter(Boolean); +} + +function requestData(requestType: string): Record[] { + return harness.fakeObs.history() + .filter(({ frame }) => frame.d.requestType === requestType) + .map(({ frame }) => frame.d.requestData as Record); +} + +function makeClip(seconds: number, audio: "none" | "silent" | "tone"): void { + const inputs = ["-f", "lavfi", "-i", `color=c=black:s=64x36:d=${seconds}`]; + if (audio === "silent") inputs.push("-f", "lavfi", "-i", `anullsrc=r=48000:cl=mono`); + if (audio === "tone") inputs.push("-f", "lavfi", "-i", `sine=frequency=440:duration=${seconds}`); + execFileSync("ffmpeg", ["-y", "-v", "error", ...inputs, "-t", String(seconds), "-shortest", clipPath]); +} + +async function startHarness(platform = "macos"): Promise { + harness = await startMcpHarness({ platform }); + servePreflightState(harness.fakeObs, () => state); + const { fakeObs } = harness; + fakeObs.respondWith("StartRecord", () => { + setTimeout(() => fakeObs.sendEvent("RecordStateChanged", { + outputActive: true, + outputState: "OBS_WEBSOCKET_OUTPUT_STARTED", + outputPath: clipPath, + }), 5); + return {}; + }); + fakeObs.respondWith("StopRecord", () => ({ outputPath: clipPath })); + fakeObs.respondWith("CreateRecordChapter", () => ({})); +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + recordDirectory = mkdtempSync(join(tmpdir(), "obs-mcp-workflows-")); + clipPath = join(recordDirectory, "clip.mp4"); + state = healthyObsState(recordDirectory); +}); + +afterEach(async () => { + await harness.close(); + rmSync(recordDirectory, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("obs-record-clip", () => { + it("refuses to start when preflight fails", async () => { + await startHarness(); + state.recording = true; + + const result = await harness.call("obs-record-clip", { durationSeconds: 1 }); + + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("record-idle"); + expect(requestTypes()).not.toContain("StartRecord"); + }); + + it("refuses audible inputs when silence is expected", async () => { + await startHarness(); + state.inputs[0] = { inputName: "Chrome", inputKind: "screen_capture", muted: false, tracks: { 1: true } }; + + const result = await harness.call("obs-record-clip", { durationSeconds: 1, expectSilent: true }); + + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("Chrome (track 1)"); + }); + + it("records, adds chapters in order, stops, and reports the file", async () => { + await startHarness(); + + const result = await harness.call("obs-record-clip", { + durationSeconds: 0.3, + chapters: [{ atSeconds: 0.2, name: "Second" }, { atSeconds: 0.05, name: "First" }], + }); + + expect(resultText(result)).toContain(`to ${clipPath}`); + expect(requestData("CreateRecordChapter").map(({ chapterName }) => chapterName)).toEqual(["First", "Second"]); + const order = requestTypes(); + expect(order.indexOf("StartRecord")).toBeLessThan(order.indexOf("CreateRecordChapter")); + expect(order.lastIndexOf("CreateRecordChapter")).toBeLessThan(order.indexOf("StopRecord")); + }); + + it("keeps recording when a chapter is rejected", async () => { + await startHarness(); + harness.fakeObs.respondWith("CreateRecordChapter", () => { + throw new FakeOBSRequestError(501, "Chapters need Hybrid MP4"); + }); + + const result = await harness.call("obs-record-clip", { + durationSeconds: 0.1, + chapters: [{ atSeconds: 0, name: "Intro" }], + }); + + expect(resultText(result)).toMatch(/Chapter "Intro" not added: .*Hybrid MP4/); + expect(requestTypes()).toContain("StopRecord"); + }); + + it("stops the recording even when waiting fails", async () => { + await startHarness(); + harness.fakeObs.respondWith("CreateRecordChapter", () => { + harness.fakeObs.disconnect(); + return {}; + }); + + await harness.call("obs-record-clip", { durationSeconds: 0.1, chapters: [{ atSeconds: 0, name: "x" }] }); + + await vi.waitFor(() => expect(requestTypes()).toContain("StopRecord")); + }); + + it.skipIf(!ffmpegAvailable)("verifies the file's length and that it is silent", async () => { + await startHarness(); + makeClip(1, "silent"); + + const result = await harness.call("obs-record-clip", { durationSeconds: 0.5, expectSilent: true }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ audioStreams: 1, problems: [] }); + // Encoded digital silence measures about -91 dB rather than -inf. + expect((result.structuredContent as { maxVolumeDb: number | null }).maxVolumeDb ?? -Infinity).toBeLessThan(-60); + expect(resultText(result)).toMatch(/Verified: 1\.0s, 1 audio stream/); + }); + + it.skipIf(!ffmpegAvailable)("flags audible audio and a short file", async () => { + await startHarness(); + makeClip(1, "tone"); + + const result = await harness.call("obs-record-clip", { durationSeconds: 4, expectSilent: true }); + + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("Expected silence, but the audio peaks at"); + expect(resultText(result)).toContain("shorter than the requested 4s"); + }, 10_000); + + it("rejects clips longer than a client timeout allows", async () => { + await startHarness(); + + const result = await harness.call("obs-record-clip", { durationSeconds: 120 }); + + expect(result.isError).toBe(true); + expect(requestTypes()).not.toContain("StartRecord"); + }); +}); + +describe("obs-capture-window", () => { + let inputs: { inputName: string; inputKind: string }[]; + + async function startCaptureHarness(platform = "macos"): Promise { + await startHarness(platform); + inputs = []; + const { fakeObs } = harness; + fakeObs.respondWith("GetInputList", () => ({ inputs })); + fakeObs.respondWith("CreateInput", (data) => { + inputs.push({ inputName: String(data.inputName), inputKind: String(data.inputKind) }); + return { sceneItemId: 2 }; + }); + fakeObs.respondWith("GetInputPropertiesListPropertyItems", ({ propertyName }) => ({ + propertyItems: propertyName === "display_uuid" + ? [{ itemName: " ", itemValue: "", itemEnabled: true }, { itemName: "Built-in Display", itemValue: "37D8832A", itemEnabled: true }] + : [ + { itemName: " ", itemValue: 0, itemEnabled: true }, + { itemName: "[Google Chrome] ChatGPT", itemValue: 31875, itemEnabled: true }, + { itemName: "[Google Chrome] YouTube", itemValue: 31876, itemEnabled: true }, + { itemName: "[Terminal] zsh", itemValue: 402, itemEnabled: true }, + ], + })); + for (const request of ["SetInputSettings", "SetInputMute", "SetInputAudioTracks", "SetSceneItemTransform"]) { + fakeObs.respondWith(request, () => ({})); + } + fakeObs.respondWith("GetVideoSettings", () => ({ baseWidth: 1920, baseHeight: 1080 })); + fakeObs.respondWith("GetSceneItemId", () => ({ sceneItemId: 2 })); + fakeObs.respondWith("GetInputSettings", () => ({ inputKind: "screen_capture", inputSettings: { type: 1, window: 31875 } })); + } + + it("creates the capture, selects a display before listing windows, silences it, and fits it", async () => { + await startCaptureHarness(); + + const result = await harness.call("obs-capture-window", { + sceneName: "Demo", + inputName: "Chrome", + window: "chatgpt", + }); + + expect(result.isError).toBeFalsy(); + expect(resultText(result)).toContain("Capturing window [Google Chrome] ChatGPT"); + expect(requestData("SetInputSettings")).toEqual([ + { inputName: "Chrome", inputSettings: { type: 1, display_uuid: "37D8832A" } }, + { inputName: "Chrome", inputSettings: { window: 31875 } }, + ]); + const order = requestTypes(); + expect(order.indexOf("SetInputSettings")).toBeLessThan( + order.lastIndexOf("GetInputPropertiesListPropertyItems"), + ); + expect(requestData("SetInputMute")).toEqual([{ inputName: "Chrome", inputMuted: true }]); + expect(requestData("SetInputAudioTracks")[0]?.inputAudioTracks).toEqual({ + 1: false, 2: false, 3: false, 4: false, 5: false, 6: false, + }); + expect(requestData("SetSceneItemTransform")[0]?.sceneItemTransform).toMatchObject({ + boundsType: "OBS_BOUNDS_SCALE_INNER", + boundsWidth: 1920, + boundsHeight: 1080, + }); + expect(result.structuredContent).toMatchObject({ appearances: [{ sourceWidth: 1512 }], warnings: [] }); + }); + + it("lists the candidates when the window name is ambiguous", async () => { + await startCaptureHarness(); + + const result = await harness.call("obs-capture-window", { + sceneName: "Demo", + inputName: "Chrome", + window: "google chrome", + }); + + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("More than one window"); + expect(resultText(result)).toContain("[Google Chrome] YouTube"); + expect(requestTypes()).not.toContain("SetInputMute"); + }); + + it("uses a given display without listing displays, and leaves audio alone when asked", async () => { + await startCaptureHarness(); + + await harness.call("obs-capture-window", { + sceneName: "Demo", + inputName: "Terminal", + window: "402", + displayUuid: "ABC", + silent: false, + fit: false, + }); + + expect(requestData("GetInputPropertiesListPropertyItems").map(({ propertyName }) => propertyName)).toEqual(["window"]); + expect(requestData("SetInputSettings")[1]).toEqual({ inputName: "Terminal", inputSettings: { window: 402 } }); + expect(requestTypes()).not.toContain("SetInputMute"); + expect(requestTypes()).not.toContain("SetSceneItemTransform"); + }); + + it("refuses an existing input of another kind", async () => { + await startCaptureHarness(); + inputs.push({ inputName: "Chrome", inputKind: "browser_source" }); + + const result = await harness.call("obs-capture-window", { sceneName: "Demo", inputName: "Chrome", window: "chatgpt" }); + + expect(resultText(result)).toContain("is a browser_source, not a screen_capture"); + }); + + it("refuses when OBS is not on macOS", async () => { + await startCaptureHarness("windows"); + + const result = await harness.call("obs-capture-window", { sceneName: "Demo", inputName: "Chrome", window: "chatgpt" }); + + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("not on macOS"); + }); +}); diff --git a/src/tools/workflows.ts b/src/tools/workflows.ts new file mode 100644 index 0000000..a603bbb --- /dev/null +++ b/src/tools/workflows.ts @@ -0,0 +1,323 @@ +/* + * 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 { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { OBSWebSocketClient } from "../client.js"; +import { appendInputScreenshot, inputStateResult, readInputState } from "./after-change.js"; +import { RECORD_OUTPUT, startOutputAndConfirm } from "./output-start.js"; +import { runPreflight } from "./preflight.js"; + +type JsonObject = Record; + +const execFileAsync = promisify(execFile); + +/** Many MCP clients time a tool call out at 60 seconds. */ +export const MAX_CLIP_SECONDS = 50; +const SCK_WINDOW_CAPTURE = 1; +const SCK_APPLICATION_CAPTURE = 2; +const ALL_TRACKS_OFF = { 1: false, 2: false, 3: false, 4: false, 5: false, 6: false }; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorResult(text: string): CallToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export type ClipCheck = { + durationSeconds?: number; + audioStreams?: number; + maxVolumeDb?: number; + skipped?: string; +}; + +/** + * Inspects a finished recording with ffprobe (and ffmpeg for loudness) when + * they are on PATH and OBS writes to this machine. Never throws. + */ +export async function inspectRecording(outputPath: string, measureLoudness: boolean): Promise { + let probe: JsonObject; + try { + const { stdout } = await execFileAsync("ffprobe", [ + "-v", "error", "-print_format", "json", "-show_format", "-show_streams", outputPath, + ], { timeout: 15_000 }); + probe = JSON.parse(stdout) as JsonObject; + } catch (error) { + const code = isObject(error) ? error.code : undefined; + return { skipped: code === "ENOENT" ? "ffprobe is not installed" : `ffprobe failed: ${errorMessage(error)}` }; + } + + const streams = Array.isArray(probe.streams) ? probe.streams.filter(isObject) : []; + const format = isObject(probe.format) ? probe.format : {}; + const check: ClipCheck = { + durationSeconds: Number(format.duration), + audioStreams: streams.filter((stream) => stream.codec_type === "audio").length, + }; + if (!measureLoudness || check.audioStreams === 0) return check; + + try { + const { stderr } = await execFileAsync("ffmpeg", [ + "-hide_banner", "-nostats", "-i", outputPath, "-map", "0:a:0", "-af", "volumedetect", "-f", "null", "-", + ], { timeout: 60_000 }); + const match = /max_volume:\s*(-?[\d.]+|-inf) dB/.exec(stderr); + if (match?.[1]) check.maxVolumeDb = match[1] === "-inf" ? -Infinity : Number(match[1]); + } catch (error) { + check.skipped = `ffmpeg loudness check failed: ${errorMessage(error)}`; + } + return check; +} + +async function recordClip( + client: OBSWebSocketClient, + args: { durationSeconds: number; chapters: { atSeconds: number; name: string }[]; expectSilent: boolean }, +): Promise { + const preflight = await runPreflight(client, { minFreeDiskMb: 1024, expectSilent: args.expectSilent }); + if (!preflight.ready) { + const failures = preflight.checks.filter(({ status }) => status === "fail"); + return errorResult( + `Not recording; preflight failed:\n${failures.map(({ id, message }) => `- ${id}: ${message}`).join("\n")}`, + ); + } + + const started = await startOutputAndConfirm(client, RECORD_OUTPUT); + if (started.isError) return started; + + const startedAt = Date.now(); + const chapterNotes: string[] = []; + let outputPath: string | undefined; + try { + const chapters = [...args.chapters].sort((a, b) => a.atSeconds - b.atSeconds); + for (const chapter of chapters) { + await sleep(Math.max(0, startedAt + chapter.atSeconds * 1000 - Date.now())); + try { + await client.sendRequest("CreateRecordChapter", { chapterName: chapter.name }); + } catch (error) { + // Chapters need Hybrid MP4/MOV; a missing chapter must not abort the take. + chapterNotes.push(`Chapter "${chapter.name}" not added: ${errorMessage(error)}`); + } + } + await sleep(Math.max(0, startedAt + args.durationSeconds * 1000 - Date.now())); + } finally { + const stopped: unknown = await client.sendRequest("StopRecord").catch(() => undefined); + if (isObject(stopped) && typeof stopped.outputPath === "string") outputPath = stopped.outputPath; + } + + if (!outputPath) { + return errorResult("Recording started, but OBS did not confirm the stop or report the file. Check obs-get-record-status"); + } + + const local = preflight.checks.some(({ id, status }) => id === "record-directory" && status === "pass"); + const inspection = local + ? await inspectRecording(outputPath, args.expectSilent) + : { skipped: "OBS is remote, so the file was not inspected" }; + + const problems: string[] = []; + if (inspection.durationSeconds !== undefined && inspection.durationSeconds < args.durationSeconds - 2) { + problems.push(`The file is ${inspection.durationSeconds.toFixed(1)}s, shorter than the requested ${args.durationSeconds}s`); + } + if (args.expectSilent && inspection.maxVolumeDb !== undefined && inspection.maxVolumeDb > -60) { + problems.push(`Expected silence, but the audio peaks at ${inspection.maxVolumeDb} dB`); + } + + const lines = [ + `Recorded ${args.durationSeconds}s to ${outputPath}`, + inspection.skipped + ? `Not verified: ${inspection.skipped}` + : `Verified: ${inspection.durationSeconds?.toFixed(1)}s, ${inspection.audioStreams} audio stream(s)` + + (inspection.maxVolumeDb !== undefined ? `, peak ${inspection.maxVolumeDb} dB` : ""), + ...chapterNotes, + ...problems.map((problem) => `Problem: ${problem}`), + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { + outputPath, + ...inspection, + ...(inspection.maxVolumeDb === -Infinity ? { maxVolumeDb: null } : {}), + chapterNotes, + problems, + }, + ...(problems.length > 0 ? { isError: true } : {}), + }; +} + +type ListItem = { itemName: string; itemValue: unknown; itemEnabled?: boolean }; + +async function listItems(client: OBSWebSocketClient, inputName: string, propertyName: string): Promise { + const response: unknown = await client.sendRequest("GetInputPropertiesListPropertyItems", { inputName, propertyName }); + const items = isObject(response) && Array.isArray(response.propertyItems) ? response.propertyItems : []; + return items.filter((item): item is ListItem => ( + isObject(item) && typeof item.itemName === "string" && item.itemEnabled !== false && item.itemName.trim() !== "" + )); +} + +async function captureWindow( + client: OBSWebSocketClient, + args: { + sceneName: string; + inputName: string; + window?: string; + application?: string; + displayUuid?: string; + silent: boolean; + fit: boolean; + includeScreenshot: boolean; + }, +): Promise { + if (!args.window === !args.application) return errorResult("Give exactly one of window or application"); + await client.connect(); + if (client.getConnectionStatus().versionInfo?.platform !== "macos") { + return errorResult("obs-capture-window drives the macOS screen_capture source; OBS is not on macOS"); + } + + const { inputs } = await client.sendRequest("GetInputList") as { inputs?: unknown[] }; + const existing = (inputs ?? []).filter(isObject).find(({ inputName }) => inputName === args.inputName); + if (existing && existing.inputKind !== "screen_capture") { + return errorResult(`Input ${args.inputName} exists but is a ${String(existing.inputKind)}, not a screen_capture`); + } + const type = args.window ? SCK_WINDOW_CAPTURE : SCK_APPLICATION_CAPTURE; + if (!existing) { + await client.sendRequest("CreateInput", { + sceneName: args.sceneName, + inputName: args.inputName, + inputKind: "screen_capture", + inputSettings: { type, show_cursor: true }, + }); + } + + // Select a display before listing applications: listing them without one + // crashed OBS 32.2.2, and application capture renders nothing without it. + let displayUuid = args.displayUuid; + if (!displayUuid) { + const displays = await listItems(client, args.inputName, "display_uuid"); + const first = displays.find(({ itemValue }) => typeof itemValue === "string" && itemValue); + if (!first) return errorResult("OBS lists no displays; grant OBS screen recording permission"); + displayUuid = first.itemValue as string; + } + await client.sendRequest("SetInputSettings", { + inputName: args.inputName, + inputSettings: { type, display_uuid: displayUuid }, + }); + + const property = args.window ? "window" : "application"; + const wanted = (args.window ?? args.application ?? "").toLowerCase(); + const items = await listItems(client, args.inputName, property); + const matches = items.filter(({ itemName, itemValue }) => ( + itemName.toLowerCase().includes(wanted) || String(itemValue).toLowerCase() === wanted + )); + if (matches.length !== 1) { + const names = (matches.length > 1 ? matches : items).slice(0, 15).map(({ itemName }) => `- ${itemName}`); + return errorResult( + `${matches.length === 0 ? "No" : "More than one"} ${property} matches "${args.window ?? args.application}". ` + + `${matches.length > 1 ? "Matches" : "Available"}:\n${names.join("\n")}`, + ); + } + const match = matches[0] as ListItem; + await client.sendRequest("SetInputSettings", { + inputName: args.inputName, + inputSettings: { [property]: match.itemValue }, + }); + + const notes: string[] = [`Capturing ${property} ${match.itemName}`]; + if (args.silent) { + // screen_capture carries the application's audio on macOS 13+. + await client.sendRequest("SetInputMute", { inputName: args.inputName, inputMuted: true }); + await client.sendRequest("SetInputAudioTracks", { inputName: args.inputName, inputAudioTracks: ALL_TRACKS_OFF }); + notes.push("Muted the capture and removed it from every audio track"); + } + + if (args.fit) { + const video = await client.sendRequest("GetVideoSettings") as JsonObject; + const { sceneItemId } = await client.sendRequest("GetSceneItemId", { + sceneName: args.sceneName, + sourceName: args.inputName, + }) as { sceneItemId: number }; + await client.sendRequest("SetSceneItemTransform", { + sceneName: args.sceneName, + sceneItemId, + sceneItemTransform: { + boundsType: "OBS_BOUNDS_SCALE_INNER", + boundsWidth: video.baseWidth, + boundsHeight: video.baseHeight, + boundsAlignment: 0, + alignment: 5, + positionX: 0, + positionY: 0, + }, + }); + notes.push(`Fitted to the ${String(video.baseWidth)}×${String(video.baseHeight)} canvas`); + } + + const result = inputStateResult(notes.join(". "), await readInputState(client, args.inputName)); + return args.includeScreenshot ? appendInputScreenshot(client, args.inputName, result) : result; +} + +export function initialize(server: McpServer, client: OBSWebSocketClient): void { + server.registerTool( + "obs-record-clip", + { + title: "Record Clip", + description: `Record a clip of up to ${MAX_CLIP_SECONDS}s in one call: runs obs-preflight, starts recording ` + + "and waits for OBS to confirm, adds chapter markers, stops, then checks the file's length and audio " + + "with ffprobe/ffmpeg when available. For longer takes use obs-start-record and obs-stop-record", + inputSchema: z.object({ + durationSeconds: z.number().positive().max(MAX_CLIP_SECONDS).describe("Clip length in seconds"), + chapters: z.array(z.object({ + atSeconds: z.number().nonnegative().describe("Seconds after the start"), + name: z.string().min(1).describe("Chapter name"), + })).default([]).describe("Chapter markers; need the Hybrid MP4 or Hybrid MOV recording format"), + expectSilent: z.boolean().default(false) + .describe("Refuse to record with audible inputs, and check the file's audio is silent"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async (args) => { + try { + return await recordClip(client, args); + } catch (error) { + return errorResult(`Record Clip failed: ${errorMessage(error)}`); + } + }, + ); + + server.registerTool( + "obs-capture-window", + { + title: "Capture Window (macOS)", + description: "Point a macOS screen_capture input at a window or application in one call: creates the " + + "input if needed, selects a display (which application capture needs), matches the window or app by " + + "name, optionally mutes it and clears its audio tracks, fits it to the canvas, and returns its size. " + + "Window capture excludes the menu bar; application capture includes it", + inputSchema: z.object({ + sceneName: z.string().describe("Scene to add the input to if it does not exist"), + inputName: z.string().describe("Name of the screen_capture input to create or reuse"), + window: z.string().optional().describe("Case-insensitive part of \"[App] Title\", or the window ID"), + application: z.string().optional().describe("Case-insensitive part of the app name, or its bundle ID"), + displayUuid: z.string().optional().describe("Display UUID; defaults to the first display OBS lists"), + silent: z.boolean().default(true).describe("Mute the capture and remove it from every audio track"), + fit: z.boolean().default(true).describe("Scale the capture to fit the canvas, centered"), + includeScreenshot: z.boolean().default(false).describe("Also return a screenshot of the capture"), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async (args) => { + try { + return await captureWindow(client, args); + } catch (error) { + return errorResult(`Capture Window failed: ${errorMessage(error)}`); + } + }, + ); +} diff --git a/test/support/fake-obs-server.ts b/test/support/fake-obs-server.ts index a740584..8629ca4 100644 --- a/test/support/fake-obs-server.ts +++ b/test/support/fake-obs-server.ts @@ -47,6 +47,7 @@ export type FakeOBSOptions = { availableRequests?: readonly string[]; obsStudioVersion?: string; obsWebSocketVersion?: string; + platform?: string; }; type ResponseStatus = { @@ -123,6 +124,7 @@ export class FakeOBSServer { private readonly autoGetVersion: boolean; private readonly obsStudioVersion: string; private readonly obsWebSocketVersion: string; + private readonly platform: string; private nextConnectionId = 1; private nextCursor = 1; private closed = false; @@ -133,6 +135,7 @@ export class FakeOBSServer { this.autoGetVersion = options.autoGetVersion ?? true; this.obsStudioVersion = options.obsStudioVersion ?? "32.2.2"; this.obsWebSocketVersion = options.obsWebSocketVersion ?? "5.7.0"; + this.platform = options.platform ?? "macos"; this.availableRequests = Array.from(new Set(["GetVersion", ...(options.availableRequests ?? [])])); const address = server.address() as AddressInfo; @@ -415,7 +418,7 @@ export class FakeOBSServer { rpcVersion: 1, availableRequests: this.availableRequests, supportedImageFormats: ["png", "jpeg", "webp"], - platform: "macos", + platform: this.platform, platformDescription: "Fake OBS Studio", }; } diff --git a/test/support/fake-obs-state.ts b/test/support/fake-obs-state.ts new file mode 100644 index 0000000..732e830 --- /dev/null +++ b/test/support/fake-obs-state.ts @@ -0,0 +1,85 @@ +/* + * 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 { FakeOBSRequestError, type FakeOBSServer } from "./fake-obs-server.js"; + +export type FakeInput = { + inputName: string; + inputKind: string; + muted?: boolean; // undefined means the input has no audio + tracks?: Record; +}; + +export type FakeSceneItem = { + sceneItemId: number; + sourceName: string; + sceneItemEnabled: boolean; + sourceWidth: number; + sourceHeight: number; +}; + +/** OBS state behind a fake server; tests mutate it to create one problem at a time. */ +export type FakeObsState = { + recording: boolean; + recordDirectory: string; + availableDiskSpace: number; + profile: Record; + inputs: FakeInput[]; + sceneItems: FakeSceneItem[]; +}; + +/** A recording setup that obs-preflight passes: silent, sized, apple_h264. */ +export function healthyObsState(recordDirectory: string): FakeObsState { + return { + recording: false, + recordDirectory, + availableDiskSpace: 50_000, + profile: { + "Output/Mode": "Simple", + "SimpleOutput/RecQuality": "Small", + "SimpleOutput/RecEncoder": "apple_h264", + }, + inputs: [ + { inputName: "Chrome", inputKind: "screen_capture", muted: true, tracks: { 1: false } }, + { inputName: "Background", inputKind: "color_source_v3" }, + ], + sceneItems: [ + { sceneItemId: 1, sourceName: "Background", sceneItemEnabled: true, sourceWidth: 1920, sourceHeight: 1080 }, + { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sourceWidth: 1512, sourceHeight: 949 }, + ], + }; +} + +/** Answers the requests obs-preflight makes from the state returned by getState. */ +export function servePreflightState(fakeObs: FakeOBSServer, getState: () => FakeObsState): void { + const findInput = (requestData: Record): FakeInput => { + const input = getState().inputs.find(({ inputName }) => inputName === requestData.inputName); + if (!input) throw new FakeOBSRequestError(600, "No source was found"); + return input; + }; + fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: getState().recording })); + fakeObs.respondWith("GetRecordDirectory", () => ({ recordDirectory: getState().recordDirectory })); + fakeObs.respondWith("GetStats", () => ({ availableDiskSpace: getState().availableDiskSpace })); + fakeObs.respondWith("GetProfileParameter", ({ parameterCategory, parameterName }) => ({ + parameterValue: getState().profile[`${String(parameterCategory)}/${String(parameterName)}`] ?? null, + defaultParameterValue: null, + })); + fakeObs.respondWith("GetInputList", () => ({ + inputs: getState().inputs.map(({ inputName, inputKind }) => ({ inputName, inputKind })), + })); + fakeObs.respondWith("GetInputMute", (data) => { + const input = findInput(data); + if (input.muted === undefined) throw new FakeOBSRequestError(604, "The specified input does not support audio"); + return { inputMuted: input.muted }; + }); + fakeObs.respondWith("GetInputAudioTracks", (data) => ({ inputAudioTracks: findInput(data).tracks ?? {} })); + fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); + fakeObs.respondWith("GetSceneItemList", () => ({ + sceneItems: getState().sceneItems.map(({ sourceWidth, sourceHeight, ...item }) => ({ + ...item, + sceneItemTransform: { sourceWidth, sourceHeight }, + })), + })); +} diff --git a/test/support/tool-count.ts b/test/support/tool-count.ts index 2dd8753..254efbe 100644 --- a/test/support/tool-count.ts +++ b/test/support/tool-count.ts @@ -5,4 +5,4 @@ */ /** Number of tools the server registers with no tool filtering configured. */ -export const TOOL_COUNT = 156; +export const TOOL_COUNT = 158;