diff --git a/README.md b/README.md index 4080f2d..e0e9b79 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/manifest.json b/manifest.json index 3e762f7..813a6a2 100644 --- a/manifest.json +++ b/manifest.json @@ -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}" } } }, @@ -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 } } } diff --git a/scripts/list-tools.mjs b/scripts/list-tools.mjs index 9a33f47..2f6bc5d 100644 --- a/scripts/list-tools.mjs +++ b/scripts/list-tools.mjs @@ -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(); diff --git a/src/server.ts b/src/server.ts index 857d179..184cb34 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; } @@ -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; diff --git a/src/tools/confirm.test.ts b/src/tools/confirm.test.ts new file mode 100644 index 0000000..61fdfe4 --- /dev/null +++ b/src/tools/confirm.test.ts @@ -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 { + 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(); + } + }); +}); diff --git a/src/tools/confirm.ts b/src/tools/confirm.ts new file mode 100644 index 0000000..9c01517 --- /dev/null +++ b/src/tools/confirm.ts @@ -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> = { + "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; + envelope?: Record; + }; +}; + +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 | 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, 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; + }, + }); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e247bb3..1e11cf0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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 }]> = [ @@ -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; } diff --git a/src/tools/output-start.test.ts b/src/tools/output-start.test.ts index 4225f6b..4632dce 100644 --- a/src/tools/output-start.test.ts +++ b/src/tools/output-start.test.ts @@ -138,10 +138,11 @@ describe("startOutputAndConfirm", () => { it("removes its event listener after completing", async () => { fakeObs.queueSuccess("StartRecord"); fakeObs.queueSuccess("GetRecordStatus", { outputActive: true }); + const before = obsClient.listenerCount("RecordStateChanged"); await startOutputAndConfirm(obsClient, fastRecord); - expect(obsClient.listenerCount("RecordStateChanged")).toBe(0); + expect(obsClient.listenerCount("RecordStateChanged")).toBe(before); }); }); diff --git a/src/tools/prompts.ts b/src/tools/prompts.ts new file mode 100644 index 0000000..ed4a5fc --- /dev/null +++ b/src/tools/prompts.ts @@ -0,0 +1,59 @@ +/* + * 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 type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +function userMessage(text: string) { + return { messages: [{ role: "user" as const, content: { type: "text" as const, text } }] }; +} + +/** + * Workflow prompts. Clients such as Claude Code, VS Code, and Cursor show them + * as slash commands; every step is also reachable through tools alone. + */ +export function initialize(server: McpServer): void { + server.registerPrompt( + "record-demo", + { + title: "Record a silent app demo", + description: "Set up a silent, window-only capture on macOS, preflight it, and record a take", + 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)"), + }), + }, + ({ window, scene, seconds }) => 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.`, + "Do not change output settings with obs-set-profile-parameter without asking: OBS needs a restart to apply them.", + ].join("\n")), + ); + + server.registerPrompt( + "pre-stream-check", + { + title: "Pre-stream check", + description: "Check OBS is ready to go live without changing anything", + }, + () => userMessage([ + "Check whether OBS is ready to go live. Use only read-only tools and change nothing.", + "", + "1. obs-get-status and obs-get-stats: connection, dropped or skipped frames, CPU, and free disk space.", + "2. obs-get-stream-status and obs-get-stream-service-settings: the stream is not already live and a service is configured. Never repeat the stream key back.", + "3. obs-get-current-scene and obs-get-scene-items: the program scene has visible, correctly sized sources. Take one obs-get-source-screenshot of the program scene and describe it.", + "4. obs-get-input-list with obs-get-input-mute and obs-get-input-volume for audio inputs: say which are live, muted, or silent.", + "5. obs-preflight: report any failures that also apply to streaming (encoder, output settings).", + "Finish with a short go / no-go summary and the specific fixes you recommend.", + ].join("\n")), + ); +} diff --git a/src/tools/resources.test.ts b/src/tools/resources.test.ts new file mode 100644 index 0000000..68817c8 --- /dev/null +++ b/src/tools/resources.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; + +const JPEG = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z"; + +let harness: McpHarness; +let closed = false; + +beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + closed = false; + harness = await startMcpHarness(); + const { fakeObs } = harness; + fakeObs.respondWith("GetSceneList", () => ({ + currentProgramSceneName: "Demo", + scenes: [{ sceneName: "Demo", sceneIndex: 0 }, { sceneName: "Be Right Back", sceneIndex: 1 }], + })); + fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: true, outputDuration: 1200 })); + fakeObs.respondWith("GetStreamStatus", () => ({ outputActive: false })); + fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); + fakeObs.respondWith("GetSceneItemList", ({ sceneName }) => ({ + sceneItems: [{ sceneItemId: 1, sourceName: `${String(sceneName)} source` }], + })); + fakeObs.respondWith("GetSourceScreenshot", () => ({ imageData: JPEG })); +}); + +afterEach(async () => { + if (!closed) await harness.close(); + vi.restoreAllMocks(); +}); + +function textOf(contents: unknown[]): unknown { + const [first] = contents as { text?: string }[]; + return JSON.parse(first?.text ?? "null"); +} + +describe("resources", () => { + it("lists fixed resources, per-scene item resources, and templates", async () => { + const { resources } = await harness.mcpClient.listResources(); + const { resourceTemplates } = await harness.mcpClient.listResourceTemplates(); + + expect(resources.map(({ uri }) => uri)).toEqual(expect.arrayContaining([ + "obs://status", + "obs://scenes", + "obs://scene/Demo/items", + "obs://scene/Be%20Right%20Back/items", + ])); + expect(resourceTemplates.map(({ uriTemplate }) => uriTemplate)).toEqual([ + "obs://scene/{sceneName}/items", + "obs://scene/{sceneName}/screenshot", + ]); + }); + + it("reads status and scene items, decoding scene names", async () => { + const status = await harness.mcpClient.readResource({ uri: "obs://status" }); + const items = await harness.mcpClient.readResource({ uri: "obs://scene/Be%20Right%20Back/items" }); + + expect(textOf(status.contents)).toMatchObject({ + connection: { connected: true }, + record: { outputActive: true }, + currentProgramSceneName: "Demo", + }); + expect(textOf(items.contents)).toMatchObject({ sceneItems: [{ sourceName: "Be Right Back source" }] }); + }); + + it("returns a scene screenshot as a JPEG blob", async () => { + const { contents } = await harness.mcpClient.readResource({ uri: "obs://scene/Demo/screenshot" }); + + expect(contents[0]).toMatchObject({ mimeType: "image/jpeg", blob: expect.any(String) }); + const request = harness.fakeObs.history().find(({ frame }) => frame.d.requestType === "GetSourceScreenshot"); + expect(request?.frame.d.requestData).toMatchObject({ sourceName: "Demo", imageFormat: "jpeg", imageWidth: 960 }); + }); + + it("notifies subscribers when OBS reports a change, and only for subscribed resources", async () => { + const updated: string[] = []; + harness.mcpClient.setNotificationHandler("notifications/resources/updated", async (notification) => { + updated.push(notification.params.uri); + }); + await harness.mcpClient.readResource({ uri: "obs://status" }); // Connects OBS. + await harness.mcpClient.subscribeResource({ uri: "obs://status" }); + await harness.mcpClient.subscribeResource({ uri: "obs://scene/Be%20Right%20Back/items" }); + + harness.fakeObs.sendEvent("RecordStateChanged", { outputActive: false, outputState: "OBS_WEBSOCKET_OUTPUT_STOPPED" }); + harness.fakeObs.sendEvent("SceneItemEnableStateChanged", { sceneName: "Be Right Back", sceneItemId: 1 }); + harness.fakeObs.sendEvent("SceneCreated", { sceneName: "New" }); + + await vi.waitFor(() => expect(updated).toEqual(["obs://status", "obs://scene/Be%20Right%20Back/items"])); + + await harness.mcpClient.unsubscribeResource({ uri: "obs://status" }); + harness.fakeObs.sendEvent("StreamStateChanged", { outputActive: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(updated).toHaveLength(2); + }); + + it("removes its OBS event listeners when the session closes", async () => { + expect(harness.obsClient.listenerCount("RecordStateChanged")).toBeGreaterThan(0); + const { obsClient } = harness; + + await harness.close(); + closed = true; + + expect(obsClient.listenerCount("RecordStateChanged")).toBe(0); + }); +}); + +describe("prompts", () => { + it("lists the workflow prompts", async () => { + const { prompts } = await harness.mcpClient.listPrompts(); + + expect(prompts.map(({ name }) => name).sort()).toEqual(["pre-stream-check", "record-demo"]); + }); + + it("fills record-demo with the window, scene, and length", async () => { + const prompt = await harness.mcpClient.getPrompt({ + name: "record-demo", + arguments: { window: "ChatGPT", scene: "Review", seconds: "20" }, + }); + const [message] = prompt.messages; + const text = message?.content.type === "text" ? message.content.text : ""; + + expect(text).toContain('"ChatGPT" window in the OBS scene "Review"'); + expect(text).toContain("obs-capture-window"); + expect(text).toContain("obs-record-clip with durationSeconds 20 and expectSilent true"); + }); +}); diff --git a/src/tools/resources.ts b/src/tools/resources.ts new file mode 100644 index 0000000..cf4c4d1 --- /dev/null +++ b/src/tools/resources.ts @@ -0,0 +1,190 @@ +/* + * 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 { ResourceTemplate, type McpServer } from "@modelcontextprotocol/server"; +import type { OBSWebSocketClient } from "../client.js"; +import { screenshotResult } from "./screenshot.js"; + +type JsonObject = Record; + +export const STATUS_URI = "obs://status"; +export const SCENES_URI = "obs://scenes"; +export const sceneItemsUri = (sceneName: string) => `obs://scene/${encodeURIComponent(sceneName)}/items`; + +const SCREENSHOT_WIDTH = 960; + +// OBS events that change what each resource returns. +const STATUS_EVENTS = [ + "RecordStateChanged", + "StreamStateChanged", + "ReplayBufferStateChanged", + "VirtualcamStateChanged", + "CurrentProgramSceneChanged", + "CurrentProfileChanged", + "CurrentSceneCollectionChanged", +]; +const SCENE_LIST_EVENTS = [ + "SceneCreated", + "SceneRemoved", + "SceneNameChanged", + "SceneListChanged", + "CurrentProgramSceneChanged", + "CurrentPreviewSceneChanged", + "CurrentSceneCollectionChanged", +]; +const SCENE_ITEM_EVENTS = [ + "SceneItemCreated", + "SceneItemRemoved", + "SceneItemListReindexed", + "SceneItemEnableStateChanged", + "SceneItemLockStateChanged", +]; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function json(uri: string, value: unknown) { + return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value, null, 2) }] }; +} + +async function sceneNames(client: OBSWebSocketClient): Promise { + const response: unknown = await client.sendRequest("GetSceneList"); + const scenes = isObject(response) && Array.isArray(response.scenes) ? response.scenes : []; + return scenes.filter(isObject).map(({ sceneName }) => sceneName).filter((name): name is string => typeof name === "string"); +} + +/** + * Exposes OBS state as resources and forwards OBS events as resource-updated + * notifications. Every resource duplicates a read-only tool, because few + * clients read resources or honor subscriptions. + */ +export function initialize(server: McpServer, client: OBSWebSocketClient): void { + server.registerResource( + "status", + STATUS_URI, + { + title: "OBS status", + description: "Connection, recording, streaming, and current scene; updates when any of them change", + mimeType: "application/json", + }, + async (uri) => { + const [record, stream, scene] = await Promise.all([ + client.sendRequest("GetRecordStatus"), + client.sendRequest("GetStreamStatus"), + client.sendRequest("GetCurrentProgramScene"), + ]); + return json(uri.href, { + connection: client.getConnectionStatus(), + record, + stream, + currentProgramSceneName: isObject(scene) ? scene.currentProgramSceneName : null, + }); + }, + ); + + server.registerResource( + "scenes", + SCENES_URI, + { + title: "OBS scenes", + description: "Scene list with the program and preview scenes; updates when scenes change", + mimeType: "application/json", + }, + async (uri) => json(uri.href, await client.sendRequest("GetSceneList")), + ); + + server.registerResource( + "scene-items", + new ResourceTemplate("obs://scene/{sceneName}/items", { + list: async () => ({ + resources: (await sceneNames(client)).map((sceneName) => ({ + uri: sceneItemsUri(sceneName), + name: `${sceneName} items`, + mimeType: "application/json", + })), + }), + complete: { + sceneName: async (value) => (await sceneNames(client)).filter((name) => name.startsWith(value)), + }, + }), + { + title: "Scene items", + description: "Items in a scene with their transforms; updates when items are added, removed, shown, or hidden", + mimeType: "application/json", + }, + async (uri, { sceneName }) => json( + uri.href, + await client.sendRequest("GetSceneItemList", { sceneName: decodeURIComponent(String(sceneName)) }), + ), + ); + + server.registerResource( + "scene-screenshot", + new ResourceTemplate("obs://scene/{sceneName}/screenshot", { list: undefined }), + { + title: "Scene screenshot", + description: `A ${SCREENSHOT_WIDTH}px-wide JPEG of a scene as OBS renders it`, + mimeType: "image/jpeg", + }, + async (uri, { sceneName }) => { + const response: unknown = await client.sendRequest("GetSourceScreenshot", { + sourceName: decodeURIComponent(String(sceneName)), + imageFormat: "jpeg", + imageWidth: SCREENSHOT_WIDTH, + }); + const image = screenshotResult(response).content.find((block) => block.type === "image"); + if (!image || image.type !== "image") throw new Error("OBS returned no screenshot"); + return { contents: [{ uri: uri.href, mimeType: image.mimeType, blob: image.data }] }; + }, + ); + + forwardEvents(server, client); +} + +/** + * 2025-era clients subscribe per URI; 2026-07-28 clients choose URIs on their + * subscriptions/listen stream, which the SDK filters. A 2025 session has + * client capabilities from initialize, which is how the two are told apart. + */ +function forwardEvents(server: McpServer, client: OBSWebSocketClient): void { + const subscriptions = new Set(); + const lowLevel = server.server; + lowLevel.registerCapabilities({ resources: { subscribe: true, listChanged: true } }); + lowLevel.setRequestHandler("resources/subscribe", async (request) => { + subscriptions.add(request.params.uri); + return {}; + }); + lowLevel.setRequestHandler("resources/unsubscribe", async (request) => { + subscriptions.delete(request.params.uri); + return {}; + }); + + const notify = (uri: string) => { + const legacySession = lowLevel.getClientCapabilities() !== undefined; + if (legacySession && !subscriptions.has(uri)) return; + void lowLevel.sendResourceUpdated({ uri }).catch(() => undefined); + }; + + const listeners: [string, (eventData: unknown) => void][] = [ + ...STATUS_EVENTS.map((event): [string, () => void] => [event, () => notify(STATUS_URI)]), + ...SCENE_LIST_EVENTS.map((event): [string, () => void] => [event, () => notify(SCENES_URI)]), + ...SCENE_ITEM_EVENTS.map((event): [string, (eventData: unknown) => void] => [event, (eventData) => { + if (isObject(eventData) && typeof eventData.sceneName === "string") notify(sceneItemsUri(eventData.sceneName)); + }]), + ...["SceneCreated", "SceneRemoved", "SceneNameChanged"].map((event): [string, () => void] => [ + event, + () => void lowLevel.sendResourceListChanged().catch(() => undefined), + ]), + ]; + for (const [event, listener] of listeners) client.on(event, listener); + + // serveStdio builds a server per session; drop this session's listeners with it. + const previousOnClose = lowLevel.onclose; + lowLevel.onclose = () => { + for (const [event, listener] of listeners) client.off(event, listener); + previousOnClose?.(); + }; +} diff --git a/src/tools/toolsets.test.ts b/src/tools/toolsets.test.ts index 2f0baf8..852bfdc 100644 --- a/src/tools/toolsets.test.ts +++ b/src/tools/toolsets.test.ts @@ -14,7 +14,7 @@ function registry(filter?: ToolFilter) { return initialize( new McpServer({ name: "obs-mcp-test", version: "0.0.0" }), new OBSWebSocketClient("ws://127.0.0.1:1"), - filter, + { filter, resourcesAndPrompts: false }, ); } diff --git a/test/live/obs-live.test.ts b/test/live/obs-live.test.ts index 8d0590d..4494b5c 100644 --- a/test/live/obs-live.test.ts +++ b/test/live/obs-live.test.ts @@ -135,6 +135,25 @@ describe.skipIf(!enabled)("live OBS through compiled MCP stdio", () => { expect(mismatches).toEqual([]); }, 60_000); + it("reads status, scene, and scene item resources from real OBS", async () => { + if (!client) throw new Error("Live MCP client is not connected"); + const read = async (uri: string) => { + const { contents } = await client!.readResource({ uri }); + const [first] = contents as { text?: string }[]; + return JSON.parse(first?.text ?? "null") as JsonObject; + }; + + const status = await read("obs://status"); + const scenes = await read("obs://scenes"); + const { resources } = await client.listResources(); + const itemsUri = resources.map(({ uri }) => uri).find((uri) => uri.endsWith("/items")); + + expect(status.connection).toEqual(expect.objectContaining({ connected: true })); + expect(scenes.scenes).toEqual(expect.any(Array)); + expect(itemsUri, "expected at least one scene").toBeDefined(); + expect((await read(itemsUri!)).sceneItems).toEqual(expect.any(Array)); + }); + it.skipIf(!mutationsEnabled)("creates and removes one scene in the approved collection", async () => { const expectedCollection = process.env.OBS_MCP_LIVE_SCENE_COLLECTION; if (!expectedCollection) { diff --git a/test/support/mcp-harness.ts b/test/support/mcp-harness.ts index c461fa7..05f6b5b 100644 --- a/test/support/mcp-harness.ts +++ b/test/support/mcp-harness.ts @@ -24,12 +24,29 @@ export function resultText(result: CallToolResult): string { .join("\n"); } -export async function startMcpHarness(options: FakeOBSOptions = {}): Promise { - const fakeObs = await FakeOBSServer.start(options); +export type HarnessOptions = FakeOBSOptions & { + /** Register tools with OBS_MCP_CONFIRM_LIVE behavior. */ + confirmLive?: boolean; + /** Declare form elicitation and answer every elicitation with this. */ + onElicit?: (message: string) => { + action: "accept" | "decline" | "cancel"; + content?: Record; + }; +}; + +export async function startMcpHarness(options: HarnessOptions = {}): Promise { + const { confirmLive = false, onElicit, ...fakeOptions } = options; + const fakeObs = await FakeOBSServer.start(fakeOptions); const obsClient = new OBSWebSocketClient(fakeObs.url); const mcpServer = new McpServer({ name: "obs-mcp-test", version: "0.0.0" }); - const mcpClient = new Client({ name: "obs-mcp-test-client", version: "0.0.0" }); - initialize(mcpServer, obsClient); + const mcpClient = new Client( + { name: "obs-mcp-test-client", version: "0.0.0" }, + onElicit ? { capabilities: { elicitation: { form: {} } } } : {}, + ); + if (onElicit) { + mcpClient.setRequestHandler("elicitation/create", async (request) => onElicit(request.params.message)); + } + initialize(mcpServer, obsClient, { confirmLive }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([mcpServer.connect(serverTransport), mcpClient.connect(clientTransport)]);