UNPKG

wyreup-mcp

Version:

Production-ready MCP server that transforms automation platform webhooks into reliable, agent-callable tools

688 lines (624 loc) 20.4 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js' import chalk from 'chalk' import { z } from 'zod' import { executeTool } from './execute.js' import { validateTool } from './validateTool.js' import { healthMonitor } from './healthMonitor.js' import { rateLimiter } from './rateLimiter.js' /** * WyreUP MCP Server implementation * * This class wraps the MCP SDK to provide tool execution capabilities. * It may be abstracted further in the future as the MCP SDK evolves. */ export class WyreupMcpServer { constructor(toolsConfig, options = {}) { this.toolsConfig = toolsConfig this.DEBUG = options.DEBUG || false this.transports = {}; // Cache validated tools on startup to avoid duplicate validation this.validatedTools = this.cacheValidatedTools() this.server = new McpServer({ name: 'wyreup-mcp', version: '0.1.0' }) this.validatedTools.forEach((tool) => { // Convert JSON Schema to Zod schema for modern SDK const zodSchema = this.convertJsonSchemaToZod(tool.input || { type: 'object', properties: {}, required: [], }) // Add description to the Zod schema const schemaWithDescription = zodSchema.describe(tool.description || `Tool: ${tool.name}`) this.server.tool( tool.name, schemaWithDescription, async (params) => { const result = await executeTool( tool, params, {}, { DEBUG: this.DEBUG, toolsBaseUrl: this.toolsConfig.base_url, } ) return this.formatToolResponse(result, tool.name) } ) }) // Add built-in health monitoring tools this.setupHealthTools() this.setupErrorHandling() } /** * Convert JSON Schema to Zod schema for modern SDK compatibility * @param {Object} jsonSchema - JSON Schema object * @returns {Object} Zod schema */ convertJsonSchemaToZod(jsonSchema) { if (!jsonSchema || typeof jsonSchema !== 'object') { return z.object({}) } if (jsonSchema.type === 'object') { const properties = jsonSchema.properties || {} const required = jsonSchema.required || [] const zodObject = {} for (const [key, prop] of Object.entries(properties)) { let zodType switch (prop.type) { case 'string': zodType = z.string() break case 'number': zodType = z.number() break case 'integer': zodType = z.number().int() break case 'boolean': zodType = z.boolean() break case 'array': zodType = z.array(z.any()) break default: zodType = z.any() } // Add description if available if (prop.description) { zodType = zodType.describe(prop.description) } // Make optional if not required if (!required.includes(key)) { zodType = zodType.optional() } zodObject[key] = zodType } return z.object(zodObject) } // Fallback for non-object schemas return z.object({}) } /** * Setup built-in health monitoring and system tools */ setupHealthTools() { // Health check tool for individual webhook endpoints this.server.tool( 'health-check', z.object({ toolName: z.string().describe('Name of the tool to check') }).describe('Perform health check on a specific webhook tool'), async ({ toolName }) => { const tool = this.validatedTools.find(t => t.name === toolName) if (!tool) { return { content: [{ type: 'text', text: JSON.stringify({ error: `Tool '${toolName}' not found` }, null, 2) }] } } const healthCheck = await healthMonitor.performHealthCheck(tool, this.DEBUG) return { content: [{ type: 'text', text: JSON.stringify(healthCheck, null, 2) }] } } ) // Health status tool for getting tool statistics this.server.tool( 'health-status', z.object({ toolName: z.string().optional().describe('Specific tool name, or leave empty for all tools') }).describe('Get health statistics for webhook tools'), async ({ toolName }) => { if (toolName) { const health = healthMonitor.getHealth(toolName) return { content: [{ type: 'text', text: JSON.stringify(health, null, 2) }] } } else { const overallHealth = healthMonitor.getOverallHealth() return { content: [{ type: 'text', text: JSON.stringify(overallHealth, null, 2) }] } } } ) // Rate limit status tool this.server.tool( 'rate-limit-status', z.object({ toolName: z.string().describe('Name of the tool to check rate limit status') }).describe('Check rate limiting status for a webhook tool'), async ({ toolName }) => { const tool = this.validatedTools.find(t => t.name === toolName) if (!tool) { return { content: [{ type: 'text', text: JSON.stringify({ error: `Tool '${toolName}' not found` }, null, 2) }] } } if (!tool.rateLimit) { return { content: [{ type: 'text', text: JSON.stringify({ message: `No rate limiting configured for '${toolName}'` }, null, 2) }] } } const status = rateLimiter.getStatus(toolName, tool.rateLimit) return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] } } ) } /** * Setup built-in health monitoring tools for a specific server connection */ setupHealthToolsForConnection(server) { // Health check tool for individual webhook endpoints server.tool( 'health-check', z.object({ toolName: z.string().describe('Name of the tool to check') }).describe('Perform health check on a specific webhook tool'), async ({ toolName }) => { const tool = this.validatedTools.find(t => t.name === toolName) if (!tool) { return { content: [{ type: 'text', text: JSON.stringify({ error: `Tool '${toolName}' not found` }, null, 2) }] } } const healthCheck = await healthMonitor.performHealthCheck(tool, this.DEBUG) return { content: [{ type: 'text', text: JSON.stringify(healthCheck, null, 2) }] } } ) // Health status tool for getting tool statistics server.tool( 'health-status', z.object({ toolName: z.string().optional().describe('Specific tool name, or leave empty for all tools') }).describe('Get health statistics for webhook tools'), async ({ toolName }) => { if (toolName) { const health = healthMonitor.getHealth(toolName) return { content: [{ type: 'text', text: JSON.stringify(health, null, 2) }] } } else { const overallHealth = healthMonitor.getOverallHealth() return { content: [{ type: 'text', text: JSON.stringify(overallHealth, null, 2) }] } } } ) // Rate limit status tool server.tool( 'rate-limit-status', z.object({ toolName: z.string().describe('Name of the tool to check rate limit status') }).describe('Check rate limiting status for a webhook tool'), async ({ toolName }) => { const tool = this.validatedTools.find(t => t.name === toolName) if (!tool) { return { content: [{ type: 'text', text: JSON.stringify({ error: `Tool '${toolName}' not found` }, null, 2) }] } } if (!tool.rateLimit) { return { content: [{ type: 'text', text: JSON.stringify({ message: `No rate limiting configured for '${toolName}'` }, null, 2) }] } } const status = rateLimiter.getStatus(toolName, tool.rateLimit) return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] } } ) } /** * Cache validated tools on startup to avoid duplicate validation * @returns {Array} Array of valid tools */ cacheValidatedTools() { if (!this.toolsConfig.tools || this.toolsConfig.tools.length === 0) { return [] } const validTools = this.toolsConfig.tools.filter((tool) => { return validateTool(tool, false, this.DEBUG) }) if (this.DEBUG) { console.log( chalk.blue( `[DEBUG] Cached ${validTools.length} valid tools on startup (${ this.toolsConfig.tools.length - validTools.length } filtered out)` ) ) } return validTools } setupErrorHandling() { this.server.onerror = (error) => { console.error(chalk.red('[MCP Error]'), error) } process.on('SIGINT', async () => { await this.server.close() process.exit(0) }) } /** * Format tool execution results for MCP protocol * Handles both static and streaming responses * * @param {Object} result - Tool execution result * @param {string} toolName - Name of the executed tool * @returns {Object|AsyncGenerator} MCP-compatible response or async generator for streams */ formatToolResponse(result, toolName) { if (result.success) { // Handle streaming responses if (result.stream) { const contentType = result.contentType || 'text/plain' if (this.DEBUG) { console.log( chalk.green( `[DEBUG] Formatting streaming response for ${toolName} (${contentType})` ) ) } return this.createStreamingGenerator( result.stream, toolName, contentType ) } // Handle binary responses if ( result.data && result.data.binary === true && result.data.contentType && result.data.data ) { return { content: [ { type: 'text', text: `Binary data returned (${result.data.contentType}). Data length: ${result.data.data.length} base64 characters.`, }, { type: 'text', text: `Binary data is available in base64 format. Note: MCP currently supports text responses only.`, }, ], } } else { // Standard JSON response return { content: [ { type: 'text', text: JSON.stringify(result.data, null, 2), }, ], } } } else { // Tool execution failed return { content: [ { type: 'text', text: `Tool execution failed: ${result.error}`, }, ], isError: true, } } } /** * Create async generator for streaming responses * Yields text chunks in sequence for MCP clients that support streaming * Falls back to buffered response for clients that don't * * @param {ReadableStream} stream - Response stream * @param {string} toolName - Tool name for debugging * @param {string} contentType - Content type of the stream * @returns {AsyncGenerator|Promise<Object>} Async generator or buffered response */ async *createStreamingGenerator(stream, toolName, contentType) { if (this.DEBUG) { console.log( chalk.green( `[DEBUG] Creating streaming generator for ${toolName} (${contentType})` ) ) } try { const reader = stream.getReader() const decoder = new TextDecoder() let chunkCount = 0 let chunks = [] while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value, { stream: true }) chunkCount++ chunks.push(text) if (this.DEBUG && chunkCount <= 5) { console.log( chalk.blue( `[DEBUG] Stream chunk ${chunkCount}: ${text.substring(0, 100)}${ text.length > 100 ? '...' : '' }` ) ) } // Yield each chunk as it arrives yield { content: [ { type: 'text', text: text, }, ], } } if (this.DEBUG) { console.log( chalk.green( `[DEBUG] Completed streaming ${chunkCount} chunks for ${toolName}` ) ) } } catch (error) { if (this.DEBUG) { console.error( chalk.red( `[DEBUG] Error in streaming generator for ${toolName}: ${error.message}` ) ) } yield { content: [ { type: 'text', text: `Stream reading failed: ${error.message}`, }, ], isError: true, } } } async runStdio() { const transport = new StdioServerTransport() await this.server.connect(transport) if (this.DEBUG) { console.error(chalk.green('WyreUP MCP Server running on stdio')) } } async runSse(port = 3333, host = 'localhost') { try { const { createServer } = await import('http') const { URL } = await import('url') const httpServer = createServer(async (req, res) => { // Enable CORS res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id' ) if (req.method === 'OPTIONS') { res.writeHead(200) res.end() return } const url = new URL(req.url || '', `http://${host}:${port}`) if (url.pathname === '/sse' && req.method === 'GET') { // Handle SSE connection establishment const transport = new SSEServerTransport('/messages', res) // Create a new server instance for this connection const connectionServer = new McpServer({ name: 'wyreup-mcp', version: '0.1.0' }) // Re-register all tools for this connection this.validatedTools.forEach((tool) => { const zodSchema = this.convertJsonSchemaToZod(tool.input || { type: 'object', properties: {}, required: [], }) const schemaWithDescription = zodSchema.describe(tool.description || `Tool: ${tool.name}`) connectionServer.tool( tool.name, schemaWithDescription, async (params) => { const result = await executeTool( tool, params, {}, { DEBUG: this.DEBUG, toolsBaseUrl: this.toolsConfig.base_url, } ) return this.formatToolResponse(result, tool.name) } ) }) // Add built-in health monitoring tools to this connection this.setupHealthToolsForConnection(connectionServer) await connectionServer.connect(transport) if (this.DEBUG) { console.log(chalk.blue(`[DEBUG] Transport sessionId: ${transport.sessionId}`)) console.log(chalk.blue(`[DEBUG] Transport available: ${!!transport}`)) } this.transports[transport.sessionId] = { transport, server: connectionServer } res.on('close', () => { if (transport.sessionId && this.transports[transport.sessionId]) { this.transports[transport.sessionId].server.close() delete this.transports[transport.sessionId] if (this.DEBUG) { console.log(chalk.blue(`[DEBUG] Cleaned up session: ${transport.sessionId}`)) } } }) if (this.DEBUG) { console.log(chalk.blue(`[DEBUG] SSE connection established with sessionId: ${transport.sessionId}`)) console.log(chalk.blue(`[DEBUG] Stored transport for session: ${transport.sessionId}`)) console.log(chalk.blue(`[DEBUG] Active sessions: ${Object.keys(this.transports).join(', ')}`)) } } else if (url.pathname === '/messages' && req.method === 'POST') { // Handle POST messages to the SSE transport const sessionId = url.searchParams.get('sessionId') const connectionInfo = this.transports[sessionId] if (connectionInfo && connectionInfo.transport) { let body = '' req.on('data', chunk => { body += chunk.toString() }) req.on('end', async () => { try { const message = JSON.parse(body) await connectionInfo.transport.handlePostMessage(req, res, message) } catch (error) { if (this.DEBUG) { console.error(chalk.red(`[DEBUG] Error handling POST message: ${error.message}`)) } res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('Invalid JSON') } }) } else { if (this.DEBUG) { console.error(chalk.red(`[DEBUG] No transport found for sessionId: ${sessionId}`)) console.error(chalk.red(`[DEBUG] Available sessions: ${Object.keys(this.transports).join(', ')}`)) } res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('No transport found for sessionId') } } else { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not found') } }) return new Promise((resolve, reject) => { httpServer.listen(port, host, (error) => { if (error) { reject(error) return } console.log( chalk.green(`✅ SSE server active on http://${host}:${port}`) ) if (this.DEBUG) { console.log( chalk.blue(`[DEBUG] WyreUP MCP Server running on SSE transport`) ) console.log( chalk.blue(`[DEBUG] SSE endpoint: http://${host}:${port}/sse`) ) console.log( chalk.blue(`[DEBUG] Messages endpoint: http://${host}:${port}/messages`) ) console.log( chalk.blue( `[DEBUG] Available tools: ${this.validatedTools .map((t) => t.name) .join(', ')}` ) ) } resolve() }) httpServer.on('error', reject) }) } catch (error) { console.error(chalk.red(`Failed to start SSE server: ${error.message}`)) throw error } } async close() { await this.server.close() } } /** * Factory function to create MCP server instances * This abstraction allows for future SDK changes without affecting main code */ export function createMcpServer(toolsConfig, options = {}) { return new WyreupMcpServer(toolsConfig, options) }