import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { Request, Response } from 'express';
import type { Express } from 'express';
import { randomUUID } from 'crypto';
import minimist from 'minimist';
import { z } from 'zod';
import type { EmailOptions, GetEventsOptions, GetEventsResponse } from 'sendlayer';
import { SendLayer as SendLayerClient } from 'sendlayer';
// Helper function to handle SendLayer errors and provide user-friendly messages
function handleSendLayerError(error: any): never {
// Check error name or message to identify error type
const errorName = error?.name || '';
const errorMessage = error?.message || 'An unknown error occurred';
if (errorName === 'SendLayerAuthenticationError' ||
errorMessage.includes('Invalid API key') ||
errorMessage.includes('401') ||
error?.response?.status === 401) {
throw new Error(
'Invalid SendLayer API key. Please check your API key and ensure it is correct. ' +
'You can get your API key from https://app.sendlayer.com/'
);
}
if (errorName === 'SendLayerValidationError' || errorMessage.includes('Validation')) {
throw new Error(`Validation error: ${errorMessage}`);
}
if (errorName === 'SendLayerAPIError' || error?.response?.status) {
const status = error?.response?.status || error?.statusCode || 'unknown';
throw new Error(`SendLayer API error (${status}): ${errorMessage}`);
}
// Generic error fallback
throw new Error(`SendLayer error: ${errorMessage}`);
}
// Parse CLI args
const argv = minimist(process.argv.slice(2));
// Transport mode: 'stdio' (default) or 'http'
const transportMode = (argv.transport as string) || process.env.TRANSPORT_MODE || 'stdio';
const httpPort = argv.port ? Number(argv.port) : (process.env.PORT ? Number(process.env.PORT) : 3000);
// API key: required
// const apiKey = (argv.key as string) || process.env.SENDLAYER_API_KEY;
// if (!apiKey) {
// console.error('No API key provided. Set SENDLAYER_API_KEY or pass --key');
// process.exit(1);
// }
// Get sender email address from command line argument or fall back to environment variable
// Optional.
const senderEmailAddress: string = argv.sender || process.env.SENDER_EMAIL_ADDRESS;
function createSendLayerClient(apiKey: string, attachmentURLTimeout?: number) {
const client = new SendLayerClient(apiKey);
if (attachmentURLTimeout && (client as any).client) {
(client as any).client.attachmentURLTimeout = attachmentURLTimeout;
}
return client;
}
// Optional attachment URL timeout in ms
// const attachmentURLTimeout = argv.attachmentTimeout
// ? Number(argv.attachmentTimeout)
// : process.env.ATTACHMENT_URL_TIMEOUT
// ? Number(process.env.ATTACHMENT_URL_TIMEOUT)
// : undefined;
// // Init SDK
// const sendlayer = new SendLayerClient(apiKey);
// if (attachmentURLTimeout && (sendlayer as any).client) {
// (sendlayer as any).client.attachmentURLTimeout = attachmentURLTimeout;
// }
// Function to create and configure the MCP server with all tools
function createMcpServer(apiKey: string): McpServer {
const sendlayer = createSendLayerClient(apiKey);
const server = new McpServer({ name: 'sendlayer-mcp', version: '1.0.0' });
// send-email tool
server.tool(
'send-email',
'Send an email using SendLayer',
{
from: z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() })
]).describe('Sender email or { email, name }'),
to: z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() }),
z.array(z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() })
]))
]).describe('Recipient(s) email or objects'),
subject: z.string(),
text: z.string().optional(),
html: z.string().optional(),
cc: z.array(z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() })
])).optional(),
bcc: z.array(z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() })
])).optional(),
replyTo: z.array(z.union([
z.string().email(),
z.object({ email: z.string().email(), name: z.string().optional() })
])).optional(),
tags: z.array(z.string()).optional(),
headers: z.record(z.string(), z.string()).optional(),
attachments: z.array(z.object({
path: z.string().describe('Local file path or https:// URL'),
type: z.string().describe('MIME type, e.g., application/pdf'),
filename: z.string().optional(),
disposition: z.string().optional(),
contentId: z.number().optional()
})).optional()
},
async (args) => {
try {
if (!args.text && !args.html) {
throw new Error('Either text or html must be provided');
}
// Type check on from, since "from" is optionally included in the arguments schema
const fromEmailAddress = args.from ?? senderEmailAddress;
if (typeof fromEmailAddress !== 'string') {
throw new Error('from argument must be provided.');
}
args.from = fromEmailAddress;
const response = await sendlayer.Emails.send(args as unknown as EmailOptions);
return {
content: [{ type: 'text', text: JSON.stringify(response) }]
};
} catch (error: any) {
handleSendLayerError(error);
}
}
);
// get-events tool
server.tool(
'get-events',
'Retrieve events with optional filters',
{
startDate: z.string().datetime().optional().describe('ISO date, inclusive'),
endDate: z.string().datetime().optional().describe('ISO date, inclusive'),
event: z.string().optional().describe('Event type filter'),
messageId: z.string().optional(),
retrieveCount: z.number().int().min(1).max(100).optional()
},
async ({ startDate, endDate, event, messageId, retrieveCount }) => {
try {
const options: GetEventsOptions = {} as any;
if (startDate) options.startDate = new Date(startDate);
if (endDate) options.endDate = new Date(endDate);
if (event) (options as any).event = event as any;
if (messageId) options.messageId = messageId;
if (retrieveCount) options.retrieveCount = retrieveCount;
const res: GetEventsResponse = await sendlayer.Events.get(options as any);
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
} catch (error: any) {
handleSendLayerError(error);
}
}
);
// list-webhooks tool
server.tool(
'list-webhooks',
'List registered webhooks',
{},
async () => {
try {
const res = await sendlayer.Webhooks.get();
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
} catch (error: any) {
handleSendLayerError(error);
}
}
);
// create-webhook tool
server.tool(
'create-webhook',
'Create a webhook',
{
url: z.string().url(),
event: z.string().describe('bounce|click|open|unsubscribe|complaint|delivery')
},
async ({ url, event }) => {
try {
const res = await sendlayer.Webhooks.create({ url, event });
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
} catch (error: any) {
handleSendLayerError(error);
}
}
);
// delete-webhook tool
server.tool(
'delete-webhook',
'Delete a webhook by ID',
{ webhookId: z.number().int().positive() },
async ({ webhookId }) => {
try {
const res = await sendlayer.Webhooks.delete(webhookId);
return { content: [{ type: 'text', text: JSON.stringify(res) }] };
} catch (error: any) {
handleSendLayerError(error);
}
}
);
return server;
}
// Stdio transport (original behavior)
async function runStdioTransport() {
const apiKey = (argv.key as string) || process.env.SENDLAYER_API_KEY;
if (!apiKey) {
console.error('No API key provided. Set SENDLAYER_API_KEY or pass --key');
process.exit(1);
}
const server = createMcpServer(apiKey);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('SendLayer MCP Server running on stdio');
}
function extractApiKey(req: Request): string | undefined {
const auth = req.headers.authorization;
if (auth && auth.toLowerCase().startsWith('bearer ')) {
return auth.slice('bearer '.length);
}
const headerKey = req.headers['x-sendlayer-api-key'] as string | undefined;
return headerKey;
}
// HTTP transport (for remote access)
async function runHttpTransport() {
const allowedHosts = process.env.ALLOWED_HOSTS
? [process.env.ALLOWED_HOSTS, 'localhost']
: ['localhost'];
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: allowedHosts
});
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
// Root endpoint for basic connectivity test
app.get('/', (_req: Request, res: Response) => {
res.json({ status: 'ok', message: 'SendLayer MCP Server is running' });
});
// Health check endpoint
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', server: 'sendlayer-mcp', version: '1.0.0' });
});
// MCP POST endpoint
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
console.log(`Received MCP request for session: ${sessionId}`);
} else {
console.log('Request body:', req.body);
}
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
const apiKey = extractApiKey(req) || (argv.key as string) || process.env.SENDLAYER_API_KEY;
if (!apiKey) {
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized: Please provide your SendLayer API key'
},
id: null
});
return;
}
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sessionId => {
// Store the transport by session ID when session is initialized
// This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
}
});
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
console.log(`Transport closed for session ${sid}, removing from transports map`);
delete transports[sid];
}
};
// Connect the transport to the MCP server BEFORE handling the request
// so responses can flow back through the same transport
const server = createMcpServer(apiKey);
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return; // Already handled
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided'
},
id: null
});
return;
}
// Handle the request with existing transport - no need to reconnect
// The existing transport is already connected to the server
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
};
app.post('/mcp', mcpPostHandler);
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
console.log(`Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', mcpGetHandler);
// Handle DELETE requests for session termination (according to MCP spec)
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
} catch (error) {
console.error('Error handling session termination:', error);
if (!res.headersSent) {
res.status(500).send('Error processing session termination');
}
}
};
app.delete('/mcp', mcpDeleteHandler);
const server = app.listen(httpPort, '0.0.0.0', () => {
console.log(`SendLayer MCP Server running on HTTP port ${httpPort}`);
console.log(`MCP endpoint: http://0.0.0.0:${httpPort}/mcp`);
console.log(`Health check: http://0.0.0.0:${httpPort}/health`);
console.log('Server is ready to accept connections');
});
server.on('error', (error: Error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
// Keep the process alive
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
// Handle server shutdown
process.on('SIGINT', async () => {
console.log('Shutting down server...');
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log('Server shutdown complete');
process.exit(0);
});
}
async function main() {
if (transportMode === 'http') {
await runHttpTransport();
} else {
await runStdioTransport();
}
}
main().catch((err) => {
console.error('Fatal error in main():', err);
process.exit(1);
});