UNPKG

easy-mcp-server

Version:

AI-era Express replacement with zero-config MCP integration - Build AI-ready APIs in 30 seconds

570 lines (519 loc) 22 kB
/** * Server Orchestrator * Coordinates the API server (DynamicAPIServer) and MCP server (DynamicAPIMCPServer) * to provide a unified, full-featured application server. */ require('dotenv').config(); // Enable TypeScript API loading (compile TS only, ignore JS) const path = require('path'); const fs = require('fs'); try { const runtimeConfigPath = path.resolve(__dirname, '..', 'tsconfig.runtime.json'); const configExists = fs.existsSync(runtimeConfigPath); // Compiler options to use (either from file or inline) const compilerOptions = { allowJs: false, module: 'commonjs', target: 'ES2020', skipLibCheck: true, skipDefaultLibCheck: true, typeRoots: [], types: [], // Disable type checking entirely checkJs: false, noImplicitAny: false, strict: false, // Prevent TypeScript from resolving types across files isolatedModules: true }; // Register ts-node with project config if available, otherwise use inline options const tsNodeConfig = { transpileOnly: true, compilerOptions: compilerOptions, skipProject: !configExists // Skip project lookup if config file doesn't exist }; if (configExists) { tsNodeConfig.project = runtimeConfigPath; } else if (process.env.NODE_ENV !== 'test') { // Only warn in non-test environments console.warn(`⚠️ tsconfig.runtime.json not found at ${runtimeConfigPath}, using inline compiler options`); } require('ts-node').register(tsNodeConfig); } catch (err) { // Log the error for debugging but don't fail if (process.env.NODE_ENV !== 'test') { console.warn('⚠️ ts-node registration failed:', err.message); } } // Import core modules const DynamicAPIServer = require(path.join(__dirname, 'api', 'api-server')); const DynamicAPIMCPServer = require(path.join(__dirname, 'mcp')); const HotReloader = require(path.join(__dirname, 'utils', 'loaders', 'hot-reloader')); const EnvHotReloader = require(path.join(__dirname, 'utils', 'loaders', 'env-hot-reloader')); const MCPBridgeReloader = require(path.join(__dirname, 'utils', 'loaders', 'mcp-bridge-reloader')); // Global safety nets: never crash the process on unexpected errors process.on('uncaughtException', (error) => { try { console.error('❌ Uncaught Exception (server will continue running):', error); } catch (_) { // no-op } }); process.on('unhandledRejection', (reason) => { try { console.error('❌ Unhandled Promise Rejection (server will continue running):', reason); } catch (_) { // no-op } }); // Initialize DynamicAPIServer with enhanced features const apiServer = new DynamicAPIServer({ port: parseInt(process.env.EASY_MCP_SERVER_PORT) || 8887, host: process.env.EASY_MCP_SERVER_HOST || '0.0.0.0', staticDirectory: process.env.EASY_MCP_SERVER_STATIC_DIRECTORY || null, defaultFile: process.env.EASY_MCP_SERVER_DEFAULT_FILE || 'index.html', enhancedHealth: true, // Enable enhanced health check llmContextFiles: true, // Enable LLM.txt and Agent.md endpoints adminEndpoints: true, // Enable /admin/retry-initialization hotReload: true, docsConfig: { // Enhanced Swagger UI configuration docExpansion: 'list', defaultModelsExpandDepth: 1, defaultModelExpandDepth: 1, tryItOutEnabled: true, requestInterceptor: function(request) { console.log('Request:', request); return request; }, responseInterceptor: function(response) { console.log('Response:', response); return response; } } }); // Get Express app for custom routes const app = apiServer.expressApp; // Get API loader and OpenAPI generator for later use const apiLoader = apiServer.getAPILoader(); const openapiGenerator = apiServer.getOpenAPIGenerator(); // Initialize bridge reloader const bridgeReloader = new MCPBridgeReloader({ root: process.cwd(), logger: console, configFile: process.env.EASY_MCP_SERVER_BRIDGE_CONFIG_PATH || 'mcp-bridge.json' }); // Avoid file watchers in Jest to prevent cross-test interference if (!process.env.JEST_WORKER_ID) { bridgeReloader.startWatching(); } // Note: /health, /api-info, /openapi.json, /docs, /LLM.txt, /Agent.md, and /admin/* endpoints // are provided by DynamicAPIServer when enabled via options // MCP and bridge-specific endpoints below (not in DynamicAPIServer) // MCP (Model Context Protocol) endpoints app.get('/mcp/tools', (req, res) => { try { const routes = apiLoader.getRoutes(); const tools = routes.map(route => { const proc = route.processorInstance; let openApi = proc?.openApi; // Fallback to generated OpenAPI if missing/incomplete try { const needsFallback = !openApi || (!openApi.requestBody && !Array.isArray(openApi.parameters)); if (needsFallback) { const { apiSpecTs } = require('./api/openapi/openapi-helper'); openApi = apiSpecTs(route.filePath); } } catch (_) { /* ignore */ } const tool = { name: `api_${route.path.replace(/\//g, '_')}_${route.method.toLowerCase()}`, description: proc?.mcpDescription || openApi?.description || proc?.description || `Execute ${route.method} request to ${route.path}`, method: route.method, path: route.path, processor: route.processor }; // Build inputSchema similar to MCP server for convenience const inputSchema = { type: 'object', properties: { body: { type: 'object', description: 'Request body' }, query: { type: 'object', description: 'Query parameters' }, headers: { type: 'object', description: 'Request headers' }, path: { type: 'object', description: 'Path parameters' } } }; if (openApi?.requestBody?.content?.['application/json']?.schema) { inputSchema.properties.body = { ...inputSchema.properties.body, ...openApi.requestBody.content['application/json'].schema }; } if (Array.isArray(openApi?.parameters) && openApi.parameters.length > 0) { const queryProps = {}; const queryRequired = []; const headerProps = {}; const headerRequired = []; const pathProps = {}; const pathRequired = []; for (const p of openApi.parameters) { if (!p || !p.name || !p.in) continue; if (p.in === 'query') { queryProps[p.name] = { ...(p.schema || {}), description: p.description || (p.schema && p.schema.description) }; if (p.required) queryRequired.push(p.name); } else if (p.in === 'header') { headerProps[p.name] = { ...(p.schema || {}), description: p.description || (p.schema && p.schema.description) }; if (p.required) headerRequired.push(p.name); } else if (p.in === 'path') { pathProps[p.name] = { ...(p.schema || {}), description: p.description || (p.schema && p.schema.description) }; pathRequired.push(p.name); } } if (Object.keys(queryProps).length > 0) { inputSchema.properties.query = { type: 'object', properties: queryProps }; if (queryRequired.length > 0) inputSchema.properties.query.required = queryRequired; } if (Object.keys(headerProps).length > 0) { inputSchema.properties.headers = { type: 'object', properties: headerProps }; if (headerRequired.length > 0) inputSchema.properties.headers.required = headerRequired; } if (Object.keys(pathProps).length > 0) { inputSchema.properties.path = { type: 'object', properties: pathProps, required: pathRequired }; } } // Fallback: derive path params from route.path if none provided if (!openApi?.parameters || !openApi.parameters.some(p => p.in === 'path')) { const matches = route.path.match(/:([A-Za-z0-9_]+)/g) || []; if (matches.length) { const props = {}; const reqd = []; for (const m of matches) { const name = m.slice(1); props[name] = { type: 'string', description: `${name} parameter` }; reqd.push(name); } inputSchema.properties.path = { type: 'object', properties: props, required: reqd }; } } tool.inputSchema = inputSchema; tool.responseSchema = openApi?.responses?.['200']?.content?.['application/json']?.schema || null; return tool; }); res.json({ success: true, tools, totalTools: tools.length, timestamp: new Date().toISOString() }); } catch (error) { res.status(500).json({ success: false, error: `Failed to get MCP tools: ${error.message}`, timestamp: new Date().toISOString() }); } }); app.post('/mcp/execute/:toolName', (req, res) => { const { toolName } = req.params; const { body, query, headers } = req.body; try { // Parse the tool name to get method and path (format: api_[path]_[http_method]) if (!toolName.startsWith('api_')) { return res.status(400).json({ success: false, error: 'Invalid tool name format. Expected: api_[path]_[method]', timestamp: new Date().toISOString() }); } const pathAndMethod = toolName.substring(4); // Remove 'api_' prefix const lastUnderscoreIndex = pathAndMethod.lastIndexOf('_'); const method = pathAndMethod.substring(lastUnderscoreIndex + 1); // Everything after the last underscore is the method const path = pathAndMethod.substring(0, lastUnderscoreIndex).replace(/_/g, '/'); // Convert underscores back to slashes for the path // Find the route const routes = apiLoader.getRoutes(); const route = routes.find(r => r.method.toUpperCase() === method.toUpperCase() && r.path === path ); if (!route) { return res.status(404).json({ success: false, error: `API endpoint not found: ${method.toUpperCase()} ${path}`, timestamp: new Date().toISOString() }); } // Execute the API endpoint executeAPIEndpoint(route, { body, query, headers }, res); } catch (error) { res.status(500).json({ success: false, error: `Error executing API endpoint: ${error.message}`, timestamp: new Date().toISOString() }); } }); // Simple HTTP Bridge endpoints app.get('/bridge/list-tools', async (req, res) => { try { const bridges = bridgeReloader.ensureBridges(); const results = {}; const promises = []; for (const [name, bridge] of bridges.entries()) { promises.push( bridge.rpcRequest('tools/list', {}).then((r) => { results[name] = r; }).catch((e) => { results[name] = { error: e.message }; }) ); } await Promise.all(promises); res.json({ servers: results }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/bridge/call-tool', async (req, res) => { const { toolName, args, server } = req.body || {}; if (!toolName) return res.status(400).json({ error: 'toolName required' }); try { const bridges = bridgeReloader.ensureBridges(); if (server) { const bridge = bridges.get(server); if (!bridge) return res.status(404).json({ error: `server not found: ${server}` }); const result = await bridge.rpcRequest('tools/call', { name: toolName, arguments: args || {} }); return res.json(result); } // If no server specified, try all servers and aggregate results const results = {}; const promises = []; for (const [name, bridge] of bridges.entries()) { promises.push( bridge.rpcRequest('tools/call', { name: toolName, arguments: args || {} }) .then((r) => { results[name] = r; }) .catch((e) => { results[name] = { error: e.message }; }) ); } await Promise.all(promises); res.json({ servers: results }); } catch (err) { res.status(500).json({ error: err.message }); } }); /** * Execute an API endpoint with mock request/response objects */ async function executeAPIEndpoint(route, args, res) { // Create mock request and response objects const mockReq = { method: route.method, path: route.path, body: args.body || {}, query: args.query || {}, headers: args.headers || {}, params: {} }; const mockRes = { statusCode: 200, headers: {}, json: function(data) { this.data = data; this.statusCode = 200; return this; }, send: function(data) { this.data = data; this.statusCode = 200; return this; }, status: function(code) { this.statusCode = code; return this; } }; try { // Execute the processor if (route.processorInstance && typeof route.processorInstance.process === 'function') { await route.processorInstance.process(mockReq, mockRes); res.json({ success: true, statusCode: mockRes.statusCode, data: mockRes.data, endpoint: `${route.method} ${route.path}`, timestamp: new Date().toISOString() }); } else { throw new Error(`Processor not available for ${route.method} ${route.path}`); } } catch (error) { res.status(500).json({ success: false, error: `Error executing processor: ${error.message}`, timestamp: new Date().toISOString() }); } } // Server startup function async function startServer() { // Display startup banner console.log('\n'); console.log(' ╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗'); console.log(' ║ ║'); console.log(' ║ 🚀 STARTING EASY MCP SERVER... ║'); console.log(' ║ ║'); console.log(' ╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝'); console.log(''); // Start REST API server using DynamicAPIServer await apiServer.start(); // Get loaded routes and errors for display const loadedRoutes = apiLoader.getRoutes(); const errors = apiLoader.getErrors(); // Display error summary if there are any if (errors.length > 0) { console.log('\n⚠️ API Loading Summary:'); console.log(` Total APIs attempted: ${loadedRoutes.length + errors.length}`); console.log(` Successfully loaded: ${loadedRoutes.length}`); console.log(` Failed to load: ${errors.length}`); // Group errors by type for better reporting const errorTypes = {}; errors.forEach(error => { const errorType = typeof error === 'object' ? error.type : 'unknown'; if (!errorTypes[errorType]) { errorTypes[errorType] = 0; } errorTypes[errorType]++; }); console.log('\n📊 Error Breakdown:'); Object.entries(errorTypes).forEach(([type, count]) => { console.log(` ${type}: ${count} errors`); }); console.log('\n💡 Server will continue running with available APIs.'); console.log(' Check /health endpoint for detailed API status.'); console.log(' Fix missing dependencies to enable failed APIs.\n'); } // Start MCP server if enabled let mcpServer = null; let hotReloader = null; let envHotReloader = null; if (process.env.EASY_MCP_SERVER_MCP_ENABLED !== 'false') { try { // Use custom MCP base path if provided, otherwise use default const mcpBasePath = process.env.EASY_MCP_SERVER_MCP_BASE_PATH || path.join(process.cwd(), 'mcp'); mcpServer = new DynamicAPIMCPServer( process.env.EASY_MCP_SERVER_MCP_HOST || '0.0.0.0', parseInt(process.env.EASY_MCP_SERVER_MCP_PORT) || 8888, { mcp: { basePath: mcpBasePath }, // Provide bridge reloader so MCP tools/list can include bridge tools bridgeReloader, // Add quiet mode option quiet: process.env.EASY_MCP_SERVER_QUIET === 'true' } ); // Set the routes for MCP server before starting (so it's available immediately) mcpServer.setRoutes(loadedRoutes); console.log(`📡 Registered ${loadedRoutes.length} API routes with MCP server`); // Start MCP server mcpServer.run().then(() => { console.log('🤖 MCP Server initialized successfully'); // Update routes again after server is fully started (in case of hot reload) mcpServer.setRoutes(apiLoader.getRoutes()); // Initialize hot reloading after MCP server is ready hotReloader = new HotReloader(apiLoader, mcpServer, { autoInstall: true, // Enable auto package installation userCwd: process.cwd(), logger: console }); hotReloader.startWatching(); // Initialize .env hot reloader envHotReloader = new EnvHotReloader({ debounceDelay: 1000, onReload: () => { console.log('🔄 Environment variables reloaded - MCP server will use latest configuration'); }, logger: console, mcpServer: mcpServer, apiLoader: apiLoader, bridgeReloader: bridgeReloader }); envHotReloader.startWatching(); }).catch(error => { console.warn('⚠️ MCP Server failed to start:', error.message); }); } catch (error) { console.warn('⚠️ MCP Server not available:', error.message); } } // Display server startup information const host = process.env.EASY_MCP_SERVER_HOST || '0.0.0.0'; const basePort = parseInt(process.env.EASY_MCP_SERVER_PORT) || 8887; const staticPath = process.env.EASY_MCP_SERVER_STATIC_DIRECTORY || './public'; console.log('\n'); console.log(' ╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗'); console.log(' ║ ║'); console.log(' ║ 🚀 EASY MCP SERVER 🚀 ║'); console.log(' ║ ║'); console.log(' ╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝'); console.log(''); console.log(' 🚀 SERVER STARTED SUCCESSFULLY'); console.log(' ' + '═'.repeat(78)); console.log(` 📍 Server Address: ${host}:${basePort}`); console.log(' 🌍 Environment: development'); console.log(''); console.log(' 📡 API ENDPOINTS:'); console.log(` • Health Check: http://localhost:${basePort}/health`); console.log(` • API Information: http://localhost:${basePort}/api-info`); console.log(` • MCP Tools: http://localhost:${basePort}/mcp/tools`); console.log(''); console.log(' 📚 DOCUMENTATION:'); console.log(` • OpenAPI JSON: http://localhost:${basePort}/openapi.json`); console.log(` • Swagger UI: http://localhost:${basePort}/docs ✨`); console.log(` • LLM Context: http://localhost:${basePort}/LLM.txt`); console.log(` • Agent Context: http://localhost:${basePort}/Agent.md`); console.log(''); if (mcpServer) { console.log(' 🤖 MCP SERVER:'); console.log(` • WebSocket: ws://${mcpServer.host}:${mcpServer.port}`); console.log(` • Routes Loaded: ${loadedRoutes.length} API endpoints`); console.log(''); } console.log(' ⚡ FEATURES:'); console.log(' • Auto-discovery of API endpoints'); console.log(' • Real-time MCP tool generation'); console.log(' • Automatic OpenAPI documentation'); console.log(' • Hot reloading enabled'); if (fs.existsSync(staticPath)) { console.log(' • Static file serving enabled'); } console.log(''); console.log(' 🎯 Ready to serve your APIs!'); console.log(' ' + '═'.repeat(78)); console.log(''); // Graceful shutdown handlers const shutdown = async () => { console.log('\n🛑 Shutting down servers...'); if (hotReloader) { hotReloader.stopWatching(); } if (envHotReloader) { envHotReloader.stopWatching(); } if (mcpServer) { await mcpServer.stop(); } if (apiServer) { await apiServer.stop(); } process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); } // Only start server if this file is run directly if (require.main === module) { startServer(); } // Export functions for external use module.exports = { app, apiLoader, openapiGenerator, mcpServer: null, // Will be set when server starts hotReloader: null, // Will be set when server starts getLoadedRoutes: () => apiLoader.getRoutes(), startServer // Export the startServer function }; // Version 0.6.14