touchdesigner-mcp-server 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,8 @@
1
+ import axios from "axios";
2
+ import { getCompatibilityPolicy, getCompatibilityPolicyType, } from "../core/compatibility.js";
1
3
  import { createErrorResult, createSuccessResult } from "../core/result.js";
2
- import { PACKAGE_VERSION } from "../core/version.js";
4
+ import { MCP_SERVER_VERSION, MIN_COMPATIBLE_API_VERSION, } from "../core/version.js";
3
5
  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";
4
- const updateGuide = `
5
- 1. Download the latest [touchdesigner-mcp-td.zip](https://github.com/8beeeaaat/touchdesigner-mcp/releases/latest/download/touchdesigner-mcp-td.zip) from the releases page.
6
- 2. Delete the existing \`touchdesigner-mcp-td\` folder and replace it with the newly extracted contents.
7
- 3. Remove the old \`mcp_webserver_base\` component from your TouchDesigner project and import the \`.tox\` from the new folder.
8
- 4. Restart TouchDesigner and the AI agent running the MCP server (e.g., Claude Desktop).
9
- `;
10
6
  /**
11
7
  * Default implementation of ITouchDesignerApi using generated API clients
12
8
  */
@@ -24,6 +20,7 @@ const defaultApiClient = {
24
20
  getTdPythonClasses: apiGetTdPythonClasses,
25
21
  updateNode: apiUpdateNode,
26
22
  };
23
+ export const ERROR_CACHE_TTL_MS = 5000; // 5 seconds
27
24
  /**
28
25
  * Null logger implementation that discards all logs
29
26
  */
@@ -68,6 +65,21 @@ export class TouchDesignerClient {
68
65
  logger;
69
66
  api;
70
67
  verifiedCompatibilityError;
68
+ cachedCompatibilityCheck;
69
+ errorCacheTimestamp;
70
+ /**
71
+ * Initialize TouchDesigner client with optional dependencies
72
+ */
73
+ constructor(params = {}) {
74
+ this.logger = params.logger || nullLogger;
75
+ this.api = params.httpClient || defaultApiClient;
76
+ this.verifiedCompatibilityError = null;
77
+ this.cachedCompatibilityCheck = false;
78
+ this.errorCacheTimestamp = null;
79
+ }
80
+ /**
81
+ * Log debug message
82
+ */
71
83
  logDebug(message, context) {
72
84
  const data = context ? { message, ...context } : { message };
73
85
  this.logger.sendLog({
@@ -76,176 +88,284 @@ export class TouchDesignerClient {
76
88
  logger: "TouchDesignerClient",
77
89
  });
78
90
  }
91
+ /**
92
+ * Check if the cached error should be cleared (TTL expired)
93
+ */
94
+ shouldClearErrorCache() {
95
+ if (!this.errorCacheTimestamp) {
96
+ return false;
97
+ }
98
+ const now = Date.now();
99
+ return now - this.errorCacheTimestamp >= ERROR_CACHE_TTL_MS;
100
+ }
79
101
  /**
80
102
  * Verify compatibility with the TouchDesigner server
81
103
  */
82
104
  async verifyCompatibility() {
105
+ // If we've already verified compatibility successfully, skip re-verification
106
+ if (this.cachedCompatibilityCheck && !this.verifiedCompatibilityError) {
107
+ return;
108
+ }
109
+ // Clear cached error if TTL has expired
110
+ if (this.verifiedCompatibilityError && this.shouldClearErrorCache()) {
111
+ this.logDebug("Clearing cached connection error (TTL expired), retrying...");
112
+ this.verifiedCompatibilityError = null;
113
+ this.errorCacheTimestamp = null;
114
+ this.cachedCompatibilityCheck = false;
115
+ }
83
116
  if (this.verifiedCompatibilityError) {
117
+ // Re-log the cached error so users know it's still failing
118
+ const ttlRemaining = this.errorCacheTimestamp
119
+ ? Math.max(0, Math.ceil((ERROR_CACHE_TTL_MS - (Date.now() - this.errorCacheTimestamp)) /
120
+ 1000))
121
+ : 0;
122
+ this.logDebug(`Using cached connection error (retry in ${ttlRemaining} seconds)`, {
123
+ cacheAge: this.errorCacheTimestamp
124
+ ? Date.now() - this.errorCacheTimestamp
125
+ : 0,
126
+ cachedError: this.verifiedCompatibilityError.message,
127
+ });
84
128
  throw this.verifiedCompatibilityError;
85
129
  }
86
130
  const result = await this.verifyVersionCompatibility();
87
131
  if (result.success) {
88
132
  this.verifiedCompatibilityError = null;
133
+ this.errorCacheTimestamp = null;
134
+ this.cachedCompatibilityCheck = true;
135
+ this.logDebug("Compatibility verified successfully");
89
136
  return;
90
137
  }
138
+ // Log when we're caching a NEW error
139
+ this.logDebug(`Caching connection error for ${ERROR_CACHE_TTL_MS / 1000} seconds`, {
140
+ error: result.error.message,
141
+ });
91
142
  this.verifiedCompatibilityError = result.error;
143
+ this.errorCacheTimestamp = Date.now();
144
+ this.cachedCompatibilityCheck = false;
92
145
  throw result.error;
93
146
  }
94
147
  /**
95
- * Initialize TouchDesigner client with optional dependencies
148
+ * Wrapper for API calls that require compatibility verification
149
+ * @private
96
150
  */
97
- constructor(params = {}) {
98
- this.logger = params.logger || nullLogger;
99
- this.api = params.httpClient || defaultApiClient;
100
- this.verifiedCompatibilityError = null;
151
+ async apiCall(message, call, context) {
152
+ this.logDebug(message, context);
153
+ await this.verifyCompatibility();
154
+ const result = await call();
155
+ return handleApiResponse(result);
101
156
  }
102
157
  /**
103
158
  * Execute a node method
104
159
  */
105
160
  async execNodeMethod(params) {
106
- this.logDebug("Executing node method", {
161
+ return this.apiCall("Executing node method", () => this.api.execNodeMethod(params), {
107
162
  method: params.method,
108
163
  nodePath: params.nodePath,
109
164
  });
110
- await this.verifyCompatibility();
111
- const result = await this.api.execNodeMethod(params);
112
- return handleApiResponse(result);
113
165
  }
114
166
  /**
115
167
  * Execute a script in TouchDesigner
116
168
  */
117
169
  async execPythonScript(params) {
118
- this.logDebug("Executing Python script", { params });
119
- await this.verifyCompatibility();
120
- const result = await this.api.execPythonScript(params);
121
- return handleApiResponse(result);
170
+ return this.apiCall("Executing Python script", () => this.api.execPythonScript(params), { params });
122
171
  }
123
172
  /**
124
173
  * Get TouchDesigner server information
125
174
  */
126
175
  async getTdInfo() {
127
- this.logDebug("Getting server info");
128
- await this.verifyCompatibility();
129
- const result = await this.api.getTdInfo();
130
- return handleApiResponse(result);
176
+ return this.apiCall("Getting server info", () => this.api.getTdInfo());
131
177
  }
132
178
  /**
133
179
  * Get list of nodes
134
180
  */
135
181
  async getNodes(params) {
136
- this.logDebug("Getting nodes for parent", {
137
- parentPath: params.parentPath,
138
- });
139
- await this.verifyCompatibility();
140
- const result = await this.api.getNodes(params);
141
- return handleApiResponse(result);
182
+ return this.apiCall("Getting nodes for parent", () => this.api.getNodes(params), { parentPath: params.parentPath });
142
183
  }
143
184
  /**
144
185
  * Get node properties
145
186
  */
146
187
  async getNodeDetail(params) {
147
- this.logDebug("Getting properties for node", {
148
- nodePath: params.nodePath,
149
- });
150
- await this.verifyCompatibility();
151
- const result = await this.api.getNodeDetail(params);
152
- return handleApiResponse(result);
188
+ return this.apiCall("Getting properties for node", () => this.api.getNodeDetail(params), { nodePath: params.nodePath });
153
189
  }
154
190
  /**
155
191
  * Get node error information
156
192
  */
157
193
  async getNodeErrors(params) {
158
- this.logDebug("Checking node errors", {
159
- nodePath: params.nodePath,
160
- });
161
- await this.verifyCompatibility();
162
- const result = await this.api.getNodeErrors(params);
163
- return handleApiResponse(result);
194
+ return this.apiCall("Checking node errors", () => this.api.getNodeErrors(params), { nodePath: params.nodePath });
164
195
  }
165
196
  /**
166
197
  * Create a new node
167
198
  */
168
199
  async createNode(params) {
169
- this.logDebug("Creating node", {
200
+ return this.apiCall("Creating node", () => this.api.createNode(params), {
170
201
  nodeName: params.nodeName,
171
202
  nodeType: params.nodeType,
172
203
  parentPath: params.parentPath,
173
204
  });
174
- await this.verifyCompatibility();
175
- const result = await this.api.createNode(params);
176
- return handleApiResponse(result);
177
205
  }
178
206
  /**
179
207
  * Update node properties
180
208
  */
181
209
  async updateNode(params) {
182
- this.logDebug("Updating node", { nodePath: params.nodePath });
183
- await this.verifyCompatibility();
184
- const result = await this.api.updateNode(params);
185
- return handleApiResponse(result);
210
+ return this.apiCall("Updating node", () => this.api.updateNode(params), {
211
+ nodePath: params.nodePath,
212
+ });
186
213
  }
187
214
  /**
188
215
  * Delete a node
189
216
  */
190
217
  async deleteNode(params) {
191
- this.logDebug("Deleting node", { nodePath: params.nodePath });
192
- await this.verifyCompatibility();
193
- const result = await this.api.deleteNode(params);
194
- return handleApiResponse(result);
218
+ return this.apiCall("Deleting node", () => this.api.deleteNode(params), {
219
+ nodePath: params.nodePath,
220
+ });
195
221
  }
196
222
  /**
197
223
  * Get list of available Python classes/modules in TouchDesigner
198
224
  */
199
225
  async getClasses() {
200
- this.logDebug("Getting Python classes");
201
- await this.verifyCompatibility();
202
- const result = await this.api.getTdPythonClasses();
203
- return handleApiResponse(result);
226
+ return this.apiCall("Getting Python classes", () => this.api.getTdPythonClasses());
204
227
  }
205
228
  /**
206
229
  * Get details of a specific class/module
207
230
  */
208
231
  async getClassDetails(className) {
209
- this.logDebug("Getting class details", { className });
210
- await this.verifyCompatibility();
211
- const result = await this.api.getTdPythonClassDetails(className);
212
- return handleApiResponse(result);
232
+ return this.apiCall("Getting class details", () => this.api.getTdPythonClassDetails(className), { className });
213
233
  }
214
234
  /**
215
235
  * Retrieve Python help() documentation for modules/classes
216
236
  */
217
237
  async getModuleHelp(params) {
218
- this.logDebug("Getting module help", { moduleName: params.moduleName });
219
- await this.verifyCompatibility();
220
- const result = await this.api.getModuleHelp(params);
221
- return handleApiResponse(result);
238
+ return this.apiCall("Getting module help", () => this.api.getModuleHelp(params), { moduleName: params.moduleName });
222
239
  }
223
240
  async verifyVersionCompatibility() {
224
- const tdInfoResult = await this.api.getTdInfo();
225
- if (!tdInfoResult.success) {
226
- return createErrorResult(new Error(`Failed to retrieve TouchDesigner info for version check: ${tdInfoResult.error}`));
241
+ let tdInfoResult;
242
+ try {
243
+ tdInfoResult = await this.api.getTdInfo();
227
244
  }
228
- const apiVersion = tdInfoResult.data?.mcpApiVersion?.trim();
229
- if (!apiVersion) {
230
- return createErrorResult(new Error(`TouchDesigner API server did not report its version. Please reinstall the TouchDesigner components from the latest release.\n${updateGuide}`));
245
+ catch (error) {
246
+ // Use axios.isAxiosError() for robust network/HTTP error detection
247
+ // AxiosError includes connection refused, timeout, network errors, etc.
248
+ // All other errors (TypeError, etc.) are programming errors and should propagate
249
+ if (!axios.isAxiosError(error)) {
250
+ // This is a programming error (e.g., TypeError, ReferenceError), not a connection error
251
+ const errorMessage = error instanceof Error ? error.message : String(error);
252
+ const errorStack = error instanceof Error ? error.stack : undefined;
253
+ this.logger.sendLog({
254
+ data: {
255
+ error: errorMessage,
256
+ errorType: "programming_error",
257
+ stack: errorStack,
258
+ },
259
+ level: "error",
260
+ logger: "TouchDesignerClient",
261
+ });
262
+ throw error;
263
+ }
264
+ // Handle AxiosError (network/HTTP errors)
265
+ const rawMessage = error.message || "Unknown network error";
266
+ const errorMessage = this.formatConnectionError(rawMessage);
267
+ this.logger.sendLog({
268
+ data: { error: rawMessage, errorType: "connection" },
269
+ level: "error",
270
+ logger: "TouchDesignerClient",
271
+ });
272
+ return createErrorResult(new Error(errorMessage));
231
273
  }
232
- const normalizedServerVersion = this.normalizeVersion(PACKAGE_VERSION);
233
- const normalizedApiVersion = this.normalizeVersion(apiVersion);
234
- if (normalizedServerVersion !== normalizedApiVersion) {
274
+ if (!tdInfoResult.success) {
275
+ const errorMessage = this.formatConnectionError(tdInfoResult.error);
235
276
  this.logger.sendLog({
236
- data: {
237
- message: "MCP server and TouchDesigner API server versions are incompatible",
238
- touchDesignerApiVersion: normalizedApiVersion,
239
- touchDesignerServerVersion: normalizedServerVersion,
240
- },
277
+ data: { error: tdInfoResult.error, errorType: "api_response" },
241
278
  level: "error",
242
279
  logger: "TouchDesignerClient",
243
280
  });
244
- return createErrorResult(new Error(`Version mismatch detected between MCP server (${normalizedServerVersion}) and TouchDesigner API server (${normalizedApiVersion}). Update both components to the same release.\n${updateGuide}`));
281
+ return createErrorResult(new Error(errorMessage));
282
+ }
283
+ const apiVersionRaw = tdInfoResult.data?.mcpApiVersion?.trim() || "";
284
+ const result = this.checkVersionCompatibility(MCP_SERVER_VERSION, apiVersionRaw);
285
+ this.logger.sendLog({
286
+ data: {
287
+ apiVersion: result.details.apiVersion,
288
+ mcpVersion: result.details.mcpVersion,
289
+ message: result.message,
290
+ minRequired: result.details.minRequired,
291
+ },
292
+ level: result.level,
293
+ logger: "TouchDesignerClient",
294
+ });
295
+ if (result.level === "error") {
296
+ return createErrorResult(new Error(result.message));
245
297
  }
246
298
  return createSuccessResult(undefined);
247
299
  }
248
- normalizeVersion(version) {
249
- return version.trim().replace(/^v/i, "");
300
+ /**
301
+ * Format connection errors with helpful messages
302
+ */
303
+ formatConnectionError(error) {
304
+ if (!error) {
305
+ return "Failed to connect to TouchDesigner API server (unknown error)";
306
+ }
307
+ // Check for common connection errors
308
+ if (error.includes("ECONNREFUSED") ||
309
+ error.toLowerCase().includes("connect refused")) {
310
+ return `🔌 TouchDesigner Connection Failed
311
+
312
+ Cannot connect to TouchDesigner API server at the configured address.
313
+
314
+ Possible causes:
315
+ 1. TouchDesigner is not running
316
+ → Please start TouchDesigner
317
+
318
+ 2. WebServer DAT is not active
319
+ → Import 'mcp_webserver_base.tox' and ensure it's active
320
+
321
+ 3. Wrong port configuration
322
+ → Default port is 9981, check your configuration
323
+
324
+ For setup instructions, visit:
325
+ https://github.com/8beeeaaat/touchdesigner-mcp/releases/latest
326
+
327
+ Original error: ${error}`;
328
+ }
329
+ if (error.includes("ETIMEDOUT") || error.includes("timeout")) {
330
+ return `⏱️ TouchDesigner Connection Timeout
331
+
332
+ The connection to TouchDesigner timed out.
333
+
334
+ Possible causes:
335
+ 1. TouchDesigner is slow to respond
336
+ 2. Network issues
337
+ 3. WebServer DAT is overloaded
338
+
339
+ Try restarting TouchDesigner or check the network connection.
340
+
341
+ Original error: ${error}`;
342
+ }
343
+ if (error.includes("ENOTFOUND") || error.includes("getaddrinfo")) {
344
+ return `🌐 Invalid Host Configuration
345
+
346
+ Cannot resolve the TouchDesigner API server hostname.
347
+
348
+ Please check your host configuration (default: 127.0.0.1)
349
+
350
+ Original error: ${error}`;
351
+ }
352
+ // Generic error message
353
+ return `Failed to connect to TouchDesigner API server: ${error}`;
354
+ }
355
+ checkVersionCompatibility(mcpVersion, apiVersion) {
356
+ const policyType = getCompatibilityPolicyType({ apiVersion, mcpVersion });
357
+ const policy = getCompatibilityPolicy(policyType);
358
+ const details = {
359
+ apiVersion,
360
+ mcpVersion,
361
+ minRequired: MIN_COMPATIBLE_API_VERSION,
362
+ };
363
+ const message = policy.message(details);
364
+ return {
365
+ compatible: policy.compatible,
366
+ details,
367
+ level: policy.level,
368
+ message,
369
+ };
250
370
  }
251
371
  }
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Zod schema for SessionConfig validation
4
+ */
5
+ const SessionConfigSchema = z
6
+ .object({
7
+ cleanupInterval: z.number().int().positive().optional(),
8
+ enabled: z.boolean(),
9
+ ttl: z.number().int().positive().optional(),
10
+ })
11
+ .strict();
12
+ /**
13
+ * Zod schema for StdioTransportConfig validation
14
+ */
15
+ const StdioTransportConfigSchema = z
16
+ .object({
17
+ type: z.literal("stdio"),
18
+ })
19
+ .strict();
20
+ /**
21
+ * Zod schema for StreamableHttpTransportConfig validation
22
+ */
23
+ const StreamableHttpTransportConfigSchema = z
24
+ .object({
25
+ endpoint: z
26
+ .string()
27
+ .min(1, "Endpoint cannot be empty")
28
+ .regex(/^\//, "Endpoint must start with /"),
29
+ host: z.string().min(1, "Host cannot be empty"),
30
+ port: z
31
+ .number()
32
+ .int()
33
+ .positive()
34
+ .min(1)
35
+ .max(65535, "Port must be between 1 and 65535"),
36
+ retryInterval: z.number().int().positive().optional(),
37
+ sessionConfig: SessionConfigSchema.optional(),
38
+ type: z.literal("streamable-http"),
39
+ })
40
+ .strict();
41
+ /**
42
+ * Zod schema for TransportConfig validation (discriminated union)
43
+ */
44
+ export const TransportConfigSchema = z.discriminatedUnion("type", [
45
+ StdioTransportConfigSchema,
46
+ StreamableHttpTransportConfigSchema,
47
+ ]);
48
+ /**
49
+ * Type guard to check if config is StdioTransportConfig
50
+ */
51
+ export function isStdioTransportConfig(config) {
52
+ return config.type === "stdio";
53
+ }
54
+ /**
55
+ * Type guard to check if config is StreamableHttpTransportConfig
56
+ */
57
+ export function isStreamableHttpTransportConfig(config) {
58
+ return config.type === "streamable-http";
59
+ }
60
+ /**
61
+ * Default values for SessionConfig
62
+ */
63
+ export const DEFAULT_SESSION_CONFIG = {
64
+ cleanupInterval: 5 * 60 * 1000, // 5 minutes
65
+ enabled: true,
66
+ ttl: 60 * 60 * 1000, // 1 hour
67
+ };
68
+ /**
69
+ * Default values for StreamableHttpTransportConfig (excluding required fields)
70
+ */
71
+ export const DEFAULT_HTTP_CONFIG = {
72
+ endpoint: "/mcp",
73
+ host: "127.0.0.1",
74
+ sessionConfig: DEFAULT_SESSION_CONFIG,
75
+ };