perplexity-mcp-server
Version:
A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with
427 lines (426 loc) • 17.3 kB
JavaScript
/**
* @fileoverview Provides a singleton Logger class that wraps Winston for file logging
* and supports sending MCP (Model Context Protocol) `notifications/message`.
* It handles different log levels compliant with RFC 5424 and MCP specifications.
* @module src/utils/internal/logger
*/
import path from "path";
import winston from "winston";
import { config } from "../../config/index.js";
/**
* Numeric severity mapping for MCP log levels (lower is more severe).
* @private
*/
const mcpLevelSeverity = {
emerg: 0,
alert: 1,
crit: 2,
error: 3,
warning: 4,
notice: 5,
info: 6,
debug: 7,
};
/**
* Maps MCP log levels to Winston's core levels for file logging.
* @private
*/
const mcpToWinstonLevel = {
debug: "debug",
info: "info",
notice: "info",
warning: "warn",
error: "error",
crit: "error",
alert: "error",
emerg: "error",
};
// The logsPath from config is resolved and validated by src/config/index.ts.
// It can be null if the directory is invalid or inaccessible, in which case file logging will be disabled.
/**
* Creates the Winston console log format.
* @returns The Winston log format for console output.
* @private
*/
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 replacer = (_key, value) => typeof value === "bigint" ? value.toString() : value;
const remainingMetaJson = JSON.stringify(metaCopy, replacer, 2);
if (remainingMetaJson !== "{}")
metaString += `\n Meta: ${remainingMetaJson}`;
}
catch (stringifyError) {
const errorMessage = stringifyError instanceof Error
? stringifyError.message
: String(stringifyError);
metaString += `\n Meta: [Error stringifying metadata: ${errorMessage}]`;
}
}
return `${timestamp} ${level}: ${message}${metaString}`;
}));
}
/**
* Singleton Logger class that wraps Winston for robust logging.
* Supports file logging, conditional console logging, and MCP notifications.
*/
export class Logger {
/** @private */
constructor() {
this.initialized = false;
this.currentMcpLevel = "info";
this.currentWinstonLevel = "info";
this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH = 1024;
this.LOG_FILE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
this.LOG_MAX_FILES = 5;
}
/**
* Initializes the Winston logger instance.
* Should be called once at application startup.
* @param level - The initial minimum MCP log level.
*/
async initialize(level = "info") {
if (this.initialized) {
this.warning("Logger already initialized.", {
loggerSetup: true,
requestId: "logger-init",
timestamp: new Date().toISOString(),
});
return;
}
// Set initialized to true at the beginning of the initialization process.
this.initialized = true;
this.currentMcpLevel = level;
this.currentWinstonLevel = mcpToWinstonLevel[level];
const resolvedLogsDir = config.logsPath;
const fileFormat = winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json());
const transports = [];
const fileTransportOptions = {
format: fileFormat,
maxsize: this.LOG_FILE_MAX_SIZE,
maxFiles: this.LOG_MAX_FILES,
tailable: true,
};
if (resolvedLogsDir) {
transports.push(new winston.transports.File({
filename: path.join(resolvedLogsDir, "error.log"),
level: "error",
...fileTransportOptions,
}), new winston.transports.File({
filename: path.join(resolvedLogsDir, "warn.log"),
level: "warn",
...fileTransportOptions,
}), new winston.transports.File({
filename: path.join(resolvedLogsDir, "info.log"),
level: "info",
...fileTransportOptions,
}), new winston.transports.File({
filename: path.join(resolvedLogsDir, "debug.log"),
level: "debug",
...fileTransportOptions,
}), new winston.transports.File({
filename: path.join(resolvedLogsDir, "combined.log"),
...fileTransportOptions,
}));
}
else {
if (process.stdout.isTTY) {
console.warn("File logging disabled as logsPath is not configured or invalid.");
}
}
this.winstonLogger = winston.createLogger({
level: this.currentWinstonLevel,
transports,
exitOnError: false,
});
// Initialize a separate logger for structured interactions
if (resolvedLogsDir) {
this.interactionLogger = winston.createLogger({
format: winston.format.combine(winston.format.timestamp(), winston.format.json({ space: 2 })),
transports: [
new winston.transports.File({
filename: path.join(resolvedLogsDir, "interactions.log"),
...fileTransportOptions,
}),
],
});
}
// Configure console transport after Winston logger is created
const consoleStatus = this._configureConsoleTransport();
const initialContext = {
loggerSetup: true,
requestId: "logger-init-deferred",
timestamp: new Date().toISOString(),
};
// Removed logging of logsDirCreatedMessage as it's no longer set
if (consoleStatus.message) {
this.info(consoleStatus.message, initialContext);
}
this.initialized = true; // Ensure this is set after successful setup
this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`, {
loggerSetup: true,
requestId: "logger-post-init",
timestamp: new Date().toISOString(),
logsPathUsed: resolvedLogsDir ?? "none",
});
}
/**
* Sets the function used to send MCP 'notifications/message'.
* @param sender - The function to call for sending notifications, or undefined to disable.
*/
setMcpNotificationSender(sender) {
this.mcpNotificationSender = sender;
const status = sender ? "enabled" : "disabled";
this.info(`MCP notification sending ${status}.`, {
loggerSetup: true,
requestId: "logger-set-sender",
timestamp: new Date().toISOString(),
});
}
/**
* Dynamically sets the minimum logging level.
* @param newLevel - The new minimum MCP log level to set.
*/
setLevel(newLevel) {
const setLevelContext = {
loggerSetup: true,
requestId: "logger-set-level",
timestamp: new Date().toISOString(),
};
if (!this.ensureInitialized()) {
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.`, setLevelContext);
return;
}
const oldLevel = this.currentMcpLevel;
this.currentMcpLevel = newLevel;
this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
if (this.winstonLogger) {
// Ensure winstonLogger is defined
this.winstonLogger.level = this.currentWinstonLevel;
}
const consoleStatus = this._configureConsoleTransport();
if (oldLevel !== newLevel) {
this.info(`Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`, setLevelContext);
if (consoleStatus.message &&
consoleStatus.message !== "Console logging status unchanged.") {
this.info(consoleStatus.message, setLevelContext);
}
}
}
/**
* Configures the console transport based on the current log level and TTY status.
* Adds or removes the console transport as needed.
* @returns {{ enabled: boolean, message: string | null }} Status of console logging.
* @private
*/
_configureConsoleTransport() {
if (!this.winstonLogger) {
return {
enabled: false,
message: "Cannot configure console: Winston logger not initialized.",
};
}
const consoleTransport = this.winstonLogger.transports.find((t) => t instanceof winston.transports.Console);
const shouldHaveConsole = this.currentMcpLevel === "debug" && process.stdout.isTTY;
let message = null;
if (shouldHaveConsole && !consoleTransport) {
const consoleFormat = createWinstonConsoleFormat();
this.winstonLogger.add(new winston.transports.Console({
level: "debug", // Console always logs debug if enabled
format: consoleFormat,
}));
message = "Console logging enabled (level: debug, stdout is TTY).";
}
else if (!shouldHaveConsole && consoleTransport) {
this.winstonLogger.remove(consoleTransport);
message = "Console logging disabled (level not debug or stdout not TTY).";
}
else {
message = "Console logging status unchanged.";
}
return { enabled: shouldHaveConsole, message };
}
/**
* Gets the singleton instance of the Logger.
* @returns The singleton Logger instance.
*/
static getInstance() {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
/**
* Ensures the logger has been initialized.
* @returns True if initialized, false otherwise.
* @private
*/
ensureInitialized() {
if (!this.initialized || !this.winstonLogger) {
if (process.stdout.isTTY) {
console.warn("Logger not initialized; message dropped.");
}
return false;
}
return true;
}
/**
* Centralized log processing method.
* @param level - The MCP severity level of the message.
* @param msg - The main log message.
* @param context - Optional request context for the log.
* @param error - Optional error object associated with the log.
* @private
*/
log(level, msg, context, error) {
if (!this.ensureInitialized())
return;
if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
return; // Do not log if message level is less severe than currentMcpLevel
}
const logData = { ...context };
const winstonLevel = mcpToWinstonLevel[level];
if (error) {
this.winstonLogger.log(winstonLevel, msg, { ...logData, error });
}
else {
this.winstonLogger.log(winstonLevel, msg, logData);
}
if (this.mcpNotificationSender) {
const mcpDataPayload = { message: msg };
if (context && Object.keys(context).length > 0)
mcpDataPayload.context = context;
if (error) {
mcpDataPayload.error = { message: error.message };
// Include stack trace in debug mode for MCP notifications, truncated for brevity
if (this.currentMcpLevel === "debug" && error.stack) {
mcpDataPayload.error.stack = error.stack.substring(0, this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH);
}
}
try {
const serverName = config?.mcpServerName ?? "MCP_SERVER_NAME_NOT_CONFIGURED";
this.mcpNotificationSender(level, mcpDataPayload, serverName);
}
catch (sendError) {
const errorMessage = sendError instanceof Error ? sendError.message : String(sendError);
const internalErrorContext = {
requestId: context?.requestId || "logger-internal-error",
timestamp: new Date().toISOString(),
originalLevel: level,
originalMessage: msg,
sendError: errorMessage,
mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
};
this.winstonLogger.error("Failed to send MCP log notification", internalErrorContext);
}
}
}
/** Logs a message at the 'debug' level. */
debug(msg, context) {
this.log("debug", msg, context);
}
/** Logs a message at the 'info' level. */
info(msg, context) {
this.log("info", msg, context);
}
/** Logs a message at the 'notice' level. */
notice(msg, context) {
this.log("notice", msg, context);
}
/** Logs a message at the 'warning' level. */
warning(msg, context) {
this.log("warning", msg, context);
}
/**
* Logs a message at the 'error' level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
error(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("error", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'crit' (critical) level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
crit(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("crit", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'alert' level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
alert(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("alert", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'emerg' (emergency) level.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
emerg(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("emerg", msg, actualContext, errorObj);
}
/**
* Logs a message at the 'emerg' (emergency) level, typically for fatal errors.
* @param msg - The main log message.
* @param err - Optional. Error object or RequestContext.
* @param context - Optional. RequestContext if `err` is an Error.
*/
fatal(msg, err, context) {
const errorObj = err instanceof Error ? err : undefined;
const actualContext = err instanceof Error ? context : err;
this.log("emerg", msg, actualContext, errorObj);
}
/**
* Logs a structured interaction object to a dedicated file.
* @param interactionName - A name for the interaction type (e.g., 'OpenRouterIO').
* @param data - The structured data to log.
*/
logInteraction(interactionName, data) {
if (!this.interactionLogger) {
this.warning("Interaction logger not available. File logging may be disabled.", data.context);
return;
}
this.interactionLogger.info({ interactionName, ...data });
}
}
/**
* The singleton instance of the Logger.
* Use this instance for all logging operations.
*/
export const logger = Logger.getInstance();