@nvhien20vn/git-mcp-server
Version:
290 lines (289 loc) • 13.5 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import winston from 'winston';
import { config } from '../../config/index.js';
// Define the numeric severity for comparison (lower is more severe)
const mcpLevelSeverity = {
emerg: 0, alert: 1, crit: 2, error: 3, warning: 4, notice: 5, info: 6, debug: 7
};
// Map MCP levels to Winston's core levels for file logging
const mcpToWinstonLevel = {
debug: 'debug',
info: 'info',
notice: 'info', // Map notice to info for file logging
warning: 'warn',
error: 'error',
crit: 'error', // Map critical levels to error for file logging
alert: 'error',
emerg: 'error',
};
// Resolve __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Calculate project root robustly (works from src/ or dist/)
const isRunningFromDist = __dirname.includes(path.sep + 'dist' + path.sep);
const levelsToGoUp = isRunningFromDist ? 3 : 2;
const pathSegments = Array(levelsToGoUp).fill('..');
const projectRoot = path.resolve(__dirname, ...pathSegments);
const logsDir = path.join(projectRoot, 'logs');
// Security: ensure logsDir is within projectRoot
const resolvedLogsDir = path.resolve(logsDir);
const isLogsDirSafe = resolvedLogsDir === projectRoot || resolvedLogsDir.startsWith(projectRoot + path.sep);
if (!isLogsDirSafe) {
// Use console.error for critical pre-init errors.
// Only log to console if TTY to avoid polluting stdout for stdio MCP clients.
if (process.stdout.isTTY) {
console.error(`FATAL: logs directory "${resolvedLogsDir}" is outside project root "${projectRoot}". File logging disabled.`);
}
}
/**
* Helper function to create the Winston console format.
* This is extracted to avoid duplication between initialize and setLevel.
*/
function createWinstonConsoleFormat() {
return winston.format.combine(winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.printf(({ timestamp, level, message, ...meta }) => {
let metaString = '';
const metaCopy = { ...meta };
if (metaCopy.error && typeof metaCopy.error === 'object') {
const errorObj = metaCopy.error;
if (errorObj.message)
metaString += `\n Error: ${errorObj.message}`;
if (errorObj.stack)
metaString += `\n Stack: ${String(errorObj.stack).split('\n').map((l) => ` ${l}`).join('\n')}`;
delete metaCopy.error;
}
if (Object.keys(metaCopy).length > 0) {
try {
const remainingMetaJson = JSON.stringify(metaCopy, null, 2);
if (remainingMetaJson !== '{}')
metaString += `\n Meta: ${remainingMetaJson}`;
}
catch (stringifyError) {
metaString += `\n Meta: [Error stringifying metadata: ${stringifyError.message}]`;
}
}
return `${timestamp} ${level}: ${message}${metaString}`;
}));
}
/**
* Singleton Logger wrapping Winston, adapted for MCP.
* Logs to files and optionally sends MCP notifications/message.
*/
class Logger {
static instance;
winstonLogger;
initialized = false;
mcpNotificationSender;
currentMcpLevel = 'info'; // Default MCP level
currentWinstonLevel = 'info'; // Default Winston level
constructor() { }
/**
* Initialize Winston logger for file transport. Must be called once at app start.
* Console transport is added conditionally.
* @param level Initial minimum level to log ('info' default).
*/
async initialize(level = 'info') {
if (this.initialized) {
this.warning('Logger already initialized.', { loggerSetup: true });
return;
}
this.currentMcpLevel = level;
this.currentWinstonLevel = mcpToWinstonLevel[level];
let logsDirCreatedMessage = null;
// Ensure logs directory exists
if (isLogsDirSafe) {
try {
if (!fs.existsSync(resolvedLogsDir)) {
fs.mkdirSync(resolvedLogsDir, { recursive: true });
logsDirCreatedMessage = `Created logs directory: ${resolvedLogsDir}`;
}
}
catch (err) {
// Conditional console output for pre-init errors to avoid issues with stdio MCP clients.
if (process.stdout.isTTY) {
console.error(`Error creating logs directory at ${resolvedLogsDir}: ${err.message}. File logging disabled.`);
}
}
}
// Common format for files
const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
const transports = [];
// Add file transports only if the directory is safe
if (isLogsDirSafe) {
transports.push(new winston.transports.File({ filename: path.join(resolvedLogsDir, 'error.log'), level: 'error', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'warn.log'), level: 'warn', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'info.log'), level: 'info', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'debug.log'), level: 'debug', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'combined.log'), format: fileFormat }));
}
else {
// Conditional console output for pre-init warnings.
if (process.stdout.isTTY) {
console.warn("File logging disabled due to unsafe logs directory path.");
}
}
let consoleLoggingEnabledMessage = null;
let consoleLoggingSkippedMessage = null;
// Conditionally add Console transport only if:
// 1. MCP level is 'debug'
// 2. stdout is a TTY (interactive terminal, not piped)
if (this.currentMcpLevel === 'debug' && process.stdout.isTTY) {
const consoleFormat = createWinstonConsoleFormat();
transports.push(new winston.transports.Console({
level: 'debug',
format: consoleFormat,
}));
consoleLoggingEnabledMessage = 'Console logging enabled at level: debug (stdout is TTY)';
}
else if (this.currentMcpLevel === 'debug' && !process.stdout.isTTY) {
consoleLoggingSkippedMessage = 'Console logging skipped: Level is debug, but stdout is not a TTY (likely stdio transport).';
}
// Create logger with the initial Winston level and configured transports
this.winstonLogger = winston.createLogger({
level: this.currentWinstonLevel,
transports,
exitOnError: false
});
// Log deferred messages now that winstonLogger is initialized
if (logsDirCreatedMessage) {
this.info(logsDirCreatedMessage, { loggerSetup: true });
}
if (consoleLoggingEnabledMessage) {
this.info(consoleLoggingEnabledMessage, { loggerSetup: true });
}
if (consoleLoggingSkippedMessage) {
this.info(consoleLoggingSkippedMessage, { loggerSetup: true });
}
this.initialized = true;
await Promise.resolve(); // Yield to event loop
this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${process.stdout.isTTY && this.currentMcpLevel === 'debug' ? 'enabled' : 'disabled'}`, { loggerSetup: true });
}
/**
* Sets the function used to send MCP 'notifications/message'.
*/
setMcpNotificationSender(sender) {
this.mcpNotificationSender = sender;
const status = sender ? 'enabled' : 'disabled';
this.info(`MCP notification sending ${status}.`, { loggerSetup: true });
}
/**
* Dynamically sets the minimum logging level.
*/
setLevel(newLevel) {
if (!this.ensureInitialized()) {
// Conditional console output if logger not usable.
if (process.stdout.isTTY) {
console.error("Cannot set level: Logger not initialized.");
}
return;
}
if (!(newLevel in mcpLevelSeverity)) {
this.warning(`Invalid MCP log level provided: ${newLevel}. Level not changed.`);
return;
}
const oldLevel = this.currentMcpLevel;
this.currentMcpLevel = newLevel;
this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
this.winstonLogger.level = this.currentWinstonLevel;
// Add or remove console transport based on the new level and TTY status
const consoleTransport = this.winstonLogger.transports.find(t => t instanceof winston.transports.Console);
const shouldHaveConsole = newLevel === 'debug' && process.stdout.isTTY;
if (shouldHaveConsole && !consoleTransport) {
// Add console transport
const consoleFormat = createWinstonConsoleFormat();
this.winstonLogger.add(new winston.transports.Console({ level: 'debug', format: consoleFormat }));
this.info('Console logging dynamically enabled.', { loggerSetup: true });
}
else if (!shouldHaveConsole && consoleTransport) {
// Remove console transport
this.winstonLogger.remove(consoleTransport);
this.info('Console logging dynamically disabled.', { loggerSetup: true });
}
if (oldLevel !== newLevel) {
this.info(`Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${shouldHaveConsole ? 'enabled' : 'disabled'}`, { loggerSetup: true });
}
}
/** Get singleton instance. */
static getInstance() {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
/** Ensures the logger has been initialized. */
ensureInitialized() {
if (!this.initialized || !this.winstonLogger) {
// Conditional console output if logger not usable.
if (process.stdout.isTTY) {
console.warn('Logger not initialized; message dropped.');
}
return false;
}
return true;
}
/** Centralized log processing */
log(level, msg, context, error) {
if (!this.ensureInitialized())
return;
if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
return;
}
const logData = { ...context };
const winstonLevel = mcpToWinstonLevel[level];
if (error) {
this.winstonLogger.log(winstonLevel, msg, { ...logData, error: error });
}
else {
this.winstonLogger.log(winstonLevel, msg, logData);
}
if (this.mcpNotificationSender) {
const mcpDataPayload = { message: msg };
if (context)
mcpDataPayload.context = context;
if (error) {
mcpDataPayload.error = { message: error.message };
if (this.currentMcpLevel === 'debug' && error.stack) {
mcpDataPayload.error.stack = error.stack.substring(0, 500);
}
}
try {
this.mcpNotificationSender(level, mcpDataPayload, config.mcpServerName);
}
catch (sendError) {
this.winstonLogger.error("Failed to send MCP log notification", {
originalLevel: level,
originalMessage: msg,
sendError: sendError instanceof Error ? sendError.message : String(sendError),
mcpPayload: mcpDataPayload
});
}
}
}
// --- Public Logging Methods ---
debug(msg, context) { this.log('debug', msg, context); }
info(msg, context) { this.log('info', msg, context); }
notice(msg, context) { this.log('notice', msg, context); }
warning(msg, context) { this.log('warning', msg, context); }
error(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const combinedContext = err instanceof Error ? context : { ...(err || {}), ...(context || {}) };
this.log('error', msg, combinedContext, errorObj);
}
crit(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const combinedContext = err instanceof Error ? context : { ...(err || {}), ...(context || {}) };
this.log('crit', msg, combinedContext, errorObj);
}
alert(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const combinedContext = err instanceof Error ? context : { ...(err || {}), ...(context || {}) };
this.log('alert', msg, combinedContext, errorObj);
}
emerg(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const combinedContext = err instanceof Error ? context : { ...(err || {}), ...(context || {}) };
this.log('emerg', msg, combinedContext, errorObj);
}
fatal(msg, context, error) {
this.log('emerg', msg, context, error);
}
}
// Export singleton instance
export const logger = Logger.getInstance();