From 6d5f6a92c63dc250dc8de68d9b804c9c8956643b Mon Sep 17 00:00:00 2001 From: Tom Main Date: Wed, 23 Sep 2026 20:34:47 -0400 Subject: [PATCH] Read the password OBS saved when asked to With OBS_MCP_READ_OBS_CONFIG=true and no OBS_WEBSOCKET_PASSWORD, the server reads server_password from obs-websocket's config.json in OBS's own config directory (macOS, Windows, Linux, and the Linux Flatpak). A local agent can then connect without a copy of the password in its configuration. The server logs which file it used, never the value. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 3 +- manifest.json | 10 +++- src/client.ts | 2 +- src/obs-config.test.ts | 103 +++++++++++++++++++++++++++++++++++++ src/obs-config.ts | 92 +++++++++++++++++++++++++++++++++ src/server.ts | 5 +- test/e2e/stdio.e2e.test.ts | 28 ++++++++++ 7 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 src/obs-config.test.ts create mode 100644 src/obs-config.ts diff --git a/README.md b/README.md index e0e9b79..bafa479 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Open that file in an MCPB-compatible desktop client. The package prompts for the | --- | --- | --- | --- | | `OBS_WEBSOCKET_URL` | No | `ws://localhost:4455` | Address of the OBS WebSocket server | | `OBS_WEBSOCKET_PASSWORD` | Only when OBS authentication is enabled | None | Password configured in OBS | +| `OBS_MCP_READ_OBS_CONFIG` | No | `false` | When `OBS_WEBSOCKET_PASSWORD` is unset, read the password OBS saved in its own `obs-websocket/config.json`. Only for a server on the same machine and user account as OBS | | `OBS_MCP_MAX_SCREENSHOT_BYTES` | No | `4194304` | Maximum decoded size of a screenshot returned through MCP; the hard ceiling is 6 MiB | | `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` | @@ -189,7 +190,7 @@ Pay particular attention to approvals for tools that: **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. -**Authentication fails:** copy the password from **Tools > WebSocket Server Settings** into `OBS_WEBSOCKET_PASSWORD`, then restart the MCP server process. +**Authentication fails:** copy the password from **Tools > WebSocket Server Settings** into `OBS_WEBSOCKET_PASSWORD`, then restart the MCP server process. When the server runs on the same machine as OBS, `OBS_MCP_READ_OBS_CONFIG=true` reads the saved password instead, so it never has to be copied into an agent's configuration. The server logs which file it used, never the password. **The agent cannot see the tools:** restart the agent session or the MCP host so it creates a fresh stdio connection and reloads the tool list. diff --git a/manifest.json b/manifest.json index 813a6a2..16685e9 100644 --- a/manifest.json +++ b/manifest.json @@ -50,7 +50,8 @@ "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_CONFIRM_LIVE": "${user_config.obs_mcp_confirm_live}" + "OBS_MCP_CONFIRM_LIVE": "${user_config.obs_mcp_confirm_live}", + "OBS_MCP_READ_OBS_CONFIG": "${user_config.obs_mcp_read_obs_config}" } } }, @@ -89,6 +90,13 @@ "description": "Ask before stopping or toggling a stream or recording.", "required": false, "default": false + }, + "obs_mcp_read_obs_config": { + "type": "boolean", + "title": "Use the password saved in OBS", + "description": "When no password is entered above, read the one OBS saved on this computer.", + "required": false, + "default": false } } } diff --git a/src/client.ts b/src/client.ts index eb28fbe..926961c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -480,7 +480,7 @@ export class OBSWebSocketClient extends EventEmitter { if (hello.authentication) { if (!this.password) { return Promise.reject(new Error( - "Password required for authentication but not provided. Set OBS_WEBSOCKET_PASSWORD environment variable.", + "Password required for authentication but not provided. Set OBS_WEBSOCKET_PASSWORD, or OBS_MCP_READ_OBS_CONFIG=true to use the password saved in OBS.", )); } authentication = this.generateAuthenticationString( diff --git a/src/obs-config.test.ts b/src/obs-config.test.ts new file mode 100644 index 0000000..4c8c98f --- /dev/null +++ b/src/obs-config.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { obsWebSocketConfigPaths, readObsConfigPassword, resolveObsPassword } from "./obs-config.js"; + +const SECRET = "s3cret-from-obs"; +let directory: string; +let stderr: ReturnType; + +function configFile(contents: string): string { + const path = join(directory, `config-${Math.random()}.json`); + writeFileSync(path, contents); + return path; +} + +function logged(): string { + return stderr.mock.calls.map((call: unknown[]) => String(call[0])).join("\n"); +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "obs-mcp-config-")); + stderr = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("obsWebSocketConfigPaths", () => { + it("follows each platform's OBS config directory", () => { + expect(obsWebSocketConfigPaths({}, "darwin", "/Users/me")).toEqual([ + "/Users/me/Library/Application Support/obs-studio/plugin_config/obs-websocket/config.json", + ]); + expect(obsWebSocketConfigPaths({ APPDATA: "C:/Users/me/AppData/Roaming" }, "win32", "C:/Users/me")[0]) + .toContain(join("C:/Users/me/AppData/Roaming", "obs-studio")); + expect(obsWebSocketConfigPaths({ XDG_CONFIG_HOME: "/xdg" }, "linux", "/home/me")).toEqual([ + "/xdg/obs-studio/plugin_config/obs-websocket/config.json", + "/home/me/.var/app/com.obsproject.Studio/config/obs-studio/plugin_config/obs-websocket/config.json", + ]); + }); +}); + +describe("readObsConfigPassword", () => { + it("returns the first readable password", () => { + const path = configFile(JSON.stringify({ auth_required: true, server_password: SECRET })); + + expect(readObsConfigPassword([join(directory, "missing.json"), path])) + .toEqual({ kind: "password", password: SECRET, path }); + }); + + it("reports that authentication is off", () => { + const path = configFile(JSON.stringify({ auth_required: false, server_password: SECRET })); + + expect(readObsConfigPassword([path])).toEqual({ kind: "no-auth", path }); + }); + + it("skips malformed files and empty passwords", () => { + const paths = [configFile("{not json"), configFile(JSON.stringify({ server_password: "" }))]; + + expect(readObsConfigPassword(paths)).toEqual({ kind: "not-found", searched: paths }); + }); +}); + +describe("resolveObsPassword", () => { + const found = () => ({ kind: "password" as const, password: SECRET, path: "/obs/config.json" }); + + it("prefers OBS_WEBSOCKET_PASSWORD and ignores the config file", () => { + const read = vi.fn(found); + + expect(resolveObsPassword({ OBS_WEBSOCKET_PASSWORD: "env", OBS_MCP_READ_OBS_CONFIG: "1" }, read)).toBe("env"); + expect(read).not.toHaveBeenCalled(); + }); + + it("does not read OBS's config unless asked to", () => { + const read = vi.fn(found); + + expect(resolveObsPassword({}, read)).toBeNull(); + expect(read).not.toHaveBeenCalled(); + }); + + it("uses OBS's password when enabled, logging only where it came from", () => { + expect(resolveObsPassword({ OBS_MCP_READ_OBS_CONFIG: "true" }, found)).toBe(SECRET); + expect(logged()).toContain("/obs/config.json"); + expect(logged()).not.toContain(SECRET); + }); + + it("explains where it looked when nothing was found", () => { + const result = resolveObsPassword( + { OBS_MCP_READ_OBS_CONFIG: "yes" }, + () => ({ kind: "not-found", searched: ["/a.json", "/b.json"] }), + ); + + expect(result).toBeNull(); + expect(logged()).toContain("/a.json, /b.json"); + }); +}); diff --git a/src/obs-config.ts b/src/obs-config.ts new file mode 100644 index 0000000..dedf1e4 --- /dev/null +++ b/src/obs-config.ts @@ -0,0 +1,92 @@ +/* + * 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 { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { logger } from "./logger.js"; + +const CONFIG_SUBPATH = ["obs-studio", "plugin_config", "obs-websocket", "config.json"]; + +function isEnabled(value: string | undefined): boolean { + return ["1", "true", "yes"].includes((value ?? "").trim().toLowerCase()); +} + +/** + * Where obs-websocket keeps config.json, most likely first. OBS stores it + * under its user config directory (os_get_config_path), and the Linux + * Flatpak build keeps that inside its sandbox. + */ +export function obsWebSocketConfigPaths( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + home = homedir(), +): string[] { + if (platform === "darwin") return [join(home, "Library", "Application Support", ...CONFIG_SUBPATH)]; + if (platform === "win32") { + return [join(env.APPDATA ?? join(home, "AppData", "Roaming"), ...CONFIG_SUBPATH)]; + } + return [ + join(env.XDG_CONFIG_HOME || join(home, ".config"), ...CONFIG_SUBPATH), + join(home, ".var", "app", "com.obsproject.Studio", "config", ...CONFIG_SUBPATH), + ]; +} + +export type ObsConfigPassword = + | { kind: "password"; password: string; path: string } + | { kind: "no-auth"; path: string } + | { kind: "not-found"; searched: string[] }; + +type ReadFile = (path: string) => string; + +/** + * Reads the password OBS itself uses from obs-websocket's config.json. + * Unreadable or malformed files count as not found; the error text never + * includes file contents. + */ +export function readObsConfigPassword( + paths: readonly string[] = obsWebSocketConfigPaths(), + readFile: ReadFile = (path) => readFileSync(path, "utf8"), +): ObsConfigPassword { + for (const path of paths) { + let config: unknown; + try { + config = JSON.parse(readFile(path)); + } catch { + continue; + } + if (typeof config !== "object" || config === null) continue; + const { auth_required: authRequired, server_password: password } = config as Record; + if (authRequired === false) return { kind: "no-auth", path }; + if (typeof password === "string" && password.length > 0) return { kind: "password", password, path }; + } + return { kind: "not-found", searched: [...paths] }; +} + +/** + * OBS_WEBSOCKET_PASSWORD wins. Otherwise, with OBS_MCP_READ_OBS_CONFIG + * enabled, falls back to the password in OBS's own config so a local agent + * needs no copy of it. Logs where the password came from, never the value. + */ +export function resolveObsPassword( + env: NodeJS.ProcessEnv = process.env, + read: () => ObsConfigPassword = () => readObsConfigPassword(obsWebSocketConfigPaths(env)), +): string | null { + if (env.OBS_WEBSOCKET_PASSWORD) return env.OBS_WEBSOCKET_PASSWORD; + if (!isEnabled(env.OBS_MCP_READ_OBS_CONFIG)) return null; + + const result = read(); + switch (result.kind) { + case "password": + logger.log(`Using the OBS WebSocket password from ${result.path}`); + return result.password; + case "no-auth": + logger.log(`OBS WebSocket authentication is off according to ${result.path}`); + return null; + case "not-found": + logger.error(`OBS_MCP_READ_OBS_CONFIG is set, but no OBS WebSocket password was found in: ${result.searched.join(", ")}`); + return null; + } +} diff --git a/src/server.ts b/src/server.ts index 184cb34..0e347d2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,11 +10,12 @@ import * as tools from "./tools/index.js"; 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"; // Create the OBS WebSocket client const obsClient = new OBSWebSocketClient( process.env.OBS_WEBSOCKET_URL || "ws://localhost:4455", - process.env.OBS_WEBSOCKET_PASSWORD || null + resolveObsPassword(), ); export let serverConnected = false; @@ -72,7 +73,7 @@ async function attemptOBSConnection(): Promise { if (reconnectAttempts === 1) { logger.error("The MCP server will remain available while OBS is offline."); - logger.error("Verify OBS is running and OBS_WEBSOCKET_URL/OBS_WEBSOCKET_PASSWORD are correct."); + logger.error("Verify OBS is running and OBS_WEBSOCKET_URL/OBS_WEBSOCKET_PASSWORD are correct (or set OBS_MCP_READ_OBS_CONFIG=true to use the password OBS saved)."); } scheduleReconnect(); diff --git a/test/e2e/stdio.e2e.test.ts b/test/e2e/stdio.e2e.test.ts index ecd2218..e5dfbf5 100644 --- a/test/e2e/stdio.e2e.test.ts +++ b/test/e2e/stdio.e2e.test.ts @@ -5,7 +5,10 @@ */ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter, once } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/client"; import { @@ -15,6 +18,7 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { FakeOBSServer } from "../support/fake-obs-server.js"; import { TOOL_COUNT } from "../support/tool-count.js"; +import { obsWebSocketConfigPaths } from "../../src/obs-config.js"; type JsonObject = Record; type JsonRpcMessage = JsonObject & { id?: string | number }; @@ -486,6 +490,30 @@ describe("compiled stdio MCP boundary", () => { await vi.waitFor(() => expect(stderr.join("")).toMatch(/Registering \d+ of \d+ tools \(read-only\)/)); }); + it("authenticates with the password OBS saved when asked to", async () => { + const home = mkdtempSync(join(tmpdir(), "obs-mcp-home-")); + try { + const environment = { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), APPDATA: join(home, "AppData") }; + const [configPath] = obsWebSocketConfigPaths(environment, process.platform, home); + mkdirSync(dirname(configPath!), { recursive: true }); + writeFileSync(configPath!, JSON.stringify({ auth_required: true, server_password: testPassword })); + const fake = await FakeOBSServer.start({ password: testPassword }); + fakeServers.push(fake); + + const { client, stderr } = await connectClient(fake, "modern", { + ...environment, + OBS_WEBSOCKET_PASSWORD: "", + OBS_MCP_READ_OBS_CONFIG: "true", + }); + + await waitForObsConnected(client); + expect(stderr.join("")).toContain("Using the OBS WebSocket password from"); + expect(stderr.join("")).not.toContain("[REDACTED]"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it("refuses to start with an unknown tool group or tool name", async () => { const environments: Record[] = [ { OBS_MCP_TOOLSETS: "scenez" },