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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The 159 tools are organized around the OBS protocol rather than a smaller opinio
- 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
- `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-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. While it records, it watches the take (see [Watching a take](#watching-a-take))
- `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, 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
Expand Down Expand Up @@ -157,6 +157,7 @@ Resources give clients that support them a live view without tool calls. Each on
| `obs://scenes` | `GetSceneList` | scenes added, removed, renamed, or switched |
| `obs://scene/{sceneName}/items` | `GetSceneItemList` | items added, removed, reordered, shown, hidden, locked |
| `obs://scene/{sceneName}/screenshot` | 960px JPEG of the scene | (read on demand) |
| `obs://take/current` | the running take: audio levels, skipped frames, black or unchanging picture, chapters, warnings | a warning or chapter |

Clients on the 2025 protocol receive `notifications/resources/updated` for URIs they subscribe to; 2026-07-28 clients choose them on their `subscriptions/listen` stream.

Expand Down Expand Up @@ -186,6 +187,16 @@ Pay particular attention to approvals for tools that:

`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.

### Watching a take

While a take records, the server watches OBS instead of waiting for the file:

- **Audio**: it subscribes to OBS's `InputVolumeMeters` events for the length of the take and records each input's loudest level after volume and mute. An input on a recorded track that rises above −60 dBFS is logged, and with `expectSilent` it is a problem. Muted inputs and inputs on unrecorded tracks are not.
- **Frames**: it polls `GetStats` every 2 seconds and warns when OBS misses frames rendering or the encoder skips them.
- **Picture**: once a second it takes a 32-pixel-wide PPM screenshot of the program scene. Two black samples in a row raise a warning (a capture that lost its window or permission looks like this). Stretches of 3 seconds or more with no change are recorded as stills, which mark dead time to trim.

The result lists the findings, and when OBS records to this machine the server writes them next to the recording as `<name>.take.json`, with chapter times and OBS events (scene switches, mutes, output state) in seconds from the start. `obs://take/current` shows the same data while the take runs.

## Troubleshooting

**Tools appear, but OBS calls fail:** make sure OBS is open and its WebSocket server is enabled. The MCP process stays available while it retries the connection.
Expand Down
42 changes: 42 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,48 @@ describe("OBSWebSocketClient request batches", () => {
});
});

describe("OBSWebSocketClient high-volume events", () => {
it("adds a subscription while anyone holds it and restores it after a reconnect", async () => {
const server = await createServer();
const client = createClient(server);
await client.connect();
expect(server.eventSubscriptions()).toBe(EventSubscription.All);

const first = client.subscribeHighVolume(EventSubscription.InputVolumeMeters);
const second = client.subscribeHighVolume(EventSubscription.InputVolumeMeters);
const withMeters = EventSubscription.All | EventSubscription.InputVolumeMeters;
await vi.waitFor(() => expect(server.eventSubscriptions()).toBe(withMeters));
const reidentifies = () => server.history().filter(({ frame }) => frame.op === OBS_OP.Reidentify).length;
expect(reidentifies()).toBe(1);

first();
first();
expect(reidentifies()).toBe(1);

server.disconnect();
await vi.waitFor(() => expect(client.isConnected()).toBe(false));
await client.connect();
expect(server.eventSubscriptions(2)).toBe(withMeters);

second();
await vi.waitFor(() => expect(server.eventSubscriptions(2)).toBe(EventSubscription.All));
});

it("keeps unapplied output settings flagged when OBS acknowledges a Reidentify", async () => {
const server = await createServer();
const client = createClient(server);
await client.connect();
client.markOutputSettingsPending();

const release = client.subscribeHighVolume(EventSubscription.InputVolumeMeters);
await server.waitForFrame(({ frame }) => frame.op === OBS_OP.Reidentify);
await new Promise((resolve) => setTimeout(resolve, 20));

expect(client.hasPendingOutputSettings()).toBe(true);
release();
});
});

