mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
630 lines • 24.3 kB
JavaScript
/**
* GenericUIServer - Slim, Configuration-Driven UI Server
*
* This replaces the monolithic UIServer with a modular, extensible architecture:
* - Configuration over hardcoding
* - Composition over inheritance
* - Separation of concerns
* - Progressive enhancement
* - Plugin-based extensibility
*
* Key improvements:
* - Schema-driven resource loading (CSS/JS loaded based on content)
* - Pluggable template system (custom renderers for different apps)
* - Dynamic route generation (no hardcoded routes per app)
* - Modular middleware system (security, compression, etc.)
* - Plugin architecture (extend without modifying core)
*/
import express from 'express';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { DEFAULT_UI_SERVER_CONFIG } from './UIServerConfig.js';
import { ResourceManager } from './ResourceManager.js';
import { TemplateEngine } from './TemplateEngine.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* GenericUIServer - The new modular, configurable UI server
*/
export class GenericUIServer {
session;
schema;
dataSource;
onUpdate;
sessionManager;
config;
pollInterval;
bindAddress;
app;
server = null;
dataPollingInterval = null;
// Modular components
resourceManager;
templateEngine;
plugins = new Map();
// Computed properties
projectRoot;
constructor(session, schema, dataSource, onUpdate, sessionManager, config = DEFAULT_UI_SERVER_CONFIG, pollInterval = 2000, bindAddress = 'localhost') {
this.session = session;
this.schema = schema;
this.dataSource = dataSource;
this.onUpdate = onUpdate;
this.sessionManager = sessionManager;
this.config = config;
this.pollInterval = pollInterval;
this.bindAddress = bindAddress;
this.app = express();
this.projectRoot = this.findProjectRoot();
// Initialize modular components
this.resourceManager = new ResourceManager(this.config, this.projectRoot);
this.templateEngine = new TemplateEngine(this.config, this.resourceManager);
// Setup server
this.initializePlugins();
this.setupMiddleware();
this.setupRoutes();
this.log('INFO', `GenericUIServer initialized with ${this.plugins.size} plugins`);
}
/**
* Start the server
*/
async start() {
// Validate resources before starting
const validation = this.resourceManager.validateResources(this.schema);
if (!validation.valid) {
this.log('WARN', `Missing resources: ${validation.missing.join(', ')}`);
}
return new Promise((resolve, reject) => {
try {
this.server = this.app.listen(this.session.port, this.bindAddress, () => {
this.log('INFO', `Server started on ${this.bindAddress}:${this.session.port}`);
this.log('INFO', `Serving schema: ${this.schema.title}`);
this.log('INFO', `Theme detection: ${this.getActiveThemes().join(', ') || 'default'}`);
this.startDataPolling();
resolve();
});
this.server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
this.log('ERROR', `Port ${this.session.port} already in use`);
reject(new Error(`Port ${this.session.port} already in use`));
}
else {
reject(error);
}
});
}
catch (error) {
reject(error);
}
});
}
/**
* Stop the server and cleanup
*/
async stop() {
return new Promise(async (resolve) => {
// Stop data polling
if (this.dataPollingInterval) {
clearInterval(this.dataPollingInterval);
this.dataPollingInterval = null;
}
// Cleanup plugins
for (const plugin of this.plugins.values()) {
if (plugin.cleanup) {
try {
await plugin.cleanup();
}
catch (error) {
this.log('ERROR', `Plugin ${plugin.name} cleanup failed: ${error}`);
}
}
}
// Close HTTP server
if (this.server) {
this.server.close(() => {
this.log('INFO', `Server stopped on port ${this.session.port}`);
resolve();
});
}
else {
resolve();
}
});
}
/**
* Register a plugin
*/
async registerPlugin(plugin) {
try {
await plugin.initialize(this);
this.plugins.set(plugin.name, plugin);
this.log('INFO', `Plugin registered: ${plugin.name}`);
}
catch (error) {
this.log('ERROR', `Failed to register plugin ${plugin.name}: ${error}`);
throw error;
}
}
/**
* Add custom route (for plugins)
*/
addRoute(path, handler) {
this.app.get(path, handler);
}
/**
* Add custom middleware (for plugins)
*/
addMiddleware(middleware) {
this.app.use(middleware);
}
/**
* Setup Express middleware with configuration-driven approach
*/
setupMiddleware() {
// Security middleware (configurable)
if (this.config.security.enableCSP) {
this.app.use((req, res, next) => {
const nonce = this.generateNonce();
res.locals.nonce = nonce;
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Dynamic CSP based on configuration
const cspDirectives = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"img-src 'self' data:",
"font-src 'self'"
];
res.setHeader('Content-Security-Policy', cspDirectives.join('; ') + ';');
next();
});
}
// Body parser with configurable limits
this.app.use(express.json({ limit: this.config.server.maxRequestSize }));
this.app.use(express.urlencoded({
extended: true,
limit: this.config.server.maxRequestSize
}));
// Authentication middleware
this.app.use((req, res, next) => {
// Allow static files and health check without token
if (req.path.startsWith('/static/') || req.path === '/api/health') {
return next();
}
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
error: 'Authentication token required',
timestamp: new Date().toISOString(),
hint: 'Include token as query parameter or Authorization header'
});
}
// Timing-safe token comparison
const expectedToken = this.session.token;
if (!this.timingSafeEquals(token, expectedToken)) {
return res.status(403).json({
success: false,
error: 'Invalid token',
timestamp: new Date().toISOString()
});
}
// Update session activity for user actions
const isUserAction = req.method === 'POST' ||
req.path.includes('/update') ||
req.path.includes('/extend');
if (isUserAction) {
this.session.lastActivity = new Date();
this.log('INFO', `User action: ${req.method} ${req.path}`);
}
next();
});
}
/**
* Setup API routes with dynamic resource handling
*/
setupRoutes() {
// Main UI route - uses TemplateEngine
this.app.get('/', async (req, res) => {
try {
const templateData = await this.buildTemplateData(res.locals.nonce);
const html = await this.templateEngine.render(templateData);
res.send(html);
}
catch (error) {
this.log('ERROR', `Failed to render UI: ${error}`);
res.status(500).send(await this.templateEngine.render({
session: this.session,
schema: this.schema,
initialData: [],
config: { pollInterval: this.pollInterval, apiBase: '/api' },
nonce: res.locals.nonce
}));
}
});
// Dynamic resource routes - uses ResourceManager
this.setupDynamicResourceRoutes();
// API routes
this.setupAPIRoutes();
// Health check
this.app.get('/api/health', (req, res) => {
const themes = this.getActiveThemes();
res.json({
success: true,
data: {
status: 'active',
framework: 'generic-ui-server',
version: '2.0.0',
architecture: 'modular',
schema: this.schema.title,
themes: themes,
plugins: Array.from(this.plugins.keys()),
expiresAt: this.session.expiresAt,
uptime: Date.now() - this.session.startTime.getTime(),
components: this.schema.components.length,
features: {
schemaBasedLoading: true,
pluginSystem: true,
configurationDriven: true,
multiThemeSupport: true
}
},
timestamp: new Date().toISOString()
});
});
}
/**
* Setup dynamic resource routes using ResourceManager
*/
setupDynamicResourceRoutes() {
// MCP Server CSS route - serves styles.css from configured directory
// IMPORTANT: This must come BEFORE the general /static/:filename.css route
this.app.get('/static/styles.css', async (req, res) => {
try {
const cssPath = this.config.resources.css.mcpServerDirectory;
if (!cssPath) {
return res.status(404).send('/* MCP CSS path not configured */');
}
const filePath = path.join(cssPath, 'styles.css');
if (fs.existsSync(filePath)) {
res.type('text/css');
if (this.config.resources.static.enableCompression) {
res.setHeader('Cache-Control', this.config.resources.static.cacheControl);
}
return res.sendFile(filePath);
}
else {
this.log('WARN', `MCP CSS not found: ${filePath}`);
// Fallback to framework CSS if MCP CSS not found
return this.serveStaticFile(res, 'styles.css', 'text/css');
}
}
catch (error) {
this.log('ERROR', `Failed to serve MCP CSS: ${error}`);
res.status(500).send('/* Error loading MCP CSS */');
}
});
// Framework CSS route - serves base framework styles for other CSS files
this.app.get('/static/:filename.css', async (req, res) => {
try {
const filename = req.params.filename;
// All other CSS should come from MCP servers or be served from static directories
res.status(404).send('/* CSS file not found - app CSS should use /static/styles.css */');
}
catch (error) {
this.log('ERROR', `Failed to serve framework CSS ${req.params.filename}: ${error}`);
res.status(500).send('/* Error loading CSS */');
}
});
// Dynamic JS route - uses ResourceManager bundling
this.app.get('/static/mcp-framework.js', async (req, res) => {
try {
res.type('application/javascript');
const bundledJS = await this.resourceManager.bundleJavaScript(this.schema);
res.send(bundledJS);
}
catch (error) {
this.log('ERROR', `Failed to bundle JavaScript: ${error}`);
res.status(500).send('// Error loading framework');
}
});
// Static files fallback
for (const dir of this.config.resources.static.directories) {
const absDir = path.isAbsolute(dir) ? dir : path.join(this.projectRoot, dir);
if (fs.existsSync(absDir)) {
this.app.use('/static', express.static(absDir));
}
}
}
/**
* Setup API routes
*/
setupAPIRoutes() {
// Get current data
this.app.get('/api/data', async (req, res) => {
try {
const data = await this.dataSource(this.session.userId);
const response = {
success: true,
data,
timestamp: new Date().toISOString()
};
res.json(response);
}
catch (error) {
const response = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
res.status(500).json(response);
}
});
// Handle updates
this.app.post('/api/update', async (req, res) => {
try {
const { action, data } = req.body;
if (!action || typeof action !== 'string') {
return res.status(400).json({
success: false,
error: 'Invalid action parameter',
timestamp: new Date().toISOString()
});
}
const sanitizedData = this.sanitizeUpdateData(data);
const result = await this.onUpdate(action, sanitizedData, this.session.userId);
const response = {
success: true,
data: result,
timestamp: new Date().toISOString()
};
res.json(response);
}
catch (error) {
this.log('ERROR', `Update failed: ${error}`);
const response = {
success: false,
error: error instanceof Error ? error.message : 'Update operation failed',
timestamp: new Date().toISOString()
};
res.status(500).json(response);
}
});
// Extend session
this.app.post('/api/extend-session', (req, res) => {
const { minutes = 30 } = req.body;
if (typeof minutes !== 'number' || minutes < 5 || minutes > 120) {
return res.status(400).json({
success: false,
error: 'Extension minutes must be between 5 and 120',
timestamp: new Date().toISOString()
});
}
if (new Date() > this.session.expiresAt) {
return res.status(410).json({
success: false,
error: 'Session has already expired',
timestamp: new Date().toISOString()
});
}
// Update local session copy
this.session.expiresAt = new Date(this.session.expiresAt.getTime() + minutes * 60 * 1000);
this.session.lastActivity = new Date();
// CRITICAL FIX: Also update the SessionManager's authoritative copy
const sessionExtended = this.sessionManager.extendSession(this.session.id, minutes);
if (!sessionExtended) {
// If SessionManager couldn't extend, revert local changes
this.session.expiresAt = new Date(this.session.expiresAt.getTime() - minutes * 60 * 1000);
return res.status(500).json({
success: false,
error: 'Failed to extend session in session manager',
timestamp: new Date().toISOString()
});
}
const response = {
success: true,
data: { expiresAt: this.session.expiresAt },
timestamp: new Date().toISOString()
};
res.json(response);
});
}
/**
* Build template data for rendering
*/
async buildTemplateData(nonce) {
const initialData = await this.dataSource(this.session.userId);
return {
session: this.session,
schema: this.schema,
initialData,
config: {
pollInterval: this.pollInterval,
apiBase: '/api'
},
nonce
};
}
/**
* Get active themes for this schema
*/
getActiveThemes() {
const resources = this.resourceManager.getRequiredResources(this.schema);
return resources.css
.filter(css => css !== '/static/styles.css')
.map(css => css.replace('/static/', '').replace('.css', ''));
}
/**
* Initialize plugins from configuration
*/
async initializePlugins() {
// This would load and initialize plugins based on config.plugins
// For now, it's a placeholder for future plugin system
if (this.config.plugins.enabled.length > 0) {
this.log('INFO', `Loading ${this.config.plugins.enabled.length} plugins...`);
// TODO: Load plugins dynamically
}
}
/**
* Serve static file with proper headers
*/
serveStaticFile(res, filename, contentType) {
for (const dir of this.config.resources.static.directories) {
const absDir = path.isAbsolute(dir) ? dir : path.join(this.projectRoot, dir);
const filePath = path.join(absDir, filename);
if (fs.existsSync(filePath)) {
res.type(contentType);
if (this.config.resources.static.enableCompression) {
res.setHeader('Cache-Control', this.config.resources.static.cacheControl);
}
return res.sendFile(filePath);
}
}
res.status(404).send(`/* ${filename} not found */`);
}
/**
* Start data polling if enabled
*/
startDataPolling() {
if (this.schema.polling?.enabled !== false) {
this.dataPollingInterval = setInterval(async () => {
// Server-side change detection could go here
// For now, clients handle their own polling
}, this.pollInterval);
}
}
/**
* Find project root directory
*/
findProjectRoot() {
let currentDir = __dirname;
while (currentDir !== path.dirname(currentDir)) {
const packagePath = path.join(currentDir, 'package.json');
if (fs.existsSync(packagePath)) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
return __dirname; // Fallback
}
/**
* Sanitize update data
*/
sanitizeUpdateData(data) {
if (!data || typeof data !== 'object') {
return {};
}
const sanitized = {};
for (const [key, value] of Object.entries(data)) {
const cleanKey = key.replace(/[^a-zA-Z0-9_]/g, '');
if (typeof value === 'string') {
const maxLength = 1000;
sanitized[cleanKey] = this.sanitizeLLMContent(value.substring(0, maxLength), cleanKey);
}
else if (typeof value === 'boolean' || typeof value === 'number') {
sanitized[cleanKey] = value;
}
else if (value === null || value === undefined) {
sanitized[cleanKey] = value;
}
else if (Array.isArray(value)) {
sanitized[cleanKey] = value.map(item => typeof item === 'object' ? this.sanitizeUpdateData(item) : item);
}
else {
this.log('WARN', `Complex object sanitized for key: ${key}`);
sanitized[cleanKey] = this.sanitizeUpdateData(value);
}
}
return sanitized;
}
/**
* Sanitize LLM-generated content
*/
sanitizeLLMContent(content, context = 'text') {
if (!content || typeof content !== 'string') {
return '';
}
let clean = content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
.replace(/data:text\/html/gi, '');
switch (context) {
case 'text':
case 'todo-text':
return clean.replace(/[<>{}[\]\\]/g, '').substring(0, 500);
case 'category':
return clean.replace(/[^a-zA-Z0-9\s\-_]/g, '').substring(0, 50);
case 'priority':
const allowedPriorities = ['low', 'medium', 'high', 'urgent'];
return allowedPriorities.includes(clean.toLowerCase()) ? clean.toLowerCase() : 'medium';
default:
return this.escapeHtml(clean);
}
}
/**
* Timing-safe string comparison
*/
timingSafeEquals(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
/**
* Generate cryptographic nonce
*/
generateNonce() {
return crypto.randomBytes(16).toString('base64');
}
/**
* Escape HTML content
*/
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Check if schema matches theme conditions
*/
matchesSchemaConditions(theme) {
if (!theme.conditions)
return false;
// Check schema title conditions
if (theme.conditions.schemaTitle) {
const titleMatch = theme.conditions.schemaTitle.some((title) => this.schema.title.toLowerCase().includes(title.toLowerCase()));
if (titleMatch)
return true;
}
// Check component type conditions
if (theme.conditions.componentTypes) {
const componentMatch = theme.conditions.componentTypes.some((type) => this.schema.components.some(comp => comp.type === type));
if (componentMatch)
return true;
}
return false;
}
/**
* Enhanced logging with component context
*/
log(level, message) {
const timestamp = new Date().toISOString();
const sessionInfo = `Session:${this.session.id.substring(0, 8)}`;
const schemaInfo = `Schema:${this.schema.title}`;
console.log(`[${timestamp}][${level}][GenericUIServer][${sessionInfo}][${schemaInfo}] ${message}`);
}
}
//# sourceMappingURL=GenericUIServer.js.map