mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
327 lines • 12.3 kB
JavaScript
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
/**
* MongoDB-based token registry for ephemeral web UI sessions
* Handles secure token generation, validation, and automatic cleanup
*/
export class TokenRegistry {
db;
collection;
serverCollection;
jwtSecret;
logger;
constructor(db, options = {}) {
this.db = db;
this.collection = db.collection('ephemeral_webui_sessions');
this.serverCollection = db.collection('registered_mcp_servers');
this.jwtSecret = options.jwtSecret || process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex');
this.logger = options.logger;
this.setupIndexes();
}
/**
* Setup MongoDB indexes for efficient queries and TTL
*/
async setupIndexes() {
try {
// TTL index for automatic cleanup
await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Performance indexes
await this.collection.createIndex({ token: 1 }, { unique: true });
await this.collection.createIndex({ userId: 1 });
await this.collection.createIndex({ serverName: 1 });
await this.collection.createIndex({ sessionKey: 1 }, { unique: true }); // Composite key index
await this.collection.createIndex({ createdAt: 1 });
this.log('info', '[TokenRegistry] MongoDB indexes created successfully');
}
catch (error) {
this.log('error', '[TokenRegistry] Failed to create indexes:', error);
}
}
/**
* Generate composite session key for proper session isolation
* Format: userId:serverName:serverType
*/
generateSessionKey(userId, serverName, serverType) {
const safeServerType = serverType || 'mcp-webui';
return `${userId}:${serverName}:${safeServerType}`;
}
/**
* Find an existing active session for a user and server using composite key
*/
async findActiveSession(userId, serverName, serverType) {
try {
const sessionKey = this.generateSessionKey(userId, serverName, serverType);
const session = await this.collection.findOne({
sessionKey,
expiresAt: { $gt: new Date() } // Only active (non-expired) sessions
});
if (session) {
// Update last accessed time
await this.collection.updateOne({ token: session.token }, { $set: { lastAccessedAt: new Date() } });
this.log('info', `[TokenRegistry] Found existing session for composite key ${sessionKey}`, {
token: session.token.substring(0, 16) + '...',
createdAt: session.createdAt.toISOString(),
expiresAt: session.expiresAt.toISOString()
});
}
else {
this.log('info', `[TokenRegistry] No existing session found for composite key ${sessionKey}`);
}
return session;
}
catch (error) {
this.log('error', `[TokenRegistry] Failed to find active session: ${error}`);
return null;
}
}
/**
* Create a new ephemeral session with secure token
*/
async createSession(options) {
const { userId, serverName, serverType, backend, ttlMinutes = 30, scopes = ['view', 'interact'], metadata = {} } = options;
const now = new Date();
const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000);
const sessionKey = this.generateSessionKey(userId, serverName, serverType);
// Generate secure token with embedded metadata
const tokenPayload = {
userId,
serverName,
serverType: serverType || 'mcp-webui',
sessionId: crypto.randomUUID(),
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(expiresAt.getTime() / 1000)
};
const token = jwt.sign(tokenPayload, this.jwtSecret, {
algorithm: 'HS256'
});
const session = {
token,
userId,
serverName,
serverType: serverType || 'mcp-webui',
sessionKey,
backend,
createdAt: now,
expiresAt,
lastAccessedAt: now,
scopes,
metadata
};
await this.collection.insertOne(session);
this.log('info', `[TokenRegistry] Created session for composite key ${sessionKey}`, {
token: token.substring(0, 16) + '...',
backend: backend.type === 'tcp' ? `${backend.host}:${backend.port}` : backend.socketPath,
expiresAt: expiresAt.toISOString()
});
return session;
}
/**
* Validate token and return session info
*/
async validateToken(token) {
try {
// First, try to find the session in the database (for UUID tokens)
const session = await this.collection.findOne({ token });
if (!session) {
this.log('warn', `[TokenRegistry] Session not found for token: ${token.substring(0, 16)}...`);
return null;
}
// Check if session has expired
if (session.expiresAt < new Date()) {
this.log('warn', `[TokenRegistry] Session expired for user ${session.userId}`);
await this.collection.deleteOne({ token });
return null;
}
// If it's a JWT token, also verify the signature
if (token.includes('.')) { // JWT tokens have dots
try {
jwt.verify(token, this.jwtSecret);
}
catch (jwtError) {
this.log('warn', `[TokenRegistry] Invalid JWT signature for token: ${token.substring(0, 16)}...`);
return null;
}
}
// Update last accessed timestamp
await this.collection.updateOne({ token }, { $set: { lastAccessedAt: new Date() } });
return session;
}
catch (error) {
this.log('error', `[TokenRegistry] Token validation error:`, error);
return null;
}
}
/**
* Extend session expiration
*/
async extendSession(token, additionalMinutes = 30) {
const session = await this.validateToken(token);
if (!session) {
return false;
}
const newExpiresAt = new Date(Date.now() + additionalMinutes * 60 * 1000);
const result = await this.collection.updateOne({ token }, {
$set: {
expiresAt: newExpiresAt,
lastAccessedAt: new Date()
}
});
if (result.modifiedCount > 0) {
this.log('info', `[TokenRegistry] Extended session for user ${session.userId} by ${additionalMinutes} minutes`);
return true;
}
return false;
}
/**
* Revoke a specific session
*/
async revokeSession(token) {
const result = await this.collection.deleteOne({ token });
if (result.deletedCount > 0) {
this.log('info', `[TokenRegistry] Revoked session: ${token.substring(0, 16)}...`);
return true;
}
return false;
}
/**
* Revoke all sessions for a user
*/
async revokeUserSessions(userId) {
const result = await this.collection.deleteMany({ userId });
if (result.deletedCount > 0) {
this.log('info', `[TokenRegistry] Revoked ${result.deletedCount} sessions for user ${userId}`);
}
return result.deletedCount;
}
/**
* Get active sessions for a user
*/
async getUserSessions(userId) {
return await this.collection
.find({
userId,
expiresAt: { $gt: new Date() }
})
.sort({ createdAt: -1 })
.toArray();
}
/**
* Get session statistics
*/
async getStats() {
const now = new Date();
const activeSessions = await this.collection
.find({ expiresAt: { $gt: now } })
.toArray();
const stats = {
totalActiveSessions: activeSessions.length,
sessionsByServer: {},
sessionsByUser: {},
oldestSession: null,
newestSession: null
};
if (activeSessions.length > 0) {
// Group by server
activeSessions.forEach(session => {
stats.sessionsByServer[session.serverName] =
(stats.sessionsByServer[session.serverName] || 0) + 1;
stats.sessionsByUser[session.userId] =
(stats.sessionsByUser[session.userId] || 0) + 1;
});
// Find oldest and newest
const sortedByCreated = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
stats.oldestSession = sortedByCreated[0].createdAt;
stats.newestSession = sortedByCreated[sortedByCreated.length - 1].createdAt;
}
return stats;
}
/**
* Cleanup expired sessions (manual cleanup, TTL should handle this automatically)
*/
async cleanupExpiredSessions() {
const result = await this.collection.deleteMany({
expiresAt: { $lt: new Date() }
});
if (result.deletedCount > 0) {
this.log('info', `[TokenRegistry] Cleaned up ${result.deletedCount} expired sessions`);
}
return result.deletedCount;
}
/**
* Register an MCP server with its backend configuration
*/
async registerServer(serverName, backend, metadata = {}) {
const now = new Date();
const serverRegistration = {
serverName,
backend,
registeredAt: now,
lastHeartbeat: now,
metadata
};
// Upsert (update if exists, insert if doesn't)
await this.serverCollection.replaceOne({ serverName }, serverRegistration, { upsert: true });
this.log('info', `[TokenRegistry] Registered server: ${serverName}`, {
backend: backend.type === 'tcp' ? `${backend.host}:${backend.port}` : backend.socketPath,
metadata
});
}
/**
* Get registered server information
*/
async getRegisteredServer(serverName) {
return await this.serverCollection.findOne({ serverName });
}
/**
* Update server heartbeat
*/
async updateServerHeartbeat(serverName) {
const result = await this.serverCollection.updateOne({ serverName }, { $set: { lastHeartbeat: new Date() } });
return result.modifiedCount > 0;
}
/**
* Get all registered servers
*/
async getRegisteredServers() {
return await this.serverCollection.find({}).toArray();
}
/**
* Unregister a server
*/
async unregisterServer(serverName) {
const result = await this.serverCollection.deleteOne({ serverName });
if (result.deletedCount > 0) {
this.log('info', `[TokenRegistry] Unregistered server: ${serverName}`);
}
return result.deletedCount > 0;
}
/**
* Clean up stale server registrations (no heartbeat for X minutes)
*/
async cleanupStaleServers(staleMinutes = 10) {
const staleThreshold = new Date(Date.now() - staleMinutes * 60 * 1000);
const result = await this.serverCollection.deleteMany({
lastHeartbeat: { $lt: staleThreshold }
});
if (result.deletedCount > 0) {
this.log('info', `[TokenRegistry] Cleaned up ${result.deletedCount} stale server registrations`);
}
return result.deletedCount;
}
/**
* Simple logging utility
*/
log(level, message, data) {
if (this.logger) {
this.logger(level, message, data);
}
else {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}][${level.toUpperCase()}] ${message}`);
if (data && process.env.DEBUG) {
console.error(JSON.stringify(data, null, 2));
}
}
}
}
//# sourceMappingURL=TokenRegistry.js.map