Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/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 <token>` 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.
Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/obs-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
22 changes: 22 additions & 0 deletions src/state-dir.ts
Original file line number Diff line number Diff line change
@@ -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");
}
15 changes: 14 additions & 1 deletion src/tools/after-change.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>) };
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: [
Expand Down Expand Up @@ -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 };

Expand Down
9 changes: 7 additions & 2 deletions src/tools/after-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -76,25 +77,29 @@ export async function readInputState(
): Promise<InputState> {
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 "
Expand Down
11 changes: 11 additions & 0 deletions src/tools/apply-scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/tools/apply-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -243,11 +244,12 @@ function describe(actions: SceneAction[]): string[] {

async function blankItems(client: OBSWebSocketClient, sceneName: string, names: string[]): Promise<string[]> {
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<CallToolResult> {
Expand Down
29 changes: 29 additions & 0 deletions src/tools/audio-only.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 44 additions & 0 deletions src/tools/audio-only.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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<string[]> {
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);
}
9 changes: 9 additions & 0 deletions src/tools/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions src/tools/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -223,12 +224,13 @@ async function checkSceneItems(client: OBSWebSocketClient): Promise<PreflightChe
const result = looked[index];
return [item, result?.ok && isObject(result.responseData) ? result.responseData.sceneItemTransform : undefined];
}));
const blank = visible
// Audio-only sources (microphones, audio files) are always 0×0.
const blank = await withoutAudioOnly(client, visible
.filter((item) => {
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` };
Expand Down
34 changes: 34 additions & 0 deletions src/tools/snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");

Expand Down
Loading
Loading