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,198 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { createErrorResult, createSuccessResult } from "../core/result.js";
5
+ import { isStreamableHttpTransportConfig } from "./config.js";
6
+ import { TransportConfigValidator } from "./validator.js";
7
+ /**
8
+ * Factory for creating MCP transport instances based on configuration
9
+ *
10
+ * This factory implements the Factory pattern to encapsulate transport creation logic.
11
+ * It validates configuration and creates the appropriate transport type (stdio or HTTP).
12
+ */
13
+ export class TransportFactory {
14
+ /**
15
+ * Reset the internal state of a StreamableHTTPServerTransport instance
16
+ *
17
+ * WARNING: This method manipulates the private field '_initialized' using Reflect.set().
18
+ * This is fragile and may break if the MCP SDK implementation changes.
19
+ * There is currently no public API to reset the initialized state, so this is required
20
+ * to ensure the transport can be reused safely after a session is closed.
21
+ * If the SDK adds a public reset/init method, use that instead.
22
+ * If the SDK changes the field name or its semantics, update this code accordingly.
23
+ */
24
+ static resetStreamableHttpState(transport) {
25
+ transport.sessionId = undefined;
26
+ Reflect.set(transport, "_initialized", false);
27
+ }
28
+ /**
29
+ * Create a transport instance from configuration
30
+ *
31
+ * @param config - Transport configuration (stdio or streamable-http)
32
+ * @param logger - Optional logger for HTTP transport session events (ignored for stdio)
33
+ * @param sessionManager - Optional session manager for HTTP transport (ignored for stdio)
34
+ * @returns Result with Transport instance or Error
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * // Create stdio transport
39
+ * const stdioResult = TransportFactory.create({ type: 'stdio' });
40
+ *
41
+ * // Create HTTP transport with logger and session manager
42
+ * const httpResult = TransportFactory.create({
43
+ * type: 'streamable-http',
44
+ * port: 6280,
45
+ * host: '127.0.0.1',
46
+ * endpoint: '/mcp'
47
+ * }, logger, sessionManager);
48
+ * ```
49
+ */
50
+ static create(config, logger, sessionManager) {
51
+ // Validate configuration first
52
+ const validationResult = TransportConfigValidator.validate(config);
53
+ if (!validationResult.success) {
54
+ return validationResult;
55
+ }
56
+ const validatedConfig = validationResult.data;
57
+ // Dispatch to appropriate factory method based on type
58
+ if (isStreamableHttpTransportConfig(validatedConfig)) {
59
+ return TransportFactory.createStreamableHttp(validatedConfig, logger, sessionManager);
60
+ }
61
+ return TransportFactory.createStdio(validatedConfig);
62
+ }
63
+ /**
64
+ * Create a stdio transport instance
65
+ *
66
+ * @param _config - Stdio transport configuration (unused, kept for API consistency)
67
+ * @returns Result with StdioServerTransport instance
68
+ */
69
+ static createStdio(_config) {
70
+ try {
71
+ const transport = new StdioServerTransport();
72
+ return createSuccessResult(transport);
73
+ }
74
+ catch (error) {
75
+ const err = error instanceof Error ? error : new Error(String(error));
76
+ return createErrorResult(new Error(`Failed to create stdio transport: ${err.message}`));
77
+ }
78
+ }
79
+ /**
80
+ * Create a streamable HTTP transport instance
81
+ *
82
+ * Creates a StreamableHTTPServerTransport with session management support.
83
+ * The transport instance is stateful and manages session lifecycle through callbacks.
84
+ *
85
+ * Note: DNS rebinding protection is handled by Express middleware (createMcpExpressApp)
86
+ * in the ExpressHttpManager, not by the transport itself.
87
+ *
88
+ * @param config - Streamable HTTP transport configuration
89
+ * @param logger - Optional logger for session lifecycle events
90
+ * @param sessionManager - Optional session manager for tracking sessions
91
+ * @returns Result with StreamableHTTPServerTransport instance or Error
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * const config: StreamableHttpTransportConfig = {
96
+ * type: 'streamable-http',
97
+ * port: 6280,
98
+ * host: '127.0.0.1',
99
+ * endpoint: '/mcp',
100
+ * sessionConfig: { enabled: true }
101
+ * };
102
+ * const result = TransportFactory.createStreamableHttp(config, logger, sessionManager);
103
+ * ```
104
+ */
105
+ static createStreamableHttp(config, logger, sessionManager) {
106
+ try {
107
+ let transport = null;
108
+ let suppressCloseEvent = false;
109
+ const handleSessionClosed = config.sessionConfig?.enabled
110
+ ? (sessionId) => {
111
+ if (logger) {
112
+ logger.sendLog({
113
+ data: `Session closed: ${sessionId}`,
114
+ level: "info",
115
+ logger: "TransportFactory",
116
+ });
117
+ }
118
+ // Clean up session from SessionManager
119
+ if (sessionManager) {
120
+ sessionManager.cleanup(sessionId);
121
+ }
122
+ suppressCloseEvent = true;
123
+ if (transport) {
124
+ TransportFactory.resetStreamableHttpState(transport);
125
+ }
126
+ }
127
+ : undefined;
128
+ // Create transport with session management only
129
+ // Security (DNS rebinding protection) is handled by Express middleware
130
+ transport = new StreamableHTTPServerTransport({
131
+ // Enable JSON responses for simple request/response scenarios
132
+ enableJsonResponse: false,
133
+ // Session close callback
134
+ // This is called when a session is terminated via DELETE request
135
+ onsessionclosed: handleSessionClosed,
136
+ // Session initialization callback
137
+ // This is called when a new session is created
138
+ onsessioninitialized: config.sessionConfig?.enabled
139
+ ? (sessionId) => {
140
+ if (logger) {
141
+ logger.sendLog({
142
+ data: `Session initialized: ${sessionId}`,
143
+ level: "info",
144
+ logger: "TransportFactory",
145
+ });
146
+ }
147
+ // Register session with SessionManager
148
+ if (sessionManager) {
149
+ sessionManager.register(sessionId);
150
+ }
151
+ }
152
+ : undefined,
153
+ // Retry interval for SSE polling behavior (optional)
154
+ retryInterval: config.retryInterval,
155
+ // Use randomUUID for session ID generation if sessions are enabled
156
+ sessionIdGenerator: config.sessionConfig?.enabled
157
+ ? () => randomUUID()
158
+ : undefined,
159
+ });
160
+ // Override the transport's close method to handle session cleanup scenarios.
161
+ //
162
+ // Why suppressCloseEvent is necessary:
163
+ // When a session is explicitly closed (via DELETE request), the SDK calls onsessionclosed
164
+ // which triggers our handleSessionClosed callback. We need to reset the transport state
165
+ // without triggering the onclose callback, which would signal a transport-level disconnection.
166
+ //
167
+ // Scenarios that trigger this code path:
168
+ // 1. Client sends DELETE request to close session → onsessionclosed fires → suppressCloseEvent = true
169
+ // 2. Session TTL expires → cleanup triggers close → suppressCloseEvent = true
170
+ //
171
+ // Relationship between suppressCloseEvent and handleSessionClosed:
172
+ // - handleSessionClosed sets suppressCloseEvent = true to indicate a session-level (not transport-level) close
173
+ // - When close() is called with suppressCloseEvent = true, we temporarily remove onclose callback
174
+ // - This prevents the MCP server from treating session cleanup as a transport disconnection
175
+ const originalClose = transport.close.bind(transport);
176
+ transport.close = (async () => {
177
+ if (suppressCloseEvent) {
178
+ const previousOnClose = transport?.onclose;
179
+ transport.onclose = undefined;
180
+ try {
181
+ await originalClose();
182
+ }
183
+ finally {
184
+ transport.onclose = previousOnClose;
185
+ suppressCloseEvent = false;
186
+ }
187
+ return;
188
+ }
189
+ await originalClose();
190
+ });
191
+ return createSuccessResult(transport);
192
+ }
193
+ catch (error) {
194
+ const err = error instanceof Error ? error : new Error(String(error));
195
+ return createErrorResult(new Error(`Failed to create streamable HTTP transport: ${err.message}`));
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Transport layer module exports
3
+ *
4
+ * This module provides type-safe configuration and validation for MCP transports:
5
+ * - stdio: Standard input/output transport
6
+ * - streamable-http: HTTP-based transport with SSE streaming
7
+ */
8
+ export { DEFAULT_HTTP_CONFIG, DEFAULT_SESSION_CONFIG, isStdioTransportConfig, isStreamableHttpTransportConfig, TransportConfigSchema, } from "./config.js";
9
+ export { ExpressHttpManager } from "./expressHttpManager.js";
10
+ export { TransportFactory } from "./factory.js";
11
+ export { SessionManager } from "./sessionManager.js";
12
+ export { TransportConfigValidator } from "./validator.js";
@@ -0,0 +1,276 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createErrorResult, createSuccessResult } from "../core/result.js";
3
+ import { DEFAULT_SESSION_CONFIG } from "./config.js";
4
+ /**
5
+ * Session Manager
6
+ *
7
+ * Manages client sessions for Streamable HTTP transport.
8
+ * Provides session creation, validation, TTL-based expiration, and automatic cleanup.
9
+ *
10
+ * Note: Session validation (checking if session ID exists) is now handled by
11
+ * StreamableHTTPServerTransport.handleRequest(). This SessionManager focuses on:
12
+ * - Session creation via SDK callbacks (onsessioninitialized)
13
+ * - Session cleanup via SDK callbacks (onsessionclosed) and TTL-based cleanup
14
+ * - Session tracking for health checks and monitoring
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const sessionManager = new SessionManager(
19
+ * { enabled: true, ttl: 60 * 60 * 1000 }, // 1 hour TTL
20
+ * logger
21
+ * );
22
+ *
23
+ * // Create session (typically called from SDK callback)
24
+ * const sessionId = sessionManager.create({ clientVersion: '1.0' });
25
+ *
26
+ * // Start automatic TTL-based cleanup
27
+ * sessionManager.startTTLCleanup();
28
+ *
29
+ * // Stop cleanup when done
30
+ * sessionManager.stopTTLCleanup();
31
+ * ```
32
+ */
33
+ export class SessionManager {
34
+ sessions = new Map();
35
+ config;
36
+ logger;
37
+ cleanupInterval = null;
38
+ onSessionExpired = null;
39
+ constructor(config, logger) {
40
+ // Apply defaults for optional values to ensure TTL cleanup is active by default
41
+ this.config = {
42
+ cleanupInterval: config.cleanupInterval ?? DEFAULT_SESSION_CONFIG.cleanupInterval,
43
+ enabled: config.enabled ?? DEFAULT_SESSION_CONFIG.enabled,
44
+ ttl: config.ttl ?? DEFAULT_SESSION_CONFIG.ttl,
45
+ };
46
+ this.logger = logger;
47
+ }
48
+ /**
49
+ * Create a new session with optional metadata
50
+ *
51
+ * @param metadata - Optional metadata to associate with the session
52
+ * @returns Session ID (UUID v4)
53
+ */
54
+ create(metadata) {
55
+ const sessionId = randomUUID();
56
+ const now = Date.now();
57
+ const session = {
58
+ createdAt: now,
59
+ id: sessionId,
60
+ lastAccessedAt: now,
61
+ metadata,
62
+ };
63
+ this.sessions.set(sessionId, session);
64
+ this.logger.sendLog({
65
+ data: `Session created: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`,
66
+ level: "info",
67
+ logger: "SessionManager",
68
+ });
69
+ return sessionId;
70
+ }
71
+ /**
72
+ * Update lastAccessedAt when a session receives activity
73
+ *
74
+ * @param sessionId - Session ID to refresh
75
+ */
76
+ touch(sessionId) {
77
+ const session = this.sessions.get(sessionId);
78
+ if (!session) {
79
+ return createErrorResult(new Error(`Session not found: ${sessionId}`));
80
+ }
81
+ session.lastAccessedAt = Date.now();
82
+ return createSuccessResult(undefined);
83
+ }
84
+ /**
85
+ * Register a callback to run when TTL cleanup expires a session.
86
+ *
87
+ * @param handler - Callback invoked with expired session ID
88
+ */
89
+ setExpirationHandler(handler) {
90
+ this.onSessionExpired = handler;
91
+ }
92
+ /**
93
+ * Register an SDK-created session for tracking
94
+ *
95
+ * This method registers sessions that were created by the MCP SDK's
96
+ * StreamableHTTPServerTransport. The SDK generates session IDs via the
97
+ * sessionIdGenerator callback, and then calls onsessioninitialized.
98
+ * This method creates a new tracking entry with current timestamps
99
+ * so the session can be managed for TTL cleanup and monitoring.
100
+ *
101
+ * @param sessionId - Session ID generated by SDK
102
+ * @param metadata - Optional metadata to associate with the session
103
+ */
104
+ register(sessionId, metadata) {
105
+ const now = Date.now();
106
+ const session = {
107
+ createdAt: now,
108
+ id: sessionId,
109
+ lastAccessedAt: now,
110
+ metadata,
111
+ };
112
+ this.sessions.set(sessionId, session);
113
+ this.logger.sendLog({
114
+ data: `Session registered: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`,
115
+ level: "info",
116
+ logger: "SessionManager",
117
+ });
118
+ }
119
+ /**
120
+ * Clean up (delete) a session by ID
121
+ *
122
+ * @param sessionId - Session ID to clean up
123
+ * @returns Result indicating success or failure
124
+ */
125
+ cleanup(sessionId) {
126
+ const existed = this.sessions.delete(sessionId);
127
+ if (existed) {
128
+ this.logger.sendLog({
129
+ data: `Session cleaned up: ${sessionId}`,
130
+ level: "info",
131
+ logger: "SessionManager",
132
+ });
133
+ return createSuccessResult(undefined);
134
+ }
135
+ return createErrorResult(new Error(`Session not found for cleanup: ${sessionId}`));
136
+ }
137
+ /**
138
+ * List all active sessions
139
+ *
140
+ * @returns Array of all active sessions
141
+ */
142
+ list() {
143
+ return Array.from(this.sessions.values());
144
+ }
145
+ /**
146
+ * Start automatic TTL-based cleanup
147
+ *
148
+ * Runs cleanup task at an interval of TTL/2 to remove expired sessions.
149
+ * Does nothing if TTL is not configured or cleanup is already running.
150
+ */
151
+ startTTLCleanup() {
152
+ // Don't start if TTL not configured or already running
153
+ if (!this.config.ttl || this.cleanupInterval) {
154
+ return;
155
+ }
156
+ const intervalMs = this.config.cleanupInterval || this.config.ttl / 2;
157
+ this.logger.sendLog({
158
+ data: `Starting TTL cleanup (interval: ${intervalMs}ms, TTL: ${this.config.ttl}ms)`,
159
+ level: "info",
160
+ logger: "SessionManager",
161
+ });
162
+ this.cleanupInterval = setInterval(() => {
163
+ try {
164
+ this.runCleanupTask();
165
+ }
166
+ catch (error) {
167
+ const err = error instanceof Error ? error : new Error(String(error));
168
+ this.logger.sendLog({
169
+ data: `CRITICAL: TTL cleanup task failed: ${err.message}. Stack: ${err.stack}`,
170
+ level: "error",
171
+ logger: "SessionManager",
172
+ });
173
+ }
174
+ }, intervalMs);
175
+ // Don't keep the process alive just for cleanup
176
+ this.cleanupInterval.unref();
177
+ }
178
+ /**
179
+ * Stop automatic TTL-based cleanup
180
+ */
181
+ stopTTLCleanup() {
182
+ if (this.cleanupInterval) {
183
+ clearInterval(this.cleanupInterval);
184
+ this.cleanupInterval = null;
185
+ this.logger.sendLog({
186
+ data: "Stopped TTL cleanup",
187
+ level: "info",
188
+ logger: "SessionManager",
189
+ });
190
+ }
191
+ }
192
+ /**
193
+ * Run a single cleanup task to remove expired sessions
194
+ *
195
+ * @private
196
+ */
197
+ runCleanupTask() {
198
+ const ttl = this.config.ttl;
199
+ if (!ttl) {
200
+ return;
201
+ }
202
+ const now = Date.now();
203
+ const expiredIds = [];
204
+ for (const [id, session] of this.sessions.entries()) {
205
+ try {
206
+ const elapsed = now - session.lastAccessedAt;
207
+ if (elapsed > ttl) {
208
+ expiredIds.push(id);
209
+ }
210
+ }
211
+ catch (error) {
212
+ // Log individual session cleanup errors but continue processing others
213
+ const err = error instanceof Error ? error : new Error(String(error));
214
+ this.logger.sendLog({
215
+ data: `Error cleaning session ${id}: ${err.message}`,
216
+ level: "error",
217
+ logger: "SessionManager",
218
+ });
219
+ }
220
+ }
221
+ for (const sessionId of expiredIds) {
222
+ try {
223
+ const result = this.onSessionExpired?.(sessionId);
224
+ if (result && typeof result.catch === "function") {
225
+ result.catch((error) => {
226
+ const err = error instanceof Error ? error : new Error(String(error));
227
+ this.logger.sendLog({
228
+ data: `Error running expiration handler for ${sessionId}: ${err.message}`,
229
+ level: "error",
230
+ logger: "SessionManager",
231
+ });
232
+ });
233
+ }
234
+ }
235
+ catch (error) {
236
+ const err = error instanceof Error ? error : new Error(String(error));
237
+ this.logger.sendLog({
238
+ data: `Error running expiration handler for ${sessionId}: ${err.message}`,
239
+ level: "error",
240
+ logger: "SessionManager",
241
+ });
242
+ }
243
+ // Ensure the session is removed from tracking even if handler fails
244
+ this.sessions.delete(sessionId);
245
+ }
246
+ if (expiredIds.length > 0) {
247
+ this.logger.sendLog({
248
+ data: `Cleanup completed: removed ${expiredIds.length} expired session(s): ${expiredIds.join(", ")}`,
249
+ level: "info",
250
+ logger: "SessionManager",
251
+ });
252
+ }
253
+ }
254
+ /**
255
+ * Get number of active sessions
256
+ *
257
+ * @returns Number of active sessions
258
+ */
259
+ getActiveSessionCount() {
260
+ return this.sessions.size;
261
+ }
262
+ /**
263
+ * Clear all sessions
264
+ *
265
+ * Useful for testing or emergency cleanup.
266
+ */
267
+ clearAll() {
268
+ const count = this.sessions.size;
269
+ this.sessions.clear();
270
+ this.logger.sendLog({
271
+ data: `All sessions cleared: ${count} session(s) removed`,
272
+ level: "info",
273
+ logger: "SessionManager",
274
+ });
275
+ }
276
+ }