describe("OBSWebSocketClient events and socket ownership", () => {
it("emits both generic and event-specific OBS events", async () => {
const server = await createServer();
Expand Down
52 changes: 50 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export enum EventSubscription {
Ui = 1 << 10,
Canvases = 1 << 11,
All = (1 << 12) - 1,
// High-volume events are left out of All; subscribe with subscribeHighVolume.
InputVolumeMeters = 1 << 16,
InputActiveStateChanged = 1 << 17,
InputShowStateChanged = 1 << 18,
SceneItemTransformChanged = 1 << 19,
}

/**
Expand Down Expand Up @@ -244,6 +249,8 @@ export class OBSWebSocketClient extends EventEmitter {
private availableRequests: Set<string> | null = null;
private versionInfo: VersionResponse | null = null;
private readonly pendingRequests = new Map<string, PendingRequest>();
// How many callers want each high-volume subscription bit.
private readonly highVolumeCounts = new Map<number, number>();

constructor(url = "ws://localhost:4455", password: string | null = null) {
super();
Expand Down Expand Up @@ -283,6 +290,45 @@ export class OBSWebSocketClient extends EventEmitter {
};
}

/** The event subscriptions this client identifies with. */
public eventSubscriptions(): number {
let mask: number = EventSubscription.All;
for (const [bit, count] of this.highVolumeCounts) if (count > 0) mask |= bit;
return mask;
}

/**
* Subscribes to a high-volume event such as InputVolumeMeters until the
* returned function is called. Callers share one subscription per event;
* the last release turns it off. Survives reconnects.
*/
public subscribeHighVolume(event: EventSubscription): () => void {
const before = this.eventSubscriptions();
this.highVolumeCounts.set(event, (this.highVolumeCounts.get(event) ?? 0) + 1);
this.reidentifyIfChanged(before);
let released = false;
return () => {
if (released) return;
released = true;
const previous = this.eventSubscriptions();
const count = (this.highVolumeCounts.get(event) ?? 1) - 1;
if (count > 0) this.highVolumeCounts.set(event, count);
else this.highVolumeCounts.delete(event);
this.reidentifyIfChanged(previous);
};
}

private reidentifyIfChanged(before: number): void {
const eventSubscriptions = this.eventSubscriptions();
const socket = this.ws;
if (eventSubscriptions === before || !this.isConnected() || !socket || socket.readyState !== WebSocket.OPEN) return;
try {
socket.send(JSON.stringify({ op: OpCode.Reidentify, d: { eventSubscriptions } }));
} catch (error) {
logger.error(`Unable to change OBS event subscriptions: ${asError(error).message}`);
}
}

public markOutputSettingsPending(): void {
this.outputSettingsPending = true;
}
Expand Down Expand Up @@ -547,8 +593,10 @@ export class OBSWebSocketClient extends EventEmitter {
logger.error("Ignoring malformed OBS Identified message");
return;
}
// OBS answers Reidentify with Identified too; only a new session means
// OBS may have restarted and rebuilt its outputs.
if (!this.identified) this.outputSettingsPending = false;
this.identified = true;
this.outputSettingsPending = false;
this.emit("identified", socket);
break;
case OpCode.RequestResponse: {
Expand Down Expand Up @@ -631,7 +679,7 @@ export class OBSWebSocketClient extends EventEmitter {
op: OpCode.Identify,
d: {
rpcVersion: hello.rpcVersion,
eventSubscriptions: EventSubscription.All,
eventSubscriptions: this.eventSubscriptions(),
...(authentication ? { authentication } : {}),
},
};
Expand Down
43 changes: 43 additions & 0 deletions src/tools/frame-sample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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 { decodePpm, encodePpm, isBlank, isSameFrame } from "./frame-sample.js";

const dataUri = (buffer: Buffer) => `data:image/ppm;base64,${buffer.toString("base64")}`;

describe("frame samples", () => {
it("decodes a binary PPM from a data URI, including header comments", () => {
const pixels = Buffer.from([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
const ppm = Buffer.concat([Buffer.from("P6\n# made by a test\n2 2\n255\n"), pixels]);

const sample = decodePpm(dataUri(ppm));

expect(sample).toMatchObject({ width: 2, height: 2 });
expect([...sample.luma]).toEqual([54, 182, 18, 255]);
});

it("decodes 16-bit PPMs", () => {
const ppm = Buffer.concat([Buffer.from("P6 1 1 65535\n"), Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff])]);

expect([...decodePpm(ppm).luma]).toEqual([255]);
});

it("rejects other formats and truncated data", () => {
expect(() => decodePpm(Buffer.from("\x89PNG...."))).toThrow("Not a binary PPM");
expect(() => decodePpm(Buffer.from("P6\n4 4\n255\n\x00\x00"))).toThrow("Truncated");
});

it("recognizes black frames and unchanged frames", () => {
const black = decodePpm(encodePpm(4, 4, () => [8, 8, 8]));
const page = decodePpm(encodePpm(4, 4, (pixel) => (pixel % 2 ? [240, 240, 240] : [20, 20, 20])));
const scrolled = decodePpm(encodePpm(4, 4, (pixel) => (pixel % 2 ? [20, 20, 20] : [240, 240, 240])));

expect(isBlank(black)).toBe(true);
expect(isBlank(page)).toBe(false);
expect(isSameFrame(page, decodePpm(encodePpm(4, 4, (pixel) => (pixel % 2 ? [240, 240, 240] : [20, 20, 20]))))).toBe(true);
expect(isSameFrame(page, scrolled)).toBe(false);
});
});
81 changes: 81 additions & 0 deletions src/tools/frame-sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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
*/

/**
* A tiny grayscale copy of a frame. OBS can return screenshots as binary PPM
* (Qt's "ppm" format), which decodes without an image library.
*/
export type FrameSample = { width: number; height: number; luma: Uint8Array };

/** Brightest pixel below this means the frame is black. */
const BLANK_MAX_LUMA = 16;
/** Mean per-pixel change below this means the frame did not change. */
const STILL_MEAN_DELTA = 1;

/** Decodes a binary (P6) PPM, as a buffer or a base64 data URI, into luma. */
export function decodePpm(input: Buffer | string): FrameSample {
const buffer = typeof input === "string"
? Buffer.from(input.slice(input.indexOf(",") + 1), "base64")
: input;

// Header: "P6", width, height, maxval, separated by whitespace and comments,
// then exactly one whitespace byte before the pixels.
const fields: number[] = [];
let offset = 0;
const magic = buffer.subarray(0, 2).toString("ascii");
if (magic !== "P6") throw new Error(`Not a binary PPM (starts with ${JSON.stringify(magic)})`);
offset = 2;
while (fields.length < 3) {
const byte = buffer[offset];
if (byte === undefined) throw new Error("Truncated PPM header");
if (byte === 0x23) { // "#": comment to end of line
while (buffer[offset] !== undefined && buffer[offset] !== 0x0a) offset++;
} else if (byte >= 0x30 && byte <= 0x39) {
let end = offset;
while ((buffer[end] ?? 0) >= 0x30 && (buffer[end] ?? 0) <= 0x39) end++;
fields.push(Number(buffer.subarray(offset, end).toString("ascii")));
offset = end;
continue;
}
offset++;
}
offset++; // the single whitespace byte after maxval

const [width = 0, height = 0, maxValue = 255] = fields;
const bytesPerSample = maxValue > 255 ? 2 : 1;
const pixels = width * height;
if (buffer.length < offset + pixels * 3 * bytesPerSample) throw new Error("Truncated PPM pixel data");

const luma = new Uint8Array(pixels);
const read = bytesPerSample === 1
? (index: number) => buffer[offset + index] ?? 0
: (index: number) => buffer.readUInt16BE(offset + index * 2);
const scale = 255 / maxValue;
for (let pixel = 0; pixel < pixels; pixel++) {
const r = read(pixel * 3), g = read(pixel * 3 + 1), b = read(pixel * 3 + 2);
luma[pixel] = Math.round((0.2126 * r + 0.7152 * g + 0.0722 * b) * scale);
}
return { width, height, luma };
}

export function isBlank(sample: FrameSample): boolean {
return sample.luma.every((value) => value < BLANK_MAX_LUMA);
}

export function isSameFrame(a: FrameSample, b: FrameSample): boolean {
if (a.width !== b.width || a.height !== b.height || a.luma.length === 0) return false;
let total = 0;
for (let index = 0; index < a.luma.length; index++) total += Math.abs((a.luma[index] ?? 0) - (b.luma[index] ?? 0));
return total / a.luma.length < STILL_MEAN_DELTA;
}

/** Builds a binary PPM filled with one color, or with a color per pixel. For tests and fakes. */
export function encodePpm(width: number, height: number, color: (pixel: number) => [number, number, number]): Buffer {
const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii");
const pixels = Buffer.alloc(width * height * 3);
for (let pixel = 0; pixel < width * height; pixel++) pixels.set(color(pixel), pixel * 3);
return Buffer.concat([header, pixels]);
}
2 changes: 1 addition & 1 deletion src/tools/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ async function checkEncoder(client: OBSWebSocketClient): Promise<PreflightCheck[
return checks;
}

async function recordedTracks(client: OBSWebSocketClient): Promise<number[]> {
export async function recordedTracks(client: OBSWebSocketClient): Promise<number[]> {
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);
Expand Down
25 changes: 25 additions & 0 deletions src/tools/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js";
import { TakeMonitor, takeUpdates } from "./take-monitor.js";

const JPEG = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z";

Expand Down Expand Up @@ -47,6 +48,7 @@ describe("resources", () => {
expect(resources.map(({ uri }) => uri)).toEqual(expect.arrayContaining([
"obs://status",
"obs://scenes",
"obs://take/current",
"obs://scene/Demo/items",
"obs://scene/Be%20Right%20Back/items",
]));
Expand Down Expand Up @@ -97,6 +99,28 @@ describe("resources", () => {
expect(updated).toHaveLength(2);
});

it("serves the running take and notifies subscribers when it changes", async () => {
const updated: string[] = [];
harness.mcpClient.setNotificationHandler("notifications/resources/updated", async (notification) => {
updated.push(notification.params.uri);
});
const read = async () => JSON.parse(String(
((await harness.mcpClient.readResource({ uri: "obs://take/current" })).contents[0] as { text: string }).text,
)) as Record<string, unknown>;
expect(await read()).toEqual({ running: false });

await harness.obsClient.connect();
const take = new TakeMonitor(harness.obsClient, { sampleIntervalMs: 60_000, statsIntervalMs: 60_000 });
await take.start();
await harness.mcpClient.subscribeResource({ uri: "obs://take/current" });
take.mark("Intro");

await vi.waitFor(() => expect(updated).toContain("obs://take/current"));
expect(await read()).toMatchObject({ running: true, chapters: [{ name: "Intro" }] });
await take.stop();
expect(await read()).toEqual({ running: false });
});

it("removes its OBS event listeners when the session closes", async () => {
expect(harness.obsClient.listenerCount("RecordStateChanged")).toBeGreaterThan(0);
const { obsClient } = harness;
Expand All @@ -105,6 +129,7 @@ describe("resources", () => {
closed = true;

expect(obsClient.listenerCount("RecordStateChanged")).toBe(0);
expect(takeUpdates.listenerCount("update")).toBe(0);
});
});

Expand Down
Loading
Loading