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
25 changes: 20 additions & 5 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 168 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 171 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,13 +101,15 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the
| `OBS_MCP_READ_ONLY` | No | `false` | Register only read-only tools; overrides both lists |
| `OBS_MCP_DYNAMIC_TOOLSETS` | No | `false` | Start with only the `OBS_MCP_TOOLSETS` groups (default `general`) and let the model turn others on and off (see [Choosing tools](#choosing-tools)) |
| `OBS_MCP_CONFIRM_LIVE` | No | `false` | Ask before stopping or toggling a stream or recording (see [Confirming live actions](#confirming-live-actions)) |
| `OBS_MCP_HTTP_PORT` | No | none (stdio) | Serve MCP over Streamable HTTP at `http://127.0.0.1:<port>/mcp` instead of stdio, so several agents share one server (see [Sharing one server](#sharing-one-server)); `0` picks a free port |
| `OBS_MCP_HTTP_TOKEN` | No | none | With `OBS_MCP_HTTP_PORT`, require `Authorization: Bearer <token>` on every request |
| `OBS_MCP_LOG_LEVEL` | No | `info` | Diagnostic output on stderr: `debug`, `info`, `error`, or `silent` |

The server never prints the password. Connection and protocol diagnostics are written to stderr so stdout remains reserved for MCP messages.

## Tool surface

The 168 tools are organized around the OBS protocol rather than a smaller opinionated workflow:
The 171 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 Down Expand Up @@ -135,7 +137,7 @@ Every tool is registered by default. Clients load each registered tool's definit

| Group | Covers |
|---|---|
| `general` | status, version, statistics, hotkeys, vendor requests, custom events, sleep |
| `general` | status, version, statistics, hotkeys, vendor requests, custom events, sleep, control of a shared server |
| `scenes` | scenes, canvases, program and preview scene, `obs-apply-scene` |
| `scene-items` | scene items, groups, transforms, ordering, locking, blend modes, `obs-snapshot`, and `obs-restore` |
| `sources` | source screenshots and active state |
Expand All @@ -150,7 +152,7 @@ Every tool is registered by default. Clients load each registered tool's definit
| `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` (90 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 41-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` (93 tools). `OBS_MCP_TOOLSETS=core OBS_MCP_READ_ONLY=true` gives a 42-tool inspection-only server. The server refuses to start when a group or tool name is unknown.

With `OBS_MCP_DYNAMIC_TOOLSETS=true`, every group is registered but only the groups in `OBS_MCP_TOOLSETS` (by default just `general`) and the tools in `OBS_MCP_TOOLS` start enabled. Three more tools manage the rest: `obs-list-toolsets` shows each group and how many of its tools are on, and `obs-enable-toolset` and `obs-disable-toolset` switch groups for the session. Each change sends `notifications/tools/list_changed`, so clients that support it (Claude Code, VS Code, Cursor) reload the list. Read-only mode still applies: enabling a group adds only its read-only tools.

Expand Down Expand Up @@ -249,6 +251,19 @@ Without `apply: true` it returns the plan. When applying, it creates what is mis

Snapshots are kept in the server's memory, the last 10, and are lost when it restarts. OBS's own scene collection file is not a substitute: OBS rewrites it while running.

### Sharing one server

By default each agent starts its own server over stdio, with its own OBS connection, and nothing stops two agents from changing OBS at once. With `OBS_MCP_HTTP_PORT`, one server listens on `http://127.0.0.1:<port>/mcp` and every agent connects to it:

```bash
OBS_MCP_HTTP_PORT=8765 OBS_MCP_READ_OBS_CONFIG=true node /path/to/OBS-MCP/build/index.js
claude mcp add --transport http obs http://127.0.0.1:8765/mcp
```

The server listens only on 127.0.0.1 and rejects requests whose Host or Origin header is not localhost, which blocks DNS rebinding from web pages. Set `OBS_MCP_HTTP_TOKEN` to also require a bearer token. Each HTTP request is served statelessly, so `OBS_MCP_DYNAMIC_TOOLSETS` is ignored over HTTP.

While agents share a server, `obs-claim-control` gives one of them control of OBS for a set time (30 minutes by default). Until it calls `obs-release-control` or the time runs out, tools that change OBS refuse calls from other clients; reading OBS and stopping outputs always work. `obs-control-status` shows who holds control. `obs-take-start` claims control for the length of the take. Clients are told apart by the name they report (Claude Code and Codex report different names), so two sessions of the same client share control.

## 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 Expand Up @@ -278,7 +293,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 168 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 171 tool definitions, and calls the extracted server through MCP against fake OBS.

### Live OBS tests

Expand Down
128 changes: 128 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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 crypto from "node:crypto";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { Readable } from "node:stream";
import {
createMcpHandler,
hostHeaderValidationResponse,
localhostAllowedHostnames,
localhostAllowedOrigins,
originValidationResponse,
type McpServer,
type ServerNotifier,
} from "@modelcontextprotocol/server";
import { logger } from "./logger.js";

export const MCP_PATH = "/mcp";
const LOOPBACK = "127.0.0.1";

export type HttpServerHandle = {
url: string;
notify: ServerNotifier;
close(): Promise<void>;
};

export type HttpOptions = {
port: number;
/** When set, every request must carry `Authorization: Bearer <token>`. */
token?: string;
};

function tokenMatches(header: string | null, token: string): boolean {
const presented = Buffer.from(header?.startsWith("Bearer ") ? header.slice(7) : "");
const expected = Buffer.from(token);
return presented.length === expected.length && crypto.timingSafeEqual(presented, expected);
}

function toRequest(req: http.IncomingMessage): Request {
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item);
}
const hasBody = req.method !== "GET" && req.method !== "HEAD";
return new Request(`http://${req.headers.host ?? LOOPBACK}${req.url ?? "/"}`, {
method: req.method ?? "GET",
headers,
...(hasBody ? { body: Readable.toWeb(req) as ReadableStream, duplex: "half" } : {}),
} as RequestInit);
}

async function send(res: http.ServerResponse, response: Response): Promise<void> {
const headers: Record<string, string | string[]> = {};
response.headers.forEach((value, name) => {
const existing = headers[name];
headers[name] = existing === undefined ? value : [...(Array.isArray(existing) ? existing : [existing]), value];
});
res.writeHead(response.status, headers);
if (!response.body) {
res.end();
return;
}
const body = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
res.on("close", () => body.destroy());
body.pipe(res);
}

/**
* Serves MCP over Streamable HTTP on 127.0.0.1 so several agents can share
* one server and one OBS connection. Host and Origin headers must name
* localhost (DNS rebinding protection), and a bearer token can be required.
*/
export async function serveHttp(factory: () => McpServer, options: HttpOptions): Promise<HttpServerHandle> {
const handler = createMcpHandler(factory, {
onerror: (error) => logger.debug(`MCP HTTP: ${error.message}`),
});
const allowedHosts = localhostAllowedHostnames();
const allowedOrigins = localhostAllowedOrigins();

const server = http.createServer((req, res) => {
void (async () => {
try {
const path = new URL(req.url ?? "/", `http://${LOOPBACK}`).pathname;
if (path !== MCP_PATH) {
res.writeHead(404, { "content-type": "text/plain" }).end(`Not found; the MCP endpoint is ${MCP_PATH}`);
return;
}
const request = toRequest(req);
const rejected = hostHeaderValidationResponse(request, allowedHosts) ?? originValidationResponse(request, allowedOrigins);
if (rejected) {
await send(res, rejected);
return;
}
if (options.token && !tokenMatches(request.headers.get("authorization"), options.token)) {
res.writeHead(401, { "content-type": "text/plain", "www-authenticate": "Bearer" }).end("Missing or wrong bearer token");
return;
}
await send(res, await handler.fetch(request));
} catch (error) {
logger.error(`MCP HTTP request failed: ${error instanceof Error ? error.message : String(error)}`);
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
res.end();
}
})();
});

await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(options.port, LOOPBACK, () => {
server.off("error", reject);
resolve();
});
});
const { port } = server.address() as AddressInfo;
return {
url: `http://${LOOPBACK}:${port}${MCP_PATH}`,
notify: handler.notify,
close: async () => {
await handler.close();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
49 changes: 42 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { PACKAGE_VERSION } from "./version.js";
import { ALL_TOOLS, assertKnownTools, parseToolFilter, type ToolFilter } from "./tools/toolsets.js";
import { logger } from "./logger.js";
import { resolveObsPassword } from "./obs-config.js";
import { serveHttp, type HttpServerHandle } from "./http.js";
import { forwardResourceEvents } from "./tools/resources.js";

// Create the OBS WebSocket client
const obsClient = new OBSWebSocketClient(
Expand All @@ -21,6 +23,8 @@ const obsClient = new OBSWebSocketClient(
export let serverConnected = false;
export let obsConnected = false;
let stdioServer: StdioServerHandle | null = null;
let httpServer: HttpServerHandle | null = null;
let stopResourceEvents: (() => void) | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let connectionAttempt: Promise<void> | null = null;
let reconnectAttempts = 0;
Expand Down Expand Up @@ -102,10 +106,23 @@ export function createServer(): McpServer {
version: PACKAGE_VERSION,
});

tools.initialize(server, obsClient, { filter: toolFilter ?? ALL_TOOLS });
tools.initialize(server, obsClient, { filter: toolFilter ?? ALL_TOOLS, sessionNotifications: !httpMode() });
return server;
}

/** OBS_MCP_HTTP_PORT selects Streamable HTTP on 127.0.0.1 instead of stdio. */
function httpPort(): number | null {
const value = process.env.OBS_MCP_HTTP_PORT?.trim();
if (!value) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`OBS_MCP_HTTP_PORT must be a port number, not "${value}"`);
return port;
}

function httpMode(): boolean {
return Boolean(process.env.OBS_MCP_HTTP_PORT?.trim());
}

function loadToolFilter(): ToolFilter {
const filter = parseToolFilter();
const registry = tools.initialize(
Expand All @@ -129,10 +146,23 @@ function loadToolFilter(): ToolFilter {
export async function startServer(): Promise<void> {
try {
toolFilter = loadToolFilter();
stdioServer = serveStdio(createServer, {
onerror: (error) => logger.error(`MCP stdio error: ${error.message}`),
});
logger.log("Initialized MCP tools and started dual-era stdio server");
const port = httpPort();
if (port !== null) {
if (toolFilter.dynamic) {
// Each HTTP request gets a fresh server, so a session cannot keep groups it turned on.
logger.error("OBS_MCP_DYNAMIC_TOOLSETS is ignored over HTTP; registering the OBS_MCP_TOOLSETS groups instead");
toolFilter = { ...toolFilter, dynamic: false };
}
httpServer = await serveHttp(createServer, { port, ...(process.env.OBS_MCP_HTTP_TOKEN ? { token: process.env.OBS_MCP_HTTP_TOKEN } : {}) });
const notify = httpServer.notify;
stopResourceEvents = forwardResourceEvents(obsClient, (uri) => notify.resourceUpdated(uri), () => notify.resourcesChanged());
logger.log(`Serving MCP over HTTP at ${httpServer.url}${process.env.OBS_MCP_HTTP_TOKEN ? " (bearer token required)" : ""}`);
} else {
stdioServer = serveStdio(createServer, {
onerror: (error) => logger.error(`MCP stdio error: ${error.message}`),
});
logger.log("Initialized MCP tools and started dual-era stdio server");
}

serverConnected = true;

Expand All @@ -141,8 +171,10 @@ export async function startServer(): Promise<void> {

// stdin EOF is the only portable graceful-shutdown signal for stdio MCP
// servers. Signals remain useful for terminals and process supervisors.
process.stdin.once("end", () => void handleShutdown("stdin end"));
process.stdin.once("close", () => void handleShutdown("stdin close"));
if (stdioServer) {
process.stdin.once("end", () => void handleShutdown("stdin end"));
process.stdin.once("close", () => void handleShutdown("stdin close"));
}
process.once("SIGINT", () => void handleShutdown("SIGINT"));
process.once("SIGTERM", () => void handleShutdown("SIGTERM"));

Expand Down Expand Up @@ -180,11 +212,14 @@ function handleShutdown(reason: string): Promise<void> {
Promise.allSettled([
obsClient.disconnect(),
stdioServer?.close() ?? Promise.resolve(),
httpServer?.close() ?? Promise.resolve(),
]).then(() => undefined),
deadline,
]);

stopResourceEvents?.();
stdioServer = null;
httpServer = null;
process.exit(0);
})();

Expand Down
58 changes: 58 additions & 0 deletions src/tools/__snapshots__/tool-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,34 @@
"openWorldHint": false
}
},
{
"name": "obs-claim-control",
"title": "Claim Control of OBS",
"inputSchema": {
"type": "object",
"properties": {
"minutes": {
"default": 30,
"description": "How long to hold control",
"type": "integer",
"minimum": 1,
"maximum": 240
},
"reason": {
"description": "Shown to other clients, e.g. \"recording the demo\"",
"type": "string",
"maxLength": 200
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
"name": "obs-contact-sheet",
"title": "Contact Sheet",
Expand Down Expand Up @@ -435,6 +463,21 @@
"openWorldHint": false
}
},
{
"name": "obs-control-status",
"title": "Control 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-create-input",
"title": "Create Input",
Expand Down Expand Up @@ -4789,6 +4832,21 @@
"openWorldHint": false
}
},
{
"name": "obs-release-control",
"title": "Release Control of OBS",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
},
{
"name": "obs-remove-input",
"title": "Remove Input",
Expand Down
Loading
Loading