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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -101,14 +101,15 @@ 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
- input settings, audio tracks, volume, mute state, media controls, and deinterlacing
- 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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;
private availableRequests: Set<string> | null = null;
private versionInfo: VersionResponse | null = null;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -413,6 +425,7 @@ export class OBSWebSocketClient extends EventEmitter {
return;
}
this.identified = true;
this.outputSettingsPending = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending settings across transient reconnects

When the WebSocket reconnects without OBS restarting鈥攆or example after a network interruption or toggling the WebSocket server鈥攖his clears outputSettingsPending even though OBS has not rebuilt its outputs. A subsequent obs-preflight therefore omits the output-settings failure and can report ready while the profile change is still unapplied; the flag should only be cleared in response to a confirmed output rebuild or an explicit acknowledgement.

Useful? React with 馃憤聽/ 馃憥.

this.emit("identified", socket);
break;
case OpCode.RequestResponse: {
Expand Down
28 changes: 28 additions & 0 deletions src/tools/__snapshots__/tool-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/tools/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track output changes made through the generic request tool

When a caller invokes SetProfileParameter through obs-call-request, src/tools/protocol.ts sends the request directly and bypasses this sole call to markOutputSettingsPending. The generic tool can therefore change Output, SimpleOutput, or AdvOut settings and the next preflight will incorrectly consider them applied; track this mutation in the shared request path or in the generic handler as well.

Useful? React with 馃憤聽/ 馃憥.

const warning = OUTPUT_PARAMETER_CATEGORIES.has(parameterCategory)
? ". Warning: OBS applies output settings only when it rebuilds its outputs; restart OBS before starting an output"
: "";
Expand Down
5 changes: 4 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -60,5 +62,6 @@ export {
record,
ui,
protocolExtensions,
protocol
protocol,
preflight
};
237 changes: 237 additions & 0 deletions src/tools/preflight.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
};

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<string, string>;
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<string, unknown>): 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<string, unknown> = {}) {
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" })]);
});
});
Loading
Loading