UNPKG

yamcp-ui

Version:

Web-based dashboard for YAMCP (Yet Another MCP) - A Model Context Protocol workspace manager. Built on top of YAMCP by Hamid Alipour (https://github.com/hamidra/yamcp)

784 lines (686 loc) • 23.1 kB
import express from "express"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; import { dirname } from "path"; import cors from "cors"; import envPaths from "env-paths"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); // Default port 8765, can be overridden with PORT environment variable // Example: PORT=3000 npx yamcp-ui const PORT = process.env.PORT || 8765; // Import YAMCP modules from global package // Helper function to safely import YAMCP modules async function importYAMCP(modulePath) { try { return await import(`yamcp/${modulePath}`); } catch (error) { console.error(`Failed to load YAMCP module ${modulePath}:`, error.message); console.error( "Make sure yamcp is installed globally: npm install -g yamcp" ); return null; } } // Load YAMCP modules (will be loaded asynchronously) let config = null; let loadProvidersMap = null; let loadWorkspaceMap = null; let addMcpProviders = null; let removeMcpProvider = null; let getMcpProviders = null; let addWorkspace = null; let removeWorkspace = null; let getWorkspaces = null; // Initialize YAMCP modules async function initializeYAMCP() { const configModule = await importYAMCP("dist/config.js"); if (configModule) config = configModule; const loaderModule = await importYAMCP("dist/store/loader.js"); if (loaderModule) { loadProvidersMap = loaderModule.loadProvidersMap; loadWorkspaceMap = loaderModule.loadWorkspaceMap; } const providerModule = await importYAMCP("dist/store/provider.js"); if (providerModule) { addMcpProviders = providerModule.addMcpProviders; removeMcpProvider = providerModule.removeMcpProvider; getMcpProviders = providerModule.getMcpProviders; } const workspaceModule = await importYAMCP("dist/store/workspace.js"); if (workspaceModule) { addWorkspace = workspaceModule.addWorkspace; removeWorkspace = workspaceModule.removeWorkspace; getWorkspaces = workspaceModule.getWorkspaces; } } // Security: Only allow requests from the same origin (localhost) app.use( cors({ origin: [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`], credentials: true, }) ); // Additional security middleware app.use((req, res, next) => { // Only allow API requests from the same host const host = req.get("host"); const allowedHosts = [`localhost:${PORT}`, `127.0.0.1:${PORT}`]; if (req.path.startsWith("/api/") && !allowedHosts.includes(host)) { return res .status(403) .json({ error: "Access denied: API only accessible from web interface" }); } next(); }); // Serve static files from the React app build directory app.use(express.static(path.join(__dirname, "dist"))); // Parse JSON bodies app.use(express.json()); // Helper function to get config paths function getConfigPaths() { if (!config) { // Fallback to envPaths if config module fails const paths = envPaths("yamcp"); return { providersPath: path.join(paths.data, "providers.json"), workspacesPath: path.join(paths.data, "workspaces.json"), logDir: paths.log, }; } return { providersPath: config.PROVIDERS_CONFIG_PATH, workspacesPath: config.WORKSPACES_CONFIG_PATH, logDir: config.LOG_DIR, }; } // Helper function to safely load JSON file function loadJSONFile(filePath, defaultValue = {}) { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, "utf-8"); return JSON.parse(content); } } catch (error) { console.error(`Error loading ${filePath}:`, error.message); } return defaultValue; } // Helper function to get real providers data function getRealProviders() { try { if (getMcpProviders) { return getMcpProviders(); } // Fallback to direct file loading const { providersPath } = getConfigPaths(); return loadJSONFile(providersPath, {}); } catch (error) { console.error("Error getting providers:", error.message); return {}; } } // Helper function to get real workspaces data function getRealWorkspaces() { try { if (getWorkspaces) { return getWorkspaces(); } // Fallback to direct file loading const { workspacesPath } = getConfigPaths(); return loadJSONFile(workspacesPath, {}); } catch (error) { console.error("Error getting workspaces:", error.message); return {}; } } // Helper function to read log files function getRecentLogs(limit = 50) { try { const { logDir } = getConfigPaths(); const logs = []; if (!fs.existsSync(logDir)) { return []; } // Get all workspace directories const workspaceDirs = fs.readdirSync(logDir).filter((dir) => { const dirPath = path.join(logDir, dir); return fs.statSync(dirPath).isDirectory(); }); // Read logs from each workspace for (const workspaceDir of workspaceDirs) { const combinedLogPath = path.join(logDir, workspaceDir, "combined.log"); if (fs.existsSync(combinedLogPath)) { try { const logContent = fs.readFileSync(combinedLogPath, "utf-8"); const logLines = logContent .trim() .split("\n") .filter((line) => line.trim()); // Parse each log line (Winston JSON format) for (const line of logLines.slice(-20)) { // Get last 20 from each file try { const logEntry = JSON.parse(line); logs.push({ id: `${workspaceDir}_${logEntry.timestamp}`, timestamp: logEntry.timestamp, level: logEntry.level, server: workspaceDir.split("_")[0], // Extract workspace name message: logEntry.message || JSON.stringify(logEntry), }); } catch (parseError) { // Skip invalid JSON lines } } } catch (fileError) { console.error( `Error reading log file ${combinedLogPath}:`, fileError.message ); } } } // Sort by timestamp and limit return logs .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) .slice(0, limit); } catch (error) { console.error("Error getting logs:", error.message); return []; } } // API Routes app.get("/api/stats", (req, res) => { try { const providers = getRealProviders(); const workspaces = getRealWorkspaces(); const totalServers = Object.keys(providers).length; const totalWorkspaces = Object.keys(workspaces).length; // For now, assume all servers are active (we'd need to track actual status) const activeServers = totalServers; const activeWorkspaces = totalWorkspaces; res.json({ totalServers, activeServers, totalWorkspaces, activeWorkspaces, issues: 0, // Could be calculated based on failed scans or connection issues }); } catch (error) { console.error("Error getting stats:", error.message); res.status(500).json({ error: "Failed to get stats" }); } }); app.get("/api/servers", (req, res) => { try { const providers = getRealProviders(); const servers = Object.entries(providers).map(([key, provider]) => { const isStdio = provider.type === "stdio"; return { id: key, name: key, namespace: provider.namespace || key, type: provider.type, status: "stopped", // Default status - would need actual tracking ...(isStdio ? { command: provider.providerParameters.command, args: provider.providerParameters.args || [], env: provider.providerParameters.env || {}, } : { url: provider.providerParameters.url, }), }; }); res.json(servers); } catch (error) { console.error("Error getting servers:", error.message); res.status(500).json({ error: "Failed to get servers" }); } }); app.get("/api/workspaces", (req, res) => { try { const workspaces = getRealWorkspaces(); const providers = getRealProviders(); const workspaceList = Object.entries(workspaces).map( ([name, serverNames]) => { // Validate that all servers in workspace exist const validServers = serverNames.filter( (serverName) => providers[serverName] ); return { id: name, name: name, description: `Workspace with ${validServers.length} server${ validServers.length === 1 ? "" : "s" }`, servers: validServers, status: "inactive", // Default status - would need actual tracking }; } ); res.json(workspaceList); } catch (error) { console.error("Error getting workspaces:", error.message); res.status(500).json({ error: "Failed to get workspaces" }); } }); app.get("/api/logs", (req, res) => { try { const logs = getRecentLogs(100); // Add some mock logs for testing if no real logs exist if (logs.length === 0) { const mockLogs = [ { id: "mock_1", timestamp: new Date().toISOString(), level: "info", server: "vibe", message: "Server started successfully", }, { id: "mock_2", timestamp: new Date(Date.now() - 60000).toISOString(), level: "error", server: "fetch-mcp", message: "Connection failed to external service", }, { id: "mock_3", timestamp: new Date(Date.now() - 120000).toISOString(), level: "warn", server: "database", message: "High memory usage detected", }, ]; res.json(mockLogs); } else { res.json(logs); } } catch (error) { console.error("Error getting logs:", error.message); res.status(500).json({ error: "Failed to get logs" }); } }); // Server actions app.post("/api/servers/:id/start", (req, res) => { const { id } = req.params; // TODO: Implement actual server starting logic // This would involve spawning the server process and tracking its status res.json({ success: true, message: `Server ${id} start requested (not implemented yet)`, }); }); app.post("/api/servers/:id/stop", (req, res) => { const { id } = req.params; // TODO: Implement actual server stopping logic // This would involve killing the server process res.json({ success: true, message: `Server ${id} stop requested (not implemented yet)`, }); }); app.delete("/api/servers/:id", (req, res) => { const { id } = req.params; try { if (removeMcpProvider) { removeMcpProvider(id); res.json({ success: true, message: `Server ${id} deleted successfully` }); } else { // Fallback to direct file manipulation const { providersPath } = getConfigPaths(); const providers = loadJSONFile(providersPath, {}); delete providers[id]; fs.writeFileSync(providersPath, JSON.stringify(providers, null, 2)); res.json({ success: true, message: `Server ${id} deleted successfully` }); } } catch (error) { console.error(`Error deleting server ${id}:`, error.message); res.status(500).json({ error: `Failed to delete server ${id}` }); } }); // Add new server app.post("/api/servers", (req, res) => { const { name, type, command, args, env, url } = req.body; try { const newProvider = { namespace: name, type: type, providerParameters: type === "stdio" ? { command, args: args || [], env: env || {}, } : { url, }, }; if (addMcpProviders) { addMcpProviders([newProvider]); } else { // Fallback to direct file manipulation const { providersPath } = getConfigPaths(); const providers = loadJSONFile(providersPath, {}); providers[name] = newProvider; fs.writeFileSync(providersPath, JSON.stringify(providers, null, 2)); } res.json({ success: true, message: `Server ${name} added successfully` }); } catch (error) { console.error(`Error adding server ${name}:`, error.message); res.status(500).json({ error: `Failed to add server ${name}` }); } }); // Update server app.put("/api/servers/:id", (req, res) => { const { id } = req.params; const { name, namespace, type, command, args, env, url } = req.body; try { const { providersPath } = getConfigPaths(); const providers = loadJSONFile(providersPath, {}); // Check if server exists if (!providers[id]) { return res.status(404).json({ error: `Server ${id} not found` }); } // If namespace changed, we need to handle the key change const newNamespace = namespace || name; // Use namespace if provided, fallback to name if (newNamespace !== id) { // Remove old entry delete providers[id]; } // Create updated provider const updatedProvider = { namespace: newNamespace, type: type, providerParameters: type === "stdio" ? { command, args: args || [], env: env || {}, } : { url, }, }; // Add updated provider providers[newNamespace] = updatedProvider; // If namespace changed, update workspaces that reference this server if (newNamespace !== id) { const { workspacesPath } = getConfigPaths(); const workspaces = loadJSONFile(workspacesPath, {}); for (const [workspaceName, serverList] of Object.entries(workspaces)) { const serverIndex = serverList.indexOf(id); if (serverIndex !== -1) { serverList[serverIndex] = newNamespace; } } fs.writeFileSync(workspacesPath, JSON.stringify(workspaces, null, 2)); } // Save providers fs.writeFileSync(providersPath, JSON.stringify(providers, null, 2)); res.json({ success: true, message: `Server ${newNamespace} updated successfully`, }); } catch (error) { console.error(`Error updating server ${id}:`, error.message); res.status(500).json({ error: `Failed to update server ${id}` }); } }); // Workspace actions app.post("/api/workspaces/:id/start", (req, res) => { const { id } = req.params; // TODO: Implement actual workspace starting logic // This would involve starting all servers in the workspace res.json({ success: true, message: `Workspace ${id} start requested (not implemented yet)`, }); }); app.post("/api/workspaces/:id/stop", (req, res) => { const { id } = req.params; // TODO: Implement actual workspace stopping logic // This would involve stopping all servers in the workspace res.json({ success: true, message: `Workspace ${id} stop requested (not implemented yet)`, }); }); app.delete("/api/workspaces/:id", (req, res) => { const { id } = req.params; try { if (removeWorkspace) { removeWorkspace(id); res.json({ success: true, message: `Workspace ${id} deleted successfully`, }); } else { // Fallback to direct file manipulation const { workspacesPath } = getConfigPaths(); const workspaces = loadJSONFile(workspacesPath, {}); delete workspaces[id]; fs.writeFileSync(workspacesPath, JSON.stringify(workspaces, null, 2)); res.json({ success: true, message: `Workspace ${id} deleted successfully`, }); } } catch (error) { console.error(`Error deleting workspace ${id}:`, error.message); res.status(500).json({ error: `Failed to delete workspace ${id}` }); } }); // Add new workspace app.post("/api/workspaces", (req, res) => { const { name, servers } = req.body; try { if (addWorkspace) { addWorkspace(name, servers); } else { // Fallback to direct file manipulation const { workspacesPath } = getConfigPaths(); const workspaces = loadJSONFile(workspacesPath, {}); workspaces[name] = servers; fs.writeFileSync(workspacesPath, JSON.stringify(workspaces, null, 2)); } res.json({ success: true, message: `Workspace ${name} created successfully`, }); } catch (error) { console.error(`Error creating workspace ${name}:`, error.message); res.status(500).json({ error: `Failed to create workspace ${name}` }); } }); // Update workspace app.put("/api/workspaces/:id", (req, res) => { const { id } = req.params; const { name, servers } = req.body; try { const { workspacesPath } = getConfigPaths(); const workspaces = loadJSONFile(workspacesPath, {}); // Check if workspace exists if (!workspaces[id]) { return res.status(404).json({ error: `Workspace ${id} not found` }); } // If name changed, we need to handle the key change const newName = name || id; // Use name if provided, fallback to id if (newName !== id) { // Remove old entry delete workspaces[id]; } // Add updated workspace workspaces[newName] = servers; // Save workspaces fs.writeFileSync(workspacesPath, JSON.stringify(workspaces, null, 2)); res.json({ success: true, message: `Workspace ${newName} updated successfully`, }); } catch (error) { console.error(`Error updating workspace ${id}:`, error.message); res.status(500).json({ error: `Failed to update workspace ${id}` }); } }); // Get log files list app.get("/api/log-files", (req, res) => { try { const { logDir } = getConfigPaths(); const logFiles = []; if (!fs.existsSync(logDir)) { return res.json([]); } // Get all workspace directories const workspaceDirs = fs.readdirSync(logDir).filter((dir) => { const dirPath = path.join(logDir, dir); return fs.statSync(dirPath).isDirectory(); }); // Get log files from each workspace for (const workspaceDir of workspaceDirs) { const workspacePath = path.join(logDir, workspaceDir); const files = fs .readdirSync(workspacePath) .filter((file) => file.endsWith(".log")); for (const file of files) { const filePath = path.join(workspacePath, file); const stats = fs.statSync(filePath); logFiles.push({ name: `${workspaceDir}/${file}`, size: stats.size, modified: stats.mtime.toISOString(), path: filePath, }); } } res.json(logFiles); } catch (error) { console.error("Error getting log files:", error.message); res.status(500).json({ error: "Failed to get log files" }); } }); // Download log file app.get("/api/log-files/:workspace/:filename", (req, res) => { const { workspace, filename } = req.params; try { const { logDir } = getConfigPaths(); const filePath = path.join(logDir, workspace, filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: "Log file not found" }); } res.download(filePath); } catch (error) { console.error("Error downloading log file:", error.message); res.status(500).json({ error: "Failed to download log file" }); } }); // Get raw JSON content for editing app.get("/api/config/providers", (req, res) => { try { const providers = getRealProviders(); res.json(providers); } catch (error) { console.error("Error getting providers config:", error.message); res.status(500).json({ error: "Failed to get providers config" }); } }); app.get("/api/config/workspaces", (req, res) => { try { const workspaces = getRealWorkspaces(); res.json(workspaces); } catch (error) { console.error("Error getting workspaces config:", error.message); res.status(500).json({ error: "Failed to get workspaces config" }); } }); // Update JSON config files app.put("/api/config/providers", (req, res) => { try { const { providersPath } = getConfigPaths(); const newConfig = req.body; // Validate that it's a valid object if (typeof newConfig !== "object" || newConfig === null) { return res.status(400).json({ error: "Invalid JSON: must be an object" }); } // Write to file fs.writeFileSync(providersPath, JSON.stringify(newConfig, null, 2)); res.json({ success: true, message: "Providers config updated successfully", }); } catch (error) { console.error("Error updating providers config:", error.message); res.status(500).json({ error: "Failed to update providers config" }); } }); app.put("/api/config/workspaces", (req, res) => { try { const { workspacesPath } = getConfigPaths(); const newConfig = req.body; // Validate that it's a valid object if (typeof newConfig !== "object" || newConfig === null) { return res.status(400).json({ error: "Invalid JSON: must be an object" }); } // Write to file fs.writeFileSync(workspacesPath, JSON.stringify(newConfig, null, 2)); res.json({ success: true, message: "Workspaces config updated successfully", }); } catch (error) { console.error("Error updating workspaces config:", error.message); res.status(500).json({ error: "Failed to update workspaces config" }); } }); // Catch all handler: send back React's index.html file for SPA routing app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "dist", "index.html")); }); // Initialize and start server async function startServer() { // Initialize YAMCP modules await initializeYAMCP(); // Try to start server with error handling const server = app.listen(PORT, "localhost", () => { console.log(`Server running on http://localhost:${PORT}`); console.log("šŸ”’ API access restricted to web interface only"); }); // Handle port in use error server.on("error", (error) => { if (error.code === "EADDRINUSE") { console.error(`\nāŒ Port ${PORT} is already in use!`); console.error(`\nšŸ’” Suggestions:`); console.error(` 1. Stop any other yamcp-ui instances running`); console.error( ` 2. Try a different port by setting PORT environment variable:` ); console.error(` PORT=8766 npx yamcp-ui`); console.error(` PORT=3000 npx yamcp-ui`); console.error(` PORT=4000 npx yamcp-ui`); console.error(`\n 3. Check what's using port ${PORT}:`); console.error(` lsof -ti:${PORT}`); console.error(`\n 4. Kill the process using the port:`); console.error(` kill $(lsof -ti:${PORT})`); console.error( `\nšŸ” Common causes: Another yamcp-ui instance, development server, or other web application\n` ); process.exit(1); } else { console.error("Failed to start server:", error); process.exit(1); } }); return server; } // Start the server startServer().catch((error) => { console.error("Failed to start server:", error); process.exit(1); });