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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the
| `OBS_MCP_TOOLSETS` | No | all tools | Comma-separated tool groups to register: `all`, `core`, or any group listed under [Choosing tools](#choosing-tools) |
| `OBS_MCP_TOOLS` | No | none | Comma-separated tool names to register in addition to `OBS_MCP_TOOLSETS` |
| `OBS_MCP_READ_ONLY` | No | `false` | Register only read-only tools; overrides both lists |
| `OBS_MCP_CONFIRM_LIVE` | No | `false` | Ask before stopping or toggling a stream or recording (see [Confirming live actions](#confirming-live-actions)) |
| `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.
Expand Down Expand Up @@ -145,6 +146,25 @@ Every tool is registered by default. Clients load each registered tool's definit

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

### Resources and prompts

Resources give clients that support them a live view without tool calls. Each one duplicates a read-only tool, since many clients read resources rarely or not at all.

| URI | Contents | Updated on |
|---|---|---|
| `obs://status` | connection, recording, streaming, program scene | output and scene changes |
| `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) |

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.

Two prompts appear as slash commands in clients that show MCP prompts: `record-demo` (capture a window silently, preflight, record a clip) and `pre-stream-check` (a read-only go/no-go review).

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

## Where it fits

OBS MCP is well suited to operational work that already has a clear outcome: inspecting a scene collection, creating or arranging sources, changing audio state, switching scenes, taking screenshots, checking output status, or carrying out a repeatable recording setup.
Expand Down
10 changes: 9 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"OBS_WEBSOCKET_URL": "${user_config.obs_websocket_url}",
"OBS_WEBSOCKET_PASSWORD": "${user_config.obs_websocket_password}",
"OBS_MCP_TOOLSETS": "${user_config.obs_mcp_toolsets}",
"OBS_MCP_READ_ONLY": "${user_config.obs_mcp_read_only}"
"OBS_MCP_READ_ONLY": "${user_config.obs_mcp_read_only}",
"OBS_MCP_CONFIRM_LIVE": "${user_config.obs_mcp_confirm_live}"
}
}
},
Expand Down Expand Up @@ -81,6 +82,13 @@
"description": "Expose only tools that do not change OBS.",
"required": false,
"default": false
},
"obs_mcp_confirm_live": {
"type": "boolean",
"title": "Confirm live actions",
"description": "Ask before stopping or toggling a stream or recording.",
"required": false,
"default": false
}
}
}
2 changes: 1 addition & 1 deletion scripts/list-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function listTools(root) {
const client = new Client({ name: "obs-mcp-tool-inspector", version });

const { initialize } = await import(pathToFileURL(resolve(root, "build/tools/index.js")).href);
await initialize(server, mockClient);
await initialize(server, mockClient, { resourcesAndPrompts: false });

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function createServer(): McpServer {
version: PACKAGE_VERSION,
});

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

Expand All @@ -110,7 +110,7 @@ function loadToolFilter(): ToolFilter {
const registry = tools.initialize(
new McpServer({ name: "obs-mcp-validation", version: PACKAGE_VERSION }),
obsClient,
filter,
{ filter, resourcesAndPrompts: false },
);
assertKnownTools(filter, registry);
const count = registry.filter(({ registered }) => registered).length;
Expand Down
138 changes: 138 additions & 0 deletions src/tools/confirm.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 { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OBSWebSocketClient } from "../client.js";
import { FakeOBSServer } from "../../test/support/fake-obs-server.js";
import { initialize } from "./index.js";
import { resultText, startMcpHarness, type HarnessOptions, type McpHarness } from "../../test/support/mcp-harness.js";
import { liveConfirmationEnabled } from "./confirm.js";

let harness: McpHarness | undefined;

async function start(options: HarnessOptions): Promise<McpHarness> {
vi.spyOn(console, "error").mockImplementation(() => undefined);
harness = await startMcpHarness(options);
harness.fakeObs.respondWith("StopStream", () => ({}));
harness.fakeObs.respondWith("StopRecord", () => ({ outputPath: "/tmp/take.mp4" }));
return harness;
}

function sent(requestType: string): boolean {
return harness!.fakeObs.history().some(({ frame }) => frame.d.requestType === requestType);
}

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

describe("live action confirmation", () => {
it("is off unless OBS_MCP_CONFIRM_LIVE is set", async () => {
expect(liveConfirmationEnabled({})).toBe(false);
expect(liveConfirmationEnabled({ OBS_MCP_CONFIRM_LIVE: "true" })).toBe(true);

const { call, mcpClient } = await start({});
const { tools } = await mcpClient.listTools();
expect(tools.find(({ name }) => name === "obs-stop-stream")?.inputSchema.properties).not.toHaveProperty("confirm");
expect((await call("obs-stop-stream")).isError).toBeFalsy();
expect(sent("StopStream")).toBe(true);
});

it("asks the user through elicitation and stops only after they accept", async () => {
const questions: string[] = [];
const { call } = await start({
confirmLive: true,
onElicit: (message) => {
questions.push(message);
return { action: "accept", content: { confirm: true } };
},
});

const result = await call("obs-stop-stream");

expect(questions).toEqual(["Stop the live stream?"]);
expect(result.isError).toBeFalsy();
expect(sent("StopStream")).toBe(true);
});

it.each([
["declines", { action: "decline" as const }],
["answers no", { action: "accept" as const, content: { confirm: false } }],
])("does nothing when the user %s", async (_label, answer) => {
const { call } = await start({ confirmLive: true, onElicit: () => answer });

const result = await call("obs-stop-record");

expect(result.isError).toBeFalsy();
expect(resultText(result)).toContain("Cancelled");
expect(sent("StopRecord")).toBe(false);
});

it("asks the model to get consent when the client cannot elicit", async () => {
const { call } = await start({ confirmLive: true });

const refused = await call("obs-stop-stream");
expect(refused.isError).toBe(true);
expect(resultText(refused)).toContain("call again with confirm: true");
expect(sent("StopStream")).toBe(false);

const confirmed = await call("obs-stop-stream", { confirm: true });
expect(confirmed.isError).toBeFalsy();
expect(sent("StopStream")).toBe(true);
});

it("does not pass confirm through to tools that take arguments", async () => {
const { call, mcpClient } = await start({ confirmLive: true });
const { tools } = await mcpClient.listTools();

expect(tools.find(({ name }) => name === "obs-stop-record")?.inputSchema.properties).toHaveProperty("confirm");
expect(tools.find(({ name }) => name === "obs-remove-scene")?.inputSchema.properties).not.toHaveProperty("confirm");
expect((await call("obs-stop-record", { confirm: true })).isError).toBeFalsy();
});

it("confirms through the 2026-07-28 input_required round trip", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const fakeObs = await FakeOBSServer.start();
fakeObs.respondWith("StopStream", () => ({}));
const obsClient = new OBSWebSocketClient(fakeObs.url);
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "obs-mcp-test", version: "0.0.0" });
initialize(server, obsClient, { confirmLive: true });
return server;
}, { legacy: "reject" });
const client = new Client(
{ name: "obs-mcp-modern-confirm", version: "0.0.0" },
{ capabilities: { elicitation: { form: {} } }, versionNegotiation: { mode: { pin: "2026-07-28" } } },
);
const questions: string[] = [];
client.setRequestHandler("elicitation/create", async (request) => {
questions.push(request.params.message);
return { action: "accept", content: { confirm: true } };
});

try {
await client.connect(new StreamableHTTPClientTransport(
new URL("http://obs-mcp.test/mcp"),
{ fetch: (input, init) => handler.fetch(new Request(input, init)) },
));
expect(client.getNegotiatedProtocolVersion()).toBe("2026-07-28");

const result = await client.callTool({ name: "obs-stop-stream", arguments: {} });

expect(result.isError).toBeFalsy();
expect(questions).toEqual(["Stop the live stream?"]);
expect(fakeObs.history().some(({ frame }) => frame.d.requestType === "StopStream")).toBe(true);
} finally {
await client.close();
await handler.close();
await obsClient.disconnect();
await fakeObs.close();
}
});
});
125 changes: 125 additions & 0 deletions src/tools/confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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 {
CLIENT_CAPABILITIES_META_KEY,
acceptedContent,
inputRequired,
inputResponse,
type CallToolResult,
type McpServer,
} from "@modelcontextprotocol/server";
import { z, type ZodObject } from "zod";

/** Tools that end a live broadcast or a take, with the question to ask. */
export const LIVE_ACTIONS: Readonly<Record<string, string>> = {
"obs-stop-stream": "Stop the live stream?",
"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.",
};

const CONFIRM_KEY = "confirm";
const CONFIRM_SCHEMA = z.object({ confirm: z.boolean().describe("Confirm the action") });

export function liveConfirmationEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return ["1", "true", "yes"].includes((env.OBS_MCP_CONFIRM_LIVE ?? "").trim().toLowerCase());
}

type RequestContext = {
mcpReq?: {
inputResponses?: Record<string, unknown>;
envelope?: Record<string, unknown>;
};
};

function clientCanElicit(server: McpServer, ctx: RequestContext): boolean {
// 2026-07-28 requests carry client capabilities in each request envelope;
// 2025-era sessions declared them once at initialize.
const envelopeCapabilities = ctx.mcpReq?.envelope?.[CLIENT_CAPABILITIES_META_KEY] as
| { elicitation?: unknown }
| undefined;
if (envelopeCapabilities) return envelopeCapabilities.elicitation !== undefined;
return server.server.getClientCapabilities()?.elicitation !== undefined;
}

function textResult(text: string, isError = false): CallToolResult {
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
}

/**
* Decides whether a live action may run: yes when the caller passed
* confirm: true or the user accepted the elicitation; otherwise the result
* to return instead (an elicitation, a cancellation, or instructions).
*/
function confirmation(
server: McpServer,
question: string,
confirmArg: boolean | undefined,
ctx: RequestContext,
): "proceed" | ReturnType<typeof inputRequired> | CallToolResult {
if (confirmArg === true) return "proceed";

const responses = ctx.mcpReq?.inputResponses;
const answered = acceptedContent(responses, CONFIRM_KEY, CONFIRM_SCHEMA);
if (answered) return answered.confirm ? "proceed" : textResult("Cancelled; nothing was changed");
if (inputResponse(responses, CONFIRM_KEY).kind !== "missing") {
return textResult("Cancelled; nothing was changed");
}

if (clientCanElicit(server, ctx)) {
return inputRequired({
inputRequests: {
[CONFIRM_KEY]: inputRequired.elicit({
message: question,
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean", title: "Confirm", description: question } },
required: ["confirm"],
},
}),
},
});
}
return textResult(
`${question} This server requires confirmation for live actions and this client cannot ask. `
+ "Ask the user, then call again with confirm: true.",
true,
);
}

/**
* Wraps tool registration so the tools in LIVE_ACTIONS ask before running.
* Adds an optional `confirm` argument to their input schema; the original
* handler never sees it.
*/
export function withLiveConfirmation(server: McpServer): McpServer {
return new Proxy(server, {
get(target, property) {
if (property === "registerTool") {
const registerTool = Reflect.get(target, property, target) as (...args: unknown[]) => unknown;
return (name: string, config: { inputSchema?: ZodObject }, callback: (...args: unknown[]) => unknown) => {
const question = LIVE_ACTIONS[name];
if (!question) return Reflect.apply(registerTool, target, [name, config, callback]);

const hadSchema = config.inputSchema !== undefined;
const inputSchema = (config.inputSchema ?? z.object({})).extend({
confirm: z.boolean().optional()
.describe("Pass true only after the user has agreed; otherwise the server asks the user"),
});
const confirmedCallback = async (args: Record<string, unknown>, ctx: RequestContext) => {
const { confirm, ...rest } = args;
const decision = confirmation(target, question, confirm as boolean | undefined, ctx);
if (decision !== "proceed") return decision;
return hadSchema ? callback(rest, ctx) : callback(ctx);
};
return Reflect.apply(registerTool, target, [name, { ...config, inputSchema }, confirmedCallback]);
};
}
const value: unknown = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
21 changes: 19 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ 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 resources from "./resources.js";
import * as prompts from "./prompts.js";
import { withStructuredToolResults } from "./results.js";
import { liveConfirmationEnabled, withLiveConfirmation } from "./confirm.js";
import { ALL_TOOLS, scopedServer, type RegisteredTool, type ToolFilter, type ToolGroup } from "./toolsets.js";

const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client: OBSWebSocketClient): void }]> = [
Expand All @@ -51,15 +54,29 @@ const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client:
* Registers the tools the filter includes and returns every tool with its
* group and whether it was registered.
*/
export type InitializeOptions = {
filter?: ToolFilter;
/** Defaults to OBS_MCP_CONFIRM_LIVE. */
confirmLive?: boolean;
/** Resources and prompts; off for servers built only to inspect the tool list. */
resourcesAndPrompts?: boolean;
};

export function initialize(
server: McpServer,
client: OBSWebSocketClient,
filter: ToolFilter = ALL_TOOLS,
{ filter = ALL_TOOLS, confirmLive = liveConfirmationEnabled(), resourcesAndPrompts = true }: InitializeOptions = {},
): RegisteredTool[] {
// Registration passes through: group filter -> live confirmation -> structured results -> server.
const structuredServer = withStructuredToolResults(server);
const confirmingServer = confirmLive ? withLiveConfirmation(structuredServer) : structuredServer;
const registry: RegisteredTool[] = [];
for (const [group, module] of MODULES) {
module.initialize(scopedServer(structuredServer, group, filter, registry), client);
module.initialize(scopedServer(confirmingServer, group, filter, registry), client);
}
if (resourcesAndPrompts) {
resources.initialize(server, client);
prompts.initialize(server);
}
return registry;
}
Expand Down
Loading
Loading