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
22 changes: 16 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 159 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 163 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 159 tools are organized around the OBS protocol rather than a smaller opinionated workflow:
The 163 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 @@ -116,6 +116,7 @@ The 159 tools are organized around the OBS protocol rather than a smaller opinio
- 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. While it records, it watches the take (see [Watching a take](#watching-a-take))
- `obs-take-start`, `obs-take-mark`, `obs-take-status`, and `obs-take-stop` for a watched recording of any length, with a chapter per step (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 All @@ -138,14 +139,14 @@ Every tool is registered by default. Clients load each registered tool's definit
| `media` | media input playback |
| `filters` | source filters |
| `transitions` | transitions, overrides, the T-Bar |
| `record` | recording, chapters, `obs-preflight`, and `obs-record-clip` |
| `record` | recording, chapters, `obs-preflight`, `obs-record-clip`, and the `obs-take-*` tools |
| `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`, 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.
`core` expands to `general`, `scenes`, `scene-items`, `sources`, `inputs`, and `record` (85 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 39-tool inspection-only server. The server refuses to start when a group or tool name is unknown.

### Resources and prompts

Expand All @@ -165,7 +166,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, 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.
With `OBS_MCP_CONFIRM_LIVE=true`, `obs-stop-stream`, `obs-toggle-stream`, `obs-stop-record`, `obs-toggle-record`, and `obs-take-stop` 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 @@ -189,6 +190,15 @@ Pay particular attention to approvals for tools that:

### Watching a take

`obs-record-clip` records up to 50 seconds in one call. For anything longer, or a demo whose steps happen while it records, use a take:

1. `obs-take-start` runs the preflight, starts recording, and waits for OBS to confirm. Only one take runs at a time.
2. `obs-take-mark` adds a named chapter before each step, both in the file (Hybrid MP4/MOV) and in the take log.
3. `obs-take-status` reports what the take has seen so far.
4. `obs-take-stop` stops recording, checks the file, and reports.

While a take records, tools that would switch the profile or scene collection, or change output, video, recording directory, or stream service settings, refuse to run, including through `obs-call-request` and `obs-batch`. Stopping and pausing are never refused. Start with `lock: false` to allow those changes.

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

### Live OBS tests

Expand Down
92 changes: 92 additions & 0 deletions src/tools/__snapshots__/tool-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -6944,6 +6944,98 @@
"openWorldHint": false
}
},
{
"name": "obs-take-mark",
"title": "Mark Take",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Chapter name"
}
},
"required": [
"name"
],
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}
},
{
"name": "obs-take-start",
"title": "Start Take",
"inputSchema": {
"type": "object",
"properties": {
"expectSilent": {
"default": false,
"description": "Refuse to start with audible inputs, and treat audio during the take as a problem",
"type": "boolean"
},
"minFreeDiskMb": {
"default": 1024,
"description": "Minimum free disk space in MB",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"lock": {
"default": true,
"description": "Refuse profile, scene collection, and output setting changes until the take stops",
"type": "boolean"
},
"chapter": {
"description": "Name of a chapter to add at the start",
"type": "string",
"minLength": 1
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}
},
{
"name": "obs-take-status",
"title": "Take Status",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
"name": "obs-take-stop",
"title": "Stop Take",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false
}
},
{
"name": "obs-test-connection",
"title": "Test OBS Connection",
Expand Down
1 change: 1 addition & 0 deletions src/tools/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const LIVE_ACTIONS: Readonly<Record<string, string>> = {
"obs-toggle-stream": "Toggle the live stream? If it is live, this ends the broadcast.",
"obs-stop-record": "Stop the recording?",
"obs-toggle-record": "Toggle recording? If it is recording, this ends the take.",
"obs-take-stop": "Stop the recording?",
};

/** OBS requests that end a live broadcast or a take, with the question to ask. */
Expand Down
11 changes: 8 additions & 3 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as protocolExtensions from "./protocol-extensions.js";
import * as protocol from "./protocol.js";
import * as preflight from "./preflight.js";
import * as workflows from "./workflows.js";
import * as takes from "./takes.js";
import * as resources from "./resources.js";
import * as prompts from "./prompts.js";
import { withStructuredToolResults } from "./results.js";
Expand All @@ -48,6 +49,7 @@ const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client:
["protocol", protocol],
["record", preflight],
["record", workflows],
["record", takes],
];

/**
Expand All @@ -67,9 +69,11 @@ export function initialize(
client: OBSWebSocketClient,
{ filter = ALL_TOOLS, confirmLive = liveConfirmationEnabled(), resourcesAndPrompts = true }: InitializeOptions = {},
): RegisteredTool[] {
// Registration passes through: group filter -> live confirmation -> structured results -> server.
// Registration passes through: group filter -> live confirmation -> take lock -> structured results -> server.
// At call time the take lock runs first, so a locked call never asks the user.
const structuredServer = withStructuredToolResults(server);
const confirmingServer = confirmLive ? withLiveConfirmation(structuredServer) : structuredServer;
const lockingServer = takes.withTakeLock(structuredServer, client);
const confirmingServer = confirmLive ? withLiveConfirmation(lockingServer) : lockingServer;
const registry: RegisteredTool[] = [];
for (const [group, module] of MODULES) {
module.initialize(scopedServer(confirmingServer, group, filter, registry), client);
Expand Down Expand Up @@ -99,5 +103,6 @@ export {
protocolExtensions,
protocol,
preflight,
workflows
workflows,
takes
};
14 changes: 11 additions & 3 deletions src/tools/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,26 @@ export function initialize(server: McpServer): void {
argsSchema: z.object({
window: z.string().describe("Part of the window title or app name to capture, e.g. \"ChatGPT\""),
scene: z.string().default("Demo").describe("Scene to record from"),
seconds: z.string().default("30").describe("Length of the take in seconds (up to 50 in one call)"),
seconds: z.string().default("30").describe("Length of a hands-off take in seconds; ignored when steps are given"),
steps: z.string().optional().describe("Steps to perform on screen, one per line; each becomes a chapter"),
}),
},
({ window, scene, seconds }) => userMessage([
({ window, scene, seconds, steps }) => userMessage([
`Record a silent demo of the "${window}" window in the OBS scene "${scene}".`,
"",
`1. If the scene "${scene}" does not exist, create it with obs-create-scene and switch to it.`,
`2. Call obs-capture-window with sceneName "${scene}", inputName "Demo Capture", window "${window}", `
+ "silent true, fit true, includeScreenshot true. If it lists several matches, ask me which one.",
"3. Look at the screenshot and the reported size. If the capture renders at 0×0 or shows the wrong window, stop and tell me.",
"4. Call obs-preflight with expectSilent true. Fix every failure, or explain it to me if you cannot.",
`5. Call obs-record-clip with durationSeconds ${seconds} and expectSilent true, then report the file path and the verification result.`,
...(steps
? [
"5. Call obs-take-start with expectSilent true. For each step below, call obs-take-mark with the step's name, "
+ "then perform it. If obs-take-status or a tool result reports a warning, tell me. Call obs-take-stop "
+ "after the last step, then report the file path, the chapters, and the verification result.",
...steps.split("\n").map((step) => step.trim()).filter(Boolean).map((step) => ` - ${step}`),
]
: [`5. Call obs-record-clip with durationSeconds ${seconds} and expectSilent true, then report the file path and the verification result.`]),
"Do not change output settings with obs-set-profile-parameter without asking: OBS needs a restart to apply them.",
].join("\n")),
);
Expand Down
13 changes: 13 additions & 0 deletions src/tools/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,17 @@ describe("prompts", () => {
expect(text).toContain("obs-capture-window");
expect(text).toContain("obs-record-clip with durationSeconds 20 and expectSilent true");
});

it("turns record-demo steps into a take with a chapter per step", async () => {
const prompt = await harness.mcpClient.getPrompt({
name: "record-demo",
arguments: { window: "ChatGPT", steps: "Open settings\n\nAsk about tasks" },
});
const [message] = prompt.messages;
const text = message?.content.type === "text" ? message.content.text : "";

expect(text).toContain("obs-take-start with expectSilent true");
expect(text).toContain(" - Open settings\n - Ask about tasks");
expect(text).not.toContain("obs-record-clip");
});
});
7 changes: 7 additions & 0 deletions src/tools/take-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class TakeMonitor {
private framesSampled = 0;
private pictureChecks: "on" | "off" = "off";
private programScene: string | null = null;
private recordingPath: string | null = null;
private readonly timers: NodeJS.Timeout[] = [];
private readonly busy = new Set<string>();
private releaseMeters: (() => void) | null = null;
Expand Down Expand Up @@ -159,6 +160,7 @@ export class TakeMonitor {
this.closeStill(this.now());
});
this.listen("RecordStateChanged", (data) => {
if (typeof data.outputPath === "string" && data.outputPath) this.recordingPath = data.outputPath;
if (data.outputActive === false && !this.stopExpected && data.outputState === "OBS_WEBSOCKET_OUTPUT_STOPPED") {
this.warn("output", "The recording stopped before it was asked to");
}
Expand Down Expand Up @@ -186,6 +188,11 @@ export class TakeMonitor {
this.changed();
}

/** The file OBS last reported for this take, from RecordStateChanged. */
outputPath(): string | null {
return this.recordingPath;
}

/** Call before stopping the recording, so the stop is not reported as a problem. */
expectStop(): void {
this.stopExpected = true;
Expand Down
Loading
Loading