index.js•30.1 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { readFile, writeFile, access } from 'fs/promises';
import { join, resolve } from 'path';
import mjml from 'mjml';
import { z } from 'zod';
// Logging utility
const log = {
info: (message, data = null) => {
console.error(`[INFO] ${new Date().toISOString()} - ${message}`, data || '');
},
error: (message, error = null) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, error || '');
},
warn: (message, data = null) => {
console.error(`[WARN] ${new Date().toISOString()} - ${message}`, data || '');
},
debug: (message, data = null) => {
if (process.env.DEBUG) {
console.error(`[DEBUG] ${new Date().toISOString()} - ${message}`, data || '');
}
}
};
// Validation schemas
const CompileMjmlSchema = z.object({
input: z.string().describe('MJML content or file path'),
beautify: z.boolean().optional().default(true).describe('Beautify the output HTML'),
minify: z.boolean().optional().default(false).describe('Minify the output HTML'),
validationLevel: z.enum(['skip', 'soft', 'strict']).optional().default('soft').describe('Validation level'),
filePath: z.boolean().optional().default(false).describe('Treat input as file path'),
keepComments: z.boolean().optional().default(false).describe('Keep comments in output'),
fonts: z.record(z.string()).optional().describe('Custom fonts configuration'),
outputPath: z.string().optional().describe('Save compiled HTML to file path'),
});
const ValidateMjmlSchema = z.object({
input: z.string().describe('MJML content or file path'),
filePath: z.boolean().optional().default(false).describe('Treat input as file path'),
validationLevel: z.enum(['skip', 'soft', 'strict']).optional().default('strict').describe('Validation level'),
});
const GenerateTemplateSchema = z.object({
template: z.enum([
'newsletter', 'welcome', 'promotional', 'transactional',
'password-reset', 'verification', 'announcement', 'invitation'
]).describe('Template type'),
variables: z.record(z.string()).optional().describe('Template variables to replace'),
customColors: z.record(z.string()).optional().describe('Custom color scheme'),
customFonts: z.record(z.string()).optional().describe('Custom fonts'),
outputPath: z.string().optional().describe('Save template to file path'),
});
const GetComponentInfoSchema = z.object({
component: z.string().optional().describe('Specific component name (optional)'),
category: z.enum(['all', 'standard', 'advanced', 'structural']).optional().default('all').describe('Component category'),
});
class MjmlMcpServer {
constructor() {
this.server = new Server(
{
name: 'mjml-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.setupErrorHandling();
}
setupErrorHandling() {
this.server.onerror = (error) => {
log.error('[MCP Error]', error);
};
process.on('SIGINT', async () => {
log.info('Shutting down MJML MCP server...');
await this.server.close();
process.exit(0);
});
}
async readInput(input, isFilePath) {
if (isFilePath) {
try {
const fullPath = resolve(input);
await access(fullPath);
const content = await readFile(fullPath, 'utf-8');
log.debug(`Read file: ${fullPath}`);
return content;
} catch (error) {
throw new McpError(
ErrorCode.InvalidRequest,
`Failed to read MJML file: ${error.message}`
);
}
}
return input;
}
async saveOutput(content, outputPath) {
if (outputPath) {
try {
const fullPath = resolve(outputPath);
await writeFile(fullPath, content, 'utf-8');
log.info(`Output saved to: ${fullPath}`);
return { saved: true, path: fullPath };
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to save output: ${error.message}`
);
}
}
return { saved: false };
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'compile_mjml',
description: 'Compile MJML to HTML with various options',
inputSchema: {
type: 'object',
properties: {
input: {
type: 'string',
description: 'MJML content or file path',
},
beautify: {
type: 'boolean',
description: 'Beautify the output HTML',
default: true,
},
minify: {
type: 'boolean',
description: 'Minify the output HTML',
default: false,
},
validationLevel: {
type: 'string',
enum: ['skip', 'soft', 'strict'],
description: 'Validation level',
default: 'soft',
},
filePath: {
type: 'boolean',
description: 'Treat input as file path',
default: false,
},
keepComments: {
type: 'boolean',
description: 'Keep comments in output',
default: false,
},
fonts: {
type: 'object',
description: 'Custom fonts configuration',
},
outputPath: {
type: 'string',
description: 'Save compiled HTML to file path',
},
},
required: ['input'],
},
},
{
name: 'validate_mjml',
description: 'Validate MJML syntax and structure',
inputSchema: {
type: 'object',
properties: {
input: {
type: 'string',
description: 'MJML content or file path',
},
filePath: {
type: 'boolean',
description: 'Treat input as file path',
default: false,
},
validationLevel: {
type: 'string',
enum: ['skip', 'soft', 'strict'],
description: 'Validation level',
default: 'strict',
},
},
required: ['input'],
},
},
{
name: 'generate_template',
description: 'Generate pre-built email templates',
inputSchema: {
type: 'object',
properties: {
template: {
type: 'string',
enum: ['newsletter', 'welcome', 'promotional', 'transactional', 'password-reset', 'verification', 'announcement', 'invitation'],
description: 'Template type',
},
variables: {
type: 'object',
description: 'Template variables to replace',
},
customColors: {
type: 'object',
description: 'Custom color scheme',
},
customFonts: {
type: 'object',
description: 'Custom fonts',
},
outputPath: {
type: 'string',
description: 'Save template to file path',
},
},
required: ['template'],
},
},
{
name: 'get_component_info',
description: 'Get MJML component reference and documentation',
inputSchema: {
type: 'object',
properties: {
component: {
type: 'string',
description: 'Specific component name (optional)',
},
category: {
type: 'string',
enum: ['all', 'standard', 'advanced', 'structural'],
description: 'Component category',
default: 'all',
},
},
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request;
try {
switch (name) {
case 'compile_mjml':
return await this.handleCompileMjml(args);
case 'validate_mjml':
return await this.handleValidateMjml(args);
case 'generate_template':
return await this.handleGenerateTemplate(args);
case 'get_component_info':
return await this.handleGetComponentInfo(args);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
log.error(`Tool execution error: ${name}`, error);
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${error.message}`
);
}
});
}
async handleCompileMjml(args) {
const parsed = CompileMjmlSchema.parse(args);
log.info('Compiling MJML', { template: parsed.template });
const mjmlContent = await this.readInput(parsed.input, parsed.filePath);
const mjmlOptions = {
beautify: parsed.beautify,
minify: parsed.minify,
validationLevel: parsed.validationLevel,
keepComments: parsed.keepComments,
fonts: parsed.fonts || {},
};
const result = mjml(mjmlContent, mjmlOptions);
if (result.errors.length > 0) {
log.warn('MJML compilation completed with errors', result.errors);
} else {
log.info('MJML compilation successful');
}
const saveResult = await this.saveOutput(result.html, parsed.outputPath);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
html: result.html,
errors: result.errors,
warnings: result.warnings,
mjmlOptions,
saved: saveResult,
}, null, 2),
},
],
};
}
async handleValidateMjml(args) {
const parsed = ValidateMjmlSchema.parse(args);
log.info('Validating MJML');
const mjmlContent = await this.readInput(parsed.input, parsed.filePath);
const mjmlOptions = {
validationLevel: parsed.validationLevel,
};
const result = mjml(mjmlContent, mjmlOptions);
const isValid = result.errors.length === 0;
log.info(`MJML validation ${isValid ? 'passed' : 'failed'}`);
return {
content: [
{
type: 'text',
text: JSON.stringify({
valid: isValid,
errors: result.errors,
warnings: result.warnings,
validationLevel: parsed.validationLevel,
}, null, 2),
},
],
};
}
async handleGenerateTemplate(args) {
const parsed = GenerateTemplateSchema.parse(args);
log.info(`Generating template: ${parsed.template}`);
const template = this.getTemplate(parsed.template, parsed.variables, parsed.customColors, parsed.customFonts);
const saveResult = await this.saveOutput(template, parsed.outputPath);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
template: parsed.template,
mjml: template,
saved: saveResult,
}, null, 2),
},
],
};
}
async handleGetComponentInfo(args) {
const parsed = GetComponentInfoSchema.parse(args);
log.info(`Getting component info: ${parsed.component || 'all'} (${parsed.category})`);
const info = this.getComponentInfo(parsed.component, parsed.category);
return {
content: [
{
type: 'text',
text: JSON.stringify(info, null, 2),
},
],
};
}
getTemplate(type, variables = {}, customColors = {}, customFonts = {}) {
const defaultColors = {
primary: '#007bff',
secondary: '#6c757d',
success: '#28a745',
danger: '#dc3545',
warning: '#ffc107',
info: '#17a2b8',
light: '#f8f9fa',
dark: '#343a40',
};
const colors = { ...defaultColors, ...customColors };
const defaultFonts = {
'Lato': 'https://fonts.googleapis.com/css?family=Lato:300,400,700,900',
'Open Sans': 'https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700,800',
};
const fonts = { ...defaultFonts, ...customFonts };
const defaultVars = {
company_name: 'Your Company',
company_logo: 'https://via.placeholder.com/150x50',
unsubscribe_url: '#',
website_url: '#',
support_email: 'support@example.com',
current_year: new Date().getFullYear(),
...variables,
};
const templates = {
newsletter: `<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="Lato, sans-serif" />
<mj-section background-color="#ffffff" />
<mj-column padding="0" />
<mj-text font-size="14px" line-height="22px" color="#4a5568" />
<mj-button background-color="${colors.primary}" color="#ffffff" border-radius="4px" />
</mj-attributes>
<mj-font name="Lato" href="${fonts['Lato']}" />
<mj-title>{{company_name}} Newsletter</mj-title>
<mj-preview>Our latest updates and news</mj-preview>
</mj-head>
<mj-body background-color="#f7fafc">
<mj-section background-color="${colors.primary}" padding="40px 0">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="24px" font-weight="bold">
{{company_name}} Newsletter
</mj-text>
<mj-text align="center" color="#ffffff" font-size="16px" padding-top="10px">
{{newsletter_date}}
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="40px 30px">
<mj-column>
<mj-text font-size="28px" font-weight="bold" color="#2d3748" align="center">
{{main_title}}
</mj-text>
<mj-text align="center" padding-top="10px">
{{main_subtitle}}
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0 30px 40px">
<mj-column>
<mj-image src="{{featured_image}}" width="100%" border-radius="8px" />
<mj-text padding-top="20px" font-size="18px" font-weight="600">
{{featured_title}}
</mj-text>
<mj-text padding-top="10px">
{{featured_content}}
</mj-text>
<mj-button href="{{featured_link}}" padding-top="20px">
Read More
</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#edf2f7" padding="40px 30px">
<mj-column>
<mj-text font-size="20px" font-weight="bold" color="#2d3748" align="center">
More Updates
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#edf2f7" padding="0 30px 40px">
<mj-column>
<mj-text font-size="16px" font-weight="600">
{{update1_title}}
</mj-text>
<mj-text padding-top="5px" font-size="14px">
{{update1_content}}
</mj-text>
<mj-divider border-color="#cbd5e0" border-width="1px" padding="20px 0" />
<mj-text font-size="16px" font-weight="600">
{{update2_title}}
</mj-text>
<mj-text padding-top="5px" font-size="14px">
{{update2_content}}
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="${colors.dark}" padding="40px 30px">
<mj-column>
<mj-social-element-mode>
<mj-social facebook-url="{{facebook_url}}" twitter-url="{{twitter_url}}" instagram-url="{{instagram_url}}" />
</mj-social-element-mode>
<mj-text align="center" color="#ffffff" font-size="12px" padding-top="20px">
© {{current_year}} {{company_name}}. All rights reserved.
</mj-text>
<mj-text align="center" color="#ffffff" font-size="12px" padding-top="10px">
<a href="{{unsubscribe_url}}" style="color: #ffffff; text-decoration: underline;">Unsubscribe</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`,
welcome: `<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="Open Sans, sans-serif" />
<mj-section background-color="#ffffff" />
<mj-column padding="0" />
<mj-text font-size="14px" line-height="22px" color="#4a5568" />
<mj-button background-color="${colors.success}" color="#ffffff" border-radius="6px" font-weight="600" />
</mj-attributes>
<mj-font name="Open Sans" href="${fonts['Open Sans']}" />
<mj-title>Welcome to {{company_name}}!</mj-title>
</mj-head>
<mj-body background-color="#f7fafc">
<mj-section background-color="${colors.success}" padding="60px 0">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="36px" font-weight="bold">
Welcome!
</mj-text>
<mj-text align="center" color="#ffffff" font-size="18px" padding-top="10px">
We're excited to have you on board
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="50px 30px">
<mj-column>
<mj-text align="center" font-size="24px" font-weight="600" color="#2d3748">
Hi {{user_name}},
</mj-text>
<mj-text align="center" padding-top="20px" font-size="16px">
Thank you for joining {{company_name}}! We're thrilled to have you as part of our community.
</mj-text>
<mj-text align="center" padding-top="15px" font-size="16px">
Your account has been successfully created and you're ready to start exploring all the amazing features we have to offer.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f7fafc" padding="40px 30px">
<mj-column>
<mj-text align="center" font-size="20px" font-weight="600" color="#2d3748">
Next Steps
</mj-text>
<mj-text align="center" padding-top="20px">
Here are a few things you can do to get started:
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0 30px 40px">
<mj-column width="50%">
<mj-text align="center" font-size="16px" font-weight="600" padding-bottom="10px">
✓ Complete Your Profile
</mj-text>
<mj-text align="center" font-size="14px">
Add your personal information and preferences
</mj-text>
</mj-column>
<mj-column width="50%">
<mj-text align="center" font-size="16px" font-weight="600" padding-bottom="10px">
✓ Explore Features
</mj-text>
<mj-text align="center" font-size="14px">
Discover everything {{company_name}} can do
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0 30px 40px">
<mj-column>
<mj-button href="{{dashboard_url}}" font-size="16px" padding="15px 30px">
Go to Dashboard
</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#edf2f7" padding="30px">
<mj-column>
<mj-text align="center" font-size="14px" color="#718096">
If you have any questions, don't hesitate to contact our support team at
<a href="mailto:{{support_email}}" style="color: ${colors.primary};">{{support_email}}</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="${colors.dark}" padding="30px">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="12px">
© {{current_year}} {{company_name}}. All rights reserved.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`,
promotional: `<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="Lato, sans-serif" />
<mj-section background-color="#ffffff" />
<mj-column padding="0" />
<mj-text font-size="14px" line-height="22px" color="#4a5568" />
<mj-button background-color="${colors.danger}" color="#ffffff" border-radius="4px" font-weight="bold" />
</mj-attributes>
<mj-font name="Lato" href="${fonts['Lato']}" />
<mj-title>🔥 Limited Time Offer from {{company_name}}</mj-title>
<mj-preview>Don't miss out on these amazing deals!</mj-preview>
</mj-head>
<mj-body background-color="#000000">
<mj-section background-color="${colors.danger}" padding="20px 0">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="24px" font-weight="bold">
🔥 FLASH SALE
</mj-text>
<mj-text align="center" color="#ffffff" font-size="18px" padding-top="5px">
{{discount_percentage}} OFF EVERYTHING
</mj-text>
<mj-text align="center" color="#ffffff" font-size="16px" padding-top="5px">
Ends in {{time_remaining}}
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="40px 30px">
<mj-column>
<mj-text align="center" font-size="28px" font-weight="bold" color="#2d3748">
{{promotion_title}}
</mj-text>
<mj-text align="center" padding-top="15px" font-size="16px">
{{promotion_description}}
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fff3cd" padding="20px 30px">
<mj-column>
<mj-text align="center" font-size="18px" font-weight="600" color="#856404">
⏰ Hurry! Limited Time Offer
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="30px">
<mj-column>
<mj-image src="{{product_image}}" width="100%" />
<mj-text align="center" font-size="20px" font-weight="bold" padding-top="20px">
{{product_name}}
</mj-text>
<mj-text align="center" padding-top="10px">
{{product_description}}
</mj-text>
<mj-text align="center" padding-top="10px">
<span style="text-decoration: line-through; color: #718096;">${{original_price}}</span>
<span style="font-size: 24px; font-weight: bold; color: ${colors.danger};">${{sale_price}}</span>
</mj-text>
<mj-button href="{{shop_url}}" background-color="${colors.danger}" padding-top="20px">
Shop Now - {{discount_percentage}} OFF
</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f8f9fa" padding="40px 30px">
<mj-column>
<mj-text align="center" font-size="18px" font-weight="600" color="#2d3748">
Why Choose {{company_name}}?
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f8f9fa" padding="0 30px 40px">
<mj-column width="33%">
<mj-text align="center" font-size="16px" font-weight="600">
✓ Free Shipping
</mj-text>
</mj-column>
<mj-column width="33%">
<mj-text align="center" font-size="16px" font-weight="600">
✓ 30-Day Returns
</mj-text>
</mj-column>
<mj-column width="34%">
<mj-text align="center" font-size="16px" font-weight="600">
✓ 24/7 Support
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#343a40" padding="30px">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="12px">
© {{current_year}} {{company_name}}. All rights reserved.
</mj-text>
<mj-text align="center" color="#ffffff" font-size="12px" padding-top="10px">
<a href="{{unsubscribe_url}}" style="color: #ffffff; text-decoration: underline;">Unsubscribe</a>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`,
};
let template = templates[type] || templates.newsletter;
// Replace variables
Object.entries(defaultVars).forEach(([key, value]) => {
const regex = new RegExp(`{{${key}}}`, 'g');
template = template.replace(regex, value);
});
return template;
}
getComponentInfo(component = null, category = 'all') {
const components = {
standard: {
'mj-text': {
description: 'Text component for displaying text content',
attributes: {
'font-family': 'Font family',
'font-size': 'Font size',
'color': 'Text color',
'align': 'Text alignment (left, center, right)',
'padding': 'Padding around the text',
},
example: '<mj-text>Hello World!</mj-text>',
},
'mj-button': {
description: 'Button component for calls to action',
attributes: {
'background-color': 'Button background color',
'color': 'Text color',
'href': 'Link URL',
'border-radius': 'Button border radius',
'font-size': 'Font size',
},
example: '<mj-button href="#">Click me</mj-button>',
},
'mj-image': {
description: 'Image component for displaying images',
attributes: {
'src': 'Image URL',
'alt': 'Alt text',
'width': 'Image width',
'height': 'Image height',
'border-radius': 'Image border radius',
},
example: '<mj-image src="https://example.com/image.jpg" />',
},
'mj-divider': {
description: 'Divider component for creating horizontal lines',
attributes: {
'border-width': 'Border width',
'border-color': 'Border color',
'padding': 'Padding around the divider',
},
example: '<mj-divider border-color="#cccccc" />',
},
'mj-spacer': {
description: 'Spacer component for adding vertical space',
attributes: {
'height': 'Spacer height',
},
example: '<mj-spacer height="20px" />',
},
},
structural: {
'mj-section': {
description: 'Section component for creating rows',
attributes: {
'background-color': 'Section background color',
'padding': 'Section padding',
'full-width': 'Make section full width',
'direction': 'Layout direction (ltr, rtl)',
},
example: '<mj-section background-color="#ffffff"><mj-column></mj-column></mj-section>',
},
'mj-column': {
description: 'Column component for creating columns within sections',
attributes: {
'width': 'Column width',
'background-color': 'Column background color',
'padding': 'Column padding',
'border': 'Column border',
},
example: '<mj-column width="50%"><mj-text>Hello</mj-text></mj-column>',
},
'mj-wrapper': {
description: 'Wrapper component for grouping sections',
attributes: {
'background-color': 'Wrapper background color',
'padding': 'Wrapper padding',
'full-width': 'Make wrapper full width',
},
example: '<mj-wrapper><mj-section></mj-section></mj-wrapper>',
},
},
advanced: {
'mj-navbar': {
description: 'Navigation bar component',
attributes: {
'background-color': 'Navbar background color',
'align': 'Navbar alignment',
'padding': 'Navbar padding',
},
example: '<mj-navbar><mj-navbar-link href="#">Home</mj-navbar-link></mj-navbar>',
},
'mj-social': {
description: 'Social media icons component',
attributes: {
'align': 'Social icons alignment',
'padding': 'Social icons padding',
'icon-size': 'Icon size',
},
example: '<mj-social><mj-social-element name="facebook" href="#" /></mj-social>',
},
'mj-table': {
description: 'Table component for displaying tabular data',
attributes: {
'cellspacing': 'Cell spacing',
'cellpadding': 'Cell padding',
'width': 'Table width',
'align': 'Table alignment',
},
example: '<mj-table><tr><td>Cell 1</td><td>Cell 2</td></tr></mj-table>',
},
'mj-carousel': {
description: 'Image carousel component',
attributes: {
'align': 'Carousel alignment',
'border-radius': 'Carousel border radius',
'icon-width': 'Navigation icon width',
},
example: '<mj-carousel><mj-carousel-image src="image1.jpg" /></mj-carousel>',
},
},
};
if (component) {
for (const [cat, comps] of Object.entries(components)) {
if (comps[component]) {
return {
component,
category: cat,
...comps[component],
};
}
}
return { error: `Component '${component}' not found` };
}
if (category !== 'all') {
return components[category] || {};
}
return components;
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
log.info('MJML MCP server started successfully');
}
}
const server = new MjmlMcpServer();
server.run().catch(console.error);