mermaid-mcp-server
by peng-shawn
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import path from 'path';
import url from 'url';
import fs from 'fs';
import { resolve } from 'import-meta-resolve';
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
/**
* Mermaid MCP Server
*
* This server provides a tool to render Mermaid diagrams as PNG images.
*
* Environment Variables:
* - MERMAID_LOG_VERBOSITY: Controls the verbosity of logging (default: 2)
* 0 = EMERGENCY - Only the most critical errors
* 1 = CRITICAL - Critical errors that require immediate attention
* 2 = ERROR - Error conditions (default)
* 3 = WARNING - Warning conditions
* 4 = INFO - Informational messages
* 5 = DEBUG - Debug-level messages
* - CONTENT_IMAGE_SUPPORTED: Controls whether images can be returned directly in the response (default: true)
* When set to 'false', the 'name' and 'folder' parameters become mandatory, and all images must be saved to disk.
*
* Example:
* MERMAID_LOG_VERBOSITY=2 node index.js # Only show ERROR and more severe logs (default)
* MERMAID_LOG_VERBOSITY=4 node index.js # Show INFO and more severe logs
* MERMAID_LOG_VERBOSITY=5 node index.js # Show DEBUG and more severe logs
* CONTENT_IMAGE_SUPPORTED=false node index.js # Require all images to be saved to disk
*
* Tool Parameters:
* - code: The mermaid markdown to generate an image from (required)
* - theme: Theme for the diagram (optional, one of: "default", "forest", "dark", "neutral")
* - backgroundColor: Background color for the diagram (optional, e.g., "white", "transparent", "#F0F0F0")
* - name: Name for the generated file (required when saving to folder or when CONTENT_IMAGE_SUPPORTED=false)
* - folder: Folder path to save the image to (optional, but required when CONTENT_IMAGE_SUPPORTED=false)
*
* File Saving Behavior:
* - When 'folder' is specified, the image will be saved to disk instead of returned in the response
* - The 'name' parameter is required when 'folder' is specified
* - If a file with the same name already exists, a timestamp will be appended to the filename
* - When CONTENT_IMAGE_SUPPORTED=false, all images must be saved to disk, and 'name' and 'folder' are required
*/
// __dirname is not available in ESM modules by default
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
// Define log levels with numeric values for comparison
enum LogLevel {
EMERGENCY = 0,
CRITICAL = 1,
ERROR = 2,
WARNING = 3,
INFO = 4,
DEBUG = 5,
}
// Get verbosity level from environment variable, default to INFO (4)
const LOG_VERBOSITY = process.env.MERMAID_LOG_VERBOSITY
? parseInt(process.env.MERMAID_LOG_VERBOSITY, 10)
: LogLevel.ERROR;
// Check if content images are supported (default: true)
const CONTENT_IMAGE_SUPPORTED = process.env.CONTENT_IMAGE_SUPPORTED !== 'false';
// Convert LogLevel to MCP log level string
function getMcpLogLevel(level: LogLevel): "error" | "info" | "debug" | "warning" | "critical" | "emergency" {
switch (level) {
case LogLevel.EMERGENCY: return "emergency";
case LogLevel.CRITICAL: return "critical";
case LogLevel.ERROR: return "error";
case LogLevel.WARNING: return "warning";
case LogLevel.DEBUG: return "debug";
case LogLevel.INFO:
default: return "info";
}
}
function log(level: LogLevel, message: string) {
// Only log if the current level is less than or equal to the verbosity setting
if (level <= LOG_VERBOSITY) {
// Get the appropriate MCP log level
const mcpLevel = getMcpLogLevel(level);
server.sendLoggingMessage({
level: mcpLevel,
data: message
});
// Only console.error is consumed by MCP inspector
console.error(`${LogLevel[level]} - ${message}`);
}
}
// Define tools
const GENERATE_TOOL: Tool = {
name: "generate",
description: "Generate PNG image from mermaid markdown",
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description: "The mermaid markdown to generate an image from"
},
theme: {
type: "string",
enum: ["default", "forest", "dark", "neutral"],
description: "Theme for the diagram (optional)"
},
backgroundColor: {
type: "string",
description: "Background color for the diagram, e.g. 'white', 'transparent', '#F0F0F0' (optional)"
},
name: {
type: "string",
description: CONTENT_IMAGE_SUPPORTED ? "Name of the diagram (optional)" : "Name for the generated file (required)"
},
folder: {
type: "string",
description: CONTENT_IMAGE_SUPPORTED ? "Absolute path to save the image to (optional)" : "Absolute path to save the image to (required)"
}
},
required: CONTENT_IMAGE_SUPPORTED ? ["code"] : ["code", "name", "folder"]
}
};
// Server implementation
const server = new Server(
{
name: "mermaid-mcp-server",
version: "0.1.4",
},
{
capabilities: {
tools: {},
logging: {}
},
}
);
function isGenerateArgs(args: unknown): args is {
code: string;
theme?: 'default' | 'forest' | 'dark' | 'neutral';
backgroundColor?: string;
name?: string;
folder?: string;
} {
return (
typeof args === 'object' &&
args !== null &&
'code' in args &&
typeof (args as any).code === 'string' &&
(!(args as any).theme || ['default', 'forest', 'dark', 'neutral'].includes((args as any).theme)) &&
(!(args as any).backgroundColor || typeof (args as any).backgroundColor === 'string') &&
(!(args as any).name || typeof (args as any).name === 'string') &&
(!(args as any).folder || typeof (args as any).folder === 'string')
);
}
async function renderMermaidPng(code: string, config: {
theme?: 'default' | 'forest' | 'dark' | 'neutral';
backgroundColor?: string;
} = {}): Promise<string> {
log(LogLevel.INFO, "Launching Puppeteer");
log(LogLevel.DEBUG, `Rendering with config: ${JSON.stringify(config)}`);
// Resolve the path to the local mermaid.js file
const distPath = path.dirname(url.fileURLToPath(resolve('mermaid', import.meta.url)));
const mermaidPath = path.resolve(distPath, 'mermaid.min.js');
log(LogLevel.DEBUG, `Using Mermaid from: ${mermaidPath}`);
const browser = await puppeteer.launch({
headless: true,
// Use the bundled browser instead of looking for Chrome on the system
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
// Declare page outside try block so it's accessible in catch and finally
let page: puppeteer.Page | null = null;
// Store console messages for error reporting
const consoleMessages: string[] = [];
try {
page = await browser.newPage();
log(LogLevel.DEBUG, "Browser page created");
// Capture browser console messages for better error reporting
page.on('console', (msg) => {
const text = msg.text();
consoleMessages.push(text);
log(LogLevel.DEBUG, text);
});
// Create a simple HTML template without the CDN reference
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Mermaid Renderer</title>
<style>
body {
background: ${config.backgroundColor || 'white'};
margin: 0;
padding: 0;
}
#container {
padding: 0;
margin: 0;
}
</style>
</head>
<body>
<div id="container"></div>
</body>
</html>
`;
// Write the HTML to a temporary file
const tempHtmlPath = path.join(__dirname, 'temp-mermaid.html');
fs.writeFileSync(tempHtmlPath, htmlContent);
log(LogLevel.INFO, `Rendering mermaid code: ${code.substring(0, 50)}...`);
log(LogLevel.DEBUG, `Full mermaid code: ${code}`);
// Navigate to the HTML file
await page.goto(`file://${tempHtmlPath}`);
log(LogLevel.DEBUG, "Navigated to HTML template");
// Add the mermaid script to the page
await page.addScriptTag({ path: mermaidPath });
log(LogLevel.DEBUG, "Added Mermaid script to page");
// Render the mermaid diagram using a more robust approach similar to the CLI
log(LogLevel.DEBUG, "Starting Mermaid rendering in browser");
const screenshot = await page.$eval('#container', async (container, mermaidCode, mermaidConfig) => {
try {
// @ts-ignore - mermaid is loaded by the script tag
window.mermaid.initialize({
startOnLoad: false,
theme: mermaidConfig.theme || 'default',
securityLevel: 'loose',
logLevel: 5
});
// This will throw an error if the mermaid syntax is invalid
// @ts-ignore - mermaid is loaded by the script tag
const { svg: svgText } = await window.mermaid.render('mermaid-svg', mermaidCode, container);
container.innerHTML = svgText;
const svg = container.querySelector('svg');
if (!svg) {
throw new Error('SVG element not found after rendering');
}
// Apply any necessary styling to the SVG
svg.style.backgroundColor = mermaidConfig.backgroundColor || 'white';
// Return the dimensions for screenshot
const rect = svg.getBoundingClientRect();
return {
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
success: true
};
} catch (error) {
// Return the error to be handled outside
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}, code, { theme: config.theme, backgroundColor: config.backgroundColor });
// Check if rendering was successful
if (!screenshot.success) {
log(LogLevel.ERROR, `Mermaid rendering failed in browser: ${screenshot.error}`);
throw new Error(`Mermaid rendering failed: ${screenshot.error}`);
}
log(LogLevel.DEBUG, "Mermaid rendered successfully in browser");
// Take a screenshot of the SVG
const svgElement = await page.$('#container svg');
if (!svgElement) {
log(LogLevel.ERROR, "SVG element not found after successful rendering");
throw new Error('SVG element not found');
}
log(LogLevel.DEBUG, "Taking screenshot of SVG");
// Take a screenshot with the correct dimensions
const base64Image = await svgElement.screenshot({
omitBackground: false,
type: 'png',
encoding: 'base64'
});
// Clean up the temporary file
fs.unlinkSync(tempHtmlPath);
log(LogLevel.DEBUG, "Temporary HTML file cleaned up");
log(LogLevel.INFO, "Mermaid rendered successfully");
return base64Image;
} catch (error) {
log(LogLevel.ERROR, `Error in renderMermaidPng: ${error instanceof Error ? error.message : String(error)}`);
log(LogLevel.ERROR, `Error stack: ${error instanceof Error ? error.stack : 'No stack trace'}`);
// Include console messages in the error for better debugging
if (page && page.isClosed() === false) {
log(LogLevel.ERROR, "Browser console messages:");
consoleMessages.forEach(msg => log(LogLevel.ERROR, ` ${msg}`));
}
throw error;
} finally {
await browser.close();
log(LogLevel.DEBUG, "Puppeteer browser closed");
}
}
/**
* Saves a generated Mermaid diagram to a file
*
* @param base64Image - The base64-encoded PNG image
* @param name - The name to use for the file (without extension)
* @param folder - The folder to save the file in
* @returns The full path to the saved file
*/
async function saveMermaidImageToFile(base64Image: string, name: string, folder: string): Promise<string> {
// Create the folder if it doesn't exist
if (!fs.existsSync(folder)) {
log(LogLevel.INFO, `Creating folder: ${folder}`);
fs.mkdirSync(folder, { recursive: true });
}
// Generate a filename, adding timestamp if file already exists
let filename = `${name}.png`;
const filePath = path.join(folder, filename);
if (fs.existsSync(filePath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
filename = `${name}-${timestamp}.png`;
log(LogLevel.INFO, `File already exists, using filename: ${filename}`);
}
// Save the image to the file
const imageBuffer = Buffer.from(base64Image, 'base64');
const fullPath = path.join(folder, filename);
fs.writeFileSync(fullPath, imageBuffer);
log(LogLevel.INFO, `Image saved to: ${fullPath}`);
return fullPath;
}
/**
* Handles Mermaid syntax errors and other errors
*
* @param error - The error that occurred
* @returns A response object with the error message
*/
function handleMermaidError(error: unknown): {
content: Array<{ type: "text"; text: string }>,
isError: boolean
} {
const errorMessage = error instanceof Error ? error.message : String(error);
const isSyntaxError = errorMessage.includes("Syntax error") ||
errorMessage.includes("Parse error") ||
errorMessage.includes("Mermaid rendering failed");
return {
content: [
{
type: "text",
text: isSyntaxError
? `Mermaid syntax error: ${errorMessage}\n\nPlease check your diagram syntax.`
: `Error generating diagram: ${errorMessage}`,
}
],
isError: true,
};
}
/**
* Processes a generate request to create a Mermaid diagram
*
* @param args - The arguments for the generate request
* @returns A response object with the generated image or file path
*/
async function processGenerateRequest(args: {
code: string;
theme?: 'default' | 'forest' | 'dark' | 'neutral';
backgroundColor?: string;
name?: string;
folder?: string;
}): Promise<{
content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
>,
isError: boolean
}> {
try {
const base64Image = await renderMermaidPng(args.code, {
theme: args.theme,
backgroundColor: args.backgroundColor
});
// Check if we need to save the image to a folder
if (!CONTENT_IMAGE_SUPPORTED) {
if (!args.folder) {
throw new Error("Folder parameter is required when CONTENT_IMAGE_SUPPORTED is false");
}
// Save the image to a file
const fullPath = await saveMermaidImageToFile(base64Image, args.name!, args.folder!);
return {
content: [
{
type: "text",
text: `Image saved to: ${fullPath}`,
}
],
isError: false,
};
}
// If folder is provided and CONTENT_IMAGE_SUPPORTED is true, save the image to the folder
// but also return the image in the response
let savedMessage = "";
if (args.folder && args.name) {
try {
const fullPath = await saveMermaidImageToFile(base64Image, args.name, args.folder);
savedMessage = `Image also saved to: ${fullPath}`;
log(LogLevel.INFO, savedMessage);
} catch (saveError) {
log(LogLevel.ERROR, `Failed to save image to folder: ${(saveError as Error).message}`);
savedMessage = `Note: Failed to save image to folder: ${(saveError as Error).message}`;
}
}
// Return the image in the response
return {
content: [
{
type: "text",
text: savedMessage ? `Here is the generated image. ${savedMessage}` : "Here is the generated image",
},
{
type: "image",
data: base64Image,
mimeType: "image/png",
},
],
isError: false,
};
} catch (error) {
return handleMermaidError(error);
}
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [GENERATE_TOOL],
}));
// Set up the request handler for tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("No arguments provided");
}
log(LogLevel.INFO, `Received request: ${name} with args: ${JSON.stringify(args)}`);
if (name === "generate") {
log(LogLevel.INFO, "Rendering Mermaid PNG");
if (!isGenerateArgs(args)) {
throw new Error("Invalid arguments for generate");
}
// Process the generate request
return await processGenerateRequest(args);
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
log(LogLevel.INFO, "Mermaid MCP Server running on stdio");
}
runServer().catch((error) => {
log(LogLevel.CRITICAL, `Fatal error running server: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});