From 8ae1390e4b359eabeefa4a269f87d499e90fe34d Mon Sep 17 00:00:00 2001 From: Tom Main Date: Tue, 22 Sep 2026 17:46:02 -0400 Subject: [PATCH] Add obs-preflight to catch conditions that make recordings fail Checks, against the conditions OBS's StartRecording silently refuses or shows a blocking dialog for: an active recording, an unwritable output directory, low disk space, an encoder the platform lacks, output settings changed since OBS last connected (which OBS ignores until it rebuilds outputs), audible inputs on recorded tracks (warning, or failure with expectSilent), and visible 0x0 sources. Open Settings/error dialogs cannot be detected over WebSocket and are reported as unknown. The fake OBS advertises responder request types, and tests share TOOL_COUNT. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 7 +- manifest.json | 2 +- src/client.ts | 13 + src/tools/__snapshots__/tool-contract.json | 28 ++ src/tools/config.ts | 1 + src/tools/index.ts | 5 +- src/tools/preflight.test.ts | 237 ++++++++++++++++ src/tools/preflight.ts | 310 +++++++++++++++++++++ src/tools/tool-inventory.test.ts | 2 +- src/tools/tools.test.ts | 5 +- test/e2e/stdio.e2e.test.ts | 11 +- test/live/obs-live.test.ts | 19 ++ test/package/mcpb-artifact.test.ts | 7 +- test/support/fake-obs-server.ts | 2 + test/support/tool-count.ts | 8 + 15 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 src/tools/preflight.test.ts create mode 100644 src/tools/preflight.ts create mode 100644 test/support/tool-count.ts diff --git a/README.md b/README.md index 1787eba..37ca415 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 155 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 156 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. @@ -101,7 +101,7 @@ The server never prints the password. Connection and protocol diagnostics are wr ## Tool surface -The 155 tools are organized around the OBS protocol rather than a smaller opinionated workflow: +The 156 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 @@ -109,6 +109,7 @@ The 155 tools are organized around the OBS protocol rather than a smaller opinio - recording, streaming, replay buffer, virtual camera, and output settings - 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 - protocol description and the guarded generic request fallback Tool discovery works without an OBS connection. Calls that need OBS return an MCP error until the WebSocket connection is ready. @@ -162,7 +163,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 155 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 156 tool definitions, and calls the extracted server through MCP against fake OBS. ### Live OBS tests diff --git a/manifest.json b/manifest.json index b6ed650..9844377 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 155 current, annotated MCP tools", + "description": "Control OBS Studio through 156 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/client.ts b/src/client.ts index 012b9a3..eb28fbe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -159,6 +159,10 @@ export class OBSWebSocketClient extends EventEmitter { private readonly password: string | null; private connected = false; private identified = false; + // OBS reads output settings only when it rebuilds its outputs (restart, + // Settings dialog, or profile switch). A new connection usually means OBS + // restarted, so the flag resets on identify. + private outputSettingsPending = false; private connectionPromise: Promise | null = null; private availableRequests: Set | null = null; private versionInfo: VersionResponse | null = null; @@ -202,6 +206,14 @@ export class OBSWebSocketClient extends EventEmitter { }; } + public markOutputSettingsPending(): void { + this.outputSettingsPending = true; + } + + public hasPendingOutputSettings(): boolean { + return this.outputSettingsPending; + } + public supportsRequest(requestType: string): boolean | null { return this.availableRequests?.has(requestType) ?? null; } @@ -413,6 +425,7 @@ export class OBSWebSocketClient extends EventEmitter { return; } this.identified = true; + this.outputSettingsPending = false; this.emit("identified", socket); break; case OpCode.RequestResponse: { diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index 49f6da1..d6265f3 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -1957,6 +1957,34 @@ "openWorldHint": false } }, + { + "name": "obs-preflight", + "title": "Recording Preflight", + "inputSchema": { + "type": "object", + "properties": { + "minFreeDiskMb": { + "default": 1024, + "description": "Minimum free disk space in MB (default 1024)", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "expectSilent": { + "default": false, + "description": "Treat any audible input as a failure instead of a warning", + "type": "boolean" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "obs-press-input-property-button", "title": "Press Input Property Button", diff --git a/src/tools/config.ts b/src/tools/config.ts index 0a65a6e..d5798b0 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -384,6 +384,7 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void async ({ parameterCategory, parameterName, parameterValue }) => { try { await client.sendRequest("SetProfileParameter", { parameterCategory, parameterName, parameterValue }); + if (OUTPUT_PARAMETER_CATEGORIES.has(parameterCategory)) client.markOutputSettingsPending(); const warning = OUTPUT_PARAMETER_CATEGORIES.has(parameterCategory) ? ". Warning: OBS applies output settings only when it rebuilds its outputs; restart OBS before starting an output" : ""; diff --git a/src/tools/index.ts b/src/tools/index.ts index 7b098c2..41967dc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -22,6 +22,7 @@ import * as record from "./record.js"; 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 { withStructuredToolResults } from "./results.js"; // Export the initialization function for all tools @@ -42,6 +43,7 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void ui.initialize(structuredServer, client); protocolExtensions.initialize(structuredServer, client); protocol.initialize(structuredServer, client); + preflight.initialize(structuredServer, client); } // Export tool modules @@ -60,5 +62,6 @@ export { record, ui, protocolExtensions, - protocol + protocol, + preflight }; diff --git a/src/tools/preflight.test.ts b/src/tools/preflight.test.ts new file mode 100644 index 0000000..79c3a91 --- /dev/null +++ b/src/tools/preflight.test.ts @@ -0,0 +1,237 @@ +/* + * 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 { 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 { 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[]; +}; + +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; +} + +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", () => ({})); +} + +async function preflight(args: Record = {}) { + const result = await harness.call("obs-preflight", args); + expect(result.isError).toBeFalsy(); + const { ready, checks } = result.structuredContent as { ready: boolean; checks: PreflightCheck[] }; + const byId = (id: string) => checks.find((check) => check.id === id); + return { result, ready, checks, byId }; +} + +beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + recordDirectory = mkdtempSync(join(tmpdir(), "obs-mcp-preflight-")); + state = healthyState(); + harness = await startMcpHarness({ obsStudioVersion: "32.2.2" }); + wireFakeObs(); +}); + +afterEach(async () => { + await harness.close(); + rmSync(recordDirectory, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("obs-preflight", () => { + it("reports ready with no failures or warnings for a healthy setup", async () => { + const { ready, checks, result } = await preflight(); + + expect(ready).toBe(true); + expect(checks.filter(({ status }) => status === "fail" || status === "warn")).toEqual([]); + expect(resultText(result)).toMatch(/^Ready to record/); + }); + + it("fails when recording is already active", async () => { + state.recording = true; + const { ready, byId } = await preflight(); + + expect(ready).toBe(false); + expect(byId("record-idle")?.status).toBe("fail"); + }); + + it("fails when the recording directory does not exist", async () => { + state.recordDirectory = join(recordDirectory, "missing"); + const { ready, byId } = await preflight(); + + expect(ready).toBe(false); + expect(byId("record-directory")).toMatchObject({ status: "fail" }); + }); + + it("fails below the free disk space minimum", async () => { + state.availableDiskSpace = 500; + const { ready, byId } = await preflight({ minFreeDiskMb: 1024 }); + + expect(ready).toBe(false); + expect(byId("disk-space")?.message).toContain("500 MB free"); + }); + + it("fails for an encoder that does not exist on the OBS platform", async () => { + state.profile["SimpleOutput/RecEncoder"] = "nvenc"; + const { ready, byId } = await preflight(); + + expect(ready).toBe(false); + expect(byId("encoder")?.message).toContain("not available on macOS"); + }); + + it("follows the streaming encoder when simple mode records at stream quality", async () => { + state.profile["SimpleOutput/RecQuality"] = "Stream"; + state.profile["SimpleOutput/StreamEncoder"] = "apple_h264"; + state.profile["SimpleOutput/RecEncoder"] = "nvenc"; + const { byId } = await preflight(); + + expect(byId("encoder")).toMatchObject({ status: "pass", message: expect.stringContaining("apple_h264") }); + }); + + it("fails after output settings were changed until OBS reconnects", async () => { + await harness.call("obs-set-profile-parameter", { + parameterCategory: "SimpleOutput", + parameterName: "RecEncoder", + parameterValue: "apple_h264", + }); + const { ready, byId } = await preflight(); + + expect(ready).toBe(false); + expect(byId("output-settings")?.message).toContain("Restart OBS"); + }); + + it("warns about an unmuted input on a recorded track, and fails when silence is expected", async () => { + state.inputs[0] = { inputName: "Chrome", inputKind: "screen_capture", muted: false, tracks: { 1: true } }; + + const warned = await preflight(); + const failed = await preflight({ expectSilent: true }); + + expect(warned.ready).toBe(true); + expect(warned.byId("audio")).toMatchObject({ status: "warn", message: expect.stringContaining("Chrome (track 1)") }); + expect(failed.ready).toBe(false); + expect(failed.byId("audio")?.status).toBe("fail"); + }); + + it("ignores unmuted inputs whose recorded tracks are disabled", async () => { + state.profile["Output/Mode"] = "Advanced"; + state.profile["AdvOut/RecEncoder"] = "com.apple.videotoolbox.videoencoder.ave.avc"; + state.profile["AdvOut/RecTracks"] = "2"; + state.inputs[0] = { inputName: "Chrome", inputKind: "screen_capture", muted: false, tracks: { 1: true, 2: false } }; + + const { byId } = await preflight({ expectSilent: true }); + + expect(byId("audio")?.status).toBe("pass"); + }); + + it("warns about visible sources that render at 0×0", async () => { + state.sceneItems[1] = { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sourceWidth: 0, sourceHeight: 0 }; + const { ready, byId } = await preflight(); + + expect(ready).toBe(true); + expect(byId("scene-items")).toMatchObject({ status: "warn", message: expect.stringContaining("Chrome") }); + }); + + it("ignores hidden 0×0 sources", async () => { + state.sceneItems[1] = { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: false, sourceWidth: 0, sourceHeight: 0 }; + const { byId } = await preflight(); + + expect(byId("scene-items")?.status).toBe("pass"); + }); + + it("reports a check as unknown instead of failing when OBS rejects its request", async () => { + harness.fakeObs.respondWith("GetStats", () => { + throw new FakeOBSRequestError(500, "boom"); + }); + const { ready, byId } = await preflight(); + + expect(ready).toBe(true); + expect(byId("disk-space")).toMatchObject({ status: "unknown", message: expect.stringContaining("boom") }); + }); + + it("fails with a single connection check when OBS is unreachable", async () => { + await harness.fakeObs.close(); + const { ready, checks } = await preflight(); + + expect(ready).toBe(false); + expect(checks).toEqual([expect.objectContaining({ id: "connection", status: "fail" })]); + }); +}); diff --git a/src/tools/preflight.ts b/src/tools/preflight.ts new file mode 100644 index 0000000..e616a53 --- /dev/null +++ b/src/tools/preflight.ts @@ -0,0 +1,310 @@ +/* + * 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 { accessSync, constants, statSync } from "node:fs"; +import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { OBSWebSocketClient } from "../client.js"; +import { READ_ONLY_TOOL } from "./request-tool.js"; + +export type PreflightStatus = "pass" | "warn" | "fail" | "unknown"; + +export type PreflightCheck = { + id: string; + status: PreflightStatus; + message: string; +}; + +export type PreflightOptions = { + minFreeDiskMb: number; + expectSilent: boolean; +}; + +type JsonObject = Record; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +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 request(client: OBSWebSocketClient, requestType: string, data?: JsonObject): Promise { + const response = await client.sendRequest(requestType, data); + return isObject(response) ? response : {}; +} + +async function profileParameter( + client: OBSWebSocketClient, + parameterCategory: string, + parameterName: string, +): Promise { + const response = await request(client, "GetProfileParameter", { parameterCategory, parameterName }); + const value = response.parameterValue ?? response.defaultParameterValue; + return typeof value === "string" ? value : null; +} + +/** Runs one check, turning an unexpected request failure into an "unknown" result. */ +async function runCheck(id: string, check: () => Promise): Promise { + try { + const result = await check(); + return Array.isArray(result) ? result : [result]; + } catch (error) { + return [{ id, status: "unknown", message: `Could not check: ${errorMessage(error)}` }]; + } +} + +async function checkOutputsIdle(client: OBSWebSocketClient): Promise { + const record = await request(client, "GetRecordStatus"); + return record.outputActive === true + ? { id: "record-idle", status: "fail", message: "Recording is already active" } + : { id: "record-idle", status: "pass", message: "Recording is not active" }; +} + +function isLocalObs(client: OBSWebSocketClient): boolean { + try { + return LOCAL_HOSTS.has(new URL(client.getConnectionStatus().url).hostname); + } catch { + return false; + } +} + +async function checkRecordDirectory(client: OBSWebSocketClient): Promise { + const { recordDirectory } = await request(client, "GetRecordDirectory"); + if (typeof recordDirectory !== "string" || !recordDirectory) { + return { id: "record-directory", status: "fail", message: "OBS has no recording directory set" }; + } + if (!isLocalObs(client)) { + return { + id: "record-directory", + status: "unknown", + message: `OBS records to ${recordDirectory}; OBS is remote, so the path was not checked`, + }; + } + try { + if (!statSync(recordDirectory).isDirectory()) throw new Error("not a directory"); + accessSync(recordDirectory, constants.W_OK); + } catch (error) { + // OBS shows a blocking "invalid path" dialog instead of starting. + return { + id: "record-directory", + status: "fail", + message: `Recording directory ${recordDirectory} is not a writable directory (${errorMessage(error)})`, + }; + } + return { id: "record-directory", status: "pass", message: `Recording to ${recordDirectory}` }; +} + +async function checkDiskSpace(client: OBSWebSocketClient, minFreeDiskMb: number): Promise { + const { availableDiskSpace } = await request(client, "GetStats"); + if (typeof availableDiskSpace !== "number") { + return { id: "disk-space", status: "unknown", message: "OBS did not report free disk space" }; + } + const free = Math.round(availableDiskSpace); + return free < minFreeDiskMb + ? { id: "disk-space", status: "fail", message: `${free} MB free, below the ${minFreeDiskMb} MB minimum` } + : { id: "disk-space", status: "pass", message: `${free} MB free` }; +} + +const NON_MAC_ENCODER = /nvenc|qsv|amd|amf|jim_/i; +const MAC_ONLY_ENCODER = /apple|videotoolbox/i; + +async function recordEncoder(client: OBSWebSocketClient): Promise<{ mode: string; encoder: string | null }> { + const mode = await profileParameter(client, "Output", "Mode") ?? "Simple"; + if (mode === "Advanced") { + const encoder = await profileParameter(client, "AdvOut", "RecEncoder"); + // "none" means the recording shares the streaming encoder. + return { + mode, + encoder: !encoder || encoder === "none" ? await profileParameter(client, "AdvOut", "Encoder") : encoder, + }; + } + const quality = await profileParameter(client, "SimpleOutput", "RecQuality"); + const encoder = quality === "Stream" + ? await profileParameter(client, "SimpleOutput", "StreamEncoder") + : await profileParameter(client, "SimpleOutput", "RecEncoder"); + return { mode, encoder }; +} + +async function checkEncoder(client: OBSWebSocketClient): Promise { + const checks: PreflightCheck[] = []; + const { mode, encoder } = await recordEncoder(client); + const platform = client.getConnectionStatus().versionInfo?.platform; + + if (!encoder) { + checks.push({ id: "encoder", status: "unknown", message: `${mode} output mode has no recording encoder set` }); + } else if (platform === "macos" && NON_MAC_ENCODER.test(encoder)) { + checks.push({ id: "encoder", status: "fail", message: `Recording encoder ${encoder} is not available on macOS` }); + } else if (platform && platform !== "macos" && MAC_ONLY_ENCODER.test(encoder)) { + checks.push({ id: "encoder", status: "fail", message: `Recording encoder ${encoder} is only available on macOS` }); + } else { + checks.push({ id: "encoder", status: "pass", message: `${mode} output mode records with ${encoder}` }); + } + + if (client.hasPendingOutputSettings()) { + checks.push({ + id: "output-settings", + status: "fail", + message: "Output settings were changed through this server since OBS last started; " + + "OBS will ignore them (or fail to start) until it rebuilds its outputs. Restart OBS first", + }); + } + return checks; +} + +async function recordedTracks(client: OBSWebSocketClient): Promise { + if (await profileParameter(client, "Output", "Mode") !== "Advanced") return [1]; + const mask = Number(await profileParameter(client, "AdvOut", "RecTracks") ?? "1"); + const tracks = [1, 2, 3, 4, 5, 6].filter((track) => (mask & (1 << (track - 1))) !== 0); + return tracks.length > 0 ? tracks : [1]; +} + +async function checkAudio(client: OBSWebSocketClient, expectSilent: boolean): Promise { + const [{ inputs }, tracks] = await Promise.all([ + request(client, "GetInputList"), + recordedTracks(client), + ]); + const audible: string[] = []; + + for (const input of Array.isArray(inputs) ? inputs : []) { + if (!isObject(input) || typeof input.inputName !== "string") continue; + const selector = { inputName: input.inputName }; + let muted: unknown; + try { + ({ inputMuted: muted } = await request(client, "GetInputMute", selector)); + } catch { + continue; // Video-only inputs reject audio requests. + } + if (muted === true) continue; + const { inputAudioTracks } = await request(client, "GetInputAudioTracks", selector); + const enabled = isObject(inputAudioTracks) + ? tracks.filter((track) => inputAudioTracks[String(track)] === true) + : tracks; + if (enabled.length > 0) audible.push(`${input.inputName} (track ${enabled.join(", ")})`); + } + + if (audible.length === 0) { + return { id: "audio", status: "pass", message: "No unmuted input feeds a recorded audio track" }; + } + return { + id: "audio", + status: expectSilent ? "fail" : "warn", + message: `Recorded audio comes from: ${audible.join("; ")}. macOS screen_capture inputs carry ` + + "application audio; mute them or clear their tracks for a silent recording", + }; +} + +async function checkSceneItems(client: OBSWebSocketClient): Promise { + const program = await request(client, "GetCurrentProgramScene"); + const sceneName = program.currentProgramSceneName ?? program.sceneName; + if (typeof sceneName !== "string") { + return { id: "scene-items", status: "unknown", message: "OBS did not report the program scene" }; + } + const { sceneItems } = await request(client, "GetSceneItemList", { sceneName }); + const items = (Array.isArray(sceneItems) ? sceneItems : []).filter(isObject); + const blank: string[] = []; + + for (const item of items) { + if (item.sceneItemEnabled === false) continue; + let transform = item.sceneItemTransform; + if (!isObject(transform) && typeof item.sceneItemId === "number") { + ({ sceneItemTransform: transform } = await request(client, "GetSceneItemTransform", { + sceneName, + sceneItemId: item.sceneItemId, + })); + } + if (isObject(transform) && (transform.sourceWidth === 0 || transform.sourceHeight === 0)) { + blank.push(String(item.sourceName ?? item.sceneItemId)); + } + } + + if (items.length === 0) { + return { id: "scene-items", status: "warn", message: `Program scene ${sceneName} is empty` }; + } + return blank.length > 0 + ? { + id: "scene-items", + status: "warn", + message: `Visible items in ${sceneName} render at 0×0: ${blank.join(", ")}. ` + + "A screen capture usually needs a display or window selected (and screen recording permission)", + } + : { id: "scene-items", status: "pass", message: `${items.length} item(s) in ${sceneName} have a size` }; +} + +const SETTINGS_DIALOG_NOTE: PreflightCheck = { + id: "settings-dialog", + status: "unknown", + message: "OBS cannot report open Settings or error dialogs over WebSocket. If a start is accepted but " + + "never becomes active, close OBS Settings or dismiss its error dialog", +}; + +export async function runPreflight( + client: OBSWebSocketClient, + options: PreflightOptions, +): Promise<{ ready: boolean; checks: PreflightCheck[] }> { + try { + await client.connect(); + } catch (error) { + return { + ready: false, + checks: [{ id: "connection", status: "fail", message: `Cannot reach OBS: ${errorMessage(error)}` }], + }; + } + + const groups = await Promise.all([ + runCheck("record-idle", () => checkOutputsIdle(client)), + runCheck("record-directory", () => checkRecordDirectory(client)), + runCheck("disk-space", () => checkDiskSpace(client, options.minFreeDiskMb)), + runCheck("encoder", () => checkEncoder(client)), + runCheck("audio", () => checkAudio(client, options.expectSilent)), + runCheck("scene-items", () => checkSceneItems(client)), + ]); + const checks = [ + { id: "connection", status: "pass" as const, message: "Connected to OBS" }, + ...groups.flat(), + SETTINGS_DIALOG_NOTE, + ]; + return { ready: !checks.some(({ status }) => status === "fail"), checks }; +} + +const STATUS_MARK: Record = { + pass: "ok ", + warn: "warn", + fail: "FAIL", + unknown: "? ", +}; + +export function initialize(server: McpServer, client: OBSWebSocketClient): void { + server.registerTool( + "obs-preflight", + { + title: "Recording Preflight", + description: "Check for the conditions that make OBS silently refuse or botch a recording: an active " + + "recording, an invalid output path, low disk space, an unusable encoder, unapplied output settings, " + + "audible inputs, and blank (0×0) sources. Read-only; run it before obs-start-record", + inputSchema: z.object({ + minFreeDiskMb: z.number().int().nonnegative().default(1024) + .describe("Minimum free disk space in MB (default 1024)"), + expectSilent: z.boolean().default(false) + .describe("Treat any audible input as a failure instead of a warning"), + }), + annotations: READ_ONLY_TOOL, + }, + async ({ minFreeDiskMb, expectSilent }): Promise => { + const result = await runPreflight(client, { minFreeDiskMb, expectSilent }); + const lines = result.checks.map(({ status, id, message }) => `${STATUS_MARK[status]} ${id}: ${message}`); + return { + content: [{ + type: "text", + text: `${result.ready ? "Ready to record" : "Not ready to record"}\n${lines.join("\n")}`, + }], + structuredContent: result, + }; + }, + ); +} diff --git a/src/tools/tool-inventory.test.ts b/src/tools/tool-inventory.test.ts index c09d2c6..84b3de0 100644 --- a/src/tools/tool-inventory.test.ts +++ b/src/tools/tool-inventory.test.ts @@ -33,7 +33,7 @@ describe("tool contract snapshot", () => { .map(({ name }) => name); expect(unsafe).toEqual([]); const readOnlyMutators = contract - .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-sleep$/.test(name)) + .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-(sleep|preflight)$/.test(name)) .map(({ name }) => name); expect(readOnlyMutators).toEqual([]); await expect(`${JSON.stringify(contract, null, 2)}\n`) diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index e011c93..82ee387 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -8,6 +8,7 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli import { describe, expect, it } from "vitest"; import { OBSWebSocketClient } from "../client.js"; import { initialize } from "./index.js"; +import { TOOL_COUNT } from "../../test/support/tool-count.js"; describe("MCP tool inventory", () => { it("publishes deterministic, fully annotated tools through MCP", async () => { @@ -27,7 +28,7 @@ describe("MCP tool inventory", () => { const second = await client.listTools(); const names = first.tools.map(({ name }) => name); - expect(first.tools).toHaveLength(155); + expect(first.tools).toHaveLength(TOOL_COUNT); expect(new Set(names).size).toBe(names.length); expect(second.tools.map(({ name }) => name)).toEqual(names); @@ -80,7 +81,7 @@ describe("MCP tool inventory", () => { expect(client.getProtocolEra()).toBe("modern"); expect(client.getNegotiatedProtocolVersion()).toBe("2026-07-28"); - expect((await client.listTools()).tools).toHaveLength(155); + expect((await client.listTools()).tools).toHaveLength(TOOL_COUNT); } finally { await client.close(); await handler.close(); diff --git a/test/e2e/stdio.e2e.test.ts b/test/e2e/stdio.e2e.test.ts index deb4694..a6272f4 100644 --- a/test/e2e/stdio.e2e.test.ts +++ b/test/e2e/stdio.e2e.test.ts @@ -14,6 +14,7 @@ import { } from "@modelcontextprotocol/client/stdio"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FakeOBSServer } from "../support/fake-obs-server.js"; +import { TOOL_COUNT } from "../support/tool-count.js"; type JsonObject = Record; type JsonRpcMessage = JsonObject & { id?: string | number }; @@ -274,8 +275,8 @@ describe("compiled stdio MCP boundary", () => { expect(client.getProtocolEra()).toBe("modern"); expect(client.getNegotiatedProtocolVersion()).toBe("2026-07-28"); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(155); - expect(new Set(tools.tools.map(({ name }) => name)).size).toBe(155); + expect(tools.tools).toHaveLength(TOOL_COUNT); + expect(new Set(tools.tools.map(({ name }) => name)).size).toBe(TOOL_COUNT); await waitForObsConnected(client); const versionCursor = fake.cursor(); @@ -373,7 +374,7 @@ describe("compiled stdio MCP boundary", () => { await client.connect(transport); mcpClients.push(client); - expect((await client.listTools()).tools).toHaveLength(155); + expect((await client.listTools()).tools).toHaveLength(TOOL_COUNT); const unavailable = await client.callTool({ name: "obs-get-scene-list", arguments: {} }); expect(unavailable.isError).toBe(true); expect(textContent(unavailable)).toContain("Unable to connect to OBS WebSocket server"); @@ -408,7 +409,7 @@ describe("compiled stdio MCP boundary", () => { expect(client.getProtocolEra()).toBe("legacy"); expect(client.getNegotiatedProtocolVersion()).toBe("2025-11-25"); - expect((await client.listTools()).tools).toHaveLength(155); + expect((await client.listTools()).tools).toHaveLength(TOOL_COUNT); await waitForObsConnected(client); const result = await client.callTool({ name: "obs-get-version", arguments: {} }); expect(result.isError).not.toBe(true); @@ -439,7 +440,7 @@ describe("compiled stdio MCP boundary", () => { const tools = await child.response("tools"); expect(tools).toHaveProperty("result.tools"); const result = tools.result; - expect(isObject(result) && Array.isArray(result.tools) ? result.tools : []).toHaveLength(155); + expect(isObject(result) && Array.isArray(result.tools) ? result.tools : []).toHaveLength(TOOL_COUNT); expect(child.invalidStdout).toEqual([]); expect(child.stderr.join("")).not.toContain("Unhandled"); }); diff --git a/test/live/obs-live.test.ts b/test/live/obs-live.test.ts index 2ec954b..7001fe2 100644 --- a/test/live/obs-live.test.ts +++ b/test/live/obs-live.test.ts @@ -99,6 +99,23 @@ describe.skipIf(!enabled)("live OBS through compiled MCP stdio", () => { expect(record.outputActive).toEqual(expect.any(Boolean)); }); + it("runs a read-only recording preflight against real OBS", async () => { + const preflight = await call("obs-preflight"); + const checks = Array.isArray(preflight.checks) ? preflight.checks : []; + + expect(preflight.ready).toEqual(expect.any(Boolean)); + expect(checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "connection", status: "pass" }), + expect.objectContaining({ id: "record-directory" }), + expect.objectContaining({ id: "encoder" }), + ])); + for (const check of checks) { + expect(check, JSON.stringify(check)).toMatchObject({ + status: expect.stringMatching(/^(pass|warn|fail|unknown)$/), + }); + } + }); + it.skipIf(!mutationsEnabled)("creates and removes one scene in the approved collection", async () => { const expectedCollection = process.env.OBS_MCP_LIVE_SCENE_COLLECTION; if (!expectedCollection) { @@ -144,6 +161,8 @@ describe.skipIf(!enabled)("live OBS through compiled MCP stdio", () => { expect(collection.currentSceneCollectionName).toBe(process.env.OBS_MCP_LIVE_SCENE_COLLECTION); const record = await call("obs-get-record-status"); expect(record.outputActive, "Recording must be inactive for the live recording test").toBe(false); + const preflight = await call("obs-preflight"); + expect(preflight.ready, JSON.stringify(preflight.checks)).toBe(true); const started = await call("obs-start-record"); try { diff --git a/test/package/mcpb-artifact.test.ts b/test/package/mcpb-artifact.test.ts index 09d312e..9919654 100644 --- a/test/package/mcpb-artifact.test.ts +++ b/test/package/mcpb-artifact.test.ts @@ -14,6 +14,7 @@ import { unpackExtension, validateManifest } from "@anthropic-ai/mcpb"; import { afterEach, describe, expect, it, vi } from "vitest"; import { listTools } from "../../scripts/list-tools.mjs"; import { FakeOBSServer } from "../support/fake-obs-server.js"; +import { TOOL_COUNT } from "../support/tool-count.js"; type ArtifactManifest = { version: string; @@ -71,8 +72,8 @@ describe("MCPB artifact", () => { const artifactToolNames = manifest.tools.map(({ name }) => name); expect(manifest.version).toBe(packageMetadata.version); - expect(artifactToolNames).toHaveLength(155); - expect(new Set(artifactToolNames).size).toBe(155); + expect(artifactToolNames).toHaveLength(TOOL_COUNT); + expect(new Set(artifactToolNames).size).toBe(TOOL_COUNT); expect(artifactToolNames).toEqual(sourceTools.map(({ name }) => name)); for (const relativePath of [ @@ -111,7 +112,7 @@ describe("MCPB artifact", () => { await client.connect(transport); clients.push(client); - expect((await client.listTools()).tools).toHaveLength(155); + expect((await client.listTools()).tools).toHaveLength(TOOL_COUNT); await waitForObsConnected(client); const version = await client.callTool({ name: "obs-get-version", arguments: {} }); expect(version.isError).not.toBe(true); diff --git a/test/support/fake-obs-server.ts b/test/support/fake-obs-server.ts index ac1232e..a740584 100644 --- a/test/support/fake-obs-server.ts +++ b/test/support/fake-obs-server.ts @@ -196,6 +196,8 @@ export class FakeOBSServer { */ respondWith(requestType: string, responder: FakeOBSResponder): void { this.responders.set(requestType, responder); + // Advertised on the next GetVersion, so wire responders before connecting. + if (!this.availableRequests.includes(requestType)) this.availableRequests.push(requestType); } async waitForFrame( diff --git a/test/support/tool-count.ts b/test/support/tool-count.ts new file mode 100644 index 0000000..2dd8753 --- /dev/null +++ b/test/support/tool-count.ts @@ -0,0 +1,8 @@ +/* + * 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 + */ + +/** Number of tools the server registers with no tool filtering configured. */ +export const TOOL_COUNT = 156;