Skip to main content
Glama

hypertool-mcp

pinoLogger.tsโ€ข11.4 kB
/** * Direct Pino logging implementation with proper memory management * Based on original implementation from hypertool-mcp commit 9340024b65482cff004a83432c254bd9d6abd939 */ import pino from "pino"; import { join } from "path"; import { homedir } from "os"; import { createHash } from "crypto"; import { APP_TECHNICAL_NAME, BRAND_NAME } from "../../config/appConfig.js"; import { RuntimeOptions } from "../../types/runtime.js"; import { LoggerInterface, LoggingConfig, LogContext, LogLevel, } from "./interfaces.js"; export const DEFAULT_PINO_LOGGING_CONFIG: LoggingConfig = { level: process.env.NODE_ENV === "development" ? "debug" : "info", enableConsole: true, enableFile: true, serverName: APP_TECHNICAL_NAME, format: "pretty", }; // Shared transport instances to prevent memory leaks let sharedStdoutTransport: any = null; let sharedStderrTransport: any = null; let sharedFileTransport: any = null; // Cache for Pino instances by configuration hash const pinoInstanceCache = new Map<string, pino.Logger>(); /** * PinoLogger class with proper child logger caching and transport management */ export class PinoLogger implements LoggerInterface { private pinoLogger: pino.Logger; private config: LoggingConfig; private runtimeOptions?: RuntimeOptions; private childLoggerCache = new Map<string, PinoLogger>(); private configHash: string; constructor( config: Partial<LoggingConfig> = {}, runtimeOptions?: RuntimeOptions ) { this.config = { ...DEFAULT_PINO_LOGGING_CONFIG, ...config }; this.runtimeOptions = runtimeOptions; // Determine format from environment variable or config if (process.env.LOG_FORMAT === "json") { this.config.format = "json"; } // Override level if provided in runtime options if (runtimeOptions?.logLevel) { this.config.level = runtimeOptions.logLevel as LogLevel; } // Calculate configuration hash for caching this.configHash = this.calculateConfigHash(); // Get or create Pino instance this.pinoLogger = this.getOrCreatePinoInstance(); } /** * Calculate a hash of the configuration for caching */ private calculateConfigHash(): string { // Create a normalized config object for hashing const normalizedConfig = { level: this.config.level, enableConsole: this.config.enableConsole, enableFile: this.config.enableFile, serverName: this.config.serverName, format: this.config.format, transport: this.runtimeOptions?.transport || "stdio", }; const configString = JSON.stringify( normalizedConfig, Object.keys(normalizedConfig).sort() ); return createHash("sha256").update(configString).digest("hex"); } /** * Get or create Pino instance with proper transport configuration */ private getOrCreatePinoInstance(): pino.Logger { // Check cache first const cachedInstance = pinoInstanceCache.get(this.configHash); if (cachedInstance) { return cachedInstance; } // Create new instance const options: pino.LoggerOptions = { level: this.config.level, }; // Configure transport based on runtime and configuration const transport = this.getTransportConfig(); if (transport) { options.transport = transport; } const newInstance = pino(options); // Cache the instance pinoInstanceCache.set(this.configHash, newInstance); return newInstance; } /** * Get transport configuration with shared instances */ private getTransportConfig(): | pino.TransportMultiOptions | pino.TransportSingleOptions | undefined { const transports: pino.TransportSingleOptions[] = []; // Console/stderr transport based on mode if (this.config.enableConsole) { const useStderr = this.runtimeOptions?.transport === "stdio"; if (useStderr) { // Use stderr for stdio mode to avoid protocol corruption if (!sharedStderrTransport) { sharedStderrTransport = this.createPrettyTransport(2); // stderr file descriptor } transports.push(sharedStderrTransport); } else { // Use stdout for http mode if (!sharedStdoutTransport) { sharedStdoutTransport = this.createPrettyTransport(1); // stdout file descriptor } transports.push(sharedStdoutTransport); } } // File transport if (this.config.enableFile) { if (!sharedFileTransport) { sharedFileTransport = this.createFileTransport(); } transports.push(sharedFileTransport); } if (transports.length === 0) { return undefined; } if (transports.length === 1) { return transports[0]; } return { targets: transports, }; } /** * Create pretty transport configuration */ private createPrettyTransport( destination: number ): pino.TransportSingleOptions { const options: any = { destination, colorize: this.config.format === "pretty", translateTime: "HH:MM:ss", ignore: "pid,hostname", }; if (this.config.format === "json") { return { target: "pino/file", options, }; } else { return { target: "pino-pretty", options, }; } } /** * Create file transport configuration */ private createFileTransport(): pino.TransportSingleOptions { const logFilePath = this.getLogFilePath(); return { target: "pino/file", options: { destination: logFilePath, mkdir: true, append: true, }, }; } /** * Get the log file path for the current server */ private getLogFilePath(): string { // Check for environment variable override first (used by daemon) if (process.env.HYPERTOOL_LOG_FILE) { return process.env.HYPERTOOL_LOG_FILE; } // Fall back to default path const logDir = join( homedir(), `.${BRAND_NAME.toLowerCase()}`, this.config.serverName, "logs" ); return join(logDir, `${this.config.serverName}.log`); } /** * Format context for log messages with Error handling */ private formatContext( context?: LogContext ): Record<string, unknown> | undefined { if (context === undefined || context === null) { return undefined; } if (context instanceof Error) { return { error: { name: context.name, message: context.message, stack: context.stack, }, }; } if (typeof context === "object") { return context as Record<string, unknown>; } // For primitive types, wrap in a context object return { context }; } // Public logging methods fatal(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.fatal(formattedContext, message); } else { this.pinoLogger.fatal(message); } } error(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.error(formattedContext, message); } else { this.pinoLogger.error(message); } } warn(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.warn(formattedContext, message); } else { this.pinoLogger.warn(message); } } info(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.info(formattedContext, message); } else { this.pinoLogger.info(message); } } debug(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.debug(formattedContext, message); } else { this.pinoLogger.debug(message); } } trace(message: string, context?: LogContext): void { const formattedContext = this.formatContext(context); if (formattedContext) { this.pinoLogger.trace(formattedContext, message); } else { this.pinoLogger.trace(message); } } /** * Create child logger with caching to prevent EventEmitter memory leaks */ child(bindings: { module?: string; [key: string]: unknown; }): LoggerInterface { // Create cache key from bindings const cacheKey = JSON.stringify(bindings); // Check cache first if (this.childLoggerCache.has(cacheKey)) { return this.childLoggerCache.get(cacheKey)!; } // Create new child logger using Pino's native child functionality // This avoids creating new transports and exit listeners const childPinoLogger = this.pinoLogger.child(bindings); // Create a wrapper that shares the parent's config but uses the child Pino instance // Importantly, we DON'T call the constructor which would create new transports const childLogger = Object.create(PinoLogger.prototype) as PinoLogger; childLogger.pinoLogger = childPinoLogger; childLogger.config = this.config; childLogger.runtimeOptions = this.runtimeOptions; childLogger.childLoggerCache = new Map<string, PinoLogger>(); // Cache the child logger this.childLoggerCache.set(cacheKey, childLogger); // Implement cache size limit to prevent unbounded growth if (this.childLoggerCache.size > 100) { const firstKey = this.childLoggerCache.keys().next().value; if (firstKey) { this.childLoggerCache.delete(firstKey); } } return childLogger; } /** * Update configuration and reinitialize Pino instance if needed */ updateConfig(config: Partial<LoggingConfig>): void { const oldConfigHash = this.configHash; this.config = { ...this.config, ...config }; // Recalculate config hash this.configHash = this.calculateConfigHash(); // Only create new instance if config actually changed if (oldConfigHash !== this.configHash) { this.pinoLogger = this.getOrCreatePinoInstance(); // Clear child logger cache since they need to be recreated with new config this.childLoggerCache.clear(); } } /** * Legacy compatibility - access to underlying Pino logger */ get pino(): pino.Logger { return this.pinoLogger; } /** * Legacy compatibility - same as pino for backward compatibility */ get mcp(): pino.Logger { return this.pinoLogger; } /** * Get diagnostic information about child logger cache */ getCacheStats(): { childLoggerCount: number; cacheSize: number; cacheKeys: string[]; } { return { childLoggerCount: this.childLoggerCache.size, cacheSize: this.childLoggerCache.size, cacheKeys: Array.from(this.childLoggerCache.keys()), }; } /** * Clear the global Pino instance cache (mainly for testing) */ static clearInstanceCache(): void { pinoInstanceCache.clear(); } /** * Get diagnostic information about Pino instance cache */ static getInstanceCacheStats(): { instanceCount: number; configHashes: string[]; } { return { instanceCount: pinoInstanceCache.size, configHashes: Array.from(pinoInstanceCache.keys()), }; } }

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/toolprint/hypertool-mcp'

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