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/dist/cli.js CHANGED
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { ConsoleLogger } from "./core/logger.js";
3
3
  import { TouchDesignerServer } from "./server/touchDesignerServer.js";
4
+ import { isStreamableHttpTransportConfig } from "./transport/config.js";
5
+ import { ExpressHttpManager } from "./transport/expressHttpManager.js";
6
+ import { TransportFactory } from "./transport/factory.js";
7
+ import { SessionManager } from "./transport/sessionManager.js";
4
8
  // Note: Environment variables should be set by the MCP Bundle runtime or CLI arguments
5
9
  const DEFAULT_HOST = "http://127.0.0.1";
6
10
  const DEFAULT_PORT = 9981;
11
+ const DEFAULT_MCP_ENDPOINT = "/mcp";
7
12
  /**
8
- * Parse command line arguments
13
+ * Parse command line arguments for TouchDesigner connection
9
14
  */
10
15
  export function parseArgs(args) {
11
16
  const argsToProcess = args || process.argv.slice(2);
@@ -25,38 +30,149 @@ export function parseArgs(args) {
25
30
  return parsed;
26
31
  }
27
32
  /**
28
- * Determine if the server should run in stdio mode
33
+ * Parse transport configuration from command line arguments
34
+ *
35
+ * Detects if HTTP mode is requested via --mcp-http-port flag.
36
+ * If not specified, defaults to stdio mode.
37
+ *
38
+ * @param args - Command line arguments (defaults to process.argv.slice(2))
39
+ * @returns Transport configuration (stdio or streamable-http)
40
+ *
41
+ * @example
42
+ * ```bash
43
+ * # Stdio mode (default)
44
+ * touchdesigner-mcp-server --host=http://localhost --port=9981
45
+ *
46
+ * # HTTP mode
47
+ * touchdesigner-mcp-server --mcp-http-port=6280 --mcp-http-host=127.0.0.1
48
+ * ```
29
49
  */
30
- export function isStdioMode(nodeEnv, argv) {
31
- const env = nodeEnv ?? process.env.NODE_ENV;
32
- const args = argv ?? process.argv;
33
- return env === "cli" || args.includes("--stdio");
50
+ export function parseTransportConfig(args) {
51
+ const argsToProcess = args || process.argv.slice(2);
52
+ // Check for HTTP mode
53
+ const httpPortArg = argsToProcess.find((arg) => arg.startsWith("--mcp-http-port="));
54
+ if (httpPortArg) {
55
+ const portStr = httpPortArg.split("=")[1];
56
+ const port = Number.parseInt(portStr, 10);
57
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
58
+ console.error(`Invalid value for --mcp-http-port: "${portStr}". Please specify a valid port number (1-65535).`);
59
+ process.exit(1);
60
+ }
61
+ const hostArg = argsToProcess.find((arg) => arg.startsWith("--mcp-http-host="));
62
+ const host = hostArg ? hostArg.split("=")[1] : "127.0.0.1";
63
+ const config = {
64
+ endpoint: DEFAULT_MCP_ENDPOINT,
65
+ host,
66
+ port,
67
+ sessionConfig: { enabled: true },
68
+ type: "streamable-http",
69
+ };
70
+ return config;
71
+ }
72
+ // Default to stdio mode
73
+ return { type: "stdio" };
34
74
  }
35
75
  /**
36
76
  * Start TouchDesigner MCP server
77
+ *
78
+ * Supports both stdio and HTTP transport modes based on command line arguments.
79
+ *
80
+ * @param params - Server startup parameters
81
+ * @param params.argv - Command line arguments
82
+ * @param params.nodeEnv - Node environment
83
+ *
84
+ * @example
85
+ * ```bash
86
+ * # Stdio mode (default)
87
+ * touchdesigner-mcp-server --host=http://localhost --port=9981
88
+ *
89
+ * # HTTP mode
90
+ * touchdesigner-mcp-server --mcp-http-port=6280 --host=http://localhost --port=9981
91
+ * ```
37
92
  */
38
93
  export async function startServer(params) {
39
94
  try {
40
- const isStdioModeFlag = isStdioMode(params?.nodeEnv, params?.argv);
41
- if (!isStdioModeFlag) {
42
- throw new Error("Sorry, this server is not yet available in the browser. Please use the CLI mode.");
43
- }
44
- // Parse command line arguments and set environment variables
95
+ // Parse transport configuration
96
+ const transportConfig = parseTransportConfig(params?.argv);
97
+ // Parse TouchDesigner connection arguments
45
98
  const args = parseArgs(params?.argv);
46
99
  process.env.TD_WEB_SERVER_HOST = args.host;
47
100
  process.env.TD_WEB_SERVER_PORT = args.port.toString();
101
+ // Create MCP server
48
102
  const server = new TouchDesignerServer();
49
- const transport = new StdioServerTransport();
50
- const result = await server.connect(transport);
51
- if (!result.success) {
52
- throw new Error(`Failed to connect: ${result.error.message}`);
103
+ // Handle stdio mode
104
+ if (transportConfig.type === "stdio") {
105
+ const transportResult = TransportFactory.create(transportConfig);
106
+ if (!transportResult.success) {
107
+ throw transportResult.error;
108
+ }
109
+ const result = await server.connect(transportResult.data);
110
+ if (!result.success) {
111
+ throw new Error(`Failed to connect: ${result.error.message}`);
112
+ }
113
+ console.error("MCP server started in stdio mode");
114
+ return;
53
115
  }
116
+ // Handle HTTP mode
117
+ if (isStreamableHttpTransportConfig(transportConfig)) {
118
+ // Use ConsoleLogger for HTTP manager and session manager
119
+ // This avoids "Not connected" errors since HTTP mode doesn't have a global MCP connection
120
+ const logger = new ConsoleLogger();
121
+ // Create session manager if enabled
122
+ const sessionManager = transportConfig.sessionConfig?.enabled
123
+ ? new SessionManager(transportConfig.sessionConfig, logger)
124
+ : null;
125
+ // Server factory for creating per-session instances
126
+ // Each session gets its own TouchDesignerServer with independent MCP protocol state
127
+ const serverFactory = () => TouchDesignerServer.create();
128
+ // Create Express HTTP manager with server factory
129
+ const httpManager = new ExpressHttpManager(transportConfig, serverFactory, sessionManager, logger);
130
+ // Start HTTP server
131
+ const startResult = await httpManager.start();
132
+ if (!startResult.success) {
133
+ throw startResult.error;
134
+ }
135
+ console.error(`MCP server started in HTTP mode on ${transportConfig.host}:${transportConfig.port}${transportConfig.endpoint}`);
136
+ // Start session cleanup if enabled
137
+ if (sessionManager) {
138
+ sessionManager.startTTLCleanup();
139
+ }
140
+ // Set up graceful shutdown
141
+ const shutdown = async () => {
142
+ console.error("\nShutting down server...");
143
+ // Stop session cleanup
144
+ if (sessionManager) {
145
+ sessionManager.stopTTLCleanup();
146
+ }
147
+ // Stop HTTP server
148
+ const stopResult = await httpManager.stop();
149
+ if (!stopResult.success) {
150
+ console.error(`Error during shutdown: ${stopResult.error.message}`);
151
+ }
152
+ console.error("Server shutdown complete");
153
+ process.exit(0);
154
+ };
155
+ process.on("SIGINT", shutdown);
156
+ process.on("SIGTERM", shutdown);
157
+ return;
158
+ }
159
+ // Type-safe exhaustive check using never type
160
+ // This ensures all cases of the TransportConfig discriminated union are handled
161
+ // If a new transport type is added, TypeScript will error at compile time
162
+ assertNever(transportConfig);
54
163
  }
55
164
  catch (error) {
56
165
  const errorMessage = error instanceof Error ? error.message : String(error);
57
166
  throw new Error(`Failed to initialize server: ${errorMessage}`);
58
167
  }
59
168
  }
169
+ /**
170
+ * Helper function for exhaustive type checking
171
+ * TypeScript will error if called with a non-never type, ensuring all cases are handled
172
+ */
173
+ function assertNever(value) {
174
+ throw new Error(`Unsupported transport type: ${value.type}`);
175
+ }
60
176
  // Start server if this file is executed directly
61
177
  startServer({
62
178
  argv: process.argv,
@@ -28,3 +28,16 @@ export class McpLogger {
28
28
  }
29
29
  }
30
30
  }
31
+ /**
32
+ * Console Logger implementation for standalone use (e.g., HTTP mode setup)
33
+ * Outputs to stderr to avoid interfering with stdio transport
34
+ */
35
+ export class ConsoleLogger {
36
+ sendLog(args) {
37
+ const timestamp = new Date().toISOString();
38
+ const level = args.level?.toUpperCase() || "INFO";
39
+ const logger = args.logger || "unknown";
40
+ const data = args.data;
41
+ console.error(`[${timestamp}] [${level}] [${logger}] ${data}`);
42
+ }
43
+ }
@@ -3,7 +3,7 @@
3
3
  * Do not edit manually.
4
4
  * TouchDesigner API
5
5
  * OpenAPI schema for generating TouchDesigner API client code
6
- * OpenAPI spec version: 1.3.0
6
+ * OpenAPI spec version: 1.4.1
7
7
  */
8
8
  import { customInstance } from '../../api/customInstance.js';
9
9
  // eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -3,7 +3,7 @@
3
3
  * Do not edit manually.
4
4
  * TouchDesigner API
5
5
  * OpenAPI schema for generating TouchDesigner API client code
6
- * OpenAPI spec version: 1.3.0
6
+ * OpenAPI spec version: 1.4.1
7
7
  */
8
8
  import * as zod from 'zod';
9
9
  /**
@@ -32,6 +32,25 @@ export class TouchDesignerServer {
32
32
  this.connectionManager = new ConnectionManager(this.server, this.logger);
33
33
  this.registerAllFeatures();
34
34
  }
35
+ /**
36
+ * Create a new TouchDesignerServer instance
37
+ *
38
+ * Factory method for creating server instances in multi-session scenarios.
39
+ * Each session should have its own server instance to maintain independent MCP protocol state.
40
+ *
41
+ * @returns McpServer instance ready for connection to a transport
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * // In TransportRegistry
46
+ * const serverFactory = () => TouchDesignerServer.create();
47
+ * const transport = await registry.getOrCreate(sessionId, body, serverFactory);
48
+ * ```
49
+ */
50
+ static create() {
51
+ const instance = new TouchDesignerServer();
52
+ return instance.server;
53
+ }
35
54
  /**
36
55
  * Connect to MCP transport
37
56
  */
@@ -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
+ };
@@ -0,0 +1,235 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { createErrorResult, createSuccessResult } from "../core/result.js";
3
+ import { TransportRegistry } from "./transportRegistry.js";
4
+ /**
5
+ * Express HTTP Manager
6
+ *
7
+ * Manages HTTP server lifecycle for Streamable HTTP transport.
8
+ * Handles multiple concurrent sessions by creating per-session transport and server instances.
9
+ *
10
+ * Key Features:
11
+ * - /mcp endpoint → routes to appropriate transport via TransportRegistry
12
+ * - /health endpoint → reports active session count
13
+ * - Per-session isolation → each client gets independent MCP protocol state
14
+ * - Graceful shutdown → cleans up all active sessions
15
+ *
16
+ * Architecture:
17
+ * ```
18
+ * Client 1 → POST /mcp → TransportRegistry.getOrCreate() → Transport 1 + Server 1
19
+ * Client 2 → POST /mcp → TransportRegistry.getOrCreate() → Transport 2 + Server 2
20
+ * ```
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const manager = new ExpressHttpManager(
25
+ * config,
26
+ * () => TouchDesignerServer.create(), // Server factory
27
+ * sessionManager,
28
+ * logger
29
+ * );
30
+ *
31
+ * // Start server
32
+ * const result = await manager.start();
33
+ *
34
+ * // Graceful shutdown
35
+ * await manager.stop();
36
+ * ```
37
+ */
38
+ export class ExpressHttpManager {
39
+ config;
40
+ serverFactory;
41
+ logger;
42
+ registry;
43
+ server = null;
44
+ /**
45
+ * Create ExpressHttpManager with server factory
46
+ *
47
+ * @param config - Streamable HTTP transport configuration
48
+ * @param serverFactory - Factory function to create new Server instances per session
49
+ * @param sessionManager - Session manager for TTL tracking (optional)
50
+ * @param logger - Logger instance
51
+ */
52
+ constructor(config, serverFactory, sessionManager, logger) {
53
+ this.config = config;
54
+ this.serverFactory = serverFactory;
55
+ this.logger = logger;
56
+ this.registry = new TransportRegistry(config, sessionManager, logger);
57
+ }
58
+ /**
59
+ * Start HTTP server with Express app from SDK
60
+ *
61
+ * @returns Result indicating success or failure
62
+ */
63
+ async start() {
64
+ try {
65
+ if (this.server?.listening) {
66
+ return createErrorResult(new Error("Express HTTP server is already running"));
67
+ }
68
+ // Create Express app using SDK's factory
69
+ // This automatically includes DNS rebinding protection for localhost
70
+ const app = createMcpExpressApp({
71
+ host: this.config.host,
72
+ });
73
+ // MCP endpoint handler - routes to appropriate transport via registry
74
+ const handleMcpRequest = async (req, res) => {
75
+ try {
76
+ // Extract session ID from header
77
+ const sessionId = req.headers["mcp-session-id"];
78
+ // Get or create transport for this session
79
+ const transport = await this.registry.getOrCreate(sessionId, req.body, this.serverFactory);
80
+ if (!transport) {
81
+ // Invalid session (session ID provided but not found, or non-initialize without session)
82
+ res.status(400).json({
83
+ error: {
84
+ code: -32000,
85
+ message: "Invalid session",
86
+ },
87
+ id: null,
88
+ jsonrpc: "2.0",
89
+ });
90
+ return;
91
+ }
92
+ // Delegate request to transport
93
+ await transport.handleRequest(req, res, req.body);
94
+ }
95
+ catch (error) {
96
+ const errorMessage = error instanceof Error ? error.message : String(error);
97
+ this.logger.sendLog({
98
+ data: `Error handling MCP request: ${errorMessage}`,
99
+ level: "error",
100
+ logger: "ExpressHttpManager",
101
+ });
102
+ if (!res.headersSent) {
103
+ res.status(500).json({
104
+ error: "Internal server error",
105
+ });
106
+ }
107
+ }
108
+ };
109
+ // Configure /mcp endpoints
110
+ // POST: JSON-RPC requests (initialize, tool calls, etc.)
111
+ // GET: SSE streaming for notifications
112
+ // DELETE: Session termination
113
+ app.post(this.config.endpoint, handleMcpRequest);
114
+ app.get(this.config.endpoint, handleMcpRequest);
115
+ app.delete(this.config.endpoint, handleMcpRequest);
116
+ // Configure /health endpoint
117
+ app.get("/health", (_req, res) => {
118
+ const sessionCount = this.registry.getCount();
119
+ res.json({
120
+ sessions: sessionCount,
121
+ status: "ok",
122
+ timestamp: new Date().toISOString(),
123
+ });
124
+ });
125
+ // Start HTTP server
126
+ await new Promise((resolve, reject) => {
127
+ try {
128
+ this.server = app.listen(this.config.port, this.config.host, () => {
129
+ this.logger.sendLog({
130
+ data: `Express HTTP server listening on ${this.config.host}:${this.config.port}`,
131
+ level: "info",
132
+ logger: "ExpressHttpManager",
133
+ });
134
+ this.logger.sendLog({
135
+ data: `MCP endpoint: ${this.config.endpoint}`,
136
+ level: "info",
137
+ logger: "ExpressHttpManager",
138
+ });
139
+ this.logger.sendLog({
140
+ data: "Health check: GET /health",
141
+ level: "info",
142
+ logger: "ExpressHttpManager",
143
+ });
144
+ resolve();
145
+ });
146
+ this.server.on("error", (error) => {
147
+ reject(error);
148
+ });
149
+ }
150
+ catch (error) {
151
+ reject(error);
152
+ }
153
+ });
154
+ return createSuccessResult(undefined);
155
+ }
156
+ catch (error) {
157
+ const err = error instanceof Error ? error : new Error(String(error));
158
+ return createErrorResult(new Error(`Failed to start Express HTTP server: ${err.message}`));
159
+ }
160
+ }
161
+ /**
162
+ * Graceful shutdown
163
+ *
164
+ * Stops HTTP server and cleans up all active sessions.
165
+ *
166
+ * @returns Result indicating success or failure
167
+ */
168
+ async stop() {
169
+ try {
170
+ if (!this.server) {
171
+ return createSuccessResult(undefined);
172
+ }
173
+ this.logger.sendLog({
174
+ data: "Stopping Express HTTP server...",
175
+ level: "info",
176
+ logger: "ExpressHttpManager",
177
+ });
178
+ // Cleanup all active sessions first
179
+ const cleanupResult = await this.registry.cleanup();
180
+ if (!cleanupResult.success) {
181
+ this.logger.sendLog({
182
+ data: `Warning: Session cleanup failed: ${cleanupResult.error.message}`,
183
+ level: "warning",
184
+ logger: "ExpressHttpManager",
185
+ });
186
+ }
187
+ // Close HTTP server
188
+ await new Promise((resolve, reject) => {
189
+ this.server?.close((error) => {
190
+ if (error) {
191
+ reject(error);
192
+ }
193
+ else {
194
+ resolve();
195
+ }
196
+ });
197
+ });
198
+ this.logger.sendLog({
199
+ data: "Express HTTP server stopped",
200
+ level: "info",
201
+ logger: "ExpressHttpManager",
202
+ });
203
+ this.server = null;
204
+ return createSuccessResult(undefined);
205
+ }
206
+ catch (error) {
207
+ const err = error instanceof Error ? error : new Error(String(error));
208
+ return createErrorResult(new Error(`Failed to stop Express HTTP server: ${err.message}`));
209
+ }
210
+ }
211
+ /**
212
+ * Check if server is running
213
+ *
214
+ * @returns True if server is running
215
+ */
216
+ isRunning() {
217
+ return this.server?.listening ?? false;
218
+ }
219
+ /**
220
+ * Get active session count
221
+ *
222
+ * @returns Number of active sessions
223
+ */
224
+ getActiveSessionCount() {
225
+ return this.registry.getCount();
226
+ }
227
+ /**
228
+ * Get all active session IDs
229
+ *
230
+ * @returns Array of session IDs
231
+ */
232
+ getActiveSessionIds() {
233
+ return this.registry.getSessionIds();
234
+ }
235
+ }