@cyanheads/git-mcp-server
Version:
An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management,
346 lines • 14.6 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.
*/
export 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;
}
/**
* Resets the singleton instance for testing purposes.
* This should only be used in test environments.
*/
static resetForTesting() {
// This allows tests to get a fresh instance for each run.
Logger.instance = new Logger();
}
/** 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) {
const errorPayload = {
message: error.message,
};
if (this.currentMcpLevel === "debug" && error.stack) {
errorPayload.stack = error.stack.substring(0, 500);
}
mcpDataPayload.error = errorPayload;
}
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, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const combinedContext = err instanceof Error ? context : { ...(err || {}), ...(context || {}) };
this.log("emerg", msg, combinedContext, errorObj);
}
}
// Export singleton instance
export const logger = Logger.getInstance();
//# sourceMappingURL=logger.js.map