Skip to main content
Glama

markdown2pdf-mcp

by 2b3pro
index.ts19.2 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, Request, } from '@modelcontextprotocol/sdk/types.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); const pkg = require('../package.json'); const hljs = require('highlight.js'); const tmp = require('tmp'); const { Remarkable } = require('remarkable'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); tmp.setGracefulCleanup(); export class MarkdownPdfServer { private server: Server; constructor() { this.server = new Server( { name: 'markdown2pdf', version: pkg.version, }, { capabilities: { tools: { create_pdf_from_markdown: true }, }, } ); this.setupToolHandlers(); // Set up error handler first to ensure it's available for all operations this.server.onerror = (error: Error): never => { // Convert all errors to McpError for consistent handling const mcpError = new McpError( ErrorCode.InternalError, `Server error: ${error.message}`, { details: { name: error.name, stack: error.stack } } ); throw mcpError; }; // Handle process termination gracefully const handleShutdown = async (signal: string) => { try { await this.server.close(); } catch (error) { // Convert unknown error to McpError const mcpError = new McpError( ErrorCode.InternalError, `Server shutdown error during ${signal}: ${error instanceof Error ? error.message : String(error)}`, { details: { signal, ...(error instanceof Error ? { name: error.name, stack: error.stack } : {}) } } ); // Throw error directly to avoid stdio interference throw mcpError; } finally { // Ensure clean exit after error is handled process.exit(0); } }; // Set up signal handlers process.once('SIGINT', () => handleShutdown('SIGINT').catch(() => process.exit(1))); process.once('SIGTERM', () => handleShutdown('SIGTERM').catch(() => process.exit(1))); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'create_pdf_from_markdown', description: 'Convert markdown content to PDF. Supports basic markdown elements like headers, lists, tables, code blocks, blockquotes, images (both local and external URLs), and Mermaid diagrams. Note: Cannot handle LaTeX math equations. Mermaid syntax errors will be displayed directly in the PDF output.', inputSchema: { type: 'object', properties: { markdown: { type: 'string', description: 'Markdown content to convert to PDF', }, outputFilename: { type: 'string', description: 'Create a filename for the PDF file to be saved (default: "final-output.pdf"). The environmental variable M2P_OUTPUT_DIR sets the output path directory. If directory is not provided, it will default to user\'s HOME directory.' }, paperFormat: { type: 'string', description: 'Paper format for the PDF (default: letter)', enum: ['letter', 'a4', 'a3', 'a5', 'legal', 'tabloid'], default: 'letter' }, paperOrientation: { type: 'string', description: 'Paper orientation for the PDF (default: portrait)', enum: ['portrait', 'landscape'], default: 'portrait' }, paperBorder: { type: 'string', description: 'Border margin for the PDF (default: 2cm). Use CSS units (cm, mm, in, px)', pattern: '^[0-9]+(\.[0-9]+)?(cm|mm|in|px)$', default: '20mm' }, watermark: { type: 'string', description: 'Optional watermark text (max 15 characters, uppercase), e.g. "DRAFT", "PRELIMINARY", "CONFIDENTIAL", "FOR REVIEW", etc', maxLength: 15, pattern: '^[A-Z0-9\\s-]+$' }, watermarkScope: { type: 'string', description: 'Control watermark visibility: "all-pages" repeats on every page, "first-page" displays on the first page only (default: all-pages)', enum: ['all-pages', 'first-page'], default: 'all-pages' }, showPageNumbers: { type: 'boolean', description: 'Include page numbers in the PDF footer (default: false)', default: false } }, required: ['markdown'] }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'create_pdf_from_markdown') { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } if (!request.params.arguments) { throw new McpError( ErrorCode.InvalidParams, 'No arguments provided' ); } const args = request.params.arguments as { markdown: string; outputFilename?: string; paperFormat?: string; paperOrientation?: string; paperBorder?: string; watermark?: string; watermarkScope?: 'all-pages' | 'first-page'; showPageNumbers?: boolean; }; // Get output directory from environment variable, outputFilename path, or default to user's home const outputDir = (() => { if (process.env.M2P_OUTPUT_DIR) { return path.resolve(process.env.M2P_OUTPUT_DIR); } if (args.outputFilename && typeof args.outputFilename === 'string') { const hasExplicitDirectory = path.isAbsolute(args.outputFilename) || path.dirname(args.outputFilename) !== '.'; if (hasExplicitDirectory) { return path.dirname(path.resolve(args.outputFilename)); } } return path.resolve(os.homedir()); })(); // Ensure output directory exists if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const { markdown, outputFilename = 'output.pdf', paperFormat = 'letter', paperOrientation = 'portrait', paperBorder = '2cm', watermark = '', watermarkScope = 'all-pages', showPageNumbers = false } = args; if (typeof markdown !== 'string' || markdown.trim().length === 0) { throw new McpError( ErrorCode.InvalidParams, 'Missing required argument: markdown' ); } // Calculate content size and validate const contentSize = markdown.length; const lineCount = markdown.split('\n').length; // Hard limit check - prevent extremely large files that will definitely fail const MAX_SIZE = 10 * 1024 * 1024; // 10MB if (contentSize > MAX_SIZE) { throw new McpError( ErrorCode.InvalidParams, `Markdown content too large (${Math.round(contentSize / 1024 / 1024)}MB). Maximum supported size is ${MAX_SIZE / 1024 / 1024}MB. Consider splitting the content into smaller documents.`, { details: { contentSize, maxSize: MAX_SIZE, lineCount } } ); } // Calculate dynamic timeouts based on content size // Base: 60s load, 7s render. Add 1s per 10KB and 1s per 100 lines const baseLoadTimeout = 60000; const baseRenderDelay = 7000; const loadTimeout = Math.min( baseLoadTimeout + Math.floor(contentSize / 10000) * 1000, 300000 // Max 5 minutes ); const renderDelay = Math.min( baseRenderDelay + Math.floor(lineCount / 100) * 1000, 30000 // Max 30 seconds ); if (contentSize > 500000) { // ~500KB console.error(`[markdown2pdf] Warning: Large markdown content detected (${Math.round(contentSize / 1024)}KB, ${lineCount} lines). Processing may take longer than usual.`); console.error(`[markdown2pdf] Using extended timeouts: load=${loadTimeout/1000}s, render=${renderDelay/1000}s`); } // Ensure output filename has .pdf extension const filename = outputFilename.toLowerCase().endsWith('.pdf') ? outputFilename : `${outputFilename}.pdf`; // Combine output directory with filename const outputPath = path.join(outputDir, filename); try { // Track operation progress through response content const progressUpdates: string[] = []; progressUpdates.push(`Starting PDF conversion (format: ${paperFormat}, orientation: ${paperOrientation})`); progressUpdates.push(`Content size: ${Math.round(contentSize / 1024)}KB (${lineCount} lines)`); progressUpdates.push(`Using output path: ${outputPath}`); await this.convertToPdf( markdown, outputPath, paperFormat, paperOrientation, paperBorder, watermark, watermarkScope, showPageNumbers, renderDelay, loadTimeout ); // Verify file was created if (!fs.existsSync(outputPath)) { throw new McpError( ErrorCode.InternalError, 'PDF file was not created', { details: { outputPath, paperFormat, paperOrientation } } ); } // Ensure absolute path is returned const absolutePath = path.resolve(outputPath); progressUpdates.push(`PDF file created successfully at: ${absolutePath}`); progressUpdates.push(`File exists: ${fs.existsSync(absolutePath)}`); return { content: [ { type: 'text', text: progressUpdates.join('\n') }, ], }; } catch (error: unknown) { if (error instanceof Error) { throw new McpError( ErrorCode.InternalError, `PDF generation failed: ${error.message}`, { details: { name: error.name, stack: error.stack, outputPath, paperFormat, paperOrientation } } ); } throw new McpError( ErrorCode.InternalError, `PDF generation failed: ${String(error)}` ); } }); } private getIncrementalPath(basePath: string): string { const dir = path.dirname(basePath); const ext = path.extname(basePath); const name = path.basename(basePath, ext); let counter = 1; let newPath = basePath; while (fs.existsSync(newPath)) { newPath = path.join(dir, `${name}-${counter}${ext}`); counter++; } return newPath; } private async convertToPdf( markdown: string, outputPath: string, paperFormat: string = 'letter', paperOrientation: string = 'portrait', paperBorder: string = '2cm', watermark: string = '', watermarkScope: 'all-pages' | 'first-page' = 'all-pages', showPageNumbers: boolean = false, renderDelay: number = 7000, loadTimeout: number = 60000 ): Promise<void> { return new Promise<void>(async (resolve, reject) => { try { // Ensure output directory exists const outputDir = path.dirname(outputPath); await fs.promises.mkdir(outputDir, { recursive: true }); // Get incremental path and ensure absolute outputPath = this.getIncrementalPath(outputPath); outputPath = path.resolve(outputPath); // Setup markdown parser with syntax highlighting const mdParser = new Remarkable({ breaks: true, preset: 'default', html: true, // Enable HTML tags highlight: (str: string, language: string) => { if (language && language === 'mermaid') { return `<div class="mermaid">${str}</div>`; } if (language && hljs.getLanguage(language)) { try { return hljs.highlight(str, { language }).value; } catch (err) { } } try { return hljs.highlightAuto(str).value; } catch (err) { } return ''; } }); const watermarkClassName = watermarkScope === 'first-page' ? 'watermark watermark--first-page' : 'watermark watermark--all-pages'; const headerOffset = '12.5mm'; const showWatermarkAllPages = Boolean( watermark && watermarkScope === 'all-pages' ); // Create HTML content const html = ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <style> @page { margin: ${paperBorder}; ${showWatermarkAllPages ? `margin-top: calc(${paperBorder} + ${headerOffset});` : ''} size: ${paperFormat} ${paperOrientation}; } ${ showWatermarkAllPages ? `@page:first { margin-top: ${paperBorder}; }` : '' } html, body { margin: 0; padding: 0; width: 100%; height: 100%; } .page { position: relative; width: ${paperFormat === 'letter' ? '8.5in' : '210mm'}; height: ${paperFormat === 'letter' ? '11in' : '297mm'}; margin: 0; padding: 20px; box-sizing: border-box; } .content { position: relative; z-index: 1; } .watermark { left: 0; top: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center; font-size: calc(${paperFormat === 'letter' ? '8.5in' : '210mm'} * 0.14); color: rgba(0, 0, 0, 0.15); font-family: Arial, sans-serif; white-space: nowrap; pointer-events: none; z-index: 0; transform: rotate(-45deg); } .watermark--all-pages { position: fixed; } .watermark--first-page { position: absolute; } </style> </head> <body> <div class="page"> <div id="mermaid-error" style="display: none; color: red;"></div> <div class="content"> ${mdParser.render(markdown)} </div> ${watermark ? `<div class="${watermarkClassName}" data-scope="${watermarkScope}">${watermark}</div>` : ''} </div> <script> document.addEventListener('DOMContentLoaded', function () { mermaid.initialize({ startOnLoad: false }); try { mermaid.run({ nodes: document.querySelectorAll('.mermaid') }); } catch (e) { const errorDiv = document.getElementById('mermaid-error'); if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.innerText = e.message; } } }); </script> </body> </html>`; // Create temporary HTML file const tmpFile = await new Promise<{ path: string, fd: number }>((resolve, reject) => { tmp.file({ postfix: '.html' }, (err: Error | null, path: string, fd: number) => { if (err) reject(err); else resolve({ path, fd }); }); }); // Close file descriptor immediately fs.closeSync(tmpFile.fd); // Write HTML content await fs.promises.writeFile(tmpFile.path, html); // Import and use Puppeteer renderer const renderPDF = (await import('./puppeteer/render.js')).default; await renderPDF({ htmlPath: tmpFile.path, pdfPath: outputPath, runningsPath: path.resolve(__dirname, 'runnings.js'), cssPath: path.resolve(__dirname, 'css', 'pdf.css'), highlightCssPath: '', paperFormat, paperOrientation, paperBorder, watermarkScope, showPageNumbers, renderDelay, loadTimeout }); resolve(); } catch (error) { reject(new McpError( ErrorCode.InternalError, `PDF generation failed: ${error instanceof Error ? error.message : String(error)}`, { details: { phase: error instanceof Error && error.message.includes('renderPDF') ? 'renderPDF' : 'setup', outputPath, paperFormat, paperOrientation, ...(error instanceof Error ? { name: error.name, stack: error.stack } : {}) } } )); } }); } private transport: StdioServerTransport | null = null; private isRunning: boolean = false; public async run(): Promise<void> { if (this.isRunning) { return; } this.isRunning = true; this.transport = new StdioServerTransport(); try { await this.server.connect(this.transport); // Keep the process running until explicitly closed return new Promise<void>((resolve) => { const cleanup = async () => { this.isRunning = false; if (this.transport) { try { await this.server.close(); } catch (error) { console.error('Error during server shutdown:', error); } this.transport = null; } resolve(); }; process.once('SIGINT', cleanup); process.once('SIGTERM', cleanup); }); } catch (error) { this.isRunning = false; if (this.transport) { try { await this.server.close(); } catch (closeError) { console.error('Error during error cleanup:', closeError); } this.transport = null; } throw error; } } } if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const server = new MarkdownPdfServer(); server.run().catch(error => { if (error instanceof Error) { throw new McpError( ErrorCode.InternalError, `Server initialization failed: ${error.message}`, { details: { name: error.name, stack: error.stack } } ); } throw new McpError( ErrorCode.InternalError, `Server initialization failed: ${String(error)}` ); }); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/2b3pro/markdown2pdf-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server