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.
- package/README.ja.md +4 -307
- package/README.md +9 -308
- package/dist/cli.js +132 -16
- package/dist/core/logger.js +13 -0
- package/dist/gen/endpoints/TouchDesignerAPI.js +1 -1
- package/dist/gen/mcp/touchDesignerAPI.zod.js +1 -1
- package/dist/server/touchDesignerServer.js +19 -0
- 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 +4 -1
|
@@ -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.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",
|