UNPKG

espocrm-power-mcp

Version:

A powerful MCP server for EspoCRM

738 lines (737 loc) 31 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const axios_1 = __importDefault(require("axios")); const promise_1 = __importDefault(require("mysql2/promise")); // Configuration from environment variables const config = { apiUrl: process.env.ESPOCRM_API_URL || 'https://crm.columbia.je/api/v1', apiKey: process.env.ESPOCRM_API_KEY || '', mysql: { host: process.env.MYSQL_HOST || '', port: parseInt(process.env.MYSQL_PORT || '3306'), user: process.env.MYSQL_USER || '', password: process.env.MYSQL_PASSWORD || '', database: process.env.MYSQL_DATABASE || '', readonly: process.env.MYSQL_READONLY === 'true', connectTimeout: 10000, // 10 seconds ssl: { rejectUnauthorized: false } }, routingMode: process.env.ROUTING_MODE || 'auto', // 'api', 'mysql', or 'auto' agentUserId: process.env.AGENT_USER_ID || '' }; // MySQL connection pool let mysqlPool = null; // Cache for entity metadata const metadataCache = new Map(); // const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - currently unused // Initialize MySQL connection async function initMySQL() { if (!config.mysql.host || !config.mysql.user) { console.error('MySQL configuration incomplete'); return false; } try { mysqlPool = promise_1.default.createPool({ host: config.mysql.host, port: config.mysql.port, user: config.mysql.user, password: config.mysql.password, database: config.mysql.database, waitForConnections: true, connectionLimit: 10, queueLimit: 0, connectTimeout: config.mysql.connectTimeout, ssl: config.mysql.ssl }); // Test connection const connection = await mysqlPool.getConnection(); await connection.ping(); connection.release(); console.error('MySQL connected successfully'); return true; } catch (error) { console.error('MySQL connection failed:', error); mysqlPool = null; return false; } } // API request helper with better error handling async function apiRequest(method, endpoint, data, params) { const url = `${config.apiUrl}${endpoint}`; try { const response = await (0, axios_1.default)({ method, url, data, params, headers: { 'X-Api-Key': config.apiKey, 'Content-Type': 'application/json', ...(method === 'POST' && { 'X-Skip-Duplicate-Check': 'true' }), }, timeout: 30000, validateStatus: (status) => status < 500 // Don't throw on 4xx errors }); if (response.status === 404) { throw new Error(`API endpoint not found: ${endpoint}`); } if (response.status === 403) { throw new Error('API access forbidden - check permissions'); } if (response.status === 401) { throw new Error('API authentication failed - check API key'); } if (response.status >= 400) { console.error('API Error Response:', response.data); const err = new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, `API error: ${response.status} ${response.statusText}`); err.details = response.data; throw err; } return response.data; } catch (error) { if (error.code === 'ECONNREFUSED') { throw new Error('API connection refused - check URL and network'); } if (error.code === 'ETIMEDOUT') { throw new Error('API request timeout'); } throw error; } } // Get current user from API async function getCurrentUser() { try { const user = await apiRequest('GET', '/App/user'); return user; } catch (error) { console.error('Could not fetch current user:', error); return null; } } // MySQL query helper async function mysqlQuery(query, params) { if (!mysqlPool) { throw new Error('MySQL not connected'); } try { const [results] = await mysqlPool.execute(query, params); return results; } catch (error) { console.error('MySQL query error:', error); throw error; } } // Determine routing based on operation and configuration function determineRouting(_entity, operation) { // If MySQL is not available or readonly for write operations, use API if (!mysqlPool || (config.mysql.readonly && ['create', 'update', 'delete'].includes(operation))) { return 'api'; } // If routing mode is forced, use it if (config.routingMode === 'api' || config.routingMode === 'mysql') { return config.routingMode; } // Auto routing: prefer MySQL for reads, API for writes if (operation === 'list' || operation === 'read') { return 'mysql'; } return 'api'; } // Create the server const server = new index_js_1.Server({ name: 'espocrm-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Tool handlers server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => { return { tools: [ { name: 'discover_entities', description: 'Discover available EspoCRM entities', inputSchema: { type: 'object', properties: {} } }, { name: 'query', description: 'Execute CRUD operations on entities with intelligent routing', inputSchema: { type: 'object', properties: { entity: { type: 'string' }, operation: { type: 'string', enum: ['list', 'read', 'create', 'update', 'delete'] }, id: { type: 'string' }, data: { type: 'object' }, params: { type: 'object' } }, required: ['entity', 'operation'] } }, { name: 'get_metadata', description: 'Get entity metadata (blocked to prevent context window issues)', inputSchema: { type: 'object', properties: { entity: { type: 'string' } }, required: ['entity'] } }, { name: 'cache_stats', description: 'Get cache statistics and performance metrics', inputSchema: { type: 'object', properties: {} } }, { name: 'analytics', description: 'Run analytics queries (demo version)', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['aggregate', 'timeseries'] }, entity: { type: 'string' }, metrics: { type: 'array', items: { type: 'string' } }, dimensions: { type: 'array', items: { type: 'string' } }, filters: { type: 'object' }, timeRange: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } }, required: ['start', 'end'] } }, required: ['type', 'entity', 'metrics'] } }, { name: 'mysql_query', description: 'Execute SQL queries directly (MySQL must be configured)', inputSchema: { type: 'object', properties: { query: { type: 'string' }, params: { type: 'array', items: { type: 'string' } } }, required: ['query'] } }, { name: 'test_auth', description: 'Test different authentication methods to diagnose connection issues', inputSchema: { type: 'object', properties: {} } }, { name: 'health_check', description: 'Check connectivity to all external services', inputSchema: { type: 'object', properties: {} } }, { name: 'get_gist', description: 'Get an agent gist by name', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } }, { name: 'create_gist', description: 'Create a new agent gist', inputSchema: { type: 'object', properties: { name: { type: 'string' }, content: { type: 'string' }, description: { type: 'string' } }, required: ['name', 'content'] } }, { name: 'update_gist', description: 'Update an existing agent gist', inputSchema: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, content: { type: 'string' }, description: { type: 'string' }, category: { type: 'string' }, assignedUserId: { type: 'string' } }, required: ['id'] } } ], }; }); // Tool execution server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'discover_entities': { try { if (mysqlPool) { try { console.log('Using MySQL for entity discovery'); const tables = await mysqlQuery("SELECT table_name FROM information_schema.tables WHERE table_schema = ?", [config.mysql.database]); const entityList = tables .map(row => row.TABLE_NAME) .filter(name => !name.includes('_')) // Exclude join tables and multi-word names .map(name => name.charAt(0).toUpperCase() + name.slice(1)); return { content: [{ type: 'text', text: JSON.stringify(entityList, null, 2) }] }; } catch (mysqlError) { console.error('MySQL discovery failed, falling back to API.', mysqlError); } } // Fallback to API if MySQL is not available or fails console.log('Using API for entity discovery'); const metadata = await apiRequest('GET', '/Metadata'); if (metadata && metadata.entityDefs) { const entities = Object.keys(metadata.entityDefs); return { content: [{ type: 'text', text: JSON.stringify(entities, null, 2) }] }; } throw new Error('Could not discover entities from API or database.'); } catch (error) { console.error('Error in discover_entities:', error); throw new Error(`Error discovering entities: ${error.message}`); } } case 'query': { const { entity, operation, id, data, params } = args; const routing = determineRouting(entity, operation); if (routing === 'mysql' && mysqlPool) { // MySQL routing const tableName = entity.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1); switch (operation) { case 'list': { const limit = params?.limit || 20; const offset = params?.offset || 0; const where = params?.where || []; let query = `SELECT * FROM ${tableName}`; const queryParams = []; if (where.length > 0) { const conditions = where.map((w) => { if (w.type === 'equals') { queryParams.push(w.value); return `${w.attribute} = ?`; } else if (w.type === 'contains') { queryParams.push(`%${w.value}%`); return `${w.attribute} LIKE ?`; } return '1=1'; }); query += ` WHERE ${conditions.join(' AND ')}`; } query += ` LIMIT ${limit} OFFSET ${offset}`; const results = await mysqlQuery(query, queryParams); const countResult = await mysqlQuery(`SELECT COUNT(*) as total FROM ${tableName}`, []); return { content: [{ type: 'text', text: JSON.stringify({ list: results, total: countResult[0].total }, null, 2) }] }; } case 'read': { const result = await mysqlQuery(`SELECT * FROM ${tableName} WHERE id = ?`, [id]); return { content: [{ type: 'text', text: JSON.stringify(result[0] || null, null, 2) }] }; } default: // Fall back to API for write operations break; } } // API routing (default) let endpoint = `/${entity}`; let method = 'GET'; let body = undefined; switch (operation) { case 'list': method = 'GET'; break; case 'read': endpoint = `/${entity}/${id}`; method = 'GET'; break; case 'create': method = 'POST'; body = data; break; case 'update': endpoint = `/${entity}/${id}`; method = 'PUT'; body = data; break; case 'delete': endpoint = `/${entity}/${id}`; method = 'DELETE'; break; } const result = await apiRequest(method, endpoint, body, params); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } case 'get_metadata': { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Metadata access is blocked to prevent context window overflow', suggestion: 'Use discover_entities for a list of entities, or query specific entities directly' }, null, 2) }] }; } case 'cache_stats': { return { content: [{ type: 'text', text: JSON.stringify({ cacheSize: metadataCache.size, routingMode: config.routingMode, mysql: { enabled: !!mysqlPool, readonly: config.mysql.readonly } }, null, 2) }] }; } case 'analytics': { const { type, entity, metrics, dimensions, filters, timeRange } = args; if (!mysqlPool) { throw new Error('Analytics tool requires a valid MySQL connection.'); } if (type === 'aggregate') { try { const tableName = entity.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1); const selectParts = []; const groupByParts = []; const queryParams = []; let query = ''; for (const metric of metrics) { if (metric === 'count') { selectParts.push('COUNT(*) as count'); } else if (metric.startsWith('sum:')) { const field = metric.split(':')[1]; selectParts.push(`SUM(t.${field}) as sum_${field}`); } else if (metric.startsWith('avg:')) { const field = metric.split(':')[1]; selectParts.push(`AVG(t.${field}) as avg_${field}`); } } if (dimensions && dimensions.length > 0) { for (const dim of dimensions) { if (dim === 'createdByName') { selectParts.push('u_created.user_name as createdByName'); groupByParts.push('u_created.user_name'); } else if (dim === 'account') { selectParts.push('a.name as account'); groupByParts.push('a.name'); } else if (dim === 'assignedUser') { selectParts.push('u_assigned.user_name as assignedUser'); groupByParts.push('u_assigned.user_name'); } else { selectParts.push(`t.${dim}`); groupByParts.push(`t.${dim}`); } } } query = `SELECT ${selectParts.join(', ')} FROM \`${tableName}\` t`; if (dimensions?.includes('createdByName')) { query += ' LEFT JOIN user u_created ON t.created_by_id = u_created.id'; } if (dimensions?.includes('assignedUser')) { query += ' LEFT JOIN user u_assigned ON t.assigned_user_id = u_assigned.id'; } if (dimensions?.includes('account')) { query += ' LEFT JOIN account a ON t.account_id = a.id'; } const whereParts = []; if (filters) { for (const [key, filter] of Object.entries(filters)) { if (filter.type === 'in') { const placeholders = filter.value.map(() => '?').join(','); whereParts.push(`t.${key} IN (${placeholders})`); queryParams.push(...filter.value); } else { whereParts.push(`t.${key} = ?`); queryParams.push(filter.value); } } } if (timeRange) { whereParts.push('t.created_at >= ?'); queryParams.push(timeRange.start); whereParts.push('t.created_at <= ?'); queryParams.push(timeRange.end); } if (whereParts.length > 0) { query += ` WHERE ${whereParts.join(' AND ')}`; } if (groupByParts.length > 0) { query += ` GROUP BY ${groupByParts.join(', ')}`; } const results = await mysqlQuery(query, queryParams); return { content: [{ type: 'text', text: JSON.stringify({ type, entity, metrics, results }, null, 2) }] }; } catch (error) { console.error('Analytics MySQL query failed:', error); throw new Error(`Analytics query failed: ${error.message}`); } } throw new Error(`Unsupported analytics type: ${type}`); } case 'mysql_query': { if (!mysqlPool) { throw new Error('MySQL not configured or connected'); } const { query, params } = args; const results = await mysqlQuery(query, params); return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; } case 'test_auth': { const methods = [ { name: 'X-Api-Key Header', headers: { 'X-Api-Key': config.apiKey } }, { name: 'Basic Auth', headers: { 'Authorization': `Basic ${Buffer.from(`${config.apiKey}:`).toString('base64')}` } }, { name: 'Bearer Token', headers: { 'Authorization': `Bearer ${config.apiKey}` } } ]; const results = []; let workingMethod = 'None'; for (const method of methods) { try { const response = await axios_1.default.get(`${config.apiUrl}/App/user`, { headers: { ...method.headers, 'Content-Type': 'application/json' }, timeout: 10000 }); results.push({ method: method.name, status: response.status, statusText: response.statusText, success: true, headers: response.headers }); if (workingMethod === 'None') { workingMethod = method.name; } } catch (error) { results.push({ method: method.name, error: error.message, success: false }); } } return { content: [{ type: 'text', text: JSON.stringify({ success: workingMethod !== 'None', workingMethod, results }, null, 2) }] }; } case 'health_check': { const health = { api: { url: config.apiUrl, status: 'disconnected', error: null }, mysql: { host: config.mysql.host, status: 'disconnected', error: null } }; // Check API try { await apiRequest('GET', '/App/user'); health.api.status = 'connected'; } catch (error) { health.api.error = error.message; } // Check MySQL if (mysqlPool) { try { await mysqlQuery('SELECT 1'); health.mysql.status = 'connected'; } catch (error) { health.mysql.error = error.message; } } else { health.mysql.error = 'Not configured'; } return { content: [{ type: 'text', text: JSON.stringify(health, null, 2) }] }; } case 'get_gist': { const { name: gistName } = args; const results = await mysqlQuery("SELECT * FROM `gist` WHERE `name` LIKE ? AND `content_type` = 'Agent Gist' AND `deleted` = 0", [`%${gistName}%`]); return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] }; } case 'create_gist': { const { name: gistName, content, description } = args; const payload = { name: `AGENT GUIDANCE: ${gistName}`, content, contentType: 'Agent Gist', assignedUserId: config.agentUserId, category: 'Code Snippit' }; if (description) { payload.description = description; } const result = await apiRequest('POST', '/Gist', payload); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } case 'update_gist': { const { id, name: gistName, content, description, category, assignedUserId } = args; const payload = {}; if (gistName) payload.name = gistName; if (content) payload.content = content; if (description) payload.description = description; if (category) payload.category = category; if (assignedUserId) payload.assignedUserId = assignedUserId; const result = await apiRequest('PUT', `/Gist/${id}`, payload); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } default: throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error instanceof types_js_1.McpError && error.details) { return { content: [{ type: 'text', text: `API Error: ${JSON.stringify(error.details, null, 2)}` }], exitCode: error.code, }; } if (error instanceof types_js_1.McpError) throw error; return { content: [{ type: 'text', text: `Error: ${error.message || 'Unknown error occurred'}` }] }; } }); // Start the server async function main() { console.log('EspoCRM MCP Server starting...'); await initMySQL(); const transport = new stdio_js_1.StdioServerTransport(); server.connect(transport); console.log('EspoCRM MCP Server started successfully'); } main().catch((error) => { console.error('Server error:', error); process.exit(1); });