UNPKG

touchdesigner-mcp-server

Version:
437 lines (423 loc) 16.1 kB
import axios from "axios"; import { getCompatibilityPolicy, getCompatibilityPolicyType, } from "../core/compatibility.js"; import { createErrorResult, createSuccessResult } from "../core/result.js"; import { EXPECTED_API_VERSION, MCP_SERVER_VERSION, MIN_COMPATIBLE_API_VERSION, } from "../core/version.js"; import { createNode as apiCreateNode, deleteNode as apiDeleteNode, execNodeMethod as apiExecNodeMethod, execPythonScript as apiExecPythonScript, getModuleHelp as apiGetModuleHelp, getNodeDetail as apiGetNodeDetail, getNodeErrors as apiGetNodeErrors, getNodes as apiGetNodes, getTdInfo as apiGetTdInfo, getTdPythonClassDetails as apiGetTdPythonClassDetails, getTdPythonClasses as apiGetTdPythonClasses, updateNode as apiUpdateNode, } from "../gen/endpoints/TouchDesignerAPI.js"; /** * Default implementation of ITouchDesignerApi using generated API clients */ const defaultApiClient = { createNode: apiCreateNode, deleteNode: apiDeleteNode, execNodeMethod: apiExecNodeMethod, execPythonScript: apiExecPythonScript, getModuleHelp: apiGetModuleHelp, getNodeDetail: apiGetNodeDetail, getNodeErrors: apiGetNodeErrors, getNodes: apiGetNodes, getTdInfo: apiGetTdInfo, getTdPythonClassDetails: apiGetTdPythonClassDetails, getTdPythonClasses: apiGetTdPythonClasses, updateNode: apiUpdateNode, }; export const ERROR_CACHE_TTL_MS = 60 * 1000; // 60 seconds export const SUCCESS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Null logger implementation that discards all logs */ const nullLogger = { sendLog: () => { }, }; /** * Handle API error response * @param response - API response object * @returns ErrorResult object indicating failure */ function handleError(response) { if (response.error) { const errorMessage = response.error; return { error: new Error(errorMessage), success: false }; } return { error: new Error("Unknown error occurred"), success: false }; } /** * Handle API response and return a structured result * @param response - API response object * @returns Result object indicating success or failure */ function handleApiResponse(response) { const { success, data } = response; if (!success) { return handleError(response); } if (data === null) { return { error: new Error("No data received"), success: false }; } if (data === undefined) { return { error: new Error("No data received"), success: false }; } return { data, success: true }; } export class TouchDesignerClient { logger; api; verifiedCompatibilityError; cachedCompatibilityCheck; errorCacheTimestamp; successCacheTimestamp; compatibilityNotice; /** * Initialize TouchDesigner client with optional dependencies */ constructor(params = {}) { this.logger = params.logger || nullLogger; this.api = params.httpClient || defaultApiClient; this.verifiedCompatibilityError = null; this.cachedCompatibilityCheck = false; this.errorCacheTimestamp = null; this.successCacheTimestamp = null; this.compatibilityNotice = null; } /** * Log debug message */ logDebug(message, context) { const data = context ? { message, ...context } : { message }; this.logger.sendLog({ data, level: "debug", logger: "TouchDesignerClient", }); } /** * Check if the cached error should be cleared (TTL expired) */ shouldClearErrorCache() { if (!this.errorCacheTimestamp) { return false; } const now = Date.now(); return now - this.errorCacheTimestamp >= ERROR_CACHE_TTL_MS; } /** * Check whether the cached successful compatibility check is still valid */ hasValidSuccessCache() { if (!this.cachedCompatibilityCheck || !this.successCacheTimestamp) { return false; } const now = Date.now(); return now - this.successCacheTimestamp < SUCCESS_CACHE_TTL_MS; } /** * Force the next API call to re-run compatibility verification. * Useful when the user explicitly requests version information. */ invalidateCompatibilityCache(reason) { if (this.cachedCompatibilityCheck) { this.logDebug("Invalidating cached compatibility check", { reason }); } this.cachedCompatibilityCheck = false; this.successCacheTimestamp = null; this.verifiedCompatibilityError = null; this.errorCacheTimestamp = null; this.compatibilityNotice = null; } getAdditionalToolResultContents() { if (!this.compatibilityNotice) { return null; } return [ { annotations: { audience: ["user", "assistant"], priority: this.compatibilityNotice.level === "warning" ? 0.2 : 0.1, }, text: this.compatibilityNotice.message, type: "text", }, ]; } /** * Verify compatibility with the TouchDesigner server */ async verifyCompatibility() { // If we've already verified compatibility successfully, skip re-verification if (this.cachedCompatibilityCheck && !this.verifiedCompatibilityError) { if (this.hasValidSuccessCache()) { return; } this.logDebug("Compatibility cache expired, re-verifying..."); this.invalidateCompatibilityCache("success cache expired"); } // Clear cached error if TTL has expired if (this.verifiedCompatibilityError && this.shouldClearErrorCache()) { this.logDebug("Clearing cached connection error (TTL expired), retrying..."); this.verifiedCompatibilityError = null; this.errorCacheTimestamp = null; this.cachedCompatibilityCheck = false; } if (this.verifiedCompatibilityError) { // Re-log the cached error so users know it's still failing const ttlRemaining = this.errorCacheTimestamp ? Math.max(0, Math.ceil((ERROR_CACHE_TTL_MS - (Date.now() - this.errorCacheTimestamp)) / 1000)) : 0; this.logDebug(`Using cached connection error (retry in ${ttlRemaining} seconds)`, { cacheAge: this.errorCacheTimestamp ? Date.now() - this.errorCacheTimestamp : 0, cachedError: this.verifiedCompatibilityError.message, }); throw this.verifiedCompatibilityError; } const result = await this.verifyVersionCompatibility(); if (result.success) { const compatibilityInfo = result.data; this.verifiedCompatibilityError = null; this.errorCacheTimestamp = null; this.cachedCompatibilityCheck = true; this.successCacheTimestamp = Date.now(); if (compatibilityInfo.level === "warning" && compatibilityInfo.message) { this.compatibilityNotice = { level: compatibilityInfo.level, message: compatibilityInfo.message, }; } else { this.compatibilityNotice = null; } this.logDebug("Compatibility verified successfully"); return; } // Log when we're caching a NEW error this.logDebug(`Caching connection error for ${ERROR_CACHE_TTL_MS / 1000} seconds`, { error: result.error.message, }); this.verifiedCompatibilityError = result.error; this.errorCacheTimestamp = Date.now(); this.cachedCompatibilityCheck = false; this.successCacheTimestamp = null; this.compatibilityNotice = null; throw result.error; } /** * Wrapper for API calls that require compatibility verification * @private */ async apiCall(message, call, context) { this.logDebug(message, context); await this.verifyCompatibility(); const result = await call(); return handleApiResponse(result); } /** * Execute a node method */ async execNodeMethod(params) { return this.apiCall("Executing node method", () => this.api.execNodeMethod(params), { method: params.method, nodePath: params.nodePath, }); } /** * Execute a script in TouchDesigner */ async execPythonScript(params) { return this.apiCall("Executing Python script", () => this.api.execPythonScript(params), { params }); } /** * Get TouchDesigner server information */ async getTdInfo() { this.invalidateCompatibilityCache("tdInfo request"); return this.apiCall("Getting server info", () => this.api.getTdInfo()); } /** * Get list of nodes */ async getNodes(params) { return this.apiCall("Getting nodes for parent", () => this.api.getNodes(params), { parentPath: params.parentPath }); } /** * Get node properties */ async getNodeDetail(params) { return this.apiCall("Getting properties for node", () => this.api.getNodeDetail(params), { nodePath: params.nodePath }); } /** * Get node error information */ async getNodeErrors(params) { return this.apiCall("Checking node errors", () => this.api.getNodeErrors(params), { nodePath: params.nodePath }); } /** * Create a new node */ async createNode(params) { return this.apiCall("Creating node", () => this.api.createNode(params), { nodeName: params.nodeName, nodeType: params.nodeType, parentPath: params.parentPath, }); } /** * Update node properties */ async updateNode(params) { return this.apiCall("Updating node", () => this.api.updateNode(params), { nodePath: params.nodePath, }); } /** * Delete a node */ async deleteNode(params) { return this.apiCall("Deleting node", () => this.api.deleteNode(params), { nodePath: params.nodePath, }); } /** * Get list of available Python classes/modules in TouchDesigner */ async getClasses() { return this.apiCall("Getting Python classes", () => this.api.getTdPythonClasses()); } /** * Get details of a specific class/module */ async getClassDetails(className) { return this.apiCall("Getting class details", () => this.api.getTdPythonClassDetails(className), { className }); } /** * Retrieve Python help() documentation for modules/classes */ async getModuleHelp(params) { return this.apiCall("Getting module help", () => this.api.getModuleHelp(params), { moduleName: params.moduleName }); } async verifyVersionCompatibility() { let tdInfoResult; try { tdInfoResult = await this.api.getTdInfo(); } catch (error) { // Use axios.isAxiosError() for robust network/HTTP error detection // AxiosError includes connection refused, timeout, network errors, etc. // All other errors (TypeError, etc.) are programming errors and should propagate if (!axios.isAxiosError(error)) { // This is a programming error (e.g., TypeError, ReferenceError), not a connection error const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; this.logger.sendLog({ data: { error: errorMessage, errorType: "programming_error", stack: errorStack, }, level: "error", logger: "TouchDesignerClient", }); throw error; } // Handle AxiosError (network/HTTP errors) const rawMessage = error.message || "Unknown network error"; const errorMessage = this.formatConnectionError(rawMessage); this.logger.sendLog({ data: { error: rawMessage, errorType: "connection" }, level: "error", logger: "TouchDesignerClient", }); return createErrorResult(new Error(errorMessage)); } if (!tdInfoResult.success) { const errorMessage = this.formatConnectionError(tdInfoResult.error); this.logger.sendLog({ data: { error: tdInfoResult.error, errorType: "api_response" }, level: "error", logger: "TouchDesignerClient", }); return createErrorResult(new Error(errorMessage)); } const apiVersionRaw = tdInfoResult.data?.mcpApiVersion?.trim() || ""; const result = this.checkVersionCompatibility(MCP_SERVER_VERSION, apiVersionRaw); this.logger.sendLog({ data: { apiVersion: result.details.apiVersion, mcpVersion: result.details.mcpVersion, message: result.message, minRequired: result.details.minRequired, }, level: result.level, logger: "TouchDesignerClient", }); if (result.level === "error") { return createErrorResult(new Error(result.message)); } return createSuccessResult({ level: result.level, message: result.message, }); } /** * Format connection errors with helpful messages */ formatConnectionError(error) { if (!error) { return "Failed to connect to TouchDesigner API server (unknown error)"; } // Check for common connection errors if (error.includes("ECONNREFUSED") || error.toLowerCase().includes("connect refused")) { return `🔌 TouchDesigner Connection Failed Cannot connect to TouchDesigner API server at the configured address. Possible causes: 1. TouchDesigner is not running → Please start TouchDesigner 2. WebServer DAT is not active → Import 'mcp_webserver_base.tox' and ensure it's active 3. Wrong port configuration → Default port is 9981, check your configuration For setup instructions, visit: https://github.com/8beeeaaat/touchdesigner-mcp/releases/latest Original error: ${error}`; } if (error.includes("ETIMEDOUT") || error.includes("timeout")) { return `⏱️ TouchDesigner Connection Timeout The connection to TouchDesigner timed out. Possible causes: 1. TouchDesigner is slow to respond 2. Network issues 3. WebServer DAT is overloaded Try restarting TouchDesigner or check the network connection. Original error: ${error}`; } if (error.includes("ENOTFOUND") || error.includes("getaddrinfo")) { return `🌐 Invalid Host Configuration Cannot resolve the TouchDesigner API server hostname. Please check your host configuration (default: 127.0.0.1) Original error: ${error}`; } // Generic error message return `Failed to connect to TouchDesigner API server: ${error}`; } checkVersionCompatibility(mcpVersion, apiVersion) { const policyType = getCompatibilityPolicyType({ apiVersion, expectedApiVersion: EXPECTED_API_VERSION, }); const policy = getCompatibilityPolicy(policyType); const details = { apiVersion, expectedApiVersion: EXPECTED_API_VERSION, mcpVersion, minRequired: MIN_COMPATIBLE_API_VERSION, }; const message = policy.message(details); return { compatible: policy.compatible, details, level: policy.level, message, }; } }