mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
345 lines • 16.6 kB
JavaScript
import { SessionManager } from './session/SessionManager.js';
import { GenericUIServer } from './server/GenericUIServer.js';
import { UIServerConfigBuilder } from './server/UIServerConfig.js';
/**
* Main MCP Web UI framework class
* Orchestrates session management and dynamic UI servers
* Now uses GenericUIServer with MCP server CSS architecture
*/
export class MCPWebUI {
sessionManager;
activeServers = new Map();
config;
cleanupInterval;
constructor(config) {
// Set defaults with proper blocked ports handling
this.config = {
sessionTimeout: 30 * 60 * 1000, // 30 minutes
pollInterval: 2000, // 2 seconds
portRange: [3000, 65535],
blockedPorts: config.blockedPorts || [], // Use provided value or default
enableLogging: true,
baseUrl: 'localhost',
protocol: 'http', // Default protocol
// If baseUrl is not localhost, default to binding all interfaces
bindAddress: config.baseUrl && config.baseUrl !== 'localhost' ? '0.0.0.0' : 'localhost',
cssPath: './static', // Default MCP server CSS path
serverName: '', // Default server name
proxyMode: false, // Default to direct mode
mongoUrl: undefined, // No MongoDB by default
mongoDbName: 'mcp_webui', // Default database name
jwtSecret: undefined, // No JWT by default
...config
};
// Auto-detect protocol from baseUrl if not explicitly provided
if (!config.protocol && config.baseUrl) {
if (config.baseUrl.startsWith('https://')) {
this.config.protocol = 'https';
}
else if (config.baseUrl.startsWith('http://')) {
this.config.protocol = 'http';
}
}
// Detect proxy mode (including gateway mode)
const proxyMode = !!(config.proxyMode ||
process.env.MCP_WEB_UI_PROXY_MODE === 'true' ||
process.env.MCP_WEB_UI_MONGO_URL ||
process.env.MCP_WEB_UI_USE_GATEWAY === 'true');
// Override bindAddress for proxy mode - always bind to localhost when using proxy
if (proxyMode ||
(process.env.MCP_WEB_UI_PROXY_PREFIX && process.env.MCP_WEB_UI_PROXY_PREFIX.trim().length > 0)) {
this.config.bindAddress = 'localhost';
}
else if (process.env.MCP_WEB_UI_BIND_ADDRESS) {
// Use explicit bind address from environment
this.config.bindAddress = process.env.MCP_WEB_UI_BIND_ADDRESS;
}
// Create session manager with proxy mode support
this.sessionManager = new SessionManager(this.config.sessionTimeout, this.config.portRange, this.config.baseUrl, this.config.protocol, this.config.blockedPorts, {
proxyMode,
mongoUrl: this.config.mongoUrl,
mongoDbName: this.config.mongoDbName,
jwtSecret: this.config.jwtSecret,
serverName: this.config.serverName,
logger: (level, message, data) => this.log(level.toUpperCase(), message)
});
// Set up automatic cleanup - check for expired sessions every minute
this.startAutomaticCleanup();
// Cleanup on process exit
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
}
/**
* Start automatic cleanup of expired sessions
*/
startAutomaticCleanup() {
this.cleanupInterval = setInterval(async () => {
await this.cleanupExpiredSessions();
}, 60 * 1000); // Check every minute
}
/**
* Cleanup expired sessions and their UI servers
*/
async cleanupExpiredSessions() {
if (this.sessionManager.isProxyMode()) {
// In proxy mode, MongoDB TTL handles expiration automatically
// We need to clean up local UI servers that may be orphaned
const activeSessions = await this.sessionManager.getActiveSessions();
const activeSessionIds = new Set(activeSessions.map(s => s.id));
// Find orphaned UI servers (servers running but no corresponding active session)
const orphanedServers = [];
for (const [sessionId, uiServer] of this.activeServers.entries()) {
if (!activeSessionIds.has(sessionId)) {
orphanedServers.push(sessionId);
}
}
if (orphanedServers.length > 0) {
this.log('INFO', `Found ${orphanedServers.length} orphaned UI servers, cleaning up...`);
for (const sessionId of orphanedServers) {
const uiServer = this.activeServers.get(sessionId);
if (uiServer) {
try {
await uiServer.stop();
this.activeServers.delete(sessionId);
this.log('INFO', `Cleaned up orphaned UI server for session ${sessionId}`);
}
catch (error) {
this.log('ERROR', `Failed to stop orphaned UI server ${sessionId}: ${error}`);
}
}
}
}
const stats = await this.sessionManager.getStats();
this.log('INFO', `Proxy mode active sessions: ${stats.totalActiveSessions || 0}, UI servers: ${this.activeServers.size}`);
}
else {
// Direct mode: manual cleanup
const now = new Date();
const activeSessions = await this.sessionManager.getActiveSessions();
const expiredSessions = activeSessions.filter(session => now > session.expiresAt);
if (expiredSessions.length > 0) {
this.log('INFO', `Found ${expiredSessions.length} expired sessions, cleaning up...`);
for (const session of expiredSessions) {
this.log('INFO', `Session ${session.id} expired (${session.expiresAt.toISOString()}), terminating...`);
await this.terminateSession(session.id);
}
}
}
}
/**
* Create a new UI session for a user
* Returns the session with URL that can be shared
* Automatically cleans up any existing session for the same user
* Uses UnifiedSessionManager which handles both direct and proxy modes
*/
async createSession(userId) {
try {
this.log('INFO', `[SESSION-CREATION] Starting session creation for user: ${userId}`);
this.log('INFO', `[SESSION-CREATION] Environment: GATEWAY=${process.env.MCP_WEB_UI_USE_GATEWAY}, URL=${process.env.MCP_WEB_UI_GATEWAY_URL}, BASE=${process.env.MCP_WEB_UI_BASE_URL}, PREFIX=${process.env.MCP_WEB_UI_PROXY_PREFIX}`);
// Create session using unified session manager
this.log('INFO', `[SESSION-CREATION] Calling sessionManager.createSession(${userId})`);
const session = await this.sessionManager.createSession(userId);
// Debug: Log what SessionManager actually returned
this.log('INFO', `[SESSION-CREATION] SessionManager returned session: ID=${session.id}, Token=${session.token}, URL=${session.url}, Port=${session.port}`);
// Check if we already have an active UI server for this session
const existingServer = this.activeServers.get(session.id);
if (existingServer) {
this.log('INFO', `[SESSION-CREATION] Reusing existing UI server for session ${session.id} on port ${session.port}`);
return session;
}
// Additional check: Look for any UI servers that might be using the same port
// This can happen if session reuse didn't work properly or there's a mismatch
this.log('INFO', `[SESSION-CREATION] Checking for port conflicts on ${session.port}. Active servers: ${this.activeServers.size}`);
const conflictingServers = [];
for (const [activeSessionId, activeServer] of this.activeServers.entries()) {
this.log('INFO', `[SESSION-CREATION] Checking active session ${activeSessionId}`);
// We'll attempt to create the server and catch the port conflict
}
// If we have multiple servers, it suggests there might be orphaned ones
if (this.activeServers.size > 0) {
this.log('WARN', `[SESSION-CREATION] Found ${this.activeServers.size} active UI servers before creating new one. This might indicate cleanup issues.`);
this.log('WARN', `[SESSION-CREATION] Active session IDs: ${Array.from(this.activeServers.keys()).join(', ')}`);
}
// Create UI server only if we don't have one already
this.log('INFO', `[SESSION-CREATION] Creating new UI server on port ${session.port}`);
const uiConfig = this.createUIServerConfig();
const uiServer = new GenericUIServer(session, this.config.schema, this.config.dataSource, this.config.onUpdate, this.sessionManager, uiConfig, this.config.pollInterval, this.config.bindAddress, this.config.protocol);
try {
await uiServer.start();
this.activeServers.set(session.id, uiServer);
this.log('INFO', `[SESSION-CREATION] UI server started successfully for user ${userId}: ${session.url}`);
this.log('INFO', `[SESSION-CREATION] Session details: ID=${session.id}, Token=${session.token}, Port=${session.port}`);
return session;
}
catch (error) {
if (error.message && error.message.includes('already in use')) {
this.log('WARN', `[SESSION-CREATION] Port ${session.port} conflict detected. Attempting to clean up conflicting servers...`);
// Find and stop any servers that might be conflicting
let cleanedUp = false;
for (const [activeSessionId, activeServer] of this.activeServers.entries()) {
try {
this.log('INFO', `[SESSION-CREATION] Stopping potentially conflicting server: ${activeSessionId}`);
await activeServer.stop();
this.activeServers.delete(activeSessionId);
cleanedUp = true;
this.log('INFO', `[SESSION-CREATION] Successfully stopped conflicting server ${activeSessionId}`);
}
catch (stopError) {
this.log('ERROR', `[SESSION-CREATION] Failed to stop server ${activeSessionId}: ${stopError}`);
}
}
if (cleanedUp) {
this.log('INFO', `[SESSION-CREATION] Retrying UI server creation after cleanup...`);
// Retry creating the server
try {
await uiServer.start();
this.activeServers.set(session.id, uiServer);
this.log('INFO', `[SESSION-CREATION] UI server started successfully after cleanup for user ${userId}: ${session.url}`);
return session;
}
catch (retryError) {
this.log('ERROR', `[SESSION-CREATION] Retry failed: ${retryError}`);
throw retryError;
}
}
}
throw error;
}
}
catch (error) {
this.log('ERROR', `Failed to create UI session for user ${userId}: ${error}`);
throw error;
}
}
/**
* Create UI server configuration with MCP server CSS support
* Environment variable driven approach
*/
createUIServerConfig() {
const configBuilder = UIServerConfigBuilder.create();
const config = configBuilder.build();
// CSS path priority:
// 1. Explicit config cssPath
// 2. Environment variable MCP_WEB_UI_CSS_PATH
// 3. Default: ./static
config.resources.css.mcpServerDirectory =
this.config.cssPath ||
process.env.MCP_WEB_UI_CSS_PATH ||
'./static';
return config;
}
/**
* Get active session by token (for validation)
*/
async getSessionByToken(token) {
return await this.sessionManager.getSessionByToken(token);
}
/**
* Manually terminate a session
*/
async terminateSession(sessionId) {
try {
const uiServer = this.activeServers.get(sessionId);
if (uiServer) {
await uiServer.stop();
this.activeServers.delete(sessionId);
}
const success = await this.sessionManager.terminateSession(sessionId);
if (success) {
this.log('INFO', `Terminated session ${sessionId}`);
}
return success;
}
catch (error) {
this.log('ERROR', `Failed to terminate session ${sessionId}: ${error}`);
return false;
}
}
/**
* Get stats about active sessions and servers
*/
async getStats() {
const sessionStats = await this.sessionManager.getStats();
return {
...sessionStats,
activeServers: this.activeServers.size,
serverPorts: Array.from(this.activeServers.values()).map(server => server.session?.port).filter(Boolean)
};
}
/**
* Cleanup and shutdown all sessions and servers
*/
async shutdown() {
this.log('INFO', 'Shutting down MCPWebUI...');
// Clear the cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
// Stop all active servers
const stopPromises = Array.from(this.activeServers.values()).map(server => server.stop().catch(error => this.log('ERROR', `Error stopping server: ${error}`)));
await Promise.all(stopPromises);
this.activeServers.clear();
// Shutdown unified session manager (handles both modes)
await this.sessionManager.shutdown();
this.log('INFO', 'MCPWebUI shutdown complete');
}
/**
* Create MCP tool definition for get_web_ui
*/
getMCPToolDefinition() {
return {
name: "get_web_ui",
description: `Get a web interface for ${this.config.schema.title}`,
inputSchema: {
type: "object",
properties: {
extend_minutes: {
type: "number",
description: "Minutes to extend session (default: 30)",
minimum: 5,
maximum: 120
}
},
additionalProperties: false
}
};
}
/**
* Handle the get_web_ui tool call
* This is what MCP servers will call
*/
async handleGetWebUI(userId, extendMinutes = 30) {
try {
const session = await this.createSession(userId);
return {
content: [{
type: "text",
text: `🌐 Your ${this.config.schema.title} dashboard is ready!\n\n` +
`🔗 **URL**: ${session.url}\n\n` +
`⏰ **Session expires**: ${session.expiresAt.toLocaleString()}\n` +
`👤 **User**: ${userId}\n\n` +
`💡 *Tip: Bookmark this link or use the "Extend" button in the UI to keep your session active.*`
}]
};
}
catch (error) {
return {
content: [{
type: "text",
text: `❌ Failed to create web UI: ${error instanceof Error ? error.message : 'Unknown error'}`
}]
};
}
}
/**
* Simple logging utility
*/
log(level, message) {
if (this.config.enableLogging) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}][${level}][MCPWebUI] ${message}`);
}
}
}
//# sourceMappingURL=MCPWebUI.js.map