espocrm-power-mcp
Version:
A powerful MCP server for EspoCRM
738 lines (737 loc) • 31 kB
JavaScript
#!/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);
});