From 18bcc206d6a297784e24d8f83d3d2e89f23fb405 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Wed, 23 Sep 2026 22:38:38 -0400 Subject: [PATCH] Share one server between agents over HTTP, with a control lease OBS_MCP_HTTP_PORT serves Streamable HTTP on 127.0.0.1 through the SDK's createMcpHandler and a small node:http adapter. Host and Origin must be localhost (DNS rebinding protection), and OBS_MCP_HTTP_TOKEN requires a bearer token. Resource notifications go through the handler's event bus once, instead of per-request listeners that would leak. obs-claim-control gives one client control of OBS for a set time: tools that change OBS refuse other clients' calls, while reading and stopping outputs always work. obs-release-control and obs-control-status manage it, and obs-take-start claims control for the length of the take. Clients are told apart by the name they report. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Vjn8uBEa4mJshaQU3pFuqX --- README.md | 25 ++- src/http.ts | 128 +++++++++++++++ src/server.ts | 49 +++++- src/tools/__snapshots__/tool-contract.json | 58 +++++++ src/tools/index.ts | 15 +- src/tools/lease.test.ts | 123 +++++++++++++++ src/tools/lease.ts | 175 +++++++++++++++++++++ src/tools/resources.ts | 66 +++++--- src/tools/takes.ts | 12 +- src/tools/tool-inventory.test.ts | 2 +- test/e2e/http.e2e.test.ts | 108 +++++++++++++ test/support/tool-count.ts | 2 +- 12 files changed, 723 insertions(+), 40 deletions(-) create mode 100644 src/http.ts create mode 100644 src/tools/lease.test.ts create mode 100644 src/tools/lease.ts create mode 100644 test/e2e/http.e2e.test.ts diff --git a/README.md b/README.md index 5258edf..b6f6a8d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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:/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 ` 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 @@ -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 | @@ -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. @@ -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:/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. @@ -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 diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..4b08cc5 --- /dev/null +++ b/src/http.ts @@ -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; +}; + +export type HttpOptions = { + port: number; + /** When set, every request must carry `Authorization: Bearer `. */ + 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 { + const headers: Record = {}; + 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 { + 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((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((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/src/server.ts b/src/server.ts index 6fa0245..b9d6325 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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( @@ -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 | null = null; let reconnectAttempts = 0; @@ -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( @@ -129,10 +146,23 @@ function loadToolFilter(): ToolFilter { export async function startServer(): Promise { 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; @@ -141,8 +171,10 @@ export async function startServer(): Promise { // 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")); @@ -180,11 +212,14 @@ function handleShutdown(reason: string): Promise { Promise.allSettled([ obsClient.disconnect(), stdioServer?.close() ?? Promise.resolve(), + httpServer?.close() ?? Promise.resolve(), ]).then(() => undefined), deadline, ]); + stopResourceEvents?.(); stdioServer = null; + httpServer = null; process.exit(0); })(); diff --git a/src/tools/__snapshots__/tool-contract.json b/src/tools/__snapshots__/tool-contract.json index df00607..f7fa6a0 100644 --- a/src/tools/__snapshots__/tool-contract.json +++ b/src/tools/__snapshots__/tool-contract.json @@ -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", @@ -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", @@ -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", diff --git a/src/tools/index.ts b/src/tools/index.ts index ac85d5a..0b80b74 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -29,6 +29,7 @@ import * as trim from "./trim.js"; import * as snapshots from "./snapshots.js"; import * as applyScene from "./apply-scene.js"; import * as toolsetTools from "./toolset-tools.js"; +import * as lease from "./lease.js"; import * as resources from "./resources.js"; import * as prompts from "./prompts.js"; import { withStructuredToolResults } from "./results.js"; @@ -37,6 +38,7 @@ import { ALL_TOOLS, scopedServer, type RegisteredTool, type ToolFilter, type Too const MODULES: ReadonlyArray<[ToolGroup, { initialize(server: McpServer, client: OBSWebSocketClient): void }]> = [ ["general", general], + ["general", lease], ["scenes", scenes], ["sources", sources], ["scene-items", sceneItems], @@ -69,17 +71,20 @@ export type InitializeOptions = { confirmLive?: boolean; /** Resources and prompts; off for servers built only to inspect the tool list. */ resourcesAndPrompts?: boolean; + /** Forward OBS events to this session; off for the per-request servers behind HTTP. */ + sessionNotifications?: boolean; }; export function initialize( server: McpServer, client: OBSWebSocketClient, - { filter = ALL_TOOLS, confirmLive = liveConfirmationEnabled(), resourcesAndPrompts = true }: InitializeOptions = {}, + { filter = ALL_TOOLS, confirmLive = liveConfirmationEnabled(), resourcesAndPrompts = true, sessionNotifications = true }: InitializeOptions = {}, ): RegisteredTool[] { - // 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. + // Registration passes through: group filter -> live confirmation -> take lock -> control lease -> structured results -> server. + // At call time the lease and the take lock run before confirmation, so a refused call never asks the user. const structuredServer = withStructuredToolResults(server); - const lockingServer = takes.withTakeLock(structuredServer, client); + const leasingServer = lease.withControlLease(structuredServer, client); + const lockingServer = takes.withTakeLock(leasingServer, client); const confirmingServer = confirmLive ? withLiveConfirmation(lockingServer) : lockingServer; const registry: RegisteredTool[] = []; for (const [group, module] of MODULES) { @@ -87,7 +92,7 @@ export function initialize( } if (filter.dynamic) toolsetTools.initialize(structuredServer, registry); if (resourcesAndPrompts) { - resources.initialize(server, client); + resources.initialize(server, client, { sessionNotifications }); prompts.initialize(server); } return registry; diff --git a/src/tools/lease.test.ts b/src/tools/lease.test.ts new file mode 100644 index 0000000..64fb39a --- /dev/null +++ b/src/tools/lease.test.ts @@ -0,0 +1,123 @@ +/* + * 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 } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createMcpHandler, McpServer, type McpHttpHandler } from "@modelcontextprotocol/server"; +import { Client, StreamableHTTPClientTransport, type CallToolResult } from "@modelcontextprotocol/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OBSWebSocketClient } from "../client.js"; +import { FakeOBSServer } from "../../test/support/fake-obs-server.js"; +import { healthyObsState, servePreflightState } from "../../test/support/fake-obs-state.js"; +import { initialize } from "./index.js"; +import { activeLease, claim } from "./lease.js"; + +let fakeObs: FakeOBSServer; +let obsClient: OBSWebSocketClient; +let handler: McpHttpHandler; +let directory: string; +const clients: Client[] = []; + +/** One MCP client, named as an agent would name itself, sharing the server with the others. */ +async function agent(name: string): Promise<(tool: string, args?: Record) => Promise> { + const client = new Client({ name, version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } }); + await client.connect(new StreamableHTTPClientTransport( + new URL("http://obs-mcp.test/mcp"), + { fetch: (input, init) => handler.fetch(new Request(input, init)) }, + )); + clients.push(client); + return async (tool, args = {}) => await client.callTool({ name: tool, arguments: args }) as CallToolResult; +} + +function text(result: CallToolResult): string { + return result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"); +} + +beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + directory = mkdtempSync(join(tmpdir(), "obs-mcp-lease-")); + fakeObs = await FakeOBSServer.start(); + servePreflightState(fakeObs, () => healthyObsState(directory)); + fakeObs.respondWith("SetCurrentProgramScene", () => ({})); + fakeObs.respondWith("CreateRecordChapter", () => ({})); + fakeObs.respondWith("StartRecord", () => { + setTimeout(() => fakeObs.sendEvent("RecordStateChanged", { outputActive: true, outputState: "OBS_WEBSOCKET_OUTPUT_STARTED", outputPath: join(directory, "take.mp4") }), 5); + return {}; + }); + fakeObs.respondWith("StopRecord", () => ({ outputPath: join(directory, "take.mp4") })); + obsClient = new OBSWebSocketClient(fakeObs.url); + handler = createMcpHandler(() => { + const server = new McpServer({ name: "obs-mcp-test", version: "0.0.0" }); + initialize(server, obsClient, { sessionNotifications: false }); + return server; + }, { legacy: "reject" }); +}); + +afterEach(async () => { + for (const client of clients.splice(0)) await client.close(); + await handler.close(); + await obsClient.disconnect(); + await fakeObs.close(); + rmSync(directory, { recursive: true, force: true }); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("control lease", () => { + it("lets only the holder change OBS, while anyone may read and stop", async () => { + const codex = await agent("codex-mcp-client"); + const claude = await agent("claude-code"); + + const claimed = await codex("obs-claim-control", { minutes: 10, reason: "recording the demo" }); + expect(text(claimed)).toMatch(/^codex-mcp-client has control \(recording the demo\) until \d\d:\d\d UTC/); + + const refused = await claude("obs-set-current-scene", { sceneName: "Other" }); + expect(refused.isError).toBe(true); + expect(text(refused)).toContain("Not changed: codex-mcp-client has control"); + expect((await claude("obs-get-version")).isError).toBeFalsy(); + expect(text(await claude("obs-control-status"))).toContain("codex-mcp-client has control"); + expect(text(await claude("obs-claim-control"))).toContain("Not claimed"); + expect(text(await claude("obs-release-control"))).toContain("Not released"); + + expect((await codex("obs-set-current-scene", { sceneName: "Other" })).isError).toBeFalsy(); + expect(text(await codex("obs-control-status"))).toContain("(this client)"); + + expect(text(await codex("obs-release-control"))).toBe("Released control of OBS"); + expect((await claude("obs-set-current-scene", { sceneName: "Other" })).isError).toBeFalsy(); + }); + + it("never blocks stopping a recording", async () => { + const codex = await agent("codex-mcp-client"); + const claude = await agent("claude-code"); + await codex("obs-claim-control"); + + expect((await claude("obs-stop-record")).isError).toBeFalsy(); + }); + + it("expires", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + expect(claim(obsClient, "codex-mcp-client", 1, "").ok).toBe(true); + expect(claim(obsClient, "claude-code", 1, "").ok).toBe(false); + + vi.setSystemTime(Date.now() + 61_000); + + expect(activeLease(obsClient)).toBeUndefined(); + expect(claim(obsClient, "claude-code", 1, "").ok).toBe(true); + }); + + it("is claimed for the length of a take by the client that started it", async () => { + const codex = await agent("codex-mcp-client"); + const claude = await agent("claude-code"); + + expect((await codex("obs-take-start")).isError).toBeFalsy(); + expect(text(await claude("obs-control-status"))).toMatch(/codex-mcp-client has control \(recording take \w+\)/); + expect((await claude("obs-take-mark", { name: "Sneaky" })).isError).toBe(true); + expect((await codex("obs-take-mark", { name: "Step 1" })).isError).toBeFalsy(); + + await codex("obs-take-stop"); + expect(text(await claude("obs-control-status"))).toContain("No client has control"); + }); +}); diff --git a/src/tools/lease.ts b/src/tools/lease.ts new file mode 100644 index 0000000..37f0b05 --- /dev/null +++ b/src/tools/lease.ts @@ -0,0 +1,175 @@ +/* + * 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_INFO_META_KEY, type CallToolResult, type McpServer, type ToolAnnotations } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { OBSWebSocketClient } from "../client.js"; +import { READ_ONLY_TOOL } from "./request-tool.js"; + +/** Which client may change OBS, and until when. */ +export type Lease = { holder: string; reason: string; since: number; expiresAt: number }; + +const leases = new WeakMap(); + +/** Always allowed: managing control itself, and stopping, which must never be blocked. */ +const ALWAYS_ALLOWED = new Set([ + "obs-claim-control", + "obs-release-control", + "obs-stop-record", + "obs-stop-stream", + "obs-take-stop", + "obs-stop-virtual-cam", + "obs-stop-replay-buffer", + "obs-stop-output", +]); + +const UNIDENTIFIED = "an unidentified client"; + +type RequestContext = { mcpReq?: { envelope?: Record } }; + +function errorResult(text: string): CallToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +/** + * The calling client's name: from the request envelope on 2026-07-28, or + * from initialize on 2025-era sessions. Clients with the same name share a lease. + */ +export function callerName(server: McpServer, ctx: unknown): string { + const envelope = (ctx as RequestContext | undefined)?.mcpReq?.envelope; + const info = envelope?.[CLIENT_INFO_META_KEY] as { name?: unknown } | undefined; + if (typeof info?.name === "string" && info.name) return info.name; + const legacy = server.server.getClientVersion()?.name; + return legacy || UNIDENTIFIED; +} + +export function activeLease(client: OBSWebSocketClient, now = Date.now()): Lease | undefined { + const lease = leases.get(client); + if (lease && lease.expiresAt <= now) { + leases.delete(client); + return undefined; + } + return lease; +} + +/** Takes control for `holder`, or returns the lease that stands in the way. */ +export function claim(client: OBSWebSocketClient, holder: string, minutes: number, reason: string): { ok: true; lease: Lease } | { ok: false; lease: Lease } { + const current = activeLease(client); + if (current && current.holder !== holder) return { ok: false, lease: current }; + const now = Date.now(); + const lease = { holder, reason, since: current?.since ?? now, expiresAt: now + minutes * 60_000 }; + leases.set(client, lease); + return { ok: true, lease }; +} + +export function release(client: OBSWebSocketClient, holder: string): boolean { + const current = activeLease(client); + if (!current || current.holder !== holder) return false; + leases.delete(client); + return true; +} + +function until(lease: Lease): string { + return new Date(lease.expiresAt).toISOString().slice(11, 16) + " UTC"; +} + +function describe(lease: Lease): string { + return `${lease.holder} has control${lease.reason ? ` (${lease.reason})` : ""} until ${until(lease)}`; +} + +/** + * Wraps tool registration so that, while one client holds control, tools + * that change OBS refuse calls from other clients. Read-only tools and + * stopping are always allowed. Other McpServer methods stay bound to the + * real server. + */ +export function withControlLease(server: McpServer, client: OBSWebSocketClient): 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: { annotations?: ToolAnnotations }, callback: (...args: unknown[]) => unknown) => { + if (config.annotations?.readOnlyHint === true || ALWAYS_ALLOWED.has(name)) { + return Reflect.apply(registerTool, target, [name, config, callback]); + } + const guarded = async (...callbackArgs: unknown[]) => { + const lease = activeLease(client); + if (lease) { + const caller = callerName(target, callbackArgs.at(-1)); + if (caller !== lease.holder) { + return errorResult(`Not changed: ${describe(lease)}. Ask them to call obs-release-control, ` + + "or wait. Reading OBS and stopping outputs still work"); + } + } + return callback(...callbackArgs); + }; + return Reflect.apply(registerTool, target, [name, config, guarded]); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +export function initialize(server: McpServer, client: OBSWebSocketClient): void { + server.registerTool( + "obs-claim-control", + { + title: "Claim Control of OBS", + description: "When several agents share this server, claim OBS for this client: until it is released or " + + "expires, other clients cannot change OBS (reading and stopping outputs still work). Claiming again " + + "extends it. obs-take-start claims control for the length of a take", + inputSchema: z.object({ + minutes: z.number().int().min(1).max(240).default(30).describe("How long to hold control"), + reason: z.string().max(200).optional().describe("Shown to other clients, e.g. \"recording the demo\""), + }), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ minutes, reason }, ctx): Promise => { + const holder = callerName(server, ctx); + const result = claim(client, holder, minutes, reason ?? ""); + if (!result.ok) return errorResult(`Not claimed: ${describe(result.lease)}`); + return { + content: [{ type: "text", text: `${describe(result.lease)}. Release it with obs-release-control` }], + structuredContent: { ...result.lease }, + }; + }, + ); + + server.registerTool( + "obs-release-control", + { + title: "Release Control of OBS", + description: "Let other clients change OBS again after obs-claim-control", + inputSchema: z.object({}), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async (ctx): Promise => { + const holder = callerName(server, ctx); + const current = activeLease(client); + if (release(client, holder)) return { content: [{ type: "text", text: "Released control of OBS" }] }; + return { content: [{ type: "text", text: current ? `Not released: ${describe(current)}` : "No client has control" }], ...(current ? { isError: true } : {}) }; + }, + ); + + server.registerTool( + "obs-control-status", + { + title: "Control Status", + description: "Report which client, if any, holds control of OBS and until when", + inputSchema: z.object({}), + annotations: READ_ONLY_TOOL, + }, + async (ctx): Promise => { + const lease = activeLease(client); + const caller = callerName(server, ctx); + return { + content: [{ type: "text", text: lease ? `${describe(lease)}${lease.holder === caller ? " (this client)" : ""}` : "No client has control; any client may change OBS" }], + structuredContent: { caller, lease: lease ?? null }, + }; + }, + ); +} diff --git a/src/tools/resources.ts b/src/tools/resources.ts index b6c59da..5ac91a0 100644 --- a/src/tools/resources.ts +++ b/src/tools/resources.ts @@ -65,7 +65,16 @@ async function sceneNames(client: OBSWebSocketClient): Promise { * notifications. Every resource duplicates a read-only tool, because few * clients read resources or honor subscriptions. */ -export function initialize(server: McpServer, client: OBSWebSocketClient): void { +export type ResourceOptions = { + /** + * Forward OBS events to this server's session as resource notifications. + * Off for the per-request servers behind HTTP, which forward once through + * the handler's event bus instead (see forwardResourceEvents). + */ + sessionNotifications?: boolean; +}; + +export function initialize(server: McpServer, client: OBSWebSocketClient, { sessionNotifications = true }: ResourceOptions = {}): void { server.registerResource( "status", STATUS_URI, @@ -157,7 +166,41 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void }, ); - forwardEvents(server, client); + if (sessionNotifications) { + forwardEvents(server, client); + } else { + // Stateless per-request servers cannot remember a 2025-style subscription; + // modern clients receive updates on their subscriptions/listen stream. + server.server.registerCapabilities({ resources: { subscribe: true, listChanged: true } }); + server.server.setRequestHandler("resources/subscribe", async () => ({})); + server.server.setRequestHandler("resources/unsubscribe", async () => ({})); + } +} + +/** + * Calls onUpdated with each resource URI that OBS events change, and + * onListChanged when scenes come and go. Returns a function that stops. + */ +export function forwardResourceEvents( + client: OBSWebSocketClient, + onUpdated: (uri: string) => void, + onListChanged: () => void, +): () => void { + const listeners: [string, (eventData: unknown) => void][] = [ + ...STATUS_EVENTS.map((event): [string, () => void] => [event, () => onUpdated(STATUS_URI)]), + ...SCENE_LIST_EVENTS.map((event): [string, () => void] => [event, () => onUpdated(SCENES_URI)]), + ...SCENE_ITEM_EVENTS.map((event): [string, (eventData: unknown) => void] => [event, (eventData) => { + if (isObject(eventData) && typeof eventData.sceneName === "string") onUpdated(sceneItemsUri(eventData.sceneName)); + }]), + ...["SceneCreated", "SceneRemoved", "SceneNameChanged"].map((event): [string, () => void] => [event, onListChanged]), + ]; + for (const [event, listener] of listeners) client.on(event, listener); + const onTakeUpdate = () => onUpdated(TAKE_URI); + takeUpdates.on("update", onTakeUpdate); + return () => { + for (const [event, listener] of listeners) client.off(event, listener); + takeUpdates.off("update", onTakeUpdate); + }; } /** @@ -183,27 +226,12 @@ function forwardEvents(server: McpServer, client: OBSWebSocketClient): void { 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); - const onTakeUpdate = () => notify(TAKE_URI); - takeUpdates.on("update", onTakeUpdate); + const stop = forwardResourceEvents(client, notify, () => void lowLevel.sendResourceListChanged().catch(() => undefined)); // 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); - takeUpdates.off("update", onTakeUpdate); + stop(); previousOnClose?.(); }; } diff --git a/src/tools/takes.ts b/src/tools/takes.ts index 13e23ef..15e2a43 100644 --- a/src/tools/takes.ts +++ b/src/tools/takes.ts @@ -12,6 +12,7 @@ import type { OBSWebSocketClient } from "../client.js"; import { RECORD_OUTPUT, startOutputAndConfirm } from "./output-start.js"; import { runPreflight } from "./preflight.js"; import { READ_ONLY_TOOL } from "./request-tool.js"; +import { callerName, claim, release } from "./lease.js"; import { describeTake, TakeMonitor, writeTakeLog, type TakeSummary } from "./take-monitor.js"; type JsonObject = Record; @@ -86,6 +87,8 @@ export type Take = { /** Refuse changes that break a running recording. */ locked: boolean; chapterNotes: string[]; + /** The client that holds control of OBS because it started this take. */ + leaseHolder?: string; }; const takes = new WeakMap(); @@ -334,11 +337,14 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async (args) => { + async (args, ctx) => { try { const started = await startTake(client, args); if (!started.ok) return started.result; const { take } = started; + // Other clients sharing this server cannot change OBS until the take stops. + const holder = callerName(server, ctx); + if (claim(client, holder, 240, `recording take ${take.id}`).ok) take.leaseHolder = holder; const status = take.monitor.summary(); return { content: [{ @@ -421,7 +427,9 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void async () => { const take = currentTake(client); if (!take) return noTake(); - return stopTake(client, take); + const result = await stopTake(client, take); + if (take.leaseHolder) release(client, take.leaseHolder); + return result; }, ); } diff --git a/src/tools/tool-inventory.test.ts b/src/tools/tool-inventory.test.ts index 62846ae..c433553 100644 --- a/src/tools/tool-inventory.test.ts +++ b/src/tools/tool-inventory.test.ts @@ -33,7 +33,7 @@ describe("tool contract snapshot", () => { .map(({ name }) => name); expect(unsafe).toEqual([]); const readOnlyMutators = contract - .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-(sleep|preflight|take-status|contact-sheet|snapshot)$/.test(name)) + .filter(({ name, annotations }) => annotations?.readOnlyHint && !/^obs-(get|list|test|describe)-|^obs-(sleep|preflight|take-status|contact-sheet|snapshot|control-status)$/.test(name)) .map(({ name }) => name); expect(readOnlyMutators).toEqual([]); await expect(`${JSON.stringify(contract, null, 2)}\n`) diff --git a/test/e2e/http.e2e.test.ts b/test/e2e/http.e2e.test.ts new file mode 100644 index 0000000..a387d81 --- /dev/null +++ b/test/e2e/http.e2e.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { spawn, type ChildProcess } from "node:child_process"; +import http from "node:http"; +import { fileURLToPath } from "node:url"; +import { Client, StreamableHTTPClientTransport, type CallToolResult } from "@modelcontextprotocol/client"; +import { getDefaultEnvironment } from "@modelcontextprotocol/client/stdio"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FakeOBSServer } from "../support/fake-obs-server.js"; + +const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); +const cliPath = fileURLToPath(new URL("../../build/index.js", import.meta.url)); + +const children: ChildProcess[] = []; +const clients: Client[] = []; +const fakes: FakeOBSServer[] = []; + +/** Starts the compiled server in HTTP mode on a free port and returns its MCP URL. */ +async function startHttpServer(fake: FakeOBSServer, env: Record = {}): Promise { + const child = spawn(process.execPath, [cliPath], { + cwd: projectRoot, + env: { ...getDefaultEnvironment(), OBS_WEBSOCKET_URL: fake.url, OBS_MCP_HTTP_PORT: "0", ...env }, + stdio: ["ignore", "ignore", "pipe"], + }); + children.push(child); + let stderr = ""; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`No HTTP URL in stderr:\n${stderr}`)), 5_000); + child.stderr!.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + const match = /Serving MCP over HTTP at (\S+)/.exec(stderr); + if (match?.[1]) { + clearTimeout(timer); + resolve(match[1]); + } + }); + }); +} + +async function agent(url: string, name: string, headers: Record = {}) { + const client = new Client({ name, version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } }); + await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } })); + clients.push(client); + return async (tool: string, args: Record = {}) => await client.callTool({ name: tool, arguments: args }) as CallToolResult; +} + +/** A raw request, so the Host header can be set (fetch forbids it). */ +function rawPost(url: string, headers: Record): Promise { + const target = new URL(url); + return new Promise((resolve, reject) => { + const request = http.request({ host: target.hostname, port: target.port, path: target.pathname, method: "POST", headers: { "content-type": "application/json", ...headers } }, (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + request.on("error", reject); + request.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })); + }); +} + +function text(result: CallToolResult): string { + return result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"); +} + +afterEach(async () => { + await Promise.allSettled(clients.splice(0).map((client) => client.close())); + for (const child of children.splice(0)) child.kill("SIGTERM"); + await Promise.allSettled(fakes.splice(0).map((fake) => fake.close())); +}); + +describe("compiled HTTP server", () => { + it("lets two agents share one server and one OBS connection, with a control lease", async () => { + const fake = await FakeOBSServer.start(); + fakes.push(fake); + fake.respondWith("SetCurrentProgramScene", () => ({})); + const url = await startHttpServer(fake); + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + + const codex = await agent(url, "codex-mcp-client"); + const claude = await agent(url, "claude-code"); + await vi.waitFor(async () => expect(text(await claude("obs-get-status"))).toContain('"connected": true'), { timeout: 3_000 }); + + await codex("obs-claim-control", { reason: "recording" }); + const refused = await claude("obs-set-current-scene", { sceneName: "B" }); + const allowed = await codex("obs-set-current-scene", { sceneName: "A" }); + + expect(text(refused)).toContain("codex-mcp-client has control (recording)"); + expect(allowed.isError).toBeFalsy(); + expect(fake.connectionCount).toBe(1); + }); + + it("rejects other Host headers, other paths, and missing tokens", async () => { + const fake = await FakeOBSServer.start(); + fakes.push(fake); + const url = await startHttpServer(fake, { OBS_MCP_HTTP_TOKEN: "local-secret" }); + const port = new URL(url).port; + + expect(await rawPost(url, { host: "evil.example", authorization: "Bearer local-secret" })).toBe(403); + expect(await rawPost(url, { host: `127.0.0.1:${port}` })).toBe(401); + expect(await rawPost(url, { host: `127.0.0.1:${port}`, authorization: "Bearer wrong-secret!" })).toBe(401); + expect(await rawPost(url.replace("/mcp", "/other"), { host: `127.0.0.1:${port}` })).toBe(404); + + const call = await agent(url, "claude-code", { authorization: "Bearer local-secret" }); + expect((await call("obs-control-status")).isError).toBeFalsy(); + }); +}); diff --git a/test/support/tool-count.ts b/test/support/tool-count.ts index 5349315..8b3f70f 100644 --- a/test/support/tool-count.ts +++ b/test/support/tool-count.ts @@ -5,4 +5,4 @@ */ /** Number of tools the server registers with no tool filtering configured. */ -export const TOOL_COUNT = 168; +export const TOOL_COUNT = 171;