touchdesigner-mcp-server
Version:
MCP server for TouchDesigner
277 lines (276 loc) • 9.59 kB
JavaScript
import { randomUUID } from "node:crypto";
import { createErrorResult, createSuccessResult } from "../core/result.js";
import { DEFAULT_SESSION_CONFIG } from "./config.js";
/**
* Session Manager
*
* Manages client sessions for Streamable HTTP transport.
* Provides session creation, validation, TTL-based expiration, and automatic cleanup.
*
* Note: Session validation (checking if session ID exists) is now handled by
* StreamableHTTPServerTransport.handleRequest(). This SessionManager focuses on:
* - Session creation via SDK callbacks (onsessioninitialized)
* - Session cleanup via SDK callbacks (onsessionclosed) and TTL-based cleanup
* - Session tracking for health checks and monitoring
*
* @example
* ```typescript
* const sessionManager = new SessionManager(
* { enabled: true, ttl: 60 * 60 * 1000 }, // 1 hour TTL
* logger
* );
*
* // Create session (typically called from SDK callback)
* const sessionId = sessionManager.create({ clientVersion: '1.0' });
*
* // Start automatic TTL-based cleanup
* sessionManager.startTTLCleanup();
*
* // Stop cleanup when done
* sessionManager.stopTTLCleanup();
* ```
*/
export class SessionManager {
sessions = new Map();
config;
logger;
cleanupInterval = null;
onSessionExpired = null;
constructor(config, logger) {
// Apply defaults for optional values to ensure TTL cleanup is active by default
this.config = {
cleanupInterval: config.cleanupInterval ?? DEFAULT_SESSION_CONFIG.cleanupInterval,
enabled: config.enabled ?? DEFAULT_SESSION_CONFIG.enabled,
ttl: config.ttl ?? DEFAULT_SESSION_CONFIG.ttl,
};
this.logger = logger;
}
/**
* Create a new session with optional metadata
*
* @param metadata - Optional metadata to associate with the session
* @returns Session ID (UUID v4)
*/
create(metadata) {
const sessionId = randomUUID();
const now = Date.now();
const session = {
createdAt: now,
id: sessionId,
lastAccessedAt: now,
metadata,
};
this.sessions.set(sessionId, session);
this.logger.sendLog({
data: `Session created: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`,
level: "info",
logger: "SessionManager",
});
return sessionId;
}
/**
* Update lastAccessedAt when a session receives activity
*
* @param sessionId - Session ID to refresh
*/
touch(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return createErrorResult(new Error(`Session not found: ${sessionId}`));
}
session.lastAccessedAt = Date.now();
return createSuccessResult(undefined);
}
/**
* Register a callback to run when TTL cleanup expires a session.
*
* @param handler - Callback invoked with expired session ID
*/
setExpirationHandler(handler) {
this.onSessionExpired = handler;
}
/**
* Register an SDK-created session for tracking
*
* This method registers sessions that were created by the MCP SDK's
* StreamableHTTPServerTransport. The SDK generates session IDs via the
* sessionIdGenerator callback, and then calls onsessioninitialized.
* This method creates a new tracking entry with current timestamps
* so the session can be managed for TTL cleanup and monitoring.
*
* @param sessionId - Session ID generated by SDK
* @param metadata - Optional metadata to associate with the session
*/
register(sessionId, metadata) {
const now = Date.now();
const session = {
createdAt: now,
id: sessionId,
lastAccessedAt: now,
metadata,
};
this.sessions.set(sessionId, session);
this.logger.sendLog({
data: `Session registered: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`,
level: "info",
logger: "SessionManager",
});
}
/**
* Clean up (delete) a session by ID
*
* @param sessionId - Session ID to clean up
* @returns Result indicating success or failure
*/
cleanup(sessionId) {
const existed = this.sessions.delete(sessionId);
if (existed) {
this.logger.sendLog({
data: `Session cleaned up: ${sessionId}`,
level: "info",
logger: "SessionManager",
});
return createSuccessResult(undefined);
}
return createErrorResult(new Error(`Session not found for cleanup: ${sessionId}`));
}
/**
* List all active sessions
*
* @returns Array of all active sessions
*/
list() {
return Array.from(this.sessions.values());
}
/**
* Start automatic TTL-based cleanup
*
* Runs cleanup task at an interval of TTL/2 to remove expired sessions.
* Does nothing if TTL is not configured or cleanup is already running.
*/
startTTLCleanup() {
// Don't start if TTL not configured or already running
if (!this.config.ttl || this.cleanupInterval) {
return;
}
const intervalMs = this.config.cleanupInterval || this.config.ttl / 2;
this.logger.sendLog({
data: `Starting TTL cleanup (interval: ${intervalMs}ms, TTL: ${this.config.ttl}ms)`,
level: "info",
logger: "SessionManager",
});
this.cleanupInterval = setInterval(() => {
try {
this.runCleanupTask();
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.sendLog({
data: `CRITICAL: TTL cleanup task failed: ${err.message}. Stack: ${err.stack}`,
level: "error",
logger: "SessionManager",
});
}
}, intervalMs);
// Don't keep the process alive just for cleanup
this.cleanupInterval.unref();
}
/**
* Stop automatic TTL-based cleanup
*/
stopTTLCleanup() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
this.logger.sendLog({
data: "Stopped TTL cleanup",
level: "info",
logger: "SessionManager",
});
}
}
/**
* Run a single cleanup task to remove expired sessions
*
* @private
*/
runCleanupTask() {
const ttl = this.config.ttl;
if (!ttl) {
return;
}
const now = Date.now();
const expiredIds = [];
for (const [id, session] of this.sessions.entries()) {
try {
const elapsed = now - session.lastAccessedAt;
if (elapsed > ttl) {
expiredIds.push(id);
}
}
catch (error) {
// Log individual session cleanup errors but continue processing others
const err = error instanceof Error ? error : new Error(String(error));
this.logger.sendLog({
data: `Error cleaning session ${id}: ${err.message}`,
level: "error",
logger: "SessionManager",
});
}
}
for (const sessionId of expiredIds) {
try {
const result = this.onSessionExpired?.(sessionId);
if (result && typeof result.catch === "function") {
result.catch((error) => {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.sendLog({
data: `Error running expiration handler for ${sessionId}: ${err.message}`,
level: "error",
logger: "SessionManager",
});
});
}
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.sendLog({
data: `Error running expiration handler for ${sessionId}: ${err.message}`,
level: "error",
logger: "SessionManager",
});
}
// Ensure the session is removed from tracking even if handler fails
this.sessions.delete(sessionId);
}
if (expiredIds.length > 0) {
this.logger.sendLog({
data: `Cleanup completed: removed ${expiredIds.length} expired session(s): ${expiredIds.join(", ")}`,
level: "info",
logger: "SessionManager",
});
}
}
/**
* Get number of active sessions
*
* @returns Number of active sessions
*/
getActiveSessionCount() {
return this.sessions.size;
}
/**
* Clear all sessions
*
* Useful for testing or emergency cleanup.
*/
clearAll() {
const count = this.sessions.size;
this.sessions.clear();
this.logger.sendLog({
data: `All sessions cleared: ${count} session(s) removed`,
level: "info",
logger: "SessionManager",
});
}
}