touchdesigner-mcp-server 1.3.1 → 1.4.1

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.
@@ -0,0 +1,272 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
+ import { createErrorResult, createSuccessResult } from "../core/result.js";
5
+ /**
6
+ * Transport Registry
7
+ *
8
+ * Manages per-session transport and server instances for Streamable HTTP transport.
9
+ * Each session gets its own isolated transport and server to maintain independent MCP protocol state.
10
+ *
11
+ * Key Responsibilities:
12
+ * - Create new transport + server instances for new sessions
13
+ * - Reuse existing transport for requests with valid session IDs
14
+ * - Clean up sessions on close or expiration
15
+ * - Track active session count for health checks
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const registry = new TransportRegistry(config, sessionManager, logger);
20
+ *
21
+ * // In HTTP request handler
22
+ * const transport = await registry.getOrCreate(
23
+ * sessionId,
24
+ * requestBody,
25
+ * () => createTouchDesignerServer()
26
+ * );
27
+ *
28
+ * if (transport) {
29
+ * await transport.handleRequest(req, res, requestBody);
30
+ * }
31
+ *
32
+ * // On shutdown
33
+ * await registry.cleanup();
34
+ * ```
35
+ */
36
+ export class TransportRegistry {
37
+ sessions = new Map();
38
+ config;
39
+ sessionManager;
40
+ logger;
41
+ constructor(config, sessionManager, logger) {
42
+ this.config = config;
43
+ this.sessionManager = sessionManager;
44
+ this.logger = logger;
45
+ if (this.sessionManager) {
46
+ this.sessionManager.setExpirationHandler(async (sessionId) => {
47
+ const entry = this.sessions.get(sessionId);
48
+ if (!entry) {
49
+ return;
50
+ }
51
+ this.logger.sendLog({
52
+ data: `Expiring session via TTL: ${sessionId}`,
53
+ level: "info",
54
+ logger: "TransportRegistry",
55
+ });
56
+ try {
57
+ await entry.transport.close();
58
+ }
59
+ catch (error) {
60
+ const err = error instanceof Error ? error : new Error(String(error));
61
+ this.logger.sendLog({
62
+ data: `Error closing expired session ${sessionId}: ${err.message}`,
63
+ level: "error",
64
+ logger: "TransportRegistry",
65
+ });
66
+ }
67
+ finally {
68
+ this.remove(sessionId);
69
+ }
70
+ });
71
+ }
72
+ }
73
+ /**
74
+ * Get or create transport for a session
75
+ *
76
+ * Logic:
77
+ * 1. If sessionId exists and valid → return existing transport
78
+ * 2. If no sessionId and request is initialize → create new transport + server
79
+ * 3. Otherwise → return null (invalid session)
80
+ *
81
+ * @param sessionId - Session ID from mcp-session-id header (undefined for new sessions)
82
+ * @param requestBody - JSON-RPC request body
83
+ * @param serverFactory - Factory function to create new Server instances
84
+ * @returns Transport instance or null if session is invalid
85
+ */
86
+ async getOrCreate(sessionId, requestBody, serverFactory) {
87
+ // Case 1: Reuse existing session
88
+ if (sessionId && this.sessions.has(sessionId)) {
89
+ const entry = this.sessions.get(sessionId);
90
+ if (entry) {
91
+ if (this.sessionManager) {
92
+ this.sessionManager.touch(sessionId);
93
+ }
94
+ this.logger.sendLog({
95
+ data: `Reusing existing session: ${sessionId}`,
96
+ level: "debug",
97
+ logger: "TransportRegistry",
98
+ });
99
+ return entry.transport;
100
+ }
101
+ }
102
+ // Case 2: Create new session (only for initialize requests without session ID)
103
+ if (!sessionId && isInitializeRequest(requestBody)) {
104
+ return await this.createSession(serverFactory);
105
+ }
106
+ // Case 3: Invalid session (session ID provided but not found, or non-initialize without session)
107
+ this.logger.sendLog({
108
+ data: `Invalid session request: sessionId=${sessionId}, isInitialize=${isInitializeRequest(requestBody)}`,
109
+ level: "warning",
110
+ logger: "TransportRegistry",
111
+ });
112
+ return null;
113
+ }
114
+ /**
115
+ * Create a new session with transport and server instances
116
+ *
117
+ * @param serverFactory - Factory function to create new Server instances
118
+ * @returns Transport instance for the new session
119
+ */
120
+ async createSession(serverFactory) {
121
+ let transport = null;
122
+ let server = null;
123
+ // Create transport with session lifecycle callbacks
124
+ transport = new StreamableHTTPServerTransport({
125
+ // Disable JSON responses (use SSE for streaming)
126
+ enableJsonResponse: false,
127
+ // Session close callback
128
+ onsessionclosed: (sessionId) => {
129
+ this.logger.sendLog({
130
+ data: `Session closed: ${sessionId}`,
131
+ level: "info",
132
+ logger: "TransportRegistry",
133
+ });
134
+ // Remove from registry
135
+ this.remove(sessionId);
136
+ // Cleanup from SessionManager
137
+ if (this.sessionManager) {
138
+ this.sessionManager.cleanup(sessionId);
139
+ }
140
+ },
141
+ // Session initialization callback
142
+ onsessioninitialized: (sessionId) => {
143
+ this.logger.sendLog({
144
+ data: `Session initialized: ${sessionId}`,
145
+ level: "info",
146
+ logger: "TransportRegistry",
147
+ });
148
+ // Store session in registry
149
+ if (transport && server) {
150
+ this.sessions.set(sessionId, {
151
+ createdAt: Date.now(),
152
+ server,
153
+ transport,
154
+ });
155
+ this.logger.sendLog({
156
+ data: `Session stored in registry: ${sessionId} (total: ${this.sessions.size})`,
157
+ level: "debug",
158
+ logger: "TransportRegistry",
159
+ });
160
+ }
161
+ // Register with SessionManager for TTL tracking
162
+ if (this.sessionManager) {
163
+ this.sessionManager.register(sessionId);
164
+ }
165
+ },
166
+ // Retry interval for SSE
167
+ retryInterval: this.config.retryInterval,
168
+ // Session ID generator
169
+ sessionIdGenerator: this.config.sessionConfig?.enabled
170
+ ? () => randomUUID()
171
+ : undefined,
172
+ });
173
+ // Handle transport close event
174
+ transport.onclose = () => {
175
+ if (transport?.sessionId) {
176
+ this.logger.sendLog({
177
+ data: `Transport closed for session: ${transport.sessionId}`,
178
+ level: "debug",
179
+ logger: "TransportRegistry",
180
+ });
181
+ this.remove(transport.sessionId);
182
+ }
183
+ };
184
+ // Create server instance
185
+ server = serverFactory();
186
+ // Connect server to transport
187
+ await server.connect(transport);
188
+ this.logger.sendLog({
189
+ data: "Created new session (session ID will be assigned after initialize)",
190
+ level: "info",
191
+ logger: "TransportRegistry",
192
+ });
193
+ return transport;
194
+ }
195
+ /**
196
+ * Remove session from registry
197
+ *
198
+ * @param sessionId - Session ID to remove
199
+ */
200
+ remove(sessionId) {
201
+ const entry = this.sessions.get(sessionId);
202
+ if (entry) {
203
+ this.sessions.delete(sessionId);
204
+ this.logger.sendLog({
205
+ data: `Session removed from registry: ${sessionId} (remaining: ${this.sessions.size})`,
206
+ level: "info",
207
+ logger: "TransportRegistry",
208
+ });
209
+ }
210
+ }
211
+ /**
212
+ * Get number of active sessions
213
+ *
214
+ * @returns Active session count
215
+ */
216
+ getCount() {
217
+ return this.sessions.size;
218
+ }
219
+ /**
220
+ * Get all session IDs
221
+ *
222
+ * @returns Array of session IDs
223
+ */
224
+ getSessionIds() {
225
+ return Array.from(this.sessions.keys());
226
+ }
227
+ /**
228
+ * Cleanup all sessions
229
+ *
230
+ * Called during graceful shutdown to close all active sessions
231
+ *
232
+ * @returns Result indicating success or failure
233
+ */
234
+ async cleanup() {
235
+ try {
236
+ this.logger.sendLog({
237
+ data: `Cleaning up ${this.sessions.size} active session(s)`,
238
+ level: "info",
239
+ logger: "TransportRegistry",
240
+ });
241
+ const closePromises = [];
242
+ for (const [sessionId, entry] of this.sessions.entries()) {
243
+ try {
244
+ // Close transport (this will trigger onsessionclosed callback)
245
+ closePromises.push(entry.transport.close());
246
+ }
247
+ catch (error) {
248
+ const err = error instanceof Error ? error : new Error(String(error));
249
+ this.logger.sendLog({
250
+ data: `Error closing session ${sessionId}: ${err.message}`,
251
+ level: "error",
252
+ logger: "TransportRegistry",
253
+ });
254
+ }
255
+ }
256
+ // Wait for all transports to close
257
+ await Promise.all(closePromises);
258
+ // Clear the map (should already be empty due to onsessionclosed callbacks)
259
+ this.sessions.clear();
260
+ this.logger.sendLog({
261
+ data: "All sessions cleaned up",
262
+ level: "info",
263
+ logger: "TransportRegistry",
264
+ });
265
+ return createSuccessResult(undefined);
266
+ }
267
+ catch (error) {
268
+ const err = error instanceof Error ? error : new Error(String(error));
269
+ return createErrorResult(new Error(`Failed to cleanup sessions: ${err.message}`));
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,78 @@
1
+ import { ZodError } from "zod";
2
+ import { createErrorResult, createSuccessResult } from "../core/result.js";
3
+ import { TransportConfigSchema } from "./config.js";
4
+ /**
5
+ * Transport configuration validator using Zod schemas
6
+ */
7
+ export class TransportConfigValidator {
8
+ /**
9
+ * Validate a transport configuration
10
+ *
11
+ * @param config - The configuration to validate
12
+ * @returns Result with validated config or validation errors
13
+ */
14
+ static validate(config) {
15
+ try {
16
+ const validatedConfig = TransportConfigSchema.parse(config);
17
+ return createSuccessResult(validatedConfig);
18
+ }
19
+ catch (error) {
20
+ if (error instanceof ZodError) {
21
+ const validationErrors = TransportConfigValidator.formatZodErrors(error);
22
+ const errorMessage = TransportConfigValidator.buildErrorMessage(validationErrors);
23
+ return createErrorResult(new Error(errorMessage));
24
+ }
25
+ // Unexpected error
26
+ const err = error instanceof Error ? error : new Error(String(error));
27
+ return createErrorResult(new Error(`Transport configuration validation failed: ${err.message}`));
28
+ }
29
+ }
30
+ /**
31
+ * Format Zod validation errors into structured ValidationError objects
32
+ *
33
+ * @param zodError - The Zod validation error
34
+ * @returns Array of structured validation errors
35
+ */
36
+ static formatZodErrors(zodError) {
37
+ return zodError.issues.map((err) => ({
38
+ field: err.path.join("."),
39
+ message: err.message,
40
+ }));
41
+ }
42
+ /**
43
+ * Build a comprehensive error message from validation errors
44
+ *
45
+ * @param errors - Array of validation errors
46
+ * @returns Formatted error message
47
+ */
48
+ static buildErrorMessage(errors) {
49
+ const errorLines = errors.map((err) => {
50
+ if (err.field) {
51
+ return ` - ${err.field}: ${err.message}`;
52
+ }
53
+ return ` - ${err.message}`;
54
+ });
55
+ return `Transport configuration validation failed:\n${errorLines.join("\n")}`;
56
+ }
57
+ /**
58
+ * Validate and merge with defaults for HTTP transport
59
+ * This is a convenience method for applying default values after validation
60
+ *
61
+ * @param config - The configuration to validate
62
+ * @returns Result with validated and merged config
63
+ */
64
+ static validateAndMergeDefaults(config) {
65
+ const validationResult = TransportConfigValidator.validate(config);
66
+ if (!validationResult.success) {
67
+ return validationResult;
68
+ }
69
+ // No merging needed for stdio
70
+ if (validationResult.data.type === "stdio") {
71
+ return validationResult;
72
+ }
73
+ // Merge defaults for HTTP transport
74
+ // Note: Defaults are already defined in config.ts
75
+ // This method is here for future extensibility if runtime merging is needed
76
+ return validationResult;
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchdesigner-mcp-server",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "MCP server for TouchDesigner",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,6 +32,7 @@
32
32
  "@types/ws": "^8.18.1",
33
33
  "@types/yargs": "^17.0.35",
34
34
  "axios": "^1.13.2",
35
+ "express": "^5.2.1",
35
36
  "mustache": "^4.2.0",
36
37
  "semver": "^7.7.3",
37
38
  "yaml": "^2.8.2",
@@ -40,6 +41,7 @@
40
41
  "devDependencies": {
41
42
  "@biomejs/biome": "2.3.8",
42
43
  "@openapitools/openapi-generator-cli": "^2.25.2",
44
+ "@types/express": "^5.0.6",
43
45
  "@types/jsdom": "^27.0.0",
44
46
  "@types/mustache": "^4.2.6",
45
47
  "@types/node": "^24.10.1",
@@ -80,6 +82,7 @@
80
82
  "format:python": "ruff format td/ && ruff check --fix td/",
81
83
  "format:yaml": "prettier --write \"**/*.{yml,yaml}\"",
82
84
  "dev": "npx @modelcontextprotocol/inspector node dist/cli.js --stdio",
85
+ "http": "npm run build && node dist/cli.js --mcp-http-port=6280 --mcp-http-host=127.0.0.1 --host=http://127.0.0.1 --port=9981",
83
86
  "test": "run-p test:*",
84
87
  "test:integration": "vitest run ./tests/integration",
85
88
  "test:unit": "vitest run ./tests/unit",