import { randomUUID } from "node:crypto";
import type { ILogger } from "../core/logger.js";
import type { Result } from "../core/result.js";
import { createErrorResult, createSuccessResult } from "../core/result.js";
import type { SessionConfig } from "./config.js";
import { DEFAULT_SESSION_CONFIG } from "./config.js";
/**
* Session metadata
*/
export interface Session {
/**
* Unique session identifier (UUID v4)
*/
id: string;
/**
* Timestamp when session was created (milliseconds since epoch)
*/
createdAt: number;
/**
* Timestamp when session was last accessed (milliseconds since epoch)
*/
lastAccessedAt: number;
/**
* Optional metadata associated with the session
*/
metadata?: Record<string, unknown>;
}
/**
* Session manager interface
*/
export interface ISessionManager {
/**
* Create a new session with auto-generated ID
*/
create(metadata?: Record<string, unknown>): string;
/**
* Refresh last access time for a session
*/
touch(sessionId: string): Result<void, Error>;
/**
* Register a callback invoked when a session expires via TTL cleanup.
*/
setExpirationHandler(
handler: (sessionId: string) => Promise<void> | void,
): void;
/**
* Register an existing session (created by SDK)
*/
register(sessionId: string, metadata?: Record<string, unknown>): void;
/**
* Clean up a session by ID
*/
cleanup(sessionId: string): Result<void, Error>;
/**
* List all active sessions
*/
list(): Session[];
/**
* Start automatic TTL-based cleanup
*/
startTTLCleanup(): void;
/**
* Stop automatic TTL-based cleanup
*/
stopTTLCleanup(): void;
/**
* Get number of active sessions
*/
getActiveSessionCount(): number;
/**
* Clear all sessions (for testing or emergency cleanup)
*/
clearAll(): void;
}
/**
* 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 implements ISessionManager {
private readonly sessions: Map<string, Session> = new Map();
private readonly config: SessionConfig;
private readonly logger: ILogger;
private cleanupInterval: NodeJS.Timeout | null = null;
private onSessionExpired:
| ((sessionId: string) => Promise<void> | void)
| null = null;
constructor(config: SessionConfig, logger: ILogger) {
// 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?: Record<string, unknown>): string {
const sessionId = randomUUID();
const now = Date.now();
const session: 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: string): Result<void, Error> {
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: (sessionId: string) => Promise<void> | void,
): void {
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: string, metadata?: Record<string, unknown>): void {
const now = Date.now();
const session: 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: string): Result<void, Error> {
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(): Session[] {
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(): void {
// 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(): void {
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
*/
private runCleanupTask(): void {
const ttl = this.config.ttl;
if (!ttl) {
return;
}
const now = Date.now();
const expiredIds: string[] = [];
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 as Promise<void>).catch === "function") {
(result as Promise<void>).catch((error: unknown) => {
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(): number {
return this.sessions.size;
}
/**
* Clear all sessions
*
* Useful for testing or emergency cleanup.
*/
clearAll(): void {
const count = this.sessions.size;
this.sessions.clear();
this.logger.sendLog({
data: `All sessions cleared: ${count} session(s) removed`,
level: "info",
logger: "SessionManager",
});
}
}