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.
- package/README.ja.md +33 -310
- package/README.md +40 -313
- package/dist/cli.js +132 -16
- package/dist/core/compatibility.js +236 -0
- package/dist/core/logger.js +21 -1
- package/dist/core/version.js +21 -1
- package/dist/features/tools/presenter/operationFormatter.js +17 -10
- package/dist/server/touchDesignerServer.js +21 -2
- package/dist/tdClient/touchDesignerClient.js +203 -83
- package/dist/transport/config.js +75 -0
- package/dist/transport/expressHttpManager.js +235 -0
- package/dist/transport/factory.js +198 -0
- package/dist/transport/index.js +12 -0
- package/dist/transport/sessionManager.js +276 -0
- package/dist/transport/transportRegistry.js +272 -0
- package/dist/transport/validator.js +78 -0
- package/package.json +17 -7
|
@@ -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
|
+
}
|
|
@@ -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
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "MCP server for TouchDesigner",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"author": "8beeeaaat",
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"mcpName": "io.github.8beeeaaat/touchdesigner-mcp-server",
|
|
16
|
+
"mcpCompatibility": {
|
|
17
|
+
"minApiVersion": "1.3.0"
|
|
18
|
+
},
|
|
16
19
|
"bugs": {
|
|
17
20
|
"url": "https://github.com/8beeeaaat/touchdesigner-mcp/issues"
|
|
18
21
|
},
|
|
@@ -23,31 +26,35 @@
|
|
|
23
26
|
"touchdesigner-mcp-server": "dist/cli.js"
|
|
24
27
|
},
|
|
25
28
|
"dependencies": {
|
|
26
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
27
30
|
"@mozilla/readability": "^0.6.0",
|
|
28
31
|
"@types/axios": "^0.14.4",
|
|
29
32
|
"@types/ws": "^8.18.1",
|
|
30
33
|
"@types/yargs": "^17.0.35",
|
|
31
34
|
"axios": "^1.13.2",
|
|
35
|
+
"express": "^5.2.1",
|
|
32
36
|
"mustache": "^4.2.0",
|
|
37
|
+
"semver": "^7.7.3",
|
|
33
38
|
"yaml": "^2.8.2",
|
|
34
39
|
"zod": "4.1.13"
|
|
35
40
|
},
|
|
36
41
|
"devDependencies": {
|
|
37
42
|
"@biomejs/biome": "2.3.8",
|
|
38
43
|
"@openapitools/openapi-generator-cli": "^2.25.2",
|
|
44
|
+
"@types/express": "^5.0.6",
|
|
39
45
|
"@types/jsdom": "^27.0.0",
|
|
40
46
|
"@types/mustache": "^4.2.6",
|
|
41
47
|
"@types/node": "^24.10.1",
|
|
42
|
-
"@
|
|
48
|
+
"@types/semver": "^7.7.1",
|
|
49
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
43
50
|
"archiver": "^7.0.1",
|
|
44
|
-
"msw": "^2.12.
|
|
51
|
+
"msw": "^2.12.4",
|
|
45
52
|
"npm-run-all": "^4.1.5",
|
|
46
53
|
"orval": "^7.17.0",
|
|
47
|
-
"prettier": "^3.7.
|
|
54
|
+
"prettier": "^3.7.4",
|
|
48
55
|
"shx": "^0.4.0",
|
|
49
56
|
"typescript": "^5.9.3",
|
|
50
|
-
"vitest": "^4.0.
|
|
57
|
+
"vitest": "^4.0.15"
|
|
51
58
|
},
|
|
52
59
|
"type": "module",
|
|
53
60
|
"exports": {
|
|
@@ -75,12 +82,15 @@
|
|
|
75
82
|
"format:python": "ruff format td/ && ruff check --fix td/",
|
|
76
83
|
"format:yaml": "prettier --write \"**/*.{yml,yaml}\"",
|
|
77
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",
|
|
78
86
|
"test": "run-p test:*",
|
|
79
87
|
"test:integration": "vitest run ./tests/integration",
|
|
80
88
|
"test:unit": "vitest run ./tests/unit",
|
|
81
89
|
"coverage": "vitest run --coverage",
|
|
90
|
+
"version": "run-p version:*",
|
|
91
|
+
"version:api": "node ./scripts/syncApiServerVersions.ts",
|
|
92
|
+
"version:mcp": "node ./scripts/syncMcpServerVersions.ts",
|
|
82
93
|
"gen": "run-s gen:*",
|
|
83
|
-
"gen:version": "node ./scripts/syncVersions.ts",
|
|
84
94
|
"gen:webserver": "openapi-generator-cli generate -i ./src/api/index.yml -g python-flask -o ./td/modules/td_server",
|
|
85
95
|
"gen:handlers": "node td/genHandlers.js",
|
|
86
96
|
"gen:mcp": "orval --config ./orval.config.ts"
|