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
15 changes: 9 additions & 6 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 156 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 158 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 @@ -104,7 +104,7 @@ The server never prints the password. Connection and protocol diagnostics are wr

## Tool surface

The 156 tools are organized around the OBS protocol rather than a smaller opinionated workflow:
The 158 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 @@ -113,6 +113,9 @@ The 156 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-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

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 @@ -129,18 +132,18 @@ Every tool is registered by default. Clients load each registered tool's definit
| `scenes` | scenes, canvases, program and preview scene |
| `scene-items` | scene items, groups, transforms, ordering, locking, blend modes |
| `sources` | source screenshots and active state |
| `inputs` | inputs, input settings and properties, audio, deinterlacing |
| `inputs` | inputs, input settings and properties, audio, deinterlacing, and `obs-capture-window` |
| `media` | media input playback |
| `filters` | source filters |
| `transitions` | transitions, overrides, the T-Bar |
| `record` | recording, chapters, and `obs-preflight` |
| `record` | recording, chapters, `obs-preflight`, and `obs-record-clip` |
| `stream` | streaming and captions |
| `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` |

`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (79 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.
`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.

## Where it fits

Expand Down Expand Up @@ -191,7 +194,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 156 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 158 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 156 current, annotated MCP tools",
"description": "Control OBS Studio through 158 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
117 changes: 117 additions & 0 deletions src/tools/__snapshots__/tool-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,61 @@
"openWorldHint": true
}
},
{
"name": "obs-capture-window",
"title": "Capture Window (macOS)",
"inputSchema": {
"type": "object",
"properties": {
"sceneName": {
"type": "string",
"description": "Scene to add the input to if it does not exist"
},
"inputName": {
"type": "string",
"description": "Name of the screen_capture input to create or reuse"
},
"window": {
"description": "Case-insensitive part of \"[App] Title\", or the window ID",
"type": "string"
},
"application": {
"description": "Case-insensitive part of the app name, or its bundle ID",
"type": "string"
},
"displayUuid": {
"description": "Display UUID; defaults to the first display OBS lists",
"type": "string"
},
"silent": {
"default": true,
"description": "Mute the capture and remove it from every audio track",
"type": "boolean"
},
"fit": {
"default": true,
"description": "Scale the capture to fit the canvas, centered",
"type": "boolean"
},
"includeScreenshot": {
"default": false,
"description": "Also return a screenshot of the capture",
"type": "boolean"
}
},
"required": [
"sceneName",
"inputName"
],
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
"name": "obs-create-input",
"title": "Create Input",
Expand Down Expand Up @@ -140,6 +195,10 @@
"sceneItemEnabled": {
"description": "Whether to set the created scene item to enabled or disabled",
"type": "boolean"
},
"includeScreenshot": {
"description": "Also return a screenshot of the input after creating it",
"type": "boolean"
}
},
"required": [
Expand Down Expand Up @@ -4403,6 +4462,60 @@
"openWorldHint": false
}
},
{
"name": "obs-record-clip",
"title": "Record Clip",
"inputSchema": {
"type": "object",
"properties": {
"durationSeconds": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 50,
"description": "Clip length in seconds"
},
"chapters": {
"default": [],
"description": "Chapter markers; need the Hybrid MP4 or Hybrid MOV recording format",
"type": "array",
"items": {
"type": "object",
"properties": {
"atSeconds": {
"type": "number",
"minimum": 0,
"description": "Seconds after the start"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Chapter name"
}
},
"required": [
"atSeconds",
"name"
]
}
},
"expectSilent": {
"default": false,
"description": "Refuse to record with audible inputs, and check the file's audio is silent",
"type": "boolean"
}
},
"required": [
"durationSeconds"
],
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}
},
{
"name": "obs-remove-input",
"title": "Remove Input",
Expand Down Expand Up @@ -5327,6 +5440,10 @@
"overlay": {
"description": "True to apply settings on top of existing ones, False to reset to defaults first",
"type": "boolean"
},
"includeScreenshot": {
"description": "Also return a screenshot of the input after the change",
"type": "boolean"
}
},
"required": [
Expand Down
138 changes: 138 additions & 0 deletions src/tools/after-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 { 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";

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

let harness: McpHarness;
let size: { sourceWidth: number; sourceHeight: number };
let settings: Record<string, unknown>;

beforeEach(async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
harness = await startMcpHarness();
size = { sourceWidth: 1512, sourceHeight: 949 };
settings = { type: 1, window: 31875 };
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("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" }));
fakeObs.respondWith("GetSceneItemList", () => ({
sceneItems: [
{ sceneItemId: 1, sourceName: "Background", sceneItemEnabled: true, sceneItemTransform: { sourceWidth: 1920, sourceHeight: 1080 } },
{ sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sceneItemTransform: { ...size } },
],
}));
fakeObs.respondWith("GetSourceScreenshot", () => ({ imageData: PNG_1X1 }));
fakeObs.respondWith("SetSceneItemTransform", () => ({}));
fakeObs.respondWith("GetSceneItemTransform", () => ({
sceneItemTransform: { ...size, positionX: 10, cropTop: 372 },
}));
});

afterEach(async () => {
await harness.close();
vi.restoreAllMocks();
});

describe("obs-set-input-settings", () => {
it("returns the resulting settings and the input's size in the program scene", async () => {
const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { show_cursor: false } });

expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
inputSettings: { type: 1, window: 31875, show_cursor: false },
appearances: [{ sceneName: "Demo", sceneItemId: 2, sourceWidth: 1512, sourceHeight: 949 }],
warnings: [],
});
expect(result.content.some(({ type }) => type === "image")).toBe(false);
});

it("waits for a capture to produce its first frame before reporting its size", async () => {
size = { sourceWidth: 0, sourceHeight: 0 };
setTimeout(() => { size = { sourceWidth: 1512, sourceHeight: 949 }; }, 300);

const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { type: 2 } });

expect(result.structuredContent).toMatchObject({ appearances: [{ sourceWidth: 1512 }], warnings: [] });
});

it("warns when the input still renders at 0脳0", async () => {
size = { sourceWidth: 0, sourceHeight: 0 };

const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: { type: 2 } });

expect(result.isError).toBeFalsy();
expect(resultText(result)).toContain("Chrome renders at 0脳0");
expect(resultText(result)).toContain("display_uuid");
});

it("adds a bounded screenshot on request", async () => {
const result = await harness.call("obs-set-input-settings", {
inputName: "Chrome",
inputSettings: {},
includeScreenshot: true,
});

expect(result.content).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "image", mimeType: "image/jpeg" }),
]));
const request = harness.fakeObs.history().find(({ frame }) => frame.d.requestType === "GetSourceScreenshot");
expect(request?.frame.d.requestData).toMatchObject({ sourceName: "Chrome", imageFormat: "jpeg", imageWidth: 960 });
});

it("still reports success when the screenshot fails", async () => {
harness.fakeObs.respondWith("GetSourceScreenshot", () => {
throw new FakeOBSRequestError(702, "Failed to render screenshot");
});

const result = await harness.call("obs-set-input-settings", { inputName: "Chrome", inputSettings: {}, includeScreenshot: true });

expect(result.isError).toBeFalsy();
expect(resultText(result)).toMatch(/No screenshot: .*Failed to render screenshot/);
});
});

describe("obs-create-input", () => {
it("returns the new scene item ID with the input's state", async () => {
const result = await harness.call("obs-create-input", {
sceneName: "Demo",
inputName: "Chrome",
inputKind: "screen_capture",
});

expect(resultText(result)).toContain("with ID 7");
expect(result.structuredContent).toMatchObject({ appearances: [{ sceneItemId: 2 }] });
});
});

describe("obs-set-scene-item-transform", () => {
it("returns the transform OBS applied", async () => {
const result = await harness.call("obs-set-scene-item-transform", { sceneName: "Demo", sceneItemId: 2, cropTop: 372 });

expect(result.structuredContent).toMatchObject({
sceneItemTransform: { cropTop: 372, sourceWidth: 1512 },
});
expect(result.structuredContent).not.toHaveProperty("warnings");
});

it("reports success with a warning when the transform cannot be read back", async () => {
harness.fakeObs.respondWith("GetSceneItemTransform", () => {
throw new FakeOBSRequestError(600, "No scene item was found");
});

const result = await harness.call("obs-set-scene-item-transform", { sceneName: "Demo", sceneItemId: 2, cropTop: 372 });

expect(result.isError).toBeFalsy();
expect(resultText(result)).toContain("Could not read the applied transform back");
});
});
Loading
Loading