espocrm-graphql-server
Version:
A modern GraphQL server for EspoCRM with TypeScript, MCP integration, and AI-optimized dynamic schema generation
401 lines (359 loc) • 10.5 kB
text/typescript
/**
* EspoCRM MCP Server for Cloudflare Workers
*
* This is a remote MCP server that can be deployed to Cloudflare Workers
* to provide internet-accessible MCP tools for AI assistants.
*/
import { McpAgent } from '@cloudflare/mcp-agent';
export interface Env {
MCP_GRAPHQL_ENDPOINT: string;
MCP_API_KEY: string;
MCP_CACHE: KVNamespace;
}
class EspoCRMMcpAgent extends McpAgent<Env> {
async executeGraphQL(query: string, variables?: any): Promise<any> {
const response = await fetch(this.env.MCP_GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.env.MCP_API_KEY,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.statusText}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
return result.data;
}
async listTools() {
return [
{
name: 'query_contacts',
description: 'Search and retrieve contact information from EspoCRM',
inputSchema: {
type: 'object',
properties: {
filter: {
type: 'object',
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string' },
},
},
limit: { type: 'number', default: 10 },
},
},
},
{
name: 'query_accounts',
description: 'Search and retrieve account/company information',
inputSchema: {
type: 'object',
properties: {
filter: {
type: 'object',
properties: {
name: { type: 'string' },
},
},
limit: { type: 'number', default: 10 },
},
},
},
{
name: 'query_leads',
description: 'Search and retrieve sales leads',
inputSchema: {
type: 'object',
properties: {
filter: {
type: 'object',
properties: {
status: { type: 'string' },
},
},
limit: { type: 'number', default: 10 },
},
},
},
{
name: 'create_contact',
description: 'Create a new contact in the CRM',
inputSchema: {
type: 'object',
required: ['firstName', 'lastName'],
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string' },
phone: { type: 'string' },
},
},
},
{
name: 'execute_graphql',
description: 'Execute a custom GraphQL query',
inputSchema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string' },
variables: { type: 'object' },
},
},
},
];
}
async executeTool(name: string, args: any) {
// Cache key for results
const cacheKey = `tool:${name}:${JSON.stringify(args)}`;
// Check cache first
const cached = await this.env.MCP_CACHE.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
let result;
switch (name) {
case 'query_contacts': {
const { filter = {}, limit = 10 } = args || {};
const query = `
query GetContacts($first: Int!, $filter: ContactFilter) {
contacts(first: $first, filter: $filter) {
edges {
node {
id
firstName
lastName
email
createdAt
modifiedAt
}
}
totalCount
}
}
`;
result = await this.executeGraphQL(query, { first: limit, filter });
break;
}
case 'query_accounts': {
const { limit = 10 } = args || {};
const query = `
query GetAccounts($first: Int!) {
accounts(first: $first) {
edges {
node {
id
name
createdAt
}
}
totalCount
}
}
`;
result = await this.executeGraphQL(query, { first: limit });
break;
}
case 'query_leads': {
const { limit = 10 } = args || {};
const query = `
query GetLeads($first: Int!) {
leads(first: $first) {
edges {
node {
id
firstName
lastName
status
createdAt
}
}
totalCount
}
}
`;
result = await this.executeGraphQL(query, { first: limit });
break;
}
case 'create_contact': {
const { firstName, lastName, email, phone } = args || {};
if (!firstName || !lastName) {
throw new Error('firstName and lastName are required');
}
const mutation = `
mutation CreateContact($input: JSON!) {
createContact(input: $input) {
id
firstName
lastName
}
}
`;
result = await this.executeGraphQL(mutation, {
input: { firstName, lastName, email, phone },
});
break;
}
case 'execute_graphql': {
const { query, variables } = args || {};
if (!query) {
throw new Error('query is required');
}
result = await this.executeGraphQL(query, variables);
break;
}
default:
throw new Error(`Unknown tool: ${name}`);
}
// Cache the result for 5 minutes
await this.env.MCP_CACHE.put(cacheKey, JSON.stringify(result), {
expirationTtl: 300,
});
return result;
}
async listResources() {
return [
{
uri: 'espocrm://schema',
name: 'GraphQL Schema',
description: 'The current GraphQL schema with all available entities',
mimeType: 'application/json',
},
{
uri: 'espocrm://entities',
name: 'Available Entities',
description: 'List of all discovered EspoCRM entities',
mimeType: 'application/json',
},
{
uri: 'espocrm://health',
name: 'System Health',
description: 'Current health status of the GraphQL server',
mimeType: 'application/json',
},
];
}
async readResource(uri: string) {
const baseUrl = this.env.MCP_GRAPHQL_ENDPOINT.replace('/graphql', '');
switch (uri) {
case 'espocrm://schema': {
const response = await fetch(`${baseUrl}/schema-info`, {
headers: this.env.MCP_API_KEY ? { 'X-API-Key': this.env.MCP_API_KEY } : {},
});
return await response.json();
}
case 'espocrm://entities': {
const response = await fetch(`${baseUrl}/schema-info`, {
headers: this.env.MCP_API_KEY ? { 'X-API-Key': this.env.MCP_API_KEY } : {},
});
const data = await response.json();
return data.discoveredEntities;
}
case 'espocrm://health': {
const response = await fetch(`${baseUrl}/health`);
return await response.json();
}
default:
throw new Error(`Unknown resource: ${uri}`);
}
}
async listPrompts() {
return [
{
name: 'crm_search',
description: 'Search across CRM entities for information',
arguments: [
{
name: 'search_term',
description: 'What to search for',
required: true,
},
],
},
{
name: 'contact_analysis',
description: 'Analyze contacts in the CRM',
arguments: [
{
name: 'filter',
description: 'Optional filter criteria',
required: false,
},
],
},
];
}
async getPrompt(name: string, args: Record<string, any>) {
switch (name) {
case 'crm_search': {
const searchTerm = args['search_term'];
if (!searchTerm) {
throw new Error('search_term is required');
}
return {
description: `Search CRM for: ${searchTerm}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Search the CRM system for information related to "${searchTerm}". Check contacts, accounts, leads, and opportunities.`,
},
},
],
};
}
case 'contact_analysis': {
const filter = args['filter'] || 'all';
return {
description: `Analyze contacts with filter: ${filter}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Analyze the contacts in the CRM system. ${filter !== 'all' ? `Focus on: ${filter}` : 'Include all contacts.'}`,
},
},
],
};
}
default:
throw new Error(`Unknown prompt: ${name}`);
}
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const agent = new EspoCRMMcpAgent(env);
// Support both SSE and Streamable HTTP transports
const url = new URL(request.url);
if (url.pathname === '/sse') {
// Legacy SSE transport
return agent.serveSSE(request);
} else if (url.pathname === '/mcp') {
// New Streamable HTTP transport
return agent.serve(request);
} else if (url.pathname === '/') {
// Health check / info endpoint
return new Response(JSON.stringify({
name: 'EspoCRM MCP Server',
version: '1.0.0',
transports: ['sse', 'streamable-http'],
endpoints: {
sse: '/sse',
'streamable-http': '/mcp',
},
}), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404 });
},
};