Skip to main content
Glama

Presentation Buddy MCP Server

by yshk-mrt
index.ts62.2 kB
// OBS MCP Server (TypeScript) // Redirect console logging to stderr to keep stdout clean for MCP communication const originalConsoleLog = console.log; const originalConsoleWarn = console.warn; const originalConsoleError = console.error; console.log = (...args: any[]) => { const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); const formatted = `[LOG] ${new Date().toISOString()} ${message}\n`; process.stderr.write(formatted); try { fs.appendFileSync(path.join(os.homedir(), 'obs_mcp_debug.log'), formatted); } catch (_) {} }; console.warn = (...args: any[]) => { const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); const formatted = `[WARN] ${new Date().toISOString()} ${message}\n`; process.stderr.write(formatted); try { fs.appendFileSync(path.join(os.homedir(), 'obs_mcp_debug.log'), formatted); } catch (_) {} }; console.error = (...args: any[]) => { const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); const formatted = `[ERR] ${new Date().toISOString()} ${message}\n`; process.stderr.write(formatted); try { fs.appendFileSync(path.join(os.homedir(), 'obs_mcp_debug.log'), formatted); } catch (_) {} }; console.log("OBS MCP Server starting... (logging to stderr)"); // import { McpServer, ReadResourceRequestSchema, ReadResourceResponse } from "@modelcontextprotocol/sdk/server/mcp.js"; // Temporary: Use any for potentially missing types import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // type ReadResourceRequestSchema = any; // Placeholder, was type const ReadResourceRequestSchema = "resources/read"; // Attempting to use a string identifier type ReadResourceResponse = any; // Placeholder import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z, ZodType } from "zod"; import WebSocket from "ws"; import crypto from "crypto"; import fs from "fs"; import path from "path"; import os from "os"; // --- Configuration --- const OBS_WEBSOCKET_URL = process.env.OBS_WEBSOCKET_URL || "ws://localhost:4455"; const OBS_PASSWORD = process.env.OBS_PASSWORD || "xCxyDXkMEOWGGXwt"; // Keep this secure or use env vars const SCREENSHOTS_DIR_NAME = "screenshots"; const WORKSPACE_ROOT = path.resolve(__dirname, "..", ".."); // Assuming src is one level down from obs-mcp-server-ts const SCREENSHOTS_DIR_ABS = path.join(WORKSPACE_ROOT, "obs-mcp-server-ts", SCREENSHOTS_DIR_NAME); interface ObsRequest { op: number; d: { requestType: string; requestId: string; requestData?: any; rpcVersion?: number; authentication?: string; }; } interface ObsResponse { op: number; d: { requestId?: string; requestType?: string; requestStatus?: { code: number; result: boolean; comment?: string; }; responseData?: any; eventData?: any; eventType?: string; authentication?: { challenge: string; salt: string; }; }; } // --- Globals --- let obsClient: WebSocket | null = null; let obsRequestIdCounter = 0; const pendingObsRequests: Map<string, { resolve: (data: any) => void; reject: (error: any) => void, mcpContext?: any, actionName: string }> = new Map(); let obsConnectionPromise: Promise<void> | null = null; let isObsConnecting = false; let reconnectInterval = 5000; // 5 seconds // --- Load tool definitions --- const TOOL_DEF_FILE = path.join(__dirname, "obs_mcp_tool_def.json"); let toolDefinitions: { name: string, version: string, description: string, actions: any[] } = { name: 'obs-mcp-default', version: '0.0.1', description: 'OBS MCP Default', actions: []}; try { const toolDefContent = fs.readFileSync(TOOL_DEF_FILE, "utf-8"); toolDefinitions = JSON.parse(toolDefContent); } catch (error) { console.error(`Error loading tool definitions from ${TOOL_DEF_FILE}:`, error); // Proceed with empty actions or default } function zodSchemaFromMcpSchema(mcpSchema: any): ZodType | undefined { if (!mcpSchema || !mcpSchema.type) { return undefined; } switch (mcpSchema.type) { case "object": const shape: Record<string, ZodType> = {}; if (mcpSchema.properties) { for (const key in mcpSchema.properties) { const propSchema = zodSchemaFromMcpSchema(mcpSchema.properties[key]); if (propSchema) { shape[key] = propSchema; } } } let zodObject = z.object(shape); // Zod doesn't have a direct equivalent for making only specific fields optional easily from schema alone // MCP's "required" array means fields NOT in it are optional. Zod by default makes all fields in an object required. // We would need to iterate 'properties' and if a key is not in 'mcpSchema.required', apply .optional() // For now, this simplified version assumes all defined properties are required unless explicitly made optional in a more complex mapping if (mcpSchema.required && Array.isArray(mcpSchema.required)) { // This part is tricky with Zod's builder pattern. // A more robust solution might involve constructing the object then iterating properties to add .optional() // For simplicity, we'll rely on Zod's default behavior (all fields required unless .optional() is called) // and users should define schemas carefully. } return zodObject; case "string": let zodString = z.string(); if (mcpSchema.description) zodString = zodString.describe(mcpSchema.description); // Handle enums if present if (mcpSchema.enum && Array.isArray(mcpSchema.enum)) { // z.enum requires at least one value, and TypeScript needs it to be const or string literal array // This dynamic creation is a bit tricky. For simplicity, assuming string enums. if (mcpSchema.enum.length > 0) { // Cast to [string, ...string[]] to satisfy z.enum type return z.enum(mcpSchema.enum as [string, ...string[]]); } } return zodString; case "boolean": let zodBoolean = z.boolean(); if (mcpSchema.description) zodBoolean = zodBoolean.describe(mcpSchema.description); return zodBoolean; case "integer": let zodInteger = z.number().int(); if (mcpSchema.description) zodInteger = zodInteger.describe(mcpSchema.description); return zodInteger; case "number": let zodNumber = z.number(); if (mcpSchema.description) zodNumber = zodNumber.describe(mcpSchema.description); return zodNumber; // Add other type mappings as needed (array, etc.) default: console.warn(`Unsupported MCP schema type: ${mcpSchema.type}`); return undefined; } } async function generateRequestId(): Promise<string> { return crypto.randomUUID(); } async function connectToObs(): Promise<void> { if (obsClient && obsClient.readyState === WebSocket.OPEN) { return Promise.resolve(); } if (isObsConnecting && obsConnectionPromise) { return obsConnectionPromise; } isObsConnecting = true; console.log(`Attempting to connect to OBS at ${OBS_WEBSOCKET_URL}...`); obsConnectionPromise = new Promise((resolve, reject) => { const ws = new WebSocket(OBS_WEBSOCKET_URL); obsClient = ws; // Assign early for listener setup ws.onopen = () => { console.log("OBS WebSocket connection established. Waiting for Hello."); // No immediate resolve here, wait for identify sequence }; ws.onmessage = async (event) => { try { const message = JSON.parse(event.data.toString()) as ObsResponse; // console.log("OBS RX:", JSON.stringify(message, null, 2)); if (message.op === 0) { // Hello console.log("OBS Hello received."); const identifyPayload: ObsRequest = { op: 1, d: { rpcVersion: 1, requestType: "Identify", // Not standard but for clarity requestId: await generateRequestId(), }, }; if (OBS_PASSWORD && message.d.authentication) { const auth = message.d.authentication; const secret = crypto.createHash('sha256').update(OBS_PASSWORD + auth.salt).digest(); const authResponse = crypto.createHash('sha256').update(secret.toString('base64') + auth.challenge).digest().toString('base64'); identifyPayload.d.authentication = authResponse; } else if (message.d.authentication && !OBS_PASSWORD) { console.warn("OBS requires authentication, but no password provided in config."); // Potentially reject or close here if strict auth is needed. } ws.send(JSON.stringify(identifyPayload)); console.log("OBS Identify sent."); } else if (message.op === 2) { // Identified console.log("Successfully connected and identified with OBS."); isObsConnecting = false; resolve(); // Connection is fully established } else if (message.op === 7) { // RequestResponse const reqId = message.d.requestId; if (reqId && pendingObsRequests.has(reqId)) { const pending = pendingObsRequests.get(reqId)!; if (message.d.requestStatus?.code === 100) { // Success code for obs-websocket v5 pending.resolve(message.d.responseData || {}); } else { const errorDetail = message.d.requestStatus?.comment || `OBS Error Code: ${message.d.requestStatus?.code}`; console.error(`OBS request '${message.d.requestType}' failed for MCP action '${pending.actionName}':`, errorDetail); pending.reject(new Error(errorDetail)); } pendingObsRequests.delete(reqId); } } else if (message.op === 5) { // Event // console.log(`OBS Event: ${message.d.eventType}`, message.d.eventData); // TODO: Translate OBS events to MCP events and broadcast // Example: // if (message.d.eventType === "CurrentProgramSceneChanged") { // server.emitEvent("SceneChanged", { to: message.d.eventData?.sceneName }); // } } else { // console.log("OBS Unhandled Op:", message.op, message.d); } } catch (e) { console.error("Error processing OBS message:", e); } }; ws.onerror = (error) => { console.error("OBS WebSocket error:", error.message); if (isObsConnecting) { // Only reject original promise if this is the initial connection attempt reject(error); } // Don't reset obsClient here if onclose will handle it }; ws.onclose = (event) => { console.log(`OBS WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason}`); obsClient = null; // Critical to mark as disconnected if (isObsConnecting) { // If it was still in the connecting phase reject(new Error(`OBS connection closed during setup. Code: ${event.code}`)); } isObsConnecting = false; obsConnectionPromise = null; // Clear the promise so next call attempts to reconnect // Clean up pending requests on disconnect pendingObsRequests.forEach(pending => { pending.reject(new Error("OBS WebSocket disconnected")); }); pendingObsRequests.clear(); console.log(`Attempting to reconnect to OBS in ${reconnectInterval / 1000}s...`); setTimeout(() => { connectToObs().catch(err => console.error("Reconnect attempt failed:", err)); // Start a new connection attempt }, reconnectInterval); }; }); return obsConnectionPromise; } async function sendToObs<T = any>(requestType: string, requestData?: any, mcpContext?: any, actionName?: string): Promise<T> { if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { if (!isObsConnecting) { // Avoid multiple concurrent connection attempts from sendToObs console.log("OBS not connected. Attempting to connect before sending."); try { await connectToObs(); // Attempt to connect first if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { // Check again after attempt throw new Error("OBS WebSocket is not connected after attempt."); } } catch (connectError) { console.error("Failed to connect to OBS for sendToObs:", connectError); throw new Error(`OBS Connection Error: ${(connectError as Error).message}`); } } else { // If it's already connecting, we should ideally wait for obsConnectionPromise // For now, throwing an error or queuing the request might be options. // Let's throw, as the logic to queue and wait can get complex quickly here. console.warn("OBS is currently connecting. Request will likely fail or be delayed."); // Fall through to try sending, but it might fail if connection isn't ready. } } // Re-check after potential connectToObs call if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { throw new Error("OBS WebSocket is not connected or ready."); } const obsRequestId = await generateRequestId(); const payload: ObsRequest = { op: 6, // Request d: { requestType, requestId: obsRequestId, requestData, }, }; console.log("Full OBS request payload:", JSON.stringify(payload, null, 2)); return new Promise((resolve, reject) => { if (mcpContext && actionName) { // Only store if it's an MCP-initiated request needing response mapping pendingObsRequests.set(obsRequestId, { resolve, reject, mcpContext, actionName }); } else { // If not from MCP tool (e.g. internal calls), handle response directly or differently // For now, still use pendingObsRequests for simplicity pendingObsRequests.set(obsRequestId, { resolve, reject, mcpContext: mcpContext!, actionName: actionName || requestType }); } // console.log("OBS TX:", JSON.stringify(payload, null, 2)); obsClient!.send(JSON.stringify(payload), (err) => { if (err) { console.error(`Error sending to OBS for request '${requestType}':`, err); pendingObsRequests.delete(obsRequestId); reject(err); } }); // Timeout for OBS requests setTimeout(() => { if (pendingObsRequests.has(obsRequestId)) { console.warn(`OBS request '${requestType}' (ID: ${obsRequestId}) timed out.`); pendingObsRequests.get(obsRequestId)?.reject(new Error(`OBS request '${requestType}' timed out.`)); pendingObsRequests.delete(obsRequestId); } }, 10000); // 10 seconds timeout }); } // --- MCP Server Setup --- const mcpServerOptions = { name: toolDefinitions.name || "obs-mcp-ts", version: toolDefinitions.version || "0.1.0", description: toolDefinitions.description || "MCP Server for controlling OBS Studio (TypeScript)", // tools: [], // Tools are added dynamically later }; const server = new McpServer(mcpServerOptions); // Dynamically add tools from JSON definition if (toolDefinitions && toolDefinitions.actions) { toolDefinitions.actions.forEach(action => { const requestSchema = action.requestDataSchema ? zodSchemaFromMcpSchema(action.requestDataSchema) : undefined; server.tool( action.name, action.description || "", requestSchema ? { params: requestSchema } : {}, async (params: any, context: any) => { // Using any for context if ToolContext is not easily available console.log(`Tool '${action.name}' called with params:`, JSON.stringify(params, null, 2)); try { // Ensure OBS is connected before attempting to send a command if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { if (!isObsConnecting) { console.log("OBS not connected. Attempting to connect before processing tool action."); await connectToObs(); // This will throw if it fails if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { throw new Error("Failed to connect to OBS."); } } else { // Wait for existing connection attempt await obsConnectionPromise; if (!obsClient || obsClient.readyState !== WebSocket.OPEN) { throw new Error("Failed to connect to OBS after waiting."); } } } let obsResponseData: any; // Map MCP actions to OBS requestTypes and params switch (action.name) { case "SwitchScene": console.log("SwitchScene params:", JSON.stringify(params, null, 2)); // Extract the scene name from the parameters const sceneName = params.scene || (params.params && params.params.scene); console.log("Extracted sceneName:", sceneName); if (!sceneName) { throw new Error("No scene name provided"); } try { // NOTE: Changed from SetCurrentProgramScene to use the direct parameters expected by OBS obsResponseData = await sendToObs( "SetCurrentProgramScene", { "sceneName": sceneName }, // Make sure we use the exact field name OBS expects context, action.name ); console.log("OBS response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("OBS error:", error); throw error; } break; case "StartStream": obsResponseData = await sendToObs("StartStream", {}, context, action.name); break; case "StopStream": obsResponseData = await sendToObs("StopStream", {}, context, action.name); break; case "StartRecording": obsResponseData = await sendToObs("StartRecord", {}, context, action.name); break; case "StopRecording": obsResponseData = await sendToObs("StopRecord", {}, context, action.name); break; case "SetSourceVisibility": console.log("SetSourceVisibility params:", JSON.stringify(params, null, 2)); // Try different parameter access patterns const sourceScene = params.scene || (params.params && params.params.scene); const sourceName = params.source || (params.params && params.params.source); const visible = params.visible !== undefined ? params.visible : (params.params && params.params.visible !== undefined ? params.params.visible : null); if (!sourceScene || !sourceName || visible === null) { throw new Error("Missing required parameters: scene, source, or visible"); } const sceneItemIdResponse = await sendToObs<{ sceneItemId: number }>( "GetSceneItemId", { sceneName: sourceScene, sourceName: sourceName }, context, action.name ); obsResponseData = await sendToObs( "SetSceneItemEnabled", { sceneName: sourceScene, sceneItemId: sceneItemIdResponse.sceneItemId, sceneItemEnabled: visible }, context, action.name ); break; case "GetSceneItemList": // Extract sceneName from params const sceneNameForItems = params.sceneName || params.scene || (params.params && (params.params.sceneName || params.params.scene)); if (!sceneNameForItems) { throw new Error("Missing required parameter: sceneName"); } obsResponseData = await sendToObs( "GetSceneItemList", { sceneName: sceneNameForItems }, context, action.name ); // Transform isGroup: null to isGroup: false to match schema if (obsResponseData && Array.isArray(obsResponseData.sceneItems)) { obsResponseData.sceneItems.forEach((item: any) => { if (item.isGroup === null) { item.isGroup = false; } }); } break; case "GetSceneList": obsResponseData = await sendToObs("GetSceneList", {}, context, action.name); break; case "SetTextContent": console.log("SetTextContent params:", JSON.stringify(params, null, 2)); // Extract parameters const textSourceName = params.source || (params.params && params.params.source); const textContent = params.text || (params.params && params.params.text); if (!textSourceName || textContent === undefined) { throw new Error("Missing required parameters: source or text"); } console.log(`Setting text content for source "${textSourceName}" to: ${textContent}`); try { // Use SetInputSettings to update the text property obsResponseData = await sendToObs( "SetInputSettings", { inputName: textSourceName, inputSettings: { text: textContent } }, context, action.name ); console.log("SetTextContent response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("Error setting text content:", error); throw error; } break; case "SetAudioMute": console.log("SetAudioMute params:", JSON.stringify(params, null, 2)); // Extract parameters const audioSourceName = params.source || (params.params && params.params.source); const muteState = params.mute !== undefined ? params.mute : (params.params && params.params.mute !== undefined ? params.params.mute : null); if (!audioSourceName || muteState === null) { throw new Error("Missing required parameters: source or mute"); } console.log(`Setting mute state for source "${audioSourceName}" to: ${muteState}`); try { obsResponseData = await sendToObs( "SetInputMute", { inputName: audioSourceName, inputMuted: muteState }, context, action.name ); console.log("SetAudioMute response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("Error setting audio mute state:", error); throw error; } break; case "SetAudioVolume": console.log("SetAudioVolume params:", JSON.stringify(params, null, 2)); // Extract parameters const volumeSourceName = params.source || (params.params && params.params.source); const volumeLevel = params.volume !== undefined ? params.volume : (params.params && params.params.volume !== undefined ? params.params.volume : null); if (!volumeSourceName || volumeLevel === null) { throw new Error("Missing required parameters: source or volume"); } if (volumeLevel < 0 || volumeLevel > 1) { throw new Error("Volume must be between 0.0 and 1.0"); } console.log(`Setting volume for source "${volumeSourceName}" to: ${volumeLevel}`); try { obsResponseData = await sendToObs( "SetInputVolume", { inputName: volumeSourceName, inputVolumeMul: volumeLevel // Using multiplier (0.0 to 1.0) format }, context, action.name ); console.log("SetAudioVolume response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("Error setting audio volume:", error); throw error; } break; case "SetSourcePosition": console.log("SetSourcePosition params:", JSON.stringify(params, null, 2)); // Extract parameters const posSceneName = params.scene || (params.params && params.params.scene); const posSourceName = params.source || (params.params && params.params.source); const xPos = params.x !== undefined ? params.x : (params.params && params.params.x !== undefined ? params.params.x : null); const yPos = params.y !== undefined ? params.y : (params.params && params.params.y !== undefined ? params.params.y : null); if (!posSceneName || !posSourceName || xPos === null || yPos === null) { throw new Error("Missing required parameters: scene, source, x, or y"); } console.log(`Setting position for source "${posSourceName}" in scene "${posSceneName}" to: x=${xPos}, y=${yPos}`); try { // First get the scene item ID const posItemIdResponse = await sendToObs<{ sceneItemId: number }>( "GetSceneItemId", { sceneName: posSceneName, sourceName: posSourceName }, context, action.name ); // Then set the position obsResponseData = await sendToObs( "SetSceneItemTransform", { sceneName: posSceneName, sceneItemId: posItemIdResponse.sceneItemId, sceneItemTransform: { positionX: xPos, positionY: yPos } }, context, action.name ); console.log("SetSourcePosition response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("Error setting source position:", error); throw error; } break; case "SetSourceScale": console.log("SetSourceScale params:", JSON.stringify(params, null, 2)); // Extract parameters const scaleSceneName = params.scene || (params.params && params.params.scene); const scaleSourceName = params.source || (params.params && params.params.source); const scaleX = params.scaleX !== undefined ? params.scaleX : (params.params && params.params.scaleX !== undefined ? params.params.scaleX : null); const scaleY = params.scaleY !== undefined ? params.scaleY : (params.params && params.params.scaleY !== undefined ? params.params.scaleY : null); if (!scaleSceneName || !scaleSourceName || scaleX === null || scaleY === null) { throw new Error("Missing required parameters: scene, source, scaleX, or scaleY"); } console.log(`Setting scale for source "${scaleSourceName}" in scene "${scaleSceneName}" to: scaleX=${scaleX}, scaleY=${scaleY}`); try { // First get the scene item ID const scaleItemIdResponse = await sendToObs<{ sceneItemId: number }>( "GetSceneItemId", { sceneName: scaleSceneName, sourceName: scaleSourceName }, context, action.name ); // Then set the scale obsResponseData = await sendToObs( "SetSceneItemTransform", { sceneName: scaleSceneName, sceneItemId: scaleItemIdResponse.sceneItemId, sceneItemTransform: { scaleX: scaleX, scaleY: scaleY } }, context, action.name ); console.log("SetSourceScale response:", JSON.stringify(obsResponseData, null, 2)); } catch (error) { console.error("Error setting source scale:", error); throw error; } break; case "TakeSourceScreenshot": // Try to get source from params or params.params const mcpInputParams = params.params || params; const sourceNameForScreenshot = mcpInputParams.source; // Assuming 'source' is the key from MCP if (!sourceNameForScreenshot) { console.error("TakeSourceScreenshot Error: Missing required parameter 'source'. Params received:", JSON.stringify(params, null, 2)); throw new Error("Missing required parameter: source"); } // Ensure sourceNameForScreenshot is a string before calling replace if (typeof sourceNameForScreenshot !== 'string') { console.error("TakeSourceScreenshot Error: 'source' parameter is not a string. Value:", sourceNameForScreenshot); throw new Error("'source' parameter must be a string."); } console.log(`Executing TakeSourceScreenshot for source: ${sourceNameForScreenshot}`); const format = mcpInputParams.imageFormat || "png"; const timestamp = Date.now(); const filename = `screenshot-${sourceNameForScreenshot.replace(/[^a-z0-9]/gi, '_')}-${timestamp}.${format}`; const absoluteFilePath = path.join(SCREENSHOTS_DIR_ABS, filename); // URI that will be returned to the client const resourceUri = `file://${absoluteFilePath}`; console.log(`Attempting to save screenshot to: ${absoluteFilePath}`); try { const obsRequestData: any = { sourceName: sourceNameForScreenshot, // OBS uses sourceName for SaveSourceScreenshot imageFormat: format, imageFilePath: absoluteFilePath, }; if (mcpInputParams.width !== undefined) obsRequestData.imageWidth = mcpInputParams.width; if (mcpInputParams.height !== undefined) obsRequestData.imageHeight = mcpInputParams.height; if (mcpInputParams.compressionQuality !== undefined) obsRequestData.imageCompressionQuality = mcpInputParams.compressionQuality; // Use SaveSourceScreenshot OBS request const obsResponse = await sendToObs("SaveSourceScreenshot", obsRequestData, context, "TakeSourceScreenshot"); console.log(`SaveSourceScreenshot OBS response:`, obsResponse); console.log(`Screenshot for source ${sourceNameForScreenshot} saved to ${absoluteFilePath}. Returning URI: ${resourceUri}`); // Return the URI to the client as a resource return { content: [{ type: "resource", resource: { uri: resourceUri, text: `Screenshot for ${sourceNameForScreenshot} available at ${filename}`, // mimeType: `image/${format}` // Optional: include mimeType if known } }], filename: filename // Additional top-level info }; } catch (e: any) { console.error(`Error in TakeSourceScreenshot for source ${sourceNameForScreenshot}:`, e.message); throw new Error(`Failed to take screenshot for ${sourceNameForScreenshot}: ${e.message}`); } break; case "SetTransitionSettings": const transitionParams = params as { transitionName: string; transitionDuration: number }; console.log(`Executing SetTransitionSettings with params:`, transitionParams); try { await sendToObs("SetCurrentSceneTransition", { transitionName: transitionParams.transitionName }, context, action.name); await sendToObs("SetCurrentSceneTransitionSettings", { transitionSettings: { duration: transitionParams.transitionDuration }, overlay: false }, context, action.name); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in SetTransitionSettings for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "TriggerStudioModeTransition": console.log(`Executing TriggerStudioModeTransition`); try { await sendToObs("TriggerStudioModeTransition", {}, context, action.name); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in TriggerStudioModeTransition for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "PlayPauseMedia": const mediaParams = params as { sourceName: string; mediaAction: string }; console.log(`Executing PlayPauseMedia with params:`, mediaParams); try { await sendToObs("TriggerMediaInputAction", { inputName: mediaParams.sourceName, mediaAction: mediaParams.mediaAction.toUpperCase() }, context, action.name); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in PlayPauseMedia for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "SetMediaTime": const timeParams = params as { sourceName: string; mediaTime: number }; console.log(`Executing SetMediaTime with params:`, timeParams); try { await sendToObs("SetMediaInputCursor", { inputName: timeParams.sourceName, mediaCursor: timeParams.mediaTime }, context, action.name); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in SetMediaTime for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "SaveReplayBuffer": console.log(`Executing SaveReplayBuffer`); try { await sendToObs("SaveReplayBuffer", {}, context, action.name); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in SaveReplayBuffer for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "SaveReplayBufferAndAdd": const replayParams = params as { sceneName: string; sourceName: string; replayFolder?: string }; console.log(`Executing SaveReplayBufferAndAdd with params:`, replayParams); try { // 1. Save the replay buffer await sendToObs("SaveReplayBuffer", {}, context, action.name); // 2. Wait a bit for the file to be written to disk await new Promise(resolve => setTimeout(resolve, 2000)); // 3. Find the latest replay file const defaultRecordingPath = process.env.OBS_REPLAY_PATH || path.join(os.homedir(), 'Movies'); const replayFolder = replayParams.replayFolder || defaultRecordingPath; console.log(`Looking for the latest replay file in: ${replayFolder}`); // Get a list of replay files in the directory let files: string[] = []; try { files = fs.readdirSync(replayFolder) .filter(file => file.toLowerCase().includes('replay') && (file.endsWith('.mp4') || file.endsWith('.mov'))) .map(file => path.join(replayFolder, file)); // Sort by modification time, newest first files.sort((a, b) => { return fs.statSync(b).mtime.getTime() - fs.statSync(a).mtime.getTime(); }); console.log(`Found ${files.length} replay files`); } catch (fsError: any) { console.error(`Error reading replay directory ${replayFolder}:`, fsError.message); return { structuredContent: { success: false, error: `Failed to read replay directory: ${fsError.message}` } }; } if (files.length === 0) { return { structuredContent: { success: false, error: "No replay files found" } }; } // Get the most recent file const latestReplayFile = files[0]; console.log(`Latest replay file: ${latestReplayFile}`); // 4. Create a media source with this file const mediaSourceResponse = await sendToObs<{ sceneItemId: number }>("CreateInput", { sceneName: replayParams.sceneName, inputName: replayParams.sourceName, inputKind: "ffmpeg_source", inputSettings: { local_file: latestReplayFile, looping: true }, sceneItemEnabled: true }, context, action.name); console.log(`Created media source with ID: ${mediaSourceResponse.sceneItemId}`); return { structuredContent: { success: true, filePath: latestReplayFile, sceneItemId: mediaSourceResponse.sceneItemId } }; } catch (e: any) { console.error(`Error in SaveReplayBufferAndAdd for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "CreateSource": const sourceParams = params as { sceneName: string; sourceName: string; sourceKind: string; sourceSettings: object; setVisible?: boolean }; console.log(`Executing CreateSource with params:`, sourceParams); try { const response = await sendToObs<{ sceneItemId: number }>("CreateInput", { sceneName: sourceParams.sceneName, inputName: sourceParams.sourceName, inputKind: sourceParams.sourceKind, inputSettings: sourceParams.sourceSettings, sceneItemEnabled: typeof sourceParams.setVisible === 'boolean' ? sourceParams.setVisible : true }, context, action.name); return { structuredContent: { success: true, sceneItemId: response.sceneItemId } }; } catch (e: any) { console.error(`Error in CreateSource for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "SetShaderFilter": const shaderParams = params as { sourceName: string; filterName: string; shaderCode?: string; shaderParameters?: Record<string, any> }; console.log(`Executing SetShaderFilter with params:`, shaderParams); try { // Directly construct filterSettings without GetSourceFilterInfo const filterSettings: Record<string, any> = {}; if (shaderParams.shaderCode) { // The actual key for shader code might depend on the specific shader filter plugin. // Common ones are 'shader_text', 'glsl', or 'code'. // Assuming 'shader_text' based on common OBS shader filter plugins. filterSettings.shader_text = shaderParams.shaderCode; } if (shaderParams.shaderParameters) { // Shader parameters are often applied directly at the root of filterSettings // or under a specific key like 'defaults'. // This assumes they are direct key-value pairs. for (const [key, value] of Object.entries(shaderParams.shaderParameters)) { filterSettings[key] = value; } } // Apply the constructed filter settings await sendToObs( "SetSourceFilterSettings", { sourceName: shaderParams.sourceName, filterName: shaderParams.filterName, filterSettings: filterSettings }, context, action.name ); console.log(`Successfully attempted to update shader filter settings for ${shaderParams.filterName} on ${shaderParams.sourceName}`); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in SetShaderFilter for OBS:`, e.message); return { structuredContent: { success: false, error: e.message } }; } case "SetLutFilter": console.log(`Executing SetLutFilter with params:`, JSON.stringify(params, null, 2)); try { // Parse the input parameters // Check if params are directly in params or in a nested params object const paramsObj = params.params || params; const sourceName = paramsObj.sourceName; const filterName = paramsObj.filterName; const amount = paramsObj.amount; const path = paramsObj.path; console.log(`SetLutFilter: Parsed parameters - sourceName: "${sourceName}", filterName: "${filterName}", amount: ${amount}, path: ${path || "undefined"}`); if (!sourceName || !filterName) { throw new Error("Missing required parameters: sourceName or filterName"); } console.log(`SetLutFilter: Setting LUT filter "${filterName}" on source "${sourceName}"`); // Direct approach - skip getting current settings console.log("Using direct filter update approach"); const filterSettings: Record<string, any> = {}; if (amount !== undefined) { filterSettings.amount = amount; } if (path) { filterSettings.path = path; } console.log(`Filter settings to apply:`, JSON.stringify(filterSettings, null, 2)); const setFilterResponse = await sendToObs( "SetSourceFilterSettings", { sourceName: sourceName, filterName: filterName, filterSettings: filterSettings }, context, action.name ); console.log(`SetSourceFilterSettings response:`, JSON.stringify(setFilterResponse, null, 2)); console.log(`Successfully updated LUT filter settings directly for ${filterName} on ${sourceName}`); return { structuredContent: { success: true } }; } catch (e: any) { console.error(`Error in SetLutFilter for OBS:`, e.message); console.error(`Error details:`, e); return { structuredContent: { success: false, error: e.message } }; } default: console.error(`Unknown MCP action: ${action.name}`); throw new Error(`Action '${action.name}' is not implemented.`); } // ★★★ デバッグログ追加箇所 ★★★ console.log(`DEBUG: Formatting response for action: '${action.name}'`); console.log(`DEBUG: obsResponseData content for MCP: ${JSON.stringify(obsResponseData, null, 2)}`); // ★★★ここまで★★★ // Return the appropriate response format based on the action if (action.name === "GetSceneItemList") { // EXPERIMENT: Return as content to test Claude client behavior console.log(`DEBUG: Formatting GetSceneItemList response as 'content' for Claude client test.`); return { content: [{ type: "text", text: JSON.stringify(obsResponseData, null, 2) }] }; } else if (action.name === "GetSceneList") { // For GetSceneList, return the full response as content return { content: [ { type: "text", text: JSON.stringify(obsResponseData, null, 2) } ] }; } else { // For other actions, return a simple success message return { content: [ { type: "text", text: `Action '${action.name}' executed successfully.` } ] }; } } catch (error: any) { console.error(`Error executing tool '${action.name}':`, error); throw error; } } ); console.log(`Registered tool: ${action.name}`); }); } else { console.warn("No tool actions found in tool_def.json or file not loaded."); } // Handler for resources/read // server.setRequestHandler(ReadResourceRequestSchema, async (req): Promise<ReadResourceResponse> => { // Temporary: Cast server to any to use setRequestHandler if it's a runtime-available method not in current typings // (server as any).setRequestHandler(ReadResourceRequestSchema, async (req: any): Promise<ReadResourceResponse> => { // const uri = req.params.uri; // console.log(`Received resources/read request for URI: ${uri}`); // if (!uri.startsWith("file://")) { // console.error("Invalid URI scheme. Only file:// is supported."); // throw new Error("Invalid URI scheme. Only file:// is supported."); // } // const requestedFilePath = uri.substring("file://".length); // // IMPORTANT SECURITY CHECK: Ensure the path is within the designated screenshots directory // const normalizedRequestedPath = path.normalize(requestedFilePath); // const normalizedScreenshotsDir = path.normalize(SCREENSHOTS_DIR_ABS); // if (!normalizedRequestedPath.startsWith(normalizedScreenshotsDir)) { // console.error(`Access denied: Path ${normalizedRequestedPath} is outside the allowed directory ${normalizedScreenshotsDir}`); // throw new Error("Access denied: Requested resource is outside the allowed directory."); // } // // Check if file exists before attempting to read // if (!fs.existsSync(normalizedRequestedPath)) { // console.error(`File not found at path: ${normalizedRequestedPath}`); // throw new Error(`File not found: ${uri}`); // } // try { // const fileBuffer = await fs.promises.readFile(normalizedRequestedPath); // const base64Content = fileBuffer.toString("base64"); // let mimeType = "application/octet-stream"; // Default MIME type // const extension = path.extname(normalizedRequestedPath).toLowerCase(); // if (extension === ".png") mimeType = "image/png"; // else if (extension === ".jpg" || extension === ".jpeg") mimeType = "image/jpeg"; // else if (extension === ".webp") mimeType = "image/webp"; // else if (extension === ".bmp") mimeType = "image/bmp"; // // Add more MIME types as needed // console.log(`Successfully read resource ${uri}, MIME type: ${mimeType}, returning ${base64Content.length} Base64 chars.`); // return { // resources: [{ // uri: uri, // mimeType: mimeType, // blob: base64Content, // }], // }; // } catch (error: any) { // console.error(`Error reading resource ${uri}:`, error.message); // throw new Error(`Failed to read resource ${uri}: ${error.message}`); // } // }); console.warn("Resource handling (resources/read) is currently disabled due to 'setRequestHandler' not being available. Screenshots will be saved, but may not be loadable by the client."); async function main() { connectToObs().catch(err => { console.error("Initial OBS connection failed:", err.message); }); const transport = new StdioServerTransport(); await server.connect(transport); // Changed from listen to connect console.log(`OBS MCP Server (${mcpServerOptions.name} v${mcpServerOptions.version}) running on stdio`); } main().catch((error) => { console.error("Fatal error in main():", error); // Ensure OBS client is closed if it exists and an error occurs during startup if (obsClient && obsClient.readyState === WebSocket.OPEN) { obsClient.close(); } process.exit(1); }); process.on('SIGINT', () => { console.log('\nGracefully shutting down from SIGINT (Ctrl-C)'); if (obsClient && obsClient.readyState === WebSocket.OPEN) { console.log('Closing OBS WebSocket connection...'); obsClient.removeAllListeners(); // Important to prevent reconnection attempts during shutdown obsClient.close(1000, "Server shutdown"); // Normal closure obsClient = null; } // MCP server transport might also need graceful shutdown if applicable process.exit(0); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yshk-mrt/obs-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server