touchdesigner-mcp-server
Version:
MCP server for TouchDesigner
236 lines (235 loc) • 8.78 kB
JavaScript
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { createErrorResult, createSuccessResult } from "../core/result.js";
import { TransportRegistry } from "./transportRegistry.js";
/**
* Express HTTP Manager
*
* Manages HTTP server lifecycle for Streamable HTTP transport.
* Handles multiple concurrent sessions by creating per-session transport and server instances.
*
* Key Features:
* - /mcp endpoint → routes to appropriate transport via TransportRegistry
* - /health endpoint → reports active session count
* - Per-session isolation → each client gets independent MCP protocol state
* - Graceful shutdown → cleans up all active sessions
*
* Architecture:
* ```
* Client 1 → POST /mcp → TransportRegistry.getOrCreate() → Transport 1 + Server 1
* Client 2 → POST /mcp → TransportRegistry.getOrCreate() → Transport 2 + Server 2
* ```
*
* @example
* ```typescript
* const manager = new ExpressHttpManager(
* config,
* () => TouchDesignerServer.create(), // Server factory
* sessionManager,
* logger
* );
*
* // Start server
* const result = await manager.start();
*
* // Graceful shutdown
* await manager.stop();
* ```
*/
export class ExpressHttpManager {
config;
serverFactory;
logger;
registry;
server = null;
/**
* Create ExpressHttpManager with server factory
*
* @param config - Streamable HTTP transport configuration
* @param serverFactory - Factory function to create new Server instances per session
* @param sessionManager - Session manager for TTL tracking (optional)
* @param logger - Logger instance
*/
constructor(config, serverFactory, sessionManager, logger) {
this.config = config;
this.serverFactory = serverFactory;
this.logger = logger;
this.registry = new TransportRegistry(config, sessionManager, logger);
}
/**
* Start HTTP server with Express app from SDK
*
* @returns Result indicating success or failure
*/
async start() {
try {
if (this.server?.listening) {
return createErrorResult(new Error("Express HTTP server is already running"));
}
// Create Express app using SDK's factory
// This automatically includes DNS rebinding protection for localhost
const app = createMcpExpressApp({
host: this.config.host,
});
// MCP endpoint handler - routes to appropriate transport via registry
const handleMcpRequest = async (req, res) => {
try {
// Extract session ID from header
const sessionId = req.headers["mcp-session-id"];
// Get or create transport for this session
const transport = await this.registry.getOrCreate(sessionId, req.body, this.serverFactory);
if (!transport) {
// Invalid session (session ID provided but not found, or non-initialize without session)
res.status(400).json({
error: {
code: -32000,
message: "Invalid session",
},
id: null,
jsonrpc: "2.0",
});
return;
}
// Delegate request to transport
await transport.handleRequest(req, res, req.body);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.sendLog({
data: `Error handling MCP request: ${errorMessage}`,
level: "error",
logger: "ExpressHttpManager",
});
if (!res.headersSent) {
res.status(500).json({
error: "Internal server error",
});
}
}
};
// Configure /mcp endpoints
// POST: JSON-RPC requests (initialize, tool calls, etc.)
// GET: SSE streaming for notifications
// DELETE: Session termination
app.post(this.config.endpoint, handleMcpRequest);
app.get(this.config.endpoint, handleMcpRequest);
app.delete(this.config.endpoint, handleMcpRequest);
// Configure /health endpoint
app.get("/health", (_req, res) => {
const sessionCount = this.registry.getCount();
res.json({
sessions: sessionCount,
status: "ok",
timestamp: new Date().toISOString(),
});
});
// Start HTTP server
await new Promise((resolve, reject) => {
try {
this.server = app.listen(this.config.port, this.config.host, () => {
this.logger.sendLog({
data: `Express HTTP server listening on ${this.config.host}:${this.config.port}`,
level: "info",
logger: "ExpressHttpManager",
});
this.logger.sendLog({
data: `MCP endpoint: ${this.config.endpoint}`,
level: "info",
logger: "ExpressHttpManager",
});
this.logger.sendLog({
data: "Health check: GET /health",
level: "info",
logger: "ExpressHttpManager",
});
resolve();
});
this.server.on("error", (error) => {
reject(error);
});
}
catch (error) {
reject(error);
}
});
return createSuccessResult(undefined);
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return createErrorResult(new Error(`Failed to start Express HTTP server: ${err.message}`));
}
}
/**
* Graceful shutdown
*
* Stops HTTP server and cleans up all active sessions.
*
* @returns Result indicating success or failure
*/
async stop() {
try {
if (!this.server) {
return createSuccessResult(undefined);
}
this.logger.sendLog({
data: "Stopping Express HTTP server...",
level: "info",
logger: "ExpressHttpManager",
});
// Cleanup all active sessions first
const cleanupResult = await this.registry.cleanup();
if (!cleanupResult.success) {
this.logger.sendLog({
data: `Warning: Session cleanup failed: ${cleanupResult.error.message}`,
level: "warning",
logger: "ExpressHttpManager",
});
}
// Close HTTP server
await new Promise((resolve, reject) => {
this.server?.close((error) => {
if (error) {
reject(error);
}
else {
resolve();
}
});
});
this.logger.sendLog({
data: "Express HTTP server stopped",
level: "info",
logger: "ExpressHttpManager",
});
this.server = null;
return createSuccessResult(undefined);
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return createErrorResult(new Error(`Failed to stop Express HTTP server: ${err.message}`));
}
}
/**
* Check if server is running
*
* @returns True if server is running
*/
isRunning() {
return this.server?.listening ?? false;
}
/**
* Get active session count
*
* @returns Number of active sessions
*/
getActiveSessionCount() {
return this.registry.getCount();
}
/**
* Get all active session IDs
*
* @returns Array of session IDs
*/
getActiveSessionIds() {
return this.registry.getSessionIds();
}
}