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
16 changes: 8 additions & 8 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 158 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 159 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 @@ -106,7 +106,7 @@ The server never prints the password. Connection and protocol diagnostics are wr

## Tool surface

The 158 tools are organized around the OBS protocol rather than a smaller opinionated workflow:
The 159 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
Expand All @@ -118,7 +118,7 @@ The 158 tools are organized around the OBS protocol rather than a smaller opinio
- `obs-record-clip`, which preflights, records up to 50 seconds with chapter markers, stops, and checks the file's length and audio with ffprobe/ffmpeg when they are installed
- `obs-capture-window` (macOS), which points a `screen_capture` input at a window or app by name, silences it, and fits it to the canvas
- input and transform changes that return the resulting settings, size, and an optional screenshot, warning when a source renders at 0脳0
- protocol description and the guarded generic request fallback
- protocol description, the guarded generic request fallback, and `obs-batch`, which sends several requests in one message, in order or one per rendered frame (so a change to two sources lands on the same frame), with `Sleep` between them. OBS's parallel mode is not offered: in obs-websocket 5.7, concurrent parallel batches deadlock its WebSocket server until OBS restarts

Tools that send a single OBS request declare an `outputSchema` generated from the pinned protocol, so clients receive typed `structuredContent` with the documented response fields. Fields are optional and nullable because OBS omits newer fields in older versions and returns undocumented nulls.

Expand All @@ -143,7 +143,7 @@ Every tool is registered by default. Clients load each registered tool's definit
| `outputs` | virtual camera, replay buffer, and generic outputs |
| `config` | profiles, scene collections, video settings, persistent data |
| `ui` | studio mode, dialogs, projectors |
| `protocol` | `obs-describe-request` and the generic `obs-call-request` |
| `protocol` | `obs-describe-request`, the generic `obs-call-request`, and `obs-batch` |

`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (81 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 38-tool inspection-only server. The server refuses to start when a group or tool name is unknown.

Expand All @@ -164,7 +164,7 @@ Two prompts appear as slash commands in clients that show MCP prompts: `record-d

### Confirming live actions

With `OBS_MCP_CONFIRM_LIVE=true`, `obs-stop-stream`, `obs-toggle-stream`, `obs-stop-record`, and `obs-toggle-record` ask before they run. Clients that support elicitation (Claude Code, VS Code, Cursor) show the user a confirmation. Other clients get an error asking the model to get the user's agreement and call again with `confirm: true`. These tools are also marked destructive, so clients that ask before destructive tools already prompt without this setting.
With `OBS_MCP_CONFIRM_LIVE=true`, `obs-stop-stream`, `obs-toggle-stream`, `obs-stop-record`, and `obs-toggle-record` ask before they run, as do `obs-call-request` and `obs-batch` when they carry one of those requests. Clients that support elicitation (Claude Code, VS Code, Cursor) show the user a confirmation. Other clients get an error asking the model to get the user's agreement and call again with `confirm: true`. These tools are also marked destructive, so clients that ask before destructive tools already prompt without this setting.

## Where it fits

Expand All @@ -184,7 +184,7 @@ Pay particular attention to approvals for tools that:
- invoke vendor requests
- save files or use the generic `obs-call-request` fallback

`obs-call-request` accepts only request types in the bundled OBS protocol, but its payload is intentionally generic. It is always advertised as destructive and open-world so a client does not silently treat an unfamiliar operation as safe.
`obs-call-request` and `obs-batch` accept only request types in the bundled OBS protocol, but their payloads are intentionally generic. With `OBS_MCP_CONFIRM_LIVE=true`, they ask before sending `StopStream`, `ToggleStream`, `StopRecord`, or `ToggleRecord`. Both are always advertised as destructive and open-world so a client does not silently treat an unfamiliar operation as safe.

## Troubleshooting

Expand Down Expand Up @@ -215,7 +215,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 158 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 159 tool definitions, and calls the extracted server through MCP against fake OBS.

### Live OBS tests

Expand All @@ -225,7 +225,7 @@ The live suite is separate from `npm run check` and CI. By default it is skipped
OBS_MCP_LIVE_TEST=1 npm run test:obs-live
```

The read-only lane checks the OBS version, scenes, active scene collection, streaming state, and recording state through the compiled MCP stdio server.
The read-only lane checks the OBS version, scenes, active scene collection, streaming state, recording state, resources, and read-only request batches through the compiled MCP stdio server. On the machine running OBS, add `OBS_MCP_READ_OBS_CONFIG=1` instead of exporting the password.

There is also a narrowly bounded mutation test. Use a disposable scene collection, select it in OBS first, and make sure streaming and recording are stopped. Then run:

Expand Down
71 changes: 71 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import WebSocket from "ws";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventSubscription, OBSWebSocketClient } from "./client.js";
import {
FakeOBSRequestError,
FakeOBSServer,
OBS_OP,
type OBSFrame,
Expand Down Expand Up @@ -235,6 +236,76 @@ describe("OBSWebSocketClient requests", () => {
});
});

describe("OBSWebSocketClient request batches", () => {
it("sends one batch message and returns a result per request, including failures", async () => {
const server = await createServer();
server.respondWith("GetInputMute", ({ inputName }) => {
if (inputName === "Camera") throw new FakeOBSRequestError(604, "Camera has no audio");
return { inputMuted: true };
});
const client = createClient(server);

const results = await client.sendBatch([
{ requestType: "GetInputMute", requestData: { inputName: "Mic" } },
{ requestType: "GetInputMute", requestData: { inputName: "Camera" } },
]);

expect(results).toEqual([
{ requestType: "GetInputMute", ok: true, code: 100, responseData: { inputMuted: true } },
{ requestType: "GetInputMute", ok: false, code: 604, comment: "Camera has no audio", responseData: {} },
]);
const batch = server.history().find(({ frame }) => frame.op === OBS_OP.RequestBatch);
expect(batch?.frame.d).toMatchObject({ executionType: 0, haltOnFailure: false });
expect((batch?.frame.d.requests as { requestId: string }[]).map(({ requestId }) => requestId)).toEqual(["0", "1"]);
expect(server.history().filter(({ frame }) => frame.op === OBS_OP.Request && frame.d.batchRequestId)).toHaveLength(2);
});

it("stops at the first failure when asked to", async () => {
const server = await createServer();
server.respondWith("SetInputMute", () => {
throw new FakeOBSRequestError(600, "No such input");
});
server.respondWith("SetInputAudioTracks", () => ({}));
const client = createClient(server);

const results = await client.sendBatch([
{ requestType: "SetInputMute", requestData: { inputName: "x", inputMuted: true } },
{ requestType: "SetInputAudioTracks", requestData: { inputName: "x", inputAudioTracks: {} } },
], { haltOnFailure: true });

expect(results.map(({ ok }) => ok)).toEqual([false]);
});

it("refuses request types OBS does not advertise before sending anything", async () => {
const server = await createServer();
const client = createClient(server);
await client.connect();
const cursor = server.cursor();

await expect(client.sendBatch([{ requestType: "MadeUpRequest" }])).rejects.toThrow("MadeUpRequest");
expect(server.cursor()).toBe(cursor);
});

it("extends the timeout by the time the batch sleeps", async () => {
const server = await createServer();
const client = createClient(server);

const results = await client.sendBatch([
{ requestType: "Sleep", requestData: { sleepMillis: 150 } },
], { timeout: undefined });

expect(results).toEqual([{ requestType: "Sleep", ok: true, code: 100, responseData: {} }]);
});

it("returns no results without contacting OBS for an empty batch", async () => {
const server = await createServer();
const client = createClient(server);

expect(await client.sendBatch([])).toEqual([]);
expect(server.connectionCount).toBe(0);
});
});

describe("OBSWebSocketClient events and socket ownership", () => {
it("emits both generic and event-specific OBS events", async () => {
const server = await createServer();
Expand Down
157 changes: 147 additions & 10 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ export enum EventSubscription {
All = (1 << 12) - 1,
}

/**
* How OBS runs the requests in a batch (RequestBatchExecutionType). Parallel
* (2) is left out on purpose: in obs-websocket 5.7 each parallel batch holds a
* thread-pool thread while waiting for its requests on the same pool, so a few
* at once deadlock the WebSocket server until OBS restarts, and its results
* come back in completion order with the wrong request labels.
*/
export enum RequestBatchExecutionType {
/** One after another, as fast as possible. */
SerialRealtime = 0,
/** One per rendered video frame; Sleep counts frames. */
SerialFrame = 1,
}

export type BatchRequest = { requestType: string; requestData?: unknown };

export type BatchResult = {
requestType: string;
ok: boolean;
code: number;
comment?: string;
responseData: unknown;
};

export type BatchOptions = {
executionType?: RequestBatchExecutionType;
/** Stop at the first failed request; later requests get no result. */
haltOnFailure?: boolean;
/** Defaults to 10 s plus the batch's own Sleep time. */
timeout?: number;
};

type JsonObject = Record<string, unknown>;
type BaseMessage = { op: number; d: JsonObject };

Expand Down Expand Up @@ -140,6 +172,51 @@ function parseRequestResponse(data: JsonObject): RequestResponseData | null {
};
}

type RawBatchResult = BatchResult & { requestId?: string };

function parseBatchResponse(data: JsonObject): { requestId: string; results: RawBatchResult[] } | null {
if (typeof data.requestId !== "string" || !Array.isArray(data.results)) return null;
const results: RawBatchResult[] = [];
for (const result of data.results) {
if (
!isObject(result)
|| typeof result.requestType !== "string"
|| !isObject(result.requestStatus)
|| typeof result.requestStatus.result !== "boolean"
|| typeof result.requestStatus.code !== "number"
) return null;
const { comment } = result.requestStatus;
results.push({
...(typeof result.requestId === "string" ? { requestId: result.requestId } : {}),
requestType: result.requestType,
ok: result.requestStatus.result,
code: result.requestStatus.code,
...(typeof comment === "string" ? { comment } : {}),
responseData: result.responseData ?? {},
});
}
return { requestId: data.requestId, results };
}

/** Orders batch results by the index each request carried as its ID, dropping the IDs. */
function inRequestOrder(results: RawBatchResult[]): BatchResult[] {
const indexed = results.map((result, position) => ({ result, index: result.requestId === undefined ? position : Number(result.requestId) }));
return indexed
.sort((a, b) => a.index - b.index)
.map(({ result: { requestId: _requestId, ...result } }) => result);
}

/** Wall-clock time a batch spends in Sleep requests, assuming 30 fps for frames. */
function batchSleepMillis(requests: readonly BatchRequest[]): number {
let total = 0;
for (const { requestType, requestData } of requests) {
if (requestType !== "Sleep" || !isObject(requestData)) continue;
if (typeof requestData.sleepMillis === "number") total += requestData.sleepMillis;
if (typeof requestData.sleepFrames === "number") total += (requestData.sleepFrames / 30) * 1000;
Comment on lines +209 to +215

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 Base frame-sleep timeouts on OBS's actual frame rate

For a serial-frame batch, sleepFrames is measured at OBS's configured frame rate, but this timeout extension hard-codes 30 fps. At 24 fps, for example, a valid 10,000-frame sleep takes about 417 seconds while the client times out after about 343 seconds; OBS is not cancelled by that local timeout and can subsequently execute the remaining requests after the tool has reported failure. Derive the duration from OBS's video settings or otherwise use a conservative timeout for frame sleeps.

Useful? React with 馃憤聽/ 馃憥.

}
return total;
}

function parseEvent(data: JsonObject): EventData | null {
if (typeof data.eventType !== "string" || typeof data.eventIntent !== "number") return null;
return {
Expand Down Expand Up @@ -242,6 +319,50 @@ export class OBSWebSocketClient extends EventEmitter {
requestData?: unknown,
timeout = 10_000,
): Promise<T> {
const socket = await this.readySocket();
this.assertAdvertised(requestType);
return this.sendTracked<T>(socket, requestType, timeout, (requestId) => ({
op: OpCode.Request,
d: {
requestType,
requestId,
...(requestData === undefined ? {} : { requestData }),
},
}));
}

/**
* Sends several requests in one message. OBS answers each one; a failed
* request does not reject the batch, so check each result's `ok`.
*/
public async sendBatch(requests: readonly BatchRequest[], options: BatchOptions = {}): Promise<BatchResult[]> {
if (requests.length === 0) return [];
const {
executionType = RequestBatchExecutionType.SerialRealtime,
haltOnFailure = false,
timeout = 10_000 + batchSleepMillis(requests),
} = options;
const socket = await this.readySocket();
for (const { requestType } of requests) this.assertAdvertised(requestType);

const results = await this.sendTracked<RawBatchResult[]>(socket, "RequestBatch", timeout, (requestId) => ({
op: OpCode.RequestBatch,
d: {
requestId,
haltOnFailure,
executionType,
// Each request carries its index as its ID, so results are matched by ID, not position.
requests: requests.map(({ requestType, requestData }, index) => ({
requestType,
requestId: String(index),
...(requestData === undefined ? {} : { requestData }),
})),
},
}));
return inRequestOrder(results);
}

private async readySocket(): Promise<WebSocket> {
if (!this.isConnected()) {
try {
await this.connect();
Expand All @@ -257,10 +378,21 @@ export class OBSWebSocketClient extends EventEmitter {
if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error("OBS WebSocket connection was not available after connecting");
}
return socket;
}

private assertAdvertised(requestType: string): void {
if (this.availableRequests && !this.availableRequests.has(requestType)) {
throw new Error(`OBS WebSocket does not advertise support for request '${requestType}'`);
}
}

private sendTracked<T>(
socket: WebSocket,
requestType: string,
timeout: number,
buildMessage: (requestId: string) => BaseMessage,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const requestId = crypto.randomUUID();
const timeoutId = setTimeout(() => {
Expand All @@ -279,17 +411,8 @@ export class OBSWebSocketClient extends EventEmitter {
};
this.pendingRequests.set(requestId, pending);

const message = {
op: OpCode.Request,
d: {
requestType,
requestId,
...(requestData === undefined ? {} : { requestData }),
},
};

try {
socket.send(JSON.stringify(message), (error) => {
socket.send(JSON.stringify(buildMessage(requestId)), (error) => {
if (error) this.rejectPending(requestId, asError(error));
});
} catch (error) {
Expand Down Expand Up @@ -434,6 +557,12 @@ export class OBSWebSocketClient extends EventEmitter {
else logger.error("Ignoring malformed OBS request response");
break;
}
case OpCode.RequestBatchResponse: {
const batch = parseBatchResponse(message.d);
if (batch) this.handleBatchResponse(socket, batch);
else logger.error("Ignoring malformed OBS request batch response");
break;
}
case OpCode.Event: {
const event = parseEvent(message.d);
if (event) this.handleEvent(event);
Expand Down Expand Up @@ -462,6 +591,14 @@ export class OBSWebSocketClient extends EventEmitter {
));
}

private handleBatchResponse(socket: WebSocket, batch: { requestId: string; results: RawBatchResult[] }): void {
const pending = this.pendingRequests.get(batch.requestId);
if (!pending || pending.socket !== socket) return;
this.pendingRequests.delete(batch.requestId);
clearTimeout(pending.timeout);
pending.resolve(batch.results);
}

private handleEvent(event: EventData): void {
this.emit("event", event.eventType, event.eventData);
this.emit(event.eventType, event.eventData);
Expand Down
Loading
Loading