mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
347 lines (327 loc) • 12.7 kB
JavaScript
/**
* TemplateEngine - Modular, configuration-driven template rendering
* Replaces hardcoded template rendering in UIServer with flexible, extensible approach
*/
/**
* Base template for vanilla JS framework
*/
export class VanillaTemplateRenderer {
name = 'vanilla';
canRender(context) {
return true; // Fallback renderer
}
render(context) {
const { schema, session, resources, nonce } = context;
return `<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.escapeHtml(schema.title)}</title>
${this.renderPreloads(resources)}
${this.renderCSS(resources)}
${this.renderInlineStyles(resources, nonce)}
</head>
<body>
${this.renderBody(context)}
${this.renderJavaScript(resources, nonce)}
${this.renderInitScript(context, nonce)}
</body>
</html>`;
}
renderPreloads(resources) {
return resources.preloadLinks.join('\n ');
}
renderCSS(resources) {
return resources.css
.map(css => `<link href="${css}" rel="stylesheet">`)
.join('\n ');
}
renderInlineStyles(resources, nonce) {
if (!resources.inlineCSS)
return '';
return `
<style nonce="${nonce}">
${resources.inlineCSS}
/* Enhanced styles for vanilla JS framework */
.mcp-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
color: #64748b;
}
.mcp-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.mcp-notification-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
}
.mcp-notification {
background: white;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease;
}
.mcp-notification-success { border-left: 4px solid #10b981; }
.mcp-notification-error { border-left: 4px solid #dc2626; }
.mcp-notification-info { border-left: 4px solid #3b82f6; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>`;
}
renderBody(context) {
const { schema } = context;
const safeDescription = schema.description ? this.escapeHtml(schema.description) : '';
return `
<!-- Vanilla JS MCP UI Container -->
<div id="mcp-app">
<header class="header">
<h1>${this.escapeHtml(schema.title)}</h1>
${safeDescription ? `<p class="description">${safeDescription}</p>` : ''}
<div class="session-info">
<span>Session expires: <span id="expire-time">Loading...</span></span>
<button id="extend-btn" class="btn-extend">Extend</button>
</div>
</header>
<main class="main">
<!-- Loading state -->
<div id="mcp-loading" class="mcp-loading" style="display: none;">
<div class="loading-spinner"></div>
<span>Loading...</span>
</div>
<!-- Error state -->
<div id="mcp-error" class="mcp-error" style="display: none;"></div>
<!-- Component containers -->
${this.renderComponentContainers(schema.components)}
</main>
</div>`;
}
renderComponentContainers(components) {
return components.map(component => {
const containerClass = `component-container component-${component.type}`;
return `<div id="${component.id}" class="${containerClass}"></div>`;
}).join('\n ');
}
renderJavaScript(resources, nonce) {
return `
<!-- Vanilla JS MCP Framework -->
${resources.javascript.map(js => `<script nonce="${nonce}" src="${js}"></script>`).join('\n ')}`;
}
renderInitScript(context, nonce) {
const { session, config, schema, resources } = context;
return `
<!-- Component Initialization -->
<script nonce="${nonce}">
// Global configuration
const mcpConfig = ${this.safeJsonStringify({
sessionToken: session.token,
pollInterval: config.pollInterval,
apiBase: config.apiBase,
userId: session.userId,
security: {
sanitizeInput: true,
validateEvents: true,
enableRateLimit: true,
maxInputLength: 1000
}
})};
// Initial data (safely embedded) - get from template data, not session
const mcpInitialData = ${this.safeJsonStringify(context.initialData || [])};
// UI Schema (for component initialization)
const mcpSchema = ${this.safeJsonStringify(schema)};
// Initialize the MCP UI when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
try {
console.log('🚀 Initializing MCP Vanilla JS UI Framework');
console.log('📊 Initial data:', mcpInitialData);
// Initialize components from schema
const components = MCP.initFromSchema(mcpSchema, { default: mcpInitialData }, mcpConfig);
console.log('✅ MCP Components initialized:', components.length);
// Setup session management
MCP.updateExpirationTime('${session.expiresAt.toISOString()}');
// Setup extend session button
const extendBtn = document.getElementById('extend-btn');
if (extendBtn) {
extendBtn.addEventListener('click', async function() {
try {
extendBtn.disabled = true;
extendBtn.textContent = 'Extending...';
await MCP.extendSession(30, mcpConfig.sessionToken);
extendBtn.textContent = 'Extended!';
setTimeout(() => {
extendBtn.disabled = false;
extendBtn.textContent = 'Extend';
}, 2000);
} catch (error) {
console.error('Failed to extend session:', error);
extendBtn.disabled = false;
extendBtn.textContent = 'Extend';
MCP.utils.showNotification('Failed to extend session', 'error');
}
});
}
// Show success notification
MCP.utils.showNotification('UI loaded successfully!', 'success', 2000);
} catch (error) {
console.error('❌ Failed to initialize MCP UI:', error);
// Show error state
const errorDiv = document.getElementById('mcp-error');
if (errorDiv) {
errorDiv.textContent = 'Failed to load UI: ' + error.message;
errorDiv.style.display = 'block';
}
}
});
// Global error handling
window.addEventListener('error', function(event) {
console.error('MCP UI Error:', event.error);
MCP.utils.showNotification('An error occurred. Please refresh the page.', 'error');
});
// Handle page visibility changes for polling optimization
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
console.log('🔄 Page visible - refreshing components');
// Components will automatically refresh when page becomes visible
}
});
</script>`;
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
safeJsonStringify(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
}
/**
* Main TemplateEngine with pluggable renderers
*/
export class TemplateEngine {
config;
resourceManager;
renderers = [];
constructor(config, resourceManager) {
this.config = config;
this.resourceManager = resourceManager;
// Register default renderers
this.registerRenderer(new VanillaTemplateRenderer());
// Register custom renderers from config
this.loadCustomRenderers();
}
/**
* Register a template renderer
*/
registerRenderer(renderer) {
this.renderers.push(renderer);
}
/**
* Render template using appropriate renderer
*/
async render(templateData) {
try {
// Get required resources based on schema
const resources = this.resourceManager.getRequiredResources(templateData.schema);
const context = {
schema: templateData.schema,
session: templateData.session,
resources,
config: templateData.config,
nonce: templateData.nonce || this.generateNonce(),
initialData: templateData.initialData
};
// Find appropriate renderer
const renderer = this.findRenderer(context);
if (!renderer) {
throw new Error('No suitable template renderer found');
}
return renderer.render(context);
}
catch (error) {
console.error('Template rendering failed:', error);
return this.renderErrorPage(error);
}
}
/**
* Find the best renderer for the context
*/
findRenderer(context) {
// Find first renderer that can handle this context
return this.renderers.find(renderer => renderer.canRender(context)) || null;
}
/**
* Load custom renderers from configuration
*/
loadCustomRenderers() {
// Implementation would load custom template renderers
// based on config.templates.customTemplates
}
/**
* Render error page
*/
renderErrorPage(error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const safeError = this.escapeHtml(errorMessage);
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - MCP Web UI</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 2rem; background: #f8fafc; }
.error-container { max-width: 600px; margin: 0 auto; background: white; border-radius: 0.5rem; padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.error-title { color: #dc2626; font-size: 1.5rem; margin-bottom: 1rem; }
.error-message { color: #374151; margin-bottom: 1.5rem; }
.error-actions { display: flex; gap: 1rem; }
.btn { padding: 0.5rem 1rem; border-radius: 0.375rem; text-decoration: none; font-weight: 500; }
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #f3f4f6; color: #374151; }
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-title">⚠️ Server Error</h1>
<p class="error-message">Failed to load the MCP Web UI: ${safeError}</p>
<div class="error-actions">
<a href="javascript:location.reload()" class="btn btn-primary">Retry</a>
<a href="/api/health" class="btn btn-secondary">Check Status</a>
</div>
</div>
</body>
</html>`;
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
generateNonce() {
return Math.random().toString(36).substring(2, 15);
}
}
//# sourceMappingURL=TemplateEngine.js.map