@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
218 lines (182 loc) • 5.72 kB
text/typescript
/**
* Session management for ProductBoard MCP Server
* Provides isolated state per connection to prevent conflicts between multiple clients
*/
import { randomBytes } from 'crypto';
import { debugLog } from './utils/debug-logger.js';
export interface SessionState {
sessionId: string;
createdAt: Date;
lastActivity: Date;
clientInfo?: {
userAgent?: string;
clientId?: string;
};
apiInstances: Map<string, any>;
configCache: Map<string, any>;
requestCount: number;
activeRequests: Set<string>;
}
export interface SessionManager {
createSession(sessionId?: string): SessionState;
getSession(sessionId: string): SessionState | undefined;
updateActivity(sessionId: string): void;
removeSession(sessionId: string): void;
cleanupInactiveSessions(): void;
getActiveSessionCount(): number;
getSessionStats(): SessionStats;
}
export interface SessionStats {
totalSessions: number;
activeSessions: number;
totalRequests: number;
averageRequestsPerSession: number;
oldestSession?: Date;
}
class ProductBoardSessionManager implements SessionManager {
private sessions = new Map<string, SessionState>();
private cleanupInterval: ReturnType<typeof setTimeout> | null = null;
private readonly sessionTimeout: number = 300000; // 5 minutes
private readonly cleanupIntervalMs: number = 60000; // 1 minute
constructor() {
this.startCleanupTimer();
debugLog('session-manager', 'SessionManager initialized', {
sessionTimeout: this.sessionTimeout,
cleanupInterval: this.cleanupIntervalMs,
});
}
createSession(sessionId?: string): SessionState {
const id = sessionId || this.generateSessionId();
const session: SessionState = {
sessionId: id,
createdAt: new Date(),
lastActivity: new Date(),
apiInstances: new Map(),
configCache: new Map(),
requestCount: 0,
activeRequests: new Set(),
};
this.sessions.set(id, session);
debugLog('session-manager', 'Session created', {
sessionId: id,
totalSessions: this.sessions.size,
});
return session;
}
getSession(sessionId: string): SessionState | undefined {
const session = this.sessions.get(sessionId);
if (session) {
this.updateActivity(sessionId);
}
return session;
}
updateActivity(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.lastActivity = new Date();
}
}
removeSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
// Clean up any remaining active requests
if (session.activeRequests.size > 0) {
debugLog(
'session-manager',
'Cleaning up active requests during session removal',
{
sessionId,
activeRequestCount: session.activeRequests.size,
}
);
session.activeRequests.clear();
}
// Clear cached instances and configurations
session.apiInstances.clear();
session.configCache.clear();
this.sessions.delete(sessionId);
debugLog('session-manager', 'Session removed', {
sessionId,
remainingSessions: this.sessions.size,
});
}
cleanupInactiveSessions(): void {
const now = new Date();
const expiredSessions: string[] = [];
for (const [sessionId, session] of this.sessions.entries()) {
const timeSinceActivity = now.getTime() - session.lastActivity.getTime();
if (timeSinceActivity > this.sessionTimeout) {
expiredSessions.push(sessionId);
}
}
if (expiredSessions.length > 0) {
debugLog('session-manager', 'Cleaning up expired sessions', {
expiredCount: expiredSessions.length,
expiredSessions,
});
for (const sessionId of expiredSessions) {
this.removeSession(sessionId);
}
}
}
getActiveSessionCount(): number {
return this.sessions.size;
}
getSessionStats(): SessionStats {
const sessions = Array.from(this.sessions.values());
const totalRequests = sessions.reduce(
(sum, session) => sum + session.requestCount,
0
);
return {
totalSessions: sessions.length,
activeSessions: sessions.length,
totalRequests,
averageRequestsPerSession:
sessions.length > 0 ? totalRequests / sessions.length : 0,
oldestSession:
sessions.length > 0
? new Date(Math.min(...sessions.map(s => s.createdAt.getTime())))
: undefined,
};
}
private generateSessionId(): string {
const timestamp = Date.now().toString(36);
const randomPart = randomBytes(8).toString('hex');
return `pb-${timestamp}-${randomPart}`;
}
private startCleanupTimer(): void {
this.cleanupInterval = setInterval(() => {
this.cleanupInactiveSessions();
}, this.cleanupIntervalMs);
// Ensure cleanup timer doesn't keep the process alive
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
}
shutdown(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Clean up all sessions
const sessionIds = Array.from(this.sessions.keys());
for (const sessionId of sessionIds) {
this.removeSession(sessionId);
}
debugLog('session-manager', 'SessionManager shutdown completed', {
cleanedUpSessions: sessionIds.length,
});
}
}
// Global session manager instance
export const sessionManager = new ProductBoardSessionManager();
// Graceful shutdown handling
process.on('SIGINT', () => {
sessionManager.shutdown();
});
process.on('SIGTERM', () => {
sessionManager.shutdown();
});