Skip to main content
Glama

MCP Printer Server

by steveclarke
utils.tsβ€’20.7 kB
/** * @fileoverview General utility functions for command execution, dependency checking, * file type detection, and print job handling for the MCP Printer server. */ import { execa, type ExecaError } from "execa" import { access, readFile } from "fs/promises" import { constants } from "fs" import { writeFileSync, mkdtempSync, unlinkSync } from "fs" import { extname, join } from "path" import { tmpdir } from "os" import { config, MARKDOWN_EXTENSIONS, type MarkdownExtension } from "./config.js" import { PDFParse } from "pdf-parse" import { validateFilePath } from "./file-security.js" import { renderMarkdownToPdf } from "./renderers/markdown.js" import { renderCodeToPdf, shouldRenderCode } from "./renderers/code.js" /** * Parse a delimited string into an array of strings. * Automatically trims whitespace and filters out empty strings. * * @param value - String to parse (can be undefined) * @param delimiter - Delimiter to split on (string or regex) * @param transform - Optional transform function to apply to each item (e.g., toLowerCase) * @returns Array of parsed strings, or empty array if value is undefined * * @example * parseDelimitedString('foo:bar:baz', ':') // ['foo', 'bar', 'baz'] * parseDelimitedString('Foo, Bar, Baz', ',', s => s.toLowerCase()) // ['foo', 'bar', 'baz'] * parseDelimitedString('opt1 opt2 opt3', /\s+/) // ['opt1', 'opt2', 'opt3'] */ export function parseDelimitedString( value: string | undefined, delimiter: string | RegExp, transform?: (item: string) => string ): string[] { if (!value) { return [] } return value .split(delimiter) .map((item) => item.trim()) .filter((item) => item.length > 0) .map((item) => (transform ? transform(item) : item)) } /** * Executes a command safely without spawning a shell. * * @param command - The command binary to execute * @param args - Array of arguments to pass to the command * @returns The trimmed stdout output from the command * @throws {Error} If the command fails or returns an error */ export async function execCommand(command: string, args: string[] = []): Promise<string> { try { const { stdout } = await execa(command, args) return stdout.trim() } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`Command failed: ${message}`) } } /** * Checks if a file exists at the given path. * * @param filePath - Path to check * @returns True if file exists and is accessible, false otherwise */ export async function fileExists(filePath: string): Promise<boolean> { try { await access(filePath, constants.F_OK) return true } catch { return false } } /** * Checks if a file contains a shebang (#!) in the first 1024 bytes. * Used to detect executable scripts without standard file extensions. * * @param filePath - Path to the file to check * @returns True if shebang found, false otherwise */ export async function hasShebang(filePath: string): Promise<boolean> { try { // Read first 1024 bytes of the file const buffer = Buffer.alloc(1024) const { open } = await import("fs/promises") const fileHandle = await open(filePath, "r") try { const { bytesRead } = await fileHandle.read(buffer, 0, 1024, 0) await fileHandle.close() if (bytesRead === 0) { return false } // Convert buffer to string and check for shebang const content = buffer.toString("utf-8", 0, bytesRead) // Handle all line ending types: Unix (\n), Windows (\r\n), old Mac (\r) const lines = content.split(/\r?\n|\r/) // Check if any line starts with #! // Note: Trim handles extra whitespace, though technically shebangs // must be the first two bytes. We're lenient to catch more cases. return lines.some((line) => line.trim().startsWith("#!")) } catch { await fileHandle.close() return false } } catch { return false } } /** * Checks if a command-line tool is available in the system PATH. * * @param command - The command to check (e.g., "pandoc") * @param name - Human-readable name for error messages * @throws {Error} If the command is not found, with installation instructions */ export async function checkDependency(command: string, name: string): Promise<void> { try { await execCommand("which", [command]) } catch { throw new Error(`${name} not found. Install with: brew install ${command}`) } } /** * Locates a Chrome or Chromium installation on the system. * First checks the MCP_PRINTER_CHROME_PATH environment variable, * then searches common macOS and Linux installation paths. * * @returns Path to Chrome/Chromium executable * @throws {Error} If Chrome is not found */ export async function findChrome(): Promise<string> { // Check environment variable first if (config.chromePath) { if (await fileExists(config.chromePath)) { return config.chromePath } // If specified path doesn't exist, continue to auto-detection } // Check macOS paths if (process.platform === "darwin") { const macPaths = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", ] for (const path of macPaths) { if (await fileExists(path)) { return path } } } // Check Linux paths if (process.platform === "linux") { const linuxCommands = ["google-chrome", "chromium", "chromium-browser"] for (const cmd of linuxCommands) { try { const path = await execCommand("which", [cmd]) if (path) return path } catch { // Continue to next command } } } throw new Error( "Chrome not found. Install Google Chrome or set MCP_PRINTER_CHROME_PATH environment variable." ) } /** * Converts HTML content to PDF using Chrome headless. * Handles Chrome detection, temp file creation, execution, error handling, and cleanup. * * @param htmlContent - HTML content to convert to PDF * @param options - Optional configuration * @param options.chromeFlags - Additional Chrome flags (e.g., ['--disable-javascript']) * @param options.tempDirPrefix - Prefix for temp directory name (default: 'mcp-printer-') * @returns Path to the generated temporary PDF file * @throws {Error} If Chrome is not found or PDF generation fails */ export async function convertHtmlToPdf( htmlContent: string, options: { chromeFlags?: string[]; tempDirPrefix?: string } = {} ): Promise<string> { const { chromeFlags = [], tempDirPrefix = "mcp-printer-" } = options // Find Chrome/Chromium executable const chromePath = await findChrome() // Create secure temp directory const tmpDir = mkdtempSync(join(tmpdir(), tempDirPrefix)) const tmpHtml = join(tmpDir, "input.html") const tmpPdf = join(tmpDir, "output.pdf") try { // Write HTML to temp file writeFileSync(tmpHtml, htmlContent, "utf-8") // Convert HTML to PDF with Chrome headless try { await execa(chromePath, [ "--headless", "--disable-gpu", ...chromeFlags, `--print-to-pdf=${tmpPdf}`, tmpHtml, ]) } catch (error) { // Chrome outputs success messages to stderr, check if PDF was actually created const execaError = error as ExecaError const stderr = String(execaError.stderr ?? "") if (!stderr.includes("written to file")) { throw new Error(`Failed to render PDF: ${execaError.message}`) } // Success - Chrome wrote the PDF and reported to stderr } // Clean up HTML file try { unlinkSync(tmpHtml) } catch { // Ignore cleanup errors } return tmpPdf } catch (error) { // Clean up temp files on error try { unlinkSync(tmpHtml) } catch {} try { unlinkSync(tmpPdf) } catch {} throw error } } /** * Determines if a file should be automatically rendered to PDF before printing. * Checks autoRenderMarkdown setting and standard markdown extensions (.md, .markdown). * * @param filePath - Path to the file to check * @returns True if the file should be rendered to PDF, false otherwise */ export function shouldRenderToPdf(filePath: string): boolean { if (!config.autoRenderMarkdown) { return false } // Extract extension using path.extname (returns '.md' or '') const ext = extname(filePath).slice(1).toLowerCase() return MARKDOWN_EXTENSIONS.includes(ext as MarkdownExtension) } /** * Execute a print job with the given file and options. * Handles copy validation, lpr argument building, and execution. * * @param filePath - Path to the file to print * @param printer - Optional printer name * @param copies - Number of copies to print * @param options - Optional CUPS options string * @returns Object with printer name and formatted options */ export async function executePrintJob( filePath: string, printer?: string, copies: number = 1, options?: string ): Promise<{ printerName: string; allOptions: string[] }> { // Validate copy count against configured maximum if (config.maxCopies > 0 && copies > config.maxCopies) { throw new Error( `Copy count (${copies}) exceeds maximum allowed (${config.maxCopies}). ` + `Set MCP_PRINTER_MAX_COPIES environment variable to increase or use 0 for unlimited.` ) } const args: string[] = [] // Use configured default printer if none specified const targetPrinter = printer || config.defaultPrinter if (targetPrinter) { args.push("-P", targetPrinter) } if (copies > 1) { args.push(`-#${copies}`) } // Build options with defaults let allOptions = [] // Add default duplex if auto-enabled in config and not already specified if (config.autoDuplex && !options?.includes("sides=")) { allOptions.push("sides=two-sided-long-edge") } // Add default options if configured if (config.defaultOptions.length > 0) { allOptions.push(...config.defaultOptions) } // Add user-specified options (these override defaults, split by spaces) if (options) { allOptions.push(...options.split(/\s+/)) } // Add each option with -o flag for (const option of allOptions) { args.push("-o", option) } // Add file path args.push(filePath) await execa("lpr", args) // Determine the printer name used const printerName = targetPrinter || (await execCommand("lpstat", ["-d"])).split(": ")[1] || "default" return { printerName, allOptions } } /** * Format a print job response message. * * @param printerName - Name of the printer used * @param copies - Number of copies printed * @param allOptions - Array of print options used * @param sourceFile - Original file path to display * @param renderType - Optional rendering description (e.g., "markdown β†’ PDF") * @returns Formatted MCP response object */ export function formatPrintResponse( printerName: string, copies: number, allOptions: string[], sourceFile: string, renderType?: string ) { let optionsInfo = "" if (allOptions.length > 0) { optionsInfo = `\n Options: ${allOptions.join(", ")}` } const renderedNote = renderType ? `\n Rendered: ${renderType}` : "" return { content: [ { type: "text" as const, text: `βœ“ File sent to printer: ${printerName}\n` + ` Copies: ${copies}${optionsInfo}\n` + ` File: ${sourceFile}${renderedNote}`, }, ], } } /** * Gets the page count from a PDF file using pdf-parse. * * @param filePath - Path to the PDF file * @returns Number of pages in the PDF * @throws {Error} If the file cannot be read or parsed */ export async function getPdfPageCount(filePath: string): Promise<number> { const parser = new PDFParse({ data: await readFile(filePath) }) try { const result = await parser.getInfo() return result.total } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`Failed to get PDF page count: ${message}`) } finally { await parser.destroy() } } /** * Calculates the number of physical sheets needed based on page count and duplex settings. * * @param pdfPages - Total number of PDF pages * @param isDuplex - Whether duplex printing is enabled * @returns Number of physical sheets that will be used */ export function calculatePhysicalSheets(pdfPages: number, isDuplex: boolean): number { return isDuplex ? Math.ceil(pdfPages / 2) : pdfPages } /** * Checks if the page count exceeds the confirmation threshold. * * @param physicalSheets - Number of physical sheets to print * @returns True if confirmation is needed, false otherwise */ export function shouldTriggerConfirmation(physicalSheets: number): boolean { return config.confirmIfOverPages > 0 && physicalSheets > config.confirmIfOverPages } /** * Formats a preview response with optional threshold warning. * * @param pdfPages - Total number of PDF pages * @param physicalSheets - Number of physical sheets needed * @param isDuplex - Whether duplex printing is enabled * @param sourceFile - Original file path * @param renderType - Optional rendering description (e.g., "markdown β†’ PDF") * @param isThresholdExceeded - Whether to include threshold warning (default: false) * @returns MCP response object with preview information */ export function formatPreviewResponse( pdfPages: number, physicalSheets: number, isDuplex: boolean, sourceFile: string, renderType?: string, isThresholdExceeded = false ) { const duplexInfo = isDuplex ? ` (${physicalSheets} sheets, duplex)` : "" const renderedNote = renderType ? `\n Rendered: ${renderType}` : "" const baseMessage = `πŸ“„ Preview: ${isThresholdExceeded ? "This document will print " : ""}${pdfPages} pages${duplexInfo}\n` + ` File: ${sourceFile}${renderedNote}` const warningMessage = isThresholdExceeded ? `\n\n⚠️ This exceeds the configured page threshold.\nAsk the user if they want to proceed with printing.` : "" return { content: [ { type: "text" as const, text: baseMessage + warningMessage, }, ], } } /** * Result of file rendering operation. */ export interface RenderResult { /** Path to the file to use (either original or rendered PDF) */ actualFilePath: string /** Path to rendered PDF temp file (null if no rendering occurred) */ renderedPdf: string | null /** Description of rendering performed (empty string if no rendering) */ renderType: string } /** * Options for file rendering. */ export interface RenderOptions { /** Path to the file to render */ filePath: string /** Show line numbers when rendering code files */ lineNumbers?: boolean /** Syntax highlighting color scheme for code files */ colorScheme?: string /** Font size for code files */ fontSize?: string /** Line spacing for code files */ lineSpacing?: string /** Force markdown rendering to PDF */ forceMarkdownRender?: boolean /** Force code rendering to PDF with syntax highlighting */ forceCodeRender?: boolean } /** * Prepares a file for printing by conditionally rendering it to PDF. * * This is the core function used by both `print_file` and `get_page_meta` tools. * It determines whether a file needs to be rendered to PDF (for enhanced formatting) * or can be printed as-is. * * **Rendering Behavior:** * - **Markdown files** (`.md`, `.markdown`): Rendered to PDF with full formatting, unless * auto-rendering is disabled or `forceMarkdownRender` is explicitly set to false * - **Code files**: Rendered to PDF with syntax highlighting, unless auto-rendering is * disabled or `forceCodeRender` is explicitly set to false, or the extension is excluded * - **PDF files**: Used as-is (no re-rendering) * - **Other files** (text, images, etc.): Passed through without modification * * **Security:** All file paths are validated against allowed/denied paths before processing. * * **Error Handling:** If rendering fails and `MCP_PRINTER_FALLBACK_ON_RENDER_ERROR` is enabled, * the original file is used. Otherwise, an error is thrown. * * @param options - File preparation options including path and rendering preferences * @param options.filePath - Absolute or relative path to the file to prepare * @param options.lineNumbers - Show line numbers in code rendering (overrides global setting) * @param options.colorScheme - Syntax highlighting theme for code (e.g., "atom-one-light", "monokai") * @param options.fontSize - Font size for code rendering (e.g., "10pt", "12pt") * @param options.lineSpacing - Line spacing multiplier for code (e.g., "1", "1.5", "2") * @param options.forceMarkdownRender - Explicitly enable/disable markdown rendering * @param options.forceCodeRender - Explicitly enable/disable code rendering * * @returns Promise resolving to a RenderResult object * @returns result.actualFilePath - The file path to actually print (original or rendered PDF) * @returns result.renderedPdf - Path to temporary PDF if rendered, null if using original file * @returns result.renderType - Human-readable description of rendering (e.g., "markdown β†’ PDF"), * empty string if no rendering occurred * * @throws {Error} If file path validation fails (security check) * @throws {Error} If rendering fails and fallback is disabled * * @example * // Prepare a markdown file (will be rendered to PDF) * const result = await prepareFileForPrinting({ filePath: "README.md" }) * // result.actualFilePath = "/tmp/rendered-abc123.pdf" * // result.renderedPdf = "/tmp/rendered-abc123.pdf" * // result.renderType = "markdown β†’ PDF" * * @example * // Prepare a PDF file (used as-is) * const result = await prepareFileForPrinting({ filePath: "document.pdf" }) * // result.actualFilePath = "document.pdf" * // result.renderedPdf = null * // result.renderType = "" */ export async function prepareFileForPrinting(options: RenderOptions): Promise<RenderResult> { // Validate file path security validateFilePath(options.filePath) let actualFilePath = options.filePath let renderedPdf: string | null = null let renderType = "" // Check if file should be auto-rendered to PDF (markdown) const shouldRenderMarkdown = options.forceMarkdownRender !== undefined ? options.forceMarkdownRender && MARKDOWN_EXTENSIONS.some((ext) => options.filePath.toLowerCase().endsWith(`.${ext}`)) : shouldRenderToPdf(options.filePath) if (shouldRenderMarkdown) { try { renderedPdf = await renderMarkdownToPdf(options.filePath) actualFilePath = renderedPdf renderType = "markdown β†’ PDF" } catch (error) { // If fallback is enabled, use original file; otherwise throw error if (config.fallbackOnRenderError) { console.error(`Warning: Failed to render ${options.filePath}, using as-is:`, error) } else { throw error } } } // Check if file should be rendered as code with syntax highlighting else if ( options.forceCodeRender !== undefined ? options.forceCodeRender : await shouldRenderCode(options.filePath) ) { try { renderedPdf = await renderCodeToPdf(options.filePath, { lineNumbers: options.lineNumbers, colorScheme: options.colorScheme, fontSize: options.fontSize, lineSpacing: options.lineSpacing, }) actualFilePath = renderedPdf renderType = "code β†’ PDF (syntax highlighted)" } catch (error) { // If fallback is enabled, use original file; otherwise throw error if (config.fallbackOnRenderError) { console.error(`Warning: Failed to render code ${options.filePath}, using as-is:`, error) } else { throw error } } } return { actualFilePath, renderedPdf, renderType } } /** * Determines if duplex printing is enabled based on configuration and options. * * @param options - CUPS options string (may contain sides= option) * @returns True if duplex printing is enabled */ export function isDuplexEnabled(options?: string): boolean { return ( config.autoDuplex || options?.includes("sides=two-sided") || config.defaultOptions.some((opt) => opt.includes("sides=two-sided")) ) } /** * Cleans up a rendered PDF temp file if it exists. * * @param renderedPdf - Path to rendered PDF temp file (or null) */ export function cleanupRenderedPdf(renderedPdf: string | null): void { if (renderedPdf) { try { unlinkSync(renderedPdf) } catch { // Ignore cleanup errors } } }

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/steveclarke/mcp-printer'

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