obsidian-mcp-server
Version:
Model Context Protocol (MCP) server designed for LLMs to interact with Obsidian vaults. Provides secure, token-aware tools for seamless knowledge base management through a standardized interface.
558 lines (508 loc) • 15.3 kB
text/typescript
/**
* Standardized Logging System for the Obsidian MCP Server
*
* This module provides structured logging capabilities aligned with MCP best practices.
* It supports categorized errors, severity levels, context tracking, performance
* monitoring, and secure logging patterns.
*
* @module utils/logging
*/
import fs from "fs";
import path from "path";
import process from "process";
import winston from "winston";
/**
* Error categories following MCP standards
*/
export enum ErrorCategoryType {
CATEGORY_VALIDATION = 'VALIDATION',
CATEGORY_AUTHENTICATION = 'AUTHENTICATION',
CATEGORY_AUTHORIZATION = 'AUTHORIZATION',
CATEGORY_BUSINESS_LOGIC = 'BUSINESS_LOGIC',
CATEGORY_DATA_ACCESS = 'DATA_ACCESS',
CATEGORY_EXTERNAL_SERVICE = 'EXTERNAL_SERVICE',
CATEGORY_SYSTEM = 'SYSTEM',
CATEGORY_UNKNOWN = 'UNKNOWN'
}
/**
* Log levels aligned with MCP's ErrorSeverityLevel
*/
export enum LogLevel {
/** Critical errors that require immediate attention */
ERROR = 0,
/** Potentially harmful situations that should be reviewed */
WARN = 1,
/** General informational messages about system operation */
INFO = 2,
/** Detailed information for debugging purposes */
DEBUG = 3,
/** Highly detailed tracing information */
TRACE = 4
}
/**
* Maps LogLevel to MCP severity level for integration with MCP error handling
*/
export const logLevelToMcpSeverity = {
[LogLevel.ERROR]: 3, // SEVERITY_ERROR
[LogLevel.WARN]: 2, // SEVERITY_WARN
[LogLevel.INFO]: 1, // SEVERITY_INFO
[LogLevel.DEBUG]: 0, // SEVERITY_DEBUG
[LogLevel.TRACE]: 0 // Also maps to SEVERITY_DEBUG
};
/**
* Structured log entry interface that follows MCP standards
*/
export interface StructuredLogEntry {
/** Timestamp when the log was created */
timestamp: string;
/** Log message */
message: string;
/** Component that generated the log */
component: string;
/** Log level */
level: LogLevel;
/** Optional context data for the log entry */
context?: Record<string, unknown>;
/** Optional error information */
error?: StandardizedErrorObject;
/** Optional processing time in milliseconds */
processingTimeMs?: number;
}
/**
* Standardized error object following MCP conventions
*/
export interface StandardizedErrorObject {
/** Human-readable error message */
errorMessage: string;
/** Machine-readable error code */
errorCode: string;
/** System area affected by the error */
errorCategory: ErrorCategoryType;
/** How critical the error is */
errorSeverity: LogLevel;
/** When the error occurred */
errorTimestamp: string;
/** Additional relevant data */
errorContext?: Record<string, unknown>;
/** Stack trace if available */
errorStack?: string;
}
/**
* Winston-compatible log levels
*/
const winstonLogLevels = {
error: 0,
warn: 1,
info: 2,
debug: 3,
trace: 4
};
/**
* Logger configuration
*/
export interface LoggerConfig {
/** Minimum log level to display */
level: LogLevel;
/** Whether to include timestamps in log output */
includeTimestamps: boolean;
/** Whether to include level in log output */
includeLevel: boolean;
/** Whether to mask sensitive data in logs */
maskSensitiveData: boolean;
/** List of field names to consider sensitive */
sensitiveFields: string[];
/** Directory for log files */
logDir?: string;
/** Whether to log to files */
files?: boolean;
/** Custom file names for log files */
fileNames?: {
combined?: string;
error?: string;
warn?: string;
info?: string;
debug?: string;
};
}
/**
* Default logger configuration
*/
const DEFAULT_CONFIG: LoggerConfig = {
level: process.env.NODE_ENV === 'production'
? LogLevel.INFO
: LogLevel.DEBUG,
includeTimestamps: true,
includeLevel: true,
maskSensitiveData: true,
sensitiveFields: ['password', 'token', 'secret', 'key', 'auth', 'credential'],
logDir: "logs",
files: true,
fileNames: {
combined: "combined.log",
error: "error.log",
warn: "warn.log",
info: "info.log",
debug: "debug.log"
}
};
/**
* Enhanced logger for MCP server operations with structured logging support
* Implements file-based logging with zero console output
*/
export class Logger {
private config: LoggerConfig;
private timers: Map<string, number> = new Map();
private logger: winston.Logger;
/**
* Creates a new logger instance
*
* @param name - Component name for this logger
* @param config - Optional configuration overrides
*/
constructor(
private name: string,
config: Partial<LoggerConfig> = {}
) {
this.config = {
...DEFAULT_CONFIG,
...config,
fileNames: {
...DEFAULT_CONFIG.fileNames,
...config.fileNames
}
};
// Initialize logger with silent transport as fallback
this.logger = winston.createLogger({
levels: winstonLogLevels,
defaultMeta: { component: this.name },
transports: [
// Silent transport to prevent "no transports" warning
new winston.transports.Console({
silent: true
})
]
});
this.initializeLogger();
}
/**
* Initialize or reinitialize the Winston logger
*/
private initializeLogger(): void {
if (this.config.files && this.config.logDir) {
if (!fs.existsSync(this.config.logDir)) {
try {
fs.mkdirSync(this.config.logDir, { recursive: true });
} catch (error) {
// Silently continue - we have a silent transport as fallback
}
}
}
// Create log format
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
// Initialize transports array with a silent transport to avoid "no transports" warning
const transports: winston.transport[] = [
new winston.transports.Console({
silent: true, // Silent transport that doesn't output logs
format: logFormat
})
];
// Add file transports if enabled
if (this.config.files && this.config.logDir) {
try {
const fileNames = this.config.fileNames || DEFAULT_CONFIG.fileNames;
// Combined log file
if (fileNames?.combined) {
transports.push(new winston.transports.File({
filename: path.join(this.config.logDir, fileNames.combined),
format: logFormat
}));
}
// Level-specific log files
if (fileNames?.error) {
transports.push(new winston.transports.File({
filename: path.join(this.config.logDir, fileNames.error),
level: 'error',
format: logFormat
}));
}
if (fileNames?.warn) {
transports.push(new winston.transports.File({
filename: path.join(this.config.logDir, fileNames.warn),
level: 'warn',
format: logFormat
}));
}
if (fileNames?.info) {
transports.push(new winston.transports.File({
filename: path.join(this.config.logDir, fileNames.info),
level: 'info',
format: logFormat
}));
}
if (fileNames?.debug) {
transports.push(new winston.transports.File({
filename: path.join(this.config.logDir, fileNames.debug),
level: 'debug',
format: logFormat
}));
}
} catch (error) {
// Silently continue with just the silent transport if file transports fail
}
}
// Replace logger configuration
this.logger.configure({
levels: winstonLogLevels,
level: LogLevel[this.config.level].toLowerCase(),
defaultMeta: { component: this.name },
transports
});
}
/**
* Maps internal LogLevel to Winston log level
*/
private levelToWinstonLevel(level: LogLevel): string {
const levelName = LogLevel[level].toLowerCase();
return levelName === 'trace' ? 'debug' : levelName;
}
/**
* Creates a standardized error object for MCP compatibility
*
* @param message - Human-readable error description
* @param code - Machine-readable error identifier
* @param category - Type of error
* @param context - Additional error context
* @returns Standardized error object
*/
createStandardizedError(
message: string,
code: string,
category: ErrorCategoryType = ErrorCategoryType.CATEGORY_UNKNOWN,
context?: Record<string, unknown>
): StandardizedErrorObject {
return {
errorMessage: message,
errorCode: code,
errorCategory: category,
errorSeverity: LogLevel.ERROR,
errorTimestamp: new Date().toISOString(),
errorContext: context ? this.processSensitiveData(context) : undefined,
errorStack: new Error().stack
};
}
/**
* Wraps an exception as a standardized error
*
* @param error - Original error object
* @param message - Optional override message
* @param category - Error category
* @returns Standardized error object
*/
wrapExceptionAsStandardizedError(
error: unknown,
message?: string,
category: ErrorCategoryType = ErrorCategoryType.CATEGORY_UNKNOWN
): StandardizedErrorObject {
const errorObject = error instanceof Error ? error : new Error(String(error));
return {
errorMessage: message || errorObject.message,
errorCode: errorObject.name || 'UNKNOWN_ERROR',
errorCategory: category,
errorSeverity: LogLevel.ERROR,
errorTimestamp: new Date().toISOString(),
errorStack: errorObject.stack
};
}
/**
* Masks sensitive data in objects before logging
*
* @param data - Data object to process
* @returns Processed data with sensitive fields masked
*/
private processSensitiveData(data: Record<string, unknown>): Record<string, unknown> {
if (!this.config.maskSensitiveData) return data;
const result: Record<string, unknown> = {};
const sensitiveFields = this.config.sensitiveFields;
for (const [key, value] of Object.entries(data)) {
// Check if key contains any sensitive field name
const isSensitive = sensitiveFields.some(field =>
key.toLowerCase().includes(field.toLowerCase())
);
if (isSensitive) {
// Mask sensitive values
result[key] = '********';
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively process nested objects
result[key] = this.processSensitiveData(value as Record<string, unknown>);
} else {
// Pass through non-sensitive values
result[key] = value;
}
}
return result;
}
/**
* Internal method to log a message using Winston
*
* @param level - Log level
* @param message - Log message
* @param context - Optional context data
*/
private log(
level: LogLevel,
message: string,
context?: Record<string, unknown>
): void {
if (level > this.config.level) return;
const winstonLevel = this.levelToWinstonLevel(level);
const processedContext = context ? this.processSensitiveData(context) : undefined;
this.logger.log(winstonLevel, message, { context: processedContext });
}
/**
* Log an error message with optional error object and context
*
* @param message - Error message
* @param errorOrContext - Error object or context
* @param context - Additional context
*/
error(
message: string,
errorOrContext?: Error | Record<string, unknown>,
context?: Record<string, unknown>
): void {
if (errorOrContext instanceof Error) {
const errorObj = this.wrapExceptionAsStandardizedError(errorOrContext);
const combinedContext = {
...(context || {}),
error: errorObj
};
this.log(LogLevel.ERROR, message, combinedContext);
} else {
this.log(LogLevel.ERROR, message, errorOrContext || context);
}
}
/**
* Log a warning message
*
* @param message - Warning message
* @param context - Optional context data
*/
warn(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.WARN, message, context);
}
/**
* Log an info message
*
* @param message - Info message
* @param context - Optional context data
*/
info(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.INFO, message, context);
}
/**
* Log a debug message
*
* @param message - Debug message
* @param context - Optional context data
*/
debug(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.DEBUG, message, context);
}
/**
* Log a trace message
*
* @param message - Trace message
* @param context - Optional context data
*/
trace(message: string, context?: Record<string, unknown>): void {
this.log(LogLevel.TRACE, message, context);
}
/**
* Set the log level
*
* @param level - New log level
*/
setLevel(level: LogLevel): void {
this.config.level = level;
this.logger.level = this.levelToWinstonLevel(level);
}
/**
* Start a timer for performance tracking
*
* @param id - Timer identifier
*/
startTimer(id: string): void {
this.timers.set(id, performance.now());
}
/**
* End a timer and log the elapsed time
*
* @param id - Timer identifier
* @param message - Log message prefix
* @param level - Log level for the timing message
*/
endTimer(
id: string,
message: string = 'Operation completed',
level: LogLevel = LogLevel.DEBUG
): number {
const startTime = this.timers.get(id);
if (startTime === undefined) {
this.warn(`Timer "${id}" does not exist`, { action: 'endTimer' });
return 0;
}
const endTime = performance.now();
const elapsedMs = endTime - startTime;
this.log(level, `${message} in ${elapsedMs.toFixed(2)}ms`, {
timerId: id,
processingTimeMs: elapsedMs
});
this.timers.delete(id);
return elapsedMs;
}
/**
* Log the result of an operation with timing information
*
* @param success - Whether the operation was successful
* @param operation - Operation name
* @param elapsedMs - Processing time in milliseconds
* @param context - Optional operation context
*/
logOperationResult(
success: boolean,
operation: string,
elapsedMs: number,
context?: Record<string, unknown>
): void {
const level = success ? LogLevel.INFO : LogLevel.ERROR;
const status = success ? 'succeeded' : 'failed';
this.log(level, `Operation ${operation} ${status}`, {
...(context || {}),
operation,
success,
processingTimeMs: elapsedMs
});
}
}
/**
* Create and return a namespaced logger
*
* @param name - Component name for the logger
* @param config - Optional configuration overrides
* @returns Configured logger instance
*/
export function createLogger(
name: string,
config?: Partial<LoggerConfig>
): Logger {
return new Logger(name, config);
}
// Create a global root logger
export const rootLogger = createLogger('obsidian-mcp-server', {
files: true,
logDir: "logs"
});