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.
@@ -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
+ }
@@ -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";