From b37e4a51b3a127c19786d758618886dd017d1cc6 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Thu, 24 Sep 2026 00:21:09 -0400 Subject: [PATCH] Fix two problems found live: audio-only 0x0 warnings, and lost snapshots Microphones, desktop audio, and media sources playing an audio file are always 0x0, but obs-preflight, obs-apply-scene, and the read-back after an input change warned about them, and the read-back waited a second for them to get a size. A shared rendersNoVideo check now skips them. Snapshots lived in the server's memory, so obs-restore could not undo a change once that server exited, which is what happened in the live test. They are now saved to the state directory (OBS_MCP_STATE_DIR, or the platform's app data folder), owner-readable only, the last 20, and listed per OBS instance. Unit tests get a fresh state directory each. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 3 +- src/obs-config.test.ts | 11 +++++++ src/state-dir.ts | 22 +++++++++++++ src/tools/after-change.test.ts | 15 ++++++++- src/tools/after-change.ts | 9 ++++-- src/tools/apply-scene.test.ts | 11 +++++++ src/tools/apply-scene.ts | 6 ++-- src/tools/audio-only.test.ts | 29 +++++++++++++++++ src/tools/audio-only.ts | 44 ++++++++++++++++++++++++++ src/tools/preflight.test.ts | 9 ++++++ src/tools/preflight.ts | 6 ++-- src/tools/snapshots.test.ts | 34 ++++++++++++++++++++ src/tools/snapshots.ts | 58 ++++++++++++++++++++++++++-------- test/support/fake-obs-state.ts | 1 + test/support/isolated-state.ts | 24 ++++++++++++++ vitest.config.ts | 1 + 16 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 src/state-dir.ts create mode 100644 src/tools/audio-only.test.ts create mode 100644 src/tools/audio-only.ts create mode 100644 test/support/isolated-state.ts diff --git a/README.md b/README.md index b6f6a8d..10a02ba 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the | `OBS_MCP_CONFIRM_LIVE` | No | `false` | Ask before stopping or toggling a stream or recording (see [Confirming live actions](#confirming-live-actions)) | | `OBS_MCP_HTTP_PORT` | No | none (stdio) | Serve MCP over Streamable HTTP at `http://127.0.0.1:/mcp` instead of stdio, so several agents share one server (see [Sharing one server](#sharing-one-server)); `0` picks a free port | | `OBS_MCP_HTTP_TOKEN` | No | none | With `OBS_MCP_HTTP_PORT`, require `Authorization: Bearer ` on every request | +| `OBS_MCP_STATE_DIR` | No | `~/Library/Application Support/obs-mcp` (macOS), `%LOCALAPPDATA%\obs-mcp` (Windows), `$XDG_STATE_HOME/obs-mcp` (Linux) | Where snapshots are saved | | `OBS_MCP_LOG_LEVEL` | No | `info` | Diagnostic output on stderr: `debug`, `info`, `error`, or `silent` | The server never prints the password. Connection and protocol diagnostics are written to stderr so stdout remains reserved for MCP messages. @@ -249,7 +250,7 @@ Without `apply: true` it returns the plan. When applying, it creates what is mis `obs-snapshot` saves the settings, mute, volume, and audio tracks of inputs, and the transform, visibility, lock, and order of scene items: by default the program scene and the inputs in it. `obs-restore` compares OBS with a snapshot and sends only what differs, in one request batch; `dryRun: true` lists the changes first. It cannot recreate a removed input or item, and leaves items added since in place; it reports both. `obs-capture-window` takes a snapshot automatically before it changes an existing capture. -Snapshots are kept in the server's memory, the last 10, and are lost when it restarts. OBS's own scene collection file is not a substitute: OBS rewrites it while running. +Snapshots are saved in the server's state directory (the last 20, readable only by you), so `obs-restore` works after a restart and from another agent's session. Each server lists only the snapshots of the OBS instance it connects to. They contain input settings, such as file paths and browser source URLs. OBS's own scene collection file is not a substitute: OBS rewrites it while running. ### Sharing one server diff --git a/src/obs-config.test.ts b/src/obs-config.test.ts index 4c8c98f..308ec61 100644 --- a/src/obs-config.test.ts +++ b/src/obs-config.test.ts @@ -101,3 +101,14 @@ describe("resolveObsPassword", () => { expect(logged()).toContain("/a.json, /b.json"); }); }); + +describe("stateDirectory", () => { + it("follows OBS_MCP_STATE_DIR, then each platform's app data folder", async () => { + const { stateDirectory } = await import("./state-dir.js"); + + expect(stateDirectory({ OBS_MCP_STATE_DIR: "/custom" }, "darwin", "/Users/me")).toBe("/custom"); + expect(stateDirectory({}, "darwin", "/Users/me")).toBe("/Users/me/Library/Application Support/obs-mcp"); + expect(stateDirectory({ XDG_STATE_HOME: "/state" }, "linux", "/home/me")).toBe("/state/obs-mcp"); + expect(stateDirectory({}, "linux", "/home/me")).toBe("/home/me/.local/state/obs-mcp"); + }); +}); diff --git a/src/state-dir.ts b/src/state-dir.ts new file mode 100644 index 0000000..973a0b4 --- /dev/null +++ b/src/state-dir.ts @@ -0,0 +1,22 @@ +/* + * 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 { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Where the server keeps state that should outlive one process, such as + * snapshots: OBS_MCP_STATE_DIR, or the platform's per-user app data folder. + */ +export function stateDirectory( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + home = homedir(), +): string { + if (env.OBS_MCP_STATE_DIR) return env.OBS_MCP_STATE_DIR; + if (platform === "darwin") return join(home, "Library", "Application Support", "obs-mcp"); + if (platform === "win32") return join(env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "obs-mcp"); + return join(env.XDG_STATE_HOME || join(home, ".local", "state"), "obs-mcp"); +} diff --git a/src/tools/after-change.test.ts b/src/tools/after-change.test.ts index 41df8e8..5e1683e 100644 --- a/src/tools/after-change.test.ts +++ b/src/tools/after-change.test.ts @@ -12,19 +12,21 @@ const PNG_1X1 = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEB let harness: McpHarness; let size: { sourceWidth: number; sourceHeight: number }; let settings: Record; +let inputKind: string; beforeEach(async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); harness = await startMcpHarness(); size = { sourceWidth: 1512, sourceHeight: 949 }; settings = { type: 1, window: 31875 }; + inputKind = "screen_capture"; 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("GetInputSettings", () => ({ inputKind, inputSettings: settings })); fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); fakeObs.respondWith("GetSceneItemList", () => ({ sceneItems: [ @@ -66,6 +68,17 @@ describe("obs-set-input-settings", () => { expect(result.structuredContent).toMatchObject({ appearances: [{ sourceWidth: 1512 }], warnings: [] }); }); + it("does not wait for or warn about an audio-only input's 0×0 size", async () => { + size = { sourceWidth: 0, sourceHeight: 0 }; + inputKind = "coreaudio_input_capture"; + const started = Date.now(); + + const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { device_id: "default" } }); + + expect(resultText(result)).not.toContain("renders at 0×0"); + expect(Date.now() - started).toBeLessThan(500); + }); + it("warns when the input still renders at 0×0", async () => { size = { sourceWidth: 0, sourceHeight: 0 }; diff --git a/src/tools/after-change.ts b/src/tools/after-change.ts index 92f9d8a..eab9c0b 100644 --- a/src/tools/after-change.ts +++ b/src/tools/after-change.ts @@ -5,6 +5,7 @@ */ import type { CallToolResult } from "@modelcontextprotocol/server"; import type { OBSWebSocketClient } from "../client.js"; +import { rendersNoVideo } from "./audio-only.js"; import { screenshotResult } from "./screenshot.js"; type JsonObject = Record; @@ -76,25 +77,29 @@ export async function readInputState( ): Promise { const warnings: string[] = []; let inputSettings: JsonObject = {}; + let inputKind: unknown; try { const response: unknown = await client.sendRequest("GetInputSettings", { inputName }); if (isObject(response) && isObject(response.inputSettings)) inputSettings = response.inputSettings; + if (isObject(response)) inputKind = response.inputKind; } catch (error) { warnings.push(`Could not read the input back: ${errorMessage(error)}`); } + // A microphone or an audio file is 0×0 by nature; there is nothing to wait for or warn about. + const noVideo = rendersNoVideo(inputKind, inputSettings); 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 (noVideo || !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)) { + if (!noVideo && 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 " diff --git a/src/tools/apply-scene.test.ts b/src/tools/apply-scene.test.ts index e941412..8581fde 100644 --- a/src/tools/apply-scene.test.ts +++ b/src/tools/apply-scene.test.ts @@ -135,6 +135,17 @@ describe("obs-apply-scene", () => { expect(sent()).toEqual([]); }); + it("does not warn about an audio file, which has no picture", async () => { + const result = await harness.call("obs-apply-scene", { + sceneName: "Demo", + sources: [{ name: "Test Tone", kind: "ffmpeg_source", settings: { is_local_file: true, local_file: "/tmp/tone.wav" }, muted: true }], + apply: true, + }); + + expect(resultText(result)).not.toContain("0×0"); + expect(result.structuredContent).toMatchObject({ blank: [] }); + }); + it("warns when a source renders at 0×0", async () => { const result = await harness.call("obs-apply-scene", { sceneName: "Demo", diff --git a/src/tools/apply-scene.ts b/src/tools/apply-scene.ts index 4401023..0caaff0 100644 --- a/src/tools/apply-scene.ts +++ b/src/tools/apply-scene.ts @@ -6,6 +6,7 @@ import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import type { BatchRequest, BatchResult, OBSWebSocketClient } from "../client.js"; +import { withoutAudioOnly } from "./audio-only.js"; import { readInputs, readItems, same, takeSnapshot, type InputState, type ItemState } from "./snapshots.js"; type JsonObject = Record; @@ -243,11 +244,12 @@ function describe(actions: SceneAction[]): string[] { async function blankItems(client: OBSWebSocketClient, sceneName: string, names: string[]): Promise { const items = await client.sendRequest("GetSceneItemList", { sceneName }) as JsonObject; - return (Array.isArray(items.sceneItems) ? items.sceneItems : []) + const blank = (Array.isArray(items.sceneItems) ? items.sceneItems : []) .filter(isObject) .filter((item) => names.includes(String(item.sourceName)) && item.sceneItemEnabled !== false) .filter((item) => isObject(item.sceneItemTransform) && (item.sceneItemTransform.sourceWidth === 0 || item.sceneItemTransform.sourceHeight === 0)) - .map((item) => String(item.sourceName)); + .map((item) => ({ name: String(item.sourceName), inputKind: item.inputKind })); + return withoutAudioOnly(client, blank); } export async function applyScene(client: OBSWebSocketClient, spec: SceneSpec, apply: boolean): Promise { diff --git a/src/tools/audio-only.test.ts b/src/tools/audio-only.test.ts new file mode 100644 index 0000000..2df88ec --- /dev/null +++ b/src/tools/audio-only.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { describe, expect, it } from "vitest"; +import { rendersNoVideo } from "./audio-only.js"; + +describe("rendersNoVideo", () => { + it.each([ + "coreaudio_input_capture", "coreaudio_output_capture", "sck_audio_capture", + "wasapi_input_capture", "wasapi_output_capture", "wasapi_process_output_capture", + "pulse_input_capture", "pulse_output_capture", "alsa_input_capture", "jack_output_capture", + ])("treats %s as audio-only", (kind) => { + expect(rendersNoVideo(kind)).toBe(true); + }); + + it.each(["screen_capture", "window_capture", "display_capture", "game_capture", "av_capture_input", "browser_source", "image_source"])( + "treats %s as visual", + (kind) => expect(rendersNoVideo(kind)).toBe(false), + ); + + it("treats a media source as audio-only only when it plays an audio file", () => { + expect(rendersNoVideo("ffmpeg_source", { local_file: "/music/Tone.WAV" })).toBe(true); + expect(rendersNoVideo("vlc_source", { local_file: "/clips/intro.m4a" })).toBe(true); + expect(rendersNoVideo("ffmpeg_source", { local_file: "/clips/intro.mp4" })).toBe(false); + expect(rendersNoVideo("ffmpeg_source")).toBe(false); + }); +}); diff --git a/src/tools/audio-only.ts b/src/tools/audio-only.ts new file mode 100644 index 0000000..c0c7017 --- /dev/null +++ b/src/tools/audio-only.ts @@ -0,0 +1,44 @@ +/* + * Modified for the independent OBS MCP project in August 2026. + * See NOTICE.md and Git history for authorship and change dates. + * SPDX-License-Identifier: GPL-2.0-only + */ +import type { OBSWebSocketClient } from "../client.js"; + +type JsonObject = Record; + +/** Audio capture kinds on every platform: always 0×0, by design. */ +const AUDIO_CAPTURE_KIND = /(^|_)(input|output|process_output)_capture$|^sck_audio_capture$|^jack_/; +/** Media sources render nothing when their file has no video. */ +const MEDIA_KINDS = new Set(["ffmpeg_source", "vlc_source"]); +const AUDIO_FILE = /\.(wav|mp3|m4a|aac|flac|ogg|oga|opus|aiff?|wma|caf)$/i; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Whether a source has no picture by nature, so a 0×0 size is not a + * problem: an audio capture, or a media source playing an audio file. + */ +export function rendersNoVideo(inputKind: unknown, inputSettings?: JsonObject): boolean { + if (typeof inputKind !== "string") return false; + if (AUDIO_CAPTURE_KIND.test(inputKind)) return true; + const file = inputSettings?.local_file; + return MEDIA_KINDS.has(inputKind) && typeof file === "string" && AUDIO_FILE.test(file); +} + +/** Drops the 0×0 items that are audio-only; reads settings only for media sources. */ +export async function withoutAudioOnly( + client: OBSWebSocketClient, + blank: { name: string; inputKind: unknown }[], +): Promise { + const media = blank.filter(({ inputKind }) => typeof inputKind === "string" && MEDIA_KINDS.has(inputKind)); + const results = await client.sendBatch(media.map(({ name }) => ({ requestType: "GetInputSettings", requestData: { inputName: name } }))) + .catch(() => []); + const settings = new Map(media.map(({ name }, index) => { + const result = results[index]; + return [name, result?.ok && isObject(result.responseData) && isObject(result.responseData.inputSettings) ? result.responseData.inputSettings : undefined]; + })); + return blank.filter(({ name, inputKind }) => !rendersNoVideo(inputKind, settings.get(name))).map(({ name }) => name); +} diff --git a/src/tools/preflight.test.ts b/src/tools/preflight.test.ts index ee6ed3c..7652713 100644 --- a/src/tools/preflight.test.ts +++ b/src/tools/preflight.test.ts @@ -151,6 +151,15 @@ describe("obs-preflight", () => { expect(byId("scene-items")).toMatchObject({ status: "warn", message: expect.stringContaining("Chrome") }); }); + it("ignores audio-only sources, which are always 0×0", async () => { + state.inputs.push({ inputName: "Mic", inputKind: "coreaudio_input_capture", muted: true, tracks: { 1: true } }); + state.sceneItems.push({ sceneItemId: 3, sourceName: "Mic", sceneItemEnabled: true, sourceWidth: 0, sourceHeight: 0 }); + + const { byId } = await preflight(); + + expect(byId("scene-items")?.status).toBe("pass"); + }); + it("ignores hidden 0×0 sources", async () => { state.sceneItems[1] = { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: false, sourceWidth: 0, sourceHeight: 0 }; const { byId } = await preflight(); diff --git a/src/tools/preflight.ts b/src/tools/preflight.ts index 215ecf1..fd065d6 100644 --- a/src/tools/preflight.ts +++ b/src/tools/preflight.ts @@ -7,6 +7,7 @@ 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 { withoutAudioOnly } from "./audio-only.js"; import { READ_ONLY_TOOL } from "./request-tool.js"; export type PreflightStatus = "pass" | "warn" | "fail" | "unknown"; @@ -223,12 +224,13 @@ async function checkSceneItems(client: OBSWebSocketClient): Promise { const transform = isObject(item.sceneItemTransform) ? item.sceneItemTransform : transforms.get(item); return isObject(transform) && (transform.sourceWidth === 0 || transform.sourceHeight === 0); }) - .map((item) => String(item.sourceName ?? item.sceneItemId)); + .map((item) => ({ name: String(item.sourceName ?? item.sceneItemId), inputKind: item.inputKind }))); if (items.length === 0) { return { id: "scene-items", status: "warn", message: `Program scene ${sceneName} is empty` }; diff --git a/src/tools/snapshots.test.ts b/src/tools/snapshots.test.ts index 292df56..46ea36b 100644 --- a/src/tools/snapshots.test.ts +++ b/src/tools/snapshots.test.ts @@ -3,6 +3,8 @@ * See NOTICE.md and Git history for authorship and change dates. * SPDX-License-Identifier: GPL-2.0-only */ +import { readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OBS_OP } from "../../test/support/fake-obs-server.js"; import { demoSceneState, serveSceneState, type FakeSceneInput, type FakeSceneItem } from "../../test/support/fake-obs-scene.js"; @@ -102,6 +104,38 @@ describe("obs-snapshot and obs-restore", () => { expect(resultText(await harness.call("obs-restore", { snapshotId: "nope" }))).toContain("No snapshot nope"); }); + it("keeps snapshots on disk, so a new server can restore them", async () => { + const taken = await harness.call("obs-snapshot", { label: "before restart" }); + const snapshotId = (taken.structuredContent as { snapshotId: string }).snapshotId; + const port = Number(new URL(harness.fakeObs.url).port); + await harness.close(); + + // A new server process, reaching the same OBS at the same address. + const state = demoSceneState(); + state.inputs.Chrome!.muted = false; + inputs = state.inputs; + items = state.scenes.Demo!; + harness = await startMcpHarness({ port }); + serveSceneState(harness.fakeObs, state); + + const restored = await harness.call("obs-restore", { snapshotId }); + expect(resultText(restored)).toContain("input Chrome: mute"); + expect(state.inputs.Chrome!.muted).toBe(true); + expect(statSync(join(process.env.OBS_MCP_STATE_DIR!, "snapshots.json")).mode & 0o777).toBe(0o600); + }); + + it("lists only snapshots of the OBS instance this server connects to", async () => { + await harness.call("obs-snapshot", { label: "this OBS" }); + const file = join(process.env.OBS_MCP_STATE_DIR!, "snapshots.json"); + const saved = JSON.parse(readFileSync(file, "utf8")) as { obsUrl: string }[]; + writeFileSync(file, JSON.stringify([...saved, { ...saved[0], id: "elsewhere", obsUrl: "ws://studio-pc:4455" }])); + + const list = await harness.call("obs-snapshot", { list: true }); + + expect(resultText(list)).toContain('"this OBS"'); + expect(resultText(list)).not.toContain("elsewhere"); + }); + it("refuses to restore before any snapshot", async () => { const result = await harness.call("obs-restore"); diff --git a/src/tools/snapshots.ts b/src/tools/snapshots.ts index 83c109f..a27868e 100644 --- a/src/tools/snapshots.ts +++ b/src/tools/snapshots.ts @@ -4,9 +4,13 @@ * SPDX-License-Identifier: GPL-2.0-only */ import crypto from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { CallToolResult, McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import type { BatchRequest, OBSWebSocketClient } from "../client.js"; +import { logger } from "../logger.js"; +import { stateDirectory } from "../state-dir.js"; type JsonObject = Record; @@ -33,6 +37,8 @@ export type ItemState = { export type Snapshot = { id: string; + /** The OBS WebSocket URL the snapshot was taken from. */ + obsUrl?: string; label?: string; takenAt: string; inputs: InputState[]; @@ -42,7 +48,9 @@ export type Snapshot = { /** A change obs-restore would make, and the request that makes it. */ export type RestoreChange = { target: string; field: string; request: BatchRequest }; -const MAX_SNAPSHOTS = 10; +/** Kept on disk across every OBS instance, newest last. */ +const MAX_SNAPSHOTS = 20; +const SNAPSHOT_FILE = "snapshots.json"; /** The transform fields SetSceneItemTransform accepts; the rest are computed by OBS. */ const WRITABLE_TRANSFORM = [ @@ -78,15 +86,40 @@ function pick(object: JsonObject, keys: string[]): JsonObject { return Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]])); } -const snapshotsByClient = new WeakMap(); +/** + * Snapshots are saved in the state directory so obs-restore works after the + * server restarts or from another agent's session. The file holds input + * settings, so it is readable only by its owner. + */ +function snapshotFile(): string { + return join(stateDirectory(), SNAPSHOT_FILE); +} -function snapshots(client: OBSWebSocketClient): Snapshot[] { - let list = snapshotsByClient.get(client); - if (!list) { - list = []; - snapshotsByClient.set(client, list); +function loadAll(): Snapshot[] { + try { + const parsed: unknown = JSON.parse(readFileSync(snapshotFile(), "utf8")); + return Array.isArray(parsed) ? parsed.filter((entry): entry is Snapshot => isObject(entry) && typeof entry.id === "string") : []; + } catch { + return []; + } +} + +function saveAll(list: Snapshot[]): void { + const path = snapshotFile(); + try { + mkdirSync(stateDirectory(), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(list.slice(-MAX_SNAPSHOTS), null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, path); + } catch (error) { + logger.error(`Could not save snapshots to ${path}: ${error instanceof Error ? error.message : String(error)}`); } - return list; +} + +/** Snapshots of the OBS instance this client connects to, oldest first. */ +function snapshots(client: OBSWebSocketClient): Snapshot[] { + const url = client.getConnectionStatus().url; + return loadAll().filter(({ obsUrl }) => obsUrl === undefined || obsUrl === url); } async function batch(client: OBSWebSocketClient, requests: BatchRequest[]) { @@ -169,14 +202,13 @@ export async function takeSnapshot( } const snapshot: Snapshot = { id: crypto.randomUUID().slice(0, 8), + obsUrl: client.getConnectionStatus().url, ...(options.label ? { label: options.label } : {}), takenAt: new Date().toISOString(), inputs: await readInputs(client, inputNames), items, }; - const list = snapshots(client); - list.push(snapshot); - if (list.length > MAX_SNAPSHOTS) list.shift(); + saveAll([...loadAll(), snapshot]); return snapshot; } @@ -272,8 +304,8 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void title: "Snapshot Scene State", description: "Save the current settings, mute, volume, and audio tracks of inputs, and the transform, " + "visibility, lock, and order of scene items, so obs-restore can undo later changes. Defaults to the " - + "program scene and the inputs in it. Snapshots live in this server's memory (the last 10); call with " - + "list: true to see them", + + "program scene and the inputs in it. Snapshots are saved on disk (the last 20) and survive restarts; call " + + "with list: true to see them", inputSchema: z.object({ scenes: z.array(z.string()).optional().describe("Scenes whose items to save"), inputs: z.array(z.string()).optional().describe("Inputs to save; defaults to the inputs in the saved scenes"), diff --git a/test/support/fake-obs-state.ts b/test/support/fake-obs-state.ts index 732e830..ad7f28f 100644 --- a/test/support/fake-obs-state.ts +++ b/test/support/fake-obs-state.ts @@ -79,6 +79,7 @@ export function servePreflightState(fakeObs: FakeOBSServer, getState: () => Fake fakeObs.respondWith("GetSceneItemList", () => ({ sceneItems: getState().sceneItems.map(({ sourceWidth, sourceHeight, ...item }) => ({ ...item, + inputKind: getState().inputs.find(({ inputName }) => inputName === item.sourceName)?.inputKind, sceneItemTransform: { sourceWidth, sourceHeight }, })), })); diff --git a/test/support/isolated-state.ts b/test/support/isolated-state.ts new file mode 100644 index 0000000..b85eaca --- /dev/null +++ b/test/support/isolated-state.ts @@ -0,0 +1,24 @@ +/* + * 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 } from "vitest"; + +// Each unit test gets its own state directory, so saved snapshots never reach +// the user's real one or leak between tests. +let directory: string | undefined; + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "obs-mcp-state-")); + process.env.OBS_MCP_STATE_DIR = directory; +}); + +afterEach(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); + directory = undefined; + delete process.env.OBS_MCP_STATE_DIR; +}); diff --git a/vitest.config.ts b/vitest.config.ts index c73b154..eec6dcb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts", "test/unit/**/*.test.ts"], exclude: ["test/e2e/**", "test/package/**", "test/live/**"], + setupFiles: ["test/support/isolated-state.ts"], }, });