This is page 3 of 4. Use http://codebase.md/cyanheads/obsidian-mcp-server?page={x} to view the full context. # Directory Structure ``` ├── .clinerules ├── .github │ ├── FUNDING.yml │ └── workflows │ └── publish.yml ├── .gitignore ├── .ncurc.json ├── CHANGELOG.md ├── Dockerfile ├── docs │ ├── obsidian_mcp_tools_spec.md │ ├── obsidian-api │ │ ├── obsidian_rest_api_spec.json │ │ └── obsidian_rest_api_spec.yaml │ └── tree.md ├── env.json ├── LICENSE ├── mcp.json ├── package-lock.json ├── package.json ├── README.md ├── repomix.config.json ├── scripts │ ├── clean.ts │ ├── fetch-openapi-spec.ts │ ├── make-executable.ts │ └── tree.ts ├── smithery.yaml ├── src │ ├── config │ │ └── index.ts │ ├── index.ts │ ├── mcp-server │ │ ├── server.ts │ │ ├── tools │ │ │ ├── obsidianDeleteNoteTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianGlobalSearchTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianListNotesTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianManageFrontmatterTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianManageTagsTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianReadNoteTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ ├── obsidianSearchReplaceTool │ │ │ │ ├── index.ts │ │ │ │ ├── logic.ts │ │ │ │ └── registration.ts │ │ │ └── obsidianUpdateNoteTool │ │ │ ├── index.ts │ │ │ ├── logic.ts │ │ │ └── registration.ts │ │ └── transports │ │ ├── auth │ │ │ ├── core │ │ │ │ ├── authContext.ts │ │ │ │ ├── authTypes.ts │ │ │ │ └── authUtils.ts │ │ │ ├── index.ts │ │ │ └── strategies │ │ │ ├── jwt │ │ │ │ └── jwtMiddleware.ts │ │ │ └── oauth │ │ │ └── oauthMiddleware.ts │ │ ├── httpErrorHandler.ts │ │ ├── httpTransport.ts │ │ └── stdioTransport.ts │ ├── services │ │ └── obsidianRestAPI │ │ ├── index.ts │ │ ├── methods │ │ │ ├── activeFileMethods.ts │ │ │ ├── commandMethods.ts │ │ │ ├── openMethods.ts │ │ │ ├── patchMethods.ts │ │ │ ├── periodicNoteMethods.ts │ │ │ ├── searchMethods.ts │ │ │ └── vaultMethods.ts │ │ ├── service.ts │ │ ├── types.ts │ │ └── vaultCache │ │ ├── index.ts │ │ └── service.ts │ ├── types-global │ │ └── errors.ts │ └── utils │ ├── index.ts │ ├── internal │ │ ├── asyncUtils.ts │ │ ├── errorHandler.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ └── requestContext.ts │ ├── metrics │ │ ├── index.ts │ │ └── tokenCounter.ts │ ├── obsidian │ │ ├── index.ts │ │ ├── obsidianApiUtils.ts │ │ └── obsidianStatUtils.ts │ ├── parsing │ │ ├── dateParser.ts │ │ ├── index.ts │ │ └── jsonParser.ts │ └── security │ ├── idGenerator.ts │ ├── index.ts │ ├── rateLimiter.ts │ └── sanitization.ts ├── tsconfig.json └── typedoc.json ``` # Files -------------------------------------------------------------------------------- /src/utils/internal/logger.ts: -------------------------------------------------------------------------------- ```typescript /** * @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 TransportStream from "winston-transport"; import { config } from "../../config/index.js"; import { RequestContext } from "./requestContext.js"; /** * Defines the supported logging levels based on RFC 5424 Syslog severity levels, * as used by the Model Context Protocol (MCP). * Levels are: 'debug'(7), 'info'(6), 'notice'(5), 'warning'(4), 'error'(3), 'crit'(2), 'alert'(1), 'emerg'(0). * Lower numeric values indicate higher severity. */ export type McpLogLevel = | "debug" | "info" | "notice" | "warning" | "error" | "crit" | "alert" | "emerg"; /** * Numeric severity mapping for MCP log levels (lower is more severe). * @private */ const mcpLevelSeverity: Record<McpLogLevel, number> = { 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: Record< McpLogLevel, "debug" | "info" | "warn" | "error" > = { debug: "debug", info: "info", notice: "info", warning: "warn", error: "error", crit: "error", alert: "error", emerg: "error", }; /** * Interface for a more structured error object, primarily for formatting console logs. * @private */ interface ErrorWithMessageAndStack { message?: string; stack?: string; [key: string]: any; } /** * Interface for the payload of an MCP log notification. * This structure is used when sending log data via MCP `notifications/message`. */ export interface McpLogPayload { message: string; context?: RequestContext; error?: { message: string; stack?: string; }; [key: string]: any; } /** * Type for the `data` parameter of the `McpNotificationSender` function. */ export type McpNotificationData = McpLogPayload | Record<string, unknown>; /** * Defines the signature for a function that can send MCP log notifications. * This function is typically provided by the MCP server instance. * @param level - The severity level of the log message. * @param data - The payload of the log notification. * @param loggerName - An optional name or identifier for the logger/server. */ export type McpNotificationSender = ( level: McpLogLevel, data: McpNotificationData, loggerName?: string, ) => void; // The logsPath from config is already resolved and validated by src/config/index.ts const resolvedLogsDir = config.logsPath; const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic. /** * Creates the Winston console log format. * @returns The Winston log format for console output. * @private */ function createWinstonConsoleFormat(): winston.Logform.Format { 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 as ErrorWithMessageAndStack; if (errorObj.message) metaString += `\n Error: ${errorObj.message}`; if (errorObj.stack) metaString += `\n Stack: ${String(errorObj.stack) .split("\n") .map((l: string) => ` ${l}`) .join("\n")}`; delete metaCopy.error; } if (Object.keys(metaCopy).length > 0) { try { const replacer = (_key: string, value: unknown) => typeof value === "bigint" ? value.toString() : value; const remainingMetaJson = JSON.stringify(metaCopy, replacer, 2); if (remainingMetaJson !== "{}") metaString += `\n Meta: ${remainingMetaJson}`; } catch (stringifyError: unknown) { 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 static instance: Logger; private winstonLogger?: winston.Logger; private initialized = false; private mcpNotificationSender?: McpNotificationSender; private currentMcpLevel: McpLogLevel = "info"; private currentWinstonLevel: "debug" | "info" | "warn" | "error" = "info"; private readonly MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH = 1024; private readonly LOG_FILE_MAX_SIZE = 5 * 1024 * 1024; // 5MB private readonly LOG_MAX_FILES = 5; /** @private */ private constructor() {} /** * Initializes the Winston logger instance. * Should be called once at application startup. * @param level - The initial minimum MCP log level. */ public async initialize(level: McpLogLevel = "info"): Promise<void> { 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]; // The logs directory (config.logsPath / resolvedLogsDir) is expected to be created and validated // by the configuration module (src/config/index.ts) before logger initialization. // If isLogsDirSafe is true, we assume resolvedLogsDir exists and is usable. // No redundant directory creation logic here. const fileFormat = winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json(), ); const transports: TransportStream[] = []; const fileTransportOptions = { format: fileFormat, maxsize: this.LOG_FILE_MAX_SIZE, maxFiles: this.LOG_MAX_FILES, tailable: true, }; if (isLogsDirSafe) { 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, }); // Configure console transport after Winston logger is created const consoleStatus = this._configureConsoleTransport(); const initialContext: RequestContext = { 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, }, ); } /** * Sets the function used to send MCP 'notifications/message'. * @param sender - The function to call for sending notifications, or undefined to disable. */ public setMcpNotificationSender( sender: McpNotificationSender | undefined, ): void { 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. */ public setLevel(newLevel: McpLogLevel): void { const setLevelContext: RequestContext = { 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 */ private _configureConsoleTransport(): { enabled: boolean; message: string | null; } { 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: string | null = 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. */ public static getInstance(): Logger { if (!Logger.instance) { Logger.instance = new Logger(); } return Logger.instance; } /** * Ensures the logger has been initialized. * @returns True if initialized, false otherwise. * @private */ private ensureInitialized(): boolean { 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 */ private log( level: McpLogLevel, msg: string, context?: RequestContext, error?: Error, ): void { if (!this.ensureInitialized()) return; if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) { return; // Do not log if message level is less severe than currentMcpLevel } const logData: Record<string, unknown> = { ...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: McpLogPayload = { 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: unknown) { const errorMessage = sendError instanceof Error ? sendError.message : String(sendError); const internalErrorContext: RequestContext = { 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. */ public debug(msg: string, context?: RequestContext): void { this.log("debug", msg, context); } /** Logs a message at the 'info' level. */ public info(msg: string, context?: RequestContext): void { this.log("info", msg, context); } /** Logs a message at the 'notice' level. */ public notice(msg: string, context?: RequestContext): void { this.log("notice", msg, context); } /** Logs a message at the 'warning' level. */ public warning(msg: string, context?: RequestContext): void { 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. */ public error( msg: string, err?: Error | RequestContext, context?: RequestContext, ): void { 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. */ public crit( msg: string, err?: Error | RequestContext, context?: RequestContext, ): void { 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. */ public alert( msg: string, err?: Error | RequestContext, context?: RequestContext, ): void { 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. */ public emerg( msg: string, err?: Error | RequestContext, context?: RequestContext, ): void { 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. */ public fatal( msg: string, err?: Error | RequestContext, context?: RequestContext, ): void { const errorObj = err instanceof Error ? err : undefined; const actualContext = err instanceof Error ? context : err; this.log("emerg", msg, actualContext, errorObj); } } /** * The singleton instance of the Logger. * Use this instance for all logging operations. */ export const logger = Logger.getInstance(); ``` -------------------------------------------------------------------------------- /src/utils/internal/errorHandler.ts: -------------------------------------------------------------------------------- ```typescript /** * @fileoverview This module provides utilities for robust error handling. * It defines structures for error context, options for handling errors, * and mappings for classifying errors. The main `ErrorHandler` class * offers static methods for consistent error processing, logging, and transformation. * @module src/utils/internal/errorHandler */ import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { generateUUID, sanitizeInputForLogging } from "../index.js"; // Import from main barrel file import { logger } from "./logger.js"; import { RequestContext } from "./requestContext.js"; // Import RequestContext /** * Defines a generic structure for providing context with errors. * This context can include identifiers like `requestId` or any other relevant * key-value pairs that aid in debugging or understanding the error's circumstances. */ export interface ErrorContext { /** * A unique identifier for the request or operation during which the error occurred. * Useful for tracing errors through logs and distributed systems. */ requestId?: string; /** * Allows for arbitrary additional context information. * Keys are strings, and values can be of any type. */ [key: string]: unknown; } /** * Configuration options for the `ErrorHandler.handleError` method. * These options control how an error is processed, logged, and whether it's rethrown. */ export interface ErrorHandlerOptions { /** * The context of the operation that caused the error. * This can include `requestId` and other relevant debugging information. */ context?: ErrorContext; /** * A descriptive name of the operation being performed when the error occurred. * This helps in identifying the source or nature of the error in logs. * Example: "UserLogin", "ProcessPayment", "FetchUserProfile". */ operation: string; /** * The input data or parameters that were being processed when the error occurred. * This input will be sanitized before logging to prevent sensitive data exposure. */ input?: unknown; /** * If true, the (potentially transformed) error will be rethrown after handling. * Defaults to `false`. */ rethrow?: boolean; /** * A specific `BaseErrorCode` to assign to the error, overriding any * automatically determined error code. */ errorCode?: BaseErrorCode; /** * A custom function to map or transform the original error into a new `Error` instance. * If provided, this function is used instead of the default `McpError` creation. * @param error - The original error that occurred. * @returns The transformed error. */ errorMapper?: (error: unknown) => Error; /** * If true, stack traces will be included in the logs. * Defaults to `true`. */ includeStack?: boolean; /** * If true, indicates that the error is critical and might require immediate attention * or could lead to system instability. This is primarily for logging and alerting. * Defaults to `false`. */ critical?: boolean; } /** * Defines a basic rule for mapping errors based on patterns. * Used internally by `COMMON_ERROR_PATTERNS` and as a base for `ErrorMapping`. */ export interface BaseErrorMapping { /** * A string or regular expression to match against the error message. * If a string is provided, it's typically used for substring matching (case-insensitive). */ pattern: string | RegExp; /** * The `BaseErrorCode` to assign if the pattern matches. */ errorCode: BaseErrorCode; /** * An optional custom message template for the mapped error. * (Note: This property is defined but not directly used by `ErrorHandler.determineErrorCode` * which focuses on `errorCode`. It's more relevant for custom mapping logic.) */ messageTemplate?: string; } /** * Extends `BaseErrorMapping` to include a factory function for creating * specific error instances and additional context for the mapping. * Used by `ErrorHandler.mapError`. * @template T The type of `Error` this mapping will produce, defaults to `Error`. */ export interface ErrorMapping<T extends Error = Error> extends BaseErrorMapping { /** * A factory function that creates and returns an instance of the mapped error type `T`. * @param error - The original error that occurred. * @param context - Optional additional context provided in the mapping rule. * @returns The newly created error instance. */ factory: (error: unknown, context?: Record<string, unknown>) => T; /** * Additional static context to be merged or passed to the `factory` function * when this mapping rule is applied. */ additionalContext?: Record<string, unknown>; } /** * Maps standard JavaScript error constructor names to `BaseErrorCode` values. * @private */ const ERROR_TYPE_MAPPINGS: Readonly<Record<string, BaseErrorCode>> = { SyntaxError: BaseErrorCode.VALIDATION_ERROR, TypeError: BaseErrorCode.VALIDATION_ERROR, ReferenceError: BaseErrorCode.INTERNAL_ERROR, RangeError: BaseErrorCode.VALIDATION_ERROR, URIError: BaseErrorCode.VALIDATION_ERROR, EvalError: BaseErrorCode.INTERNAL_ERROR, }; /** * Array of `BaseErrorMapping` rules to classify errors by message/name patterns. * Order matters: more specific patterns should precede generic ones. * @private */ const COMMON_ERROR_PATTERNS: ReadonlyArray<Readonly<BaseErrorMapping>> = [ { pattern: /auth|unauthorized|unauthenticated|not.*logged.*in|invalid.*token|expired.*token/i, errorCode: BaseErrorCode.UNAUTHORIZED, }, { pattern: /permission|forbidden|access.*denied|not.*allowed/i, errorCode: BaseErrorCode.FORBIDDEN, }, { pattern: /not found|missing|no such|doesn't exist|couldn't find/i, errorCode: BaseErrorCode.NOT_FOUND, }, { pattern: /invalid|validation|malformed|bad request|wrong format|missing required/i, errorCode: BaseErrorCode.VALIDATION_ERROR, }, { pattern: /conflict|already exists|duplicate|unique constraint/i, errorCode: BaseErrorCode.CONFLICT, }, { pattern: /rate limit|too many requests|throttled/i, errorCode: BaseErrorCode.RATE_LIMITED, }, { pattern: /timeout|timed out|deadline exceeded/i, errorCode: BaseErrorCode.TIMEOUT, }, { pattern: /service unavailable|bad gateway|gateway timeout|upstream error/i, errorCode: BaseErrorCode.SERVICE_UNAVAILABLE, }, ]; /** * Creates a "safe" RegExp for testing error messages. * Ensures case-insensitivity and removes the global flag. * @param pattern - The string or RegExp pattern. * @returns A new RegExp instance. * @private */ function createSafeRegex(pattern: string | RegExp): RegExp { if (pattern instanceof RegExp) { let flags = pattern.flags.replace("g", ""); if (!flags.includes("i")) { flags += "i"; } return new RegExp(pattern.source, flags); } return new RegExp(pattern, "i"); } /** * Retrieves a descriptive name for an error object or value. * @param error - The error object or value. * @returns A string representing the error's name or type. * @private */ function getErrorName(error: unknown): string { if (error instanceof Error) { return error.name || "Error"; } if (error === null) { return "NullValueEncountered"; } if (error === undefined) { return "UndefinedValueEncountered"; } if ( typeof error === "object" && error !== null && error.constructor && typeof error.constructor.name === "string" && error.constructor.name !== "Object" ) { return `${error.constructor.name}Encountered`; } return `${typeof error}Encountered`; } /** * Extracts a message string from an error object or value. * @param error - The error object or value. * @returns The error message string. * @private */ function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } if (error === null) { return "Null value encountered as error"; } if (error === undefined) { return "Undefined value encountered as error"; } if (typeof error === "string") { return error; } try { const str = String(error); if (str === "[object Object]" && error !== null) { try { return `Non-Error object encountered: ${JSON.stringify(error)}`; } catch (stringifyError) { return `Unstringifyable non-Error object encountered (constructor: ${error.constructor?.name || "Unknown"})`; } } return str; } catch (e) { return `Error converting error to string: ${e instanceof Error ? e.message : "Unknown conversion error"}`; } } /** * A utility class providing static methods for comprehensive error handling. */ export class ErrorHandler { /** * Determines an appropriate `BaseErrorCode` for a given error. * Checks `McpError` instances, `ERROR_TYPE_MAPPINGS`, and `COMMON_ERROR_PATTERNS`. * Defaults to `BaseErrorCode.INTERNAL_ERROR`. * @param error - The error instance or value to classify. * @returns The determined error code. */ public static determineErrorCode(error: unknown): BaseErrorCode { if (error instanceof McpError) { return error.code; } const errorName = getErrorName(error); const errorMessage = getErrorMessage(error); if (errorName in ERROR_TYPE_MAPPINGS) { return ERROR_TYPE_MAPPINGS[errorName as keyof typeof ERROR_TYPE_MAPPINGS]; } for (const mapping of COMMON_ERROR_PATTERNS) { const regex = createSafeRegex(mapping.pattern); if (regex.test(errorMessage) || regex.test(errorName)) { return mapping.errorCode; } } return BaseErrorCode.INTERNAL_ERROR; } /** * Handles an error with consistent logging and optional transformation. * Sanitizes input, determines error code, logs details, and can rethrow. * @param error - The error instance or value that occurred. * @param options - Configuration for handling the error. * @returns The handled (and potentially transformed) error instance. */ public static handleError( error: unknown, options: ErrorHandlerOptions, ): Error { const { context = {}, operation, input, rethrow = false, errorCode: explicitErrorCode, includeStack = true, critical = false, errorMapper, } = options; const sanitizedInput = input !== undefined ? sanitizeInputForLogging(input) : undefined; const originalErrorName = getErrorName(error); const originalErrorMessage = getErrorMessage(error); const originalStack = error instanceof Error ? error.stack : undefined; let finalError: Error; let loggedErrorCode: BaseErrorCode; const errorDetailsSeed = error instanceof McpError && typeof error.details === "object" && error.details !== null ? { ...error.details } : {}; const consolidatedDetails: Record<string, unknown> = { ...errorDetailsSeed, ...context, // Spread context here to allow its properties to be overridden by more specific error details if needed originalErrorName, originalMessage: originalErrorMessage, }; if ( originalStack && !(error instanceof McpError && error.details?.originalStack) // Avoid duplicating if already in McpError details ) { consolidatedDetails.originalStack = originalStack; } if (error instanceof McpError) { loggedErrorCode = error.code; // If an errorMapper is provided, use it. Otherwise, reconstruct McpError with consolidated details. finalError = errorMapper ? errorMapper(error) : new McpError(error.code, error.message, consolidatedDetails); } else { loggedErrorCode = explicitErrorCode || ErrorHandler.determineErrorCode(error); const message = `Error in ${operation}: ${originalErrorMessage}`; finalError = errorMapper ? errorMapper(error) : new McpError(loggedErrorCode, message, consolidatedDetails); } // Preserve stack trace if the error was transformed but the new error doesn't have one if ( finalError !== error && // if error was transformed error instanceof Error && // original was an Error finalError instanceof Error && // final is an Error !finalError.stack && // final has no stack error.stack // original had a stack ) { finalError.stack = error.stack; } const logRequestId = typeof context.requestId === "string" && context.requestId ? context.requestId : generateUUID(); // Generate if not provided in context const logTimestamp = typeof context.timestamp === "string" && context.timestamp ? context.timestamp : new Date().toISOString(); // Generate if not provided // Prepare log payload, ensuring RequestContext properties are at the top level for logger const logPayload: Record<string, unknown> = { requestId: logRequestId, timestamp: logTimestamp, operation, input: sanitizedInput, critical, errorCode: loggedErrorCode, originalErrorType: originalErrorName, // Renamed from originalErrorName for clarity in logs finalErrorType: getErrorName(finalError), // Spread remaining context properties, excluding what's already explicitly set ...Object.fromEntries( Object.entries(context).filter( ([key]) => key !== "requestId" && key !== "timestamp", ), ), }; // Add detailed error information if (finalError instanceof McpError && finalError.details) { logPayload.errorDetails = finalError.details; // Already consolidated } else { // For non-McpErrors or McpErrors without details, use consolidatedDetails logPayload.errorDetails = consolidatedDetails; } if (includeStack) { const stack = finalError instanceof Error ? finalError.stack : originalStack; if (stack) { logPayload.stack = stack; } } // Log using the logger, casting logPayload to RequestContext for compatibility // The logger's `error` method expects a RequestContext as its second or third argument. logger.error( `Error in ${operation}: ${finalError.message || originalErrorMessage}`, finalError, // Pass the actual error object logPayload as RequestContext, // Pass the structured log data as context ); if (rethrow) { throw finalError; } return finalError; } /** * Maps an error to a specific error type `T` based on `ErrorMapping` rules. * Returns original/default error if no mapping matches. * @template T The target error type, extending `Error`. * @param error - The error instance or value to map. * @param mappings - An array of mapping rules to apply. * @param defaultFactory - Optional factory for a default error if no mapping matches. * @returns The mapped error of type `T`, or the original/defaulted error. */ public static mapError<T extends Error>( error: unknown, mappings: ReadonlyArray<ErrorMapping<T>>, defaultFactory?: (error: unknown, context?: Record<string, unknown>) => T, ): T | Error { const errorMessage = getErrorMessage(error); const errorName = getErrorName(error); for (const mapping of mappings) { const regex = createSafeRegex(mapping.pattern); if (regex.test(errorMessage) || regex.test(errorName)) { return mapping.factory(error, mapping.additionalContext); } } if (defaultFactory) { return defaultFactory(error); } // Ensure a proper Error object is returned return error instanceof Error ? error : new Error(String(error)); } /** * Formats an error into a consistent object structure for API responses or structured logging. * @param error - The error instance or value to format. * @returns A structured representation of the error. */ public static formatError(error: unknown): Record<string, unknown> { if (error instanceof McpError) { return { code: error.code, message: error.message, details: typeof error.details === "object" && error.details !== null ? error.details // Use existing details if they are an object : {}, // Default to empty object if details are not suitable }; } if (error instanceof Error) { return { code: ErrorHandler.determineErrorCode(error), message: error.message, details: { errorType: error.name || "Error" }, // Ensure errorType is always present }; } // Handle non-Error types return { code: BaseErrorCode.UNKNOWN_ERROR, message: getErrorMessage(error), // Use helper to get a string message details: { errorType: getErrorName(error) }, // Use helper to get a type name }; } /** * Safely executes a function (sync or async) and handles errors using `ErrorHandler.handleError`. * The error is always rethrown by default by `handleError` when `rethrow` is true. * @template T The expected return type of the function `fn`. * @param fn - The function to execute. * @param options - Error handling options (excluding `rethrow`, as it's forced to true). * @returns A promise resolving with the result of `fn` if successful. * @throws {McpError | Error} The error processed by `ErrorHandler.handleError`. * @example * ```typescript * async function fetchData(userId: string, context: RequestContext) { * return ErrorHandler.tryCatch( * async () => { * const response = await fetch(`/api/users/${userId}`); * if (!response.ok) throw new Error(`Failed to fetch user: ${response.status}`); * return response.json(); * }, * { operation: 'fetchUserData', context, input: { userId } } // rethrow is implicitly true * ); * } * ``` */ public static async tryCatch<T>( fn: () => Promise<T> | T, options: Omit<ErrorHandlerOptions, "rethrow">, // Omit rethrow from options type ): Promise<T> { try { // Await the promise if fn returns one, otherwise resolve directly. const result = fn(); return await Promise.resolve(result); } catch (error) { // ErrorHandler.handleError will return the error to be thrown. // rethrow is true by default when calling handleError this way. throw ErrorHandler.handleError(error, { ...options, rethrow: true }); } } } ``` -------------------------------------------------------------------------------- /src/mcp-server/tools/obsidianGlobalSearchTool/logic.ts: -------------------------------------------------------------------------------- ```typescript import path from "node:path/posix"; import { z } from "zod"; import { NoteJson, ObsidianRestApiService, SimpleSearchResult, } from "../../../services/obsidianRestAPI/index.js"; // Removed NoteStat import import { VaultCacheService } from "../../../services/obsidianRestAPI/vaultCache/index.js"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Import formatTimestamp utility import { config } from "../../../config/index.js"; import { dateParser, formatTimestamp, logger, RequestContext, retryWithDelay, sanitizeInputForLogging, } from "../../../utils/index.js"; // ==================================================================================== // Schema Definitions (Updated for Pagination, Match Limit, and Path Filter) // ==================================================================================== const ObsidianGlobalSearchInputSchema = z .object({ query: z .string() .min(1) .describe("The search query (text or regex pattern)."), searchInPath: z .string() .optional() .describe( "Optional vault-relative path to recursively search within (e.g., 'Notes/Projects'). If omitted, searches the entire vault.", ), contextLength: z .number() .int() .positive() .optional() .default(100) .describe("Characters of context around matches."), modified_since: z .string() .optional() .describe( "Filter files modified *since* this date/time (e.g., '2 weeks ago', '2024-01-15').", ), modified_until: z .string() .optional() .describe( "Filter files modified *until* this date/time (e.g., 'today', '2024-03-20 17:00').", ), useRegex: z .boolean() .optional() .default(false) .describe("Treat 'query' as regex. Defaults to false."), caseSensitive: z .boolean() .optional() .default(false) .describe("Perform case-sensitive search. Defaults to false."), pageSize: z .number() .int() .positive() .optional() .default(50) .describe("Maximum number of result files per page. Defaults to 50."), page: z .number() .int() .positive() .optional() .default(1) .describe("Page number of results to return. Defaults to 1."), maxMatchesPerFile: z .number() .int() .positive() .optional() .default(5) .describe("Maximum number of matches to show per file. Defaults to 5."), }) .describe( "Performs search across vault content using text or regex. Supports filtering by modification date, directory path, pagination, and limiting matches per file.", ); export const ObsidianGlobalSearchInputSchemaShape = ObsidianGlobalSearchInputSchema.shape; export type ObsidianGlobalSearchInput = z.infer< typeof ObsidianGlobalSearchInputSchema >; // ==================================================================================== // Response Structure Definition (Updated) // ==================================================================================== export interface MatchContext { context: string; matchText?: string; // Made optional position?: number; // Made optional (Position relative to the start of the context snippet) } // Updated GlobalSearchResult to use formatted time strings and include numeric mtime for sorting export interface GlobalSearchResult { path: string; filename: string; matches: MatchContext[]; modifiedTime: string; // Formatted string createdTime: string; // Formatted string numericMtime: number; // Numeric mtime for robust sorting } // Added alsoFoundInFiles export interface ObsidianGlobalSearchResponse { success: boolean; message: string; results: GlobalSearchResult[]; totalFilesFound: number; // Total files matching query *before* pagination totalMatchesFound: number; // Total matches across all found files *before* pagination currentPage: number; pageSize: number; totalPages: number; alsoFoundInFiles?: string[]; // List of filenames found but not on the current page } // ==================================================================================== // Helper Function (findMatchesInContent - for Cache Fallback) // ==================================================================================== // Removed lineNumber calculation and return function findMatchesInContent( content: string, query: string, useRegex: boolean, caseSensitive: boolean, contextLength: number, context: RequestContext, ): MatchContext[] { const matches: MatchContext[] = []; let regex: RegExp; const operation = "findMatchesInContent"; const opContext = { ...context, operation }; try { const flags = `g${caseSensitive ? "" : "i"}`; regex = useRegex ? new RegExp(query, flags) : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags); } catch (e) { const errorMsg = `[${operation}] Invalid regex pattern: ${query}`; logger.error(errorMsg, e instanceof Error ? e : undefined, opContext); throw new McpError( BaseErrorCode.VALIDATION_ERROR, `Invalid regex pattern: ${query}`, opContext, ); } let match; // Removed line number calculation logic while ((match = regex.exec(content)) !== null) { const matchIndex = match.index; const matchText = match[0]; const startIndex = Math.max(0, matchIndex - contextLength); const endIndex = Math.min( content.length, matchIndex + matchText.length + contextLength, ); const contextSnippet = content.substring(startIndex, endIndex); // Find position *within* the snippet for consistency with API fallback const positionInSnippet = contextSnippet .toLowerCase() .indexOf(matchText.toLowerCase()); matches.push({ // lineNumber removed context: contextSnippet, matchText: matchText, // Included for cache search position: positionInSnippet >= 0 ? positionInSnippet : 0, // Included for cache search }); if (matchText.length === 0) regex.lastIndex++; } return matches; } // ==================================================================================== // Core Logic Function (API-First with Cache Fallback) // ==================================================================================== const API_SEARCH_TIMEOUT_MS = config.obsidianApiSearchTimeoutMs; export const processObsidianGlobalSearch = async ( params: ObsidianGlobalSearchInput, context: RequestContext, obsidianService: ObsidianRestApiService, vaultCacheService: VaultCacheService | undefined, ): Promise<ObsidianGlobalSearchResponse> => { const operation = "processObsidianGlobalSearch"; const opContext = { ...context, operation }; logger.info( `Processing obsidian_global_search request: "${params.query}" (API-first)`, { ...opContext, params: sanitizeInputForLogging(params) }, ); let sinceDate: Date | null = null; let untilDate: Date | null = null; let strategyMessage = ""; let allFilteredResults: GlobalSearchResult[] = []; // Store all results matching filters before pagination let totalMatchesCount = 0; // Total matches across all files before limiting per file // Normalize searchInPath: remove leading/trailing slashes and ensure it ends with a slash if not empty const searchPathPrefix = params.searchInPath ? params.searchInPath.replace(/^\/+|\/+$/g, "") + (params.searchInPath === "/" ? "" : "/") : ""; // Empty string means search entire vault // 1. Parse Date Filters const dateParseContext = { ...opContext, subOperation: "parseDates" }; try { if (params.modified_since) sinceDate = await dateParser.parseToDate( params.modified_since, dateParseContext, ); if (params.modified_until) untilDate = await dateParser.parseToDate( params.modified_until, dateParseContext, ); } catch (error) { const errMsg = `Invalid date format provided`; logger.error( errMsg, error instanceof Error ? error : undefined, dateParseContext, ); throw new McpError( BaseErrorCode.VALIDATION_ERROR, errMsg, dateParseContext, ); } // 2. Attempt API Search with Retries and Timeout let apiFailedOrTimedOut = false; try { strategyMessage = `Attempting live API search with retries (timeout: ${API_SEARCH_TIMEOUT_MS / 1000}s per attempt). `; const apiSearchContext = { ...opContext, subOperation: "searchApiSimpleWithRetry", }; const apiResults: SimpleSearchResult[] = await retryWithDelay( async () => { logger.info( `Calling obsidianService.searchSimple for query: "${params.query}"`, apiSearchContext, ); const apiCallPromise = obsidianService.searchSimple( params.query, params.contextLength, apiSearchContext, ); const timeoutPromise = new Promise<never>((_, reject) => setTimeout( () => reject( new Error( `API search timed out after ${API_SEARCH_TIMEOUT_MS}ms`, ), ), API_SEARCH_TIMEOUT_MS, ), ); return await Promise.race([apiCallPromise, timeoutPromise]); }, { operationName: "obsidianService.searchSimple", context: apiSearchContext, maxRetries: 2, // Total of 3 attempts delayMs: 500, shouldRetry: (err: unknown) => { // Retry on any error during the API call phase logger.warning(`API search attempt failed. Retrying...`, { ...apiSearchContext, error: err instanceof Error ? err.message : String(err), }); return true; }, }, ); strategyMessage += `API search successful, returned ${apiResults.length} potential files. `; logger.info( `API searchSimple returned ${apiResults.length} files with potential matches.`, apiSearchContext, ); // Process API results (fetch stats for date filtering and inclusion) const fetchStatsContext = { ...opContext, subOperation: "fetchStatsForApiResults", }; let processedCount = 0; for (const apiResult of apiResults) { const filePathFromApi = apiResult.filename; // API uses 'filename' for the full path // Apply path filter if (searchPathPrefix && !filePathFromApi.startsWith(searchPathPrefix)) { continue; // Skip if file is not in the specified path } let mtime: number; let ctime: number; // Fetch stats regardless of date filtering to include in results try { const noteJson = (await obsidianService.getFileContent( filePathFromApi, "json", fetchStatsContext, )) as NoteJson; mtime = noteJson.stat.mtime; ctime = noteJson.stat.ctime; // Get ctime // Apply date filtering if needed if ( (sinceDate && mtime < sinceDate.getTime()) || (untilDate && mtime > untilDate.getTime()) ) { continue; // Skip due to date filter } } catch (statError) { logger.warning( `Failed to fetch stats for file ${filePathFromApi}. Skipping file. Error: ${statError instanceof Error ? statError.message : String(statError)}`, fetchStatsContext, ); continue; // Skip if stats cannot be fetched } // Transform SimpleSearchMatch[] to MatchContext[] - OMITTING matchText and position const transformedMatches: MatchContext[] = []; for (const apiMatch of apiResult.matches) { transformedMatches.push({ // lineNumber removed context: apiMatch.context, // Use the context provided by the API // matchText and position are omitted as they cannot be reliably determined from API result }); } // Apply match limit per file const limitedMatches = transformedMatches.slice( 0, params.maxMatchesPerFile, ); // Only add if we actually found matches after transformation/filtering if (limitedMatches.length > 0) { allFilteredResults.push({ // Add to the unfiltered list first path: filePathFromApi, filename: path.basename(filePathFromApi), matches: limitedMatches, // Use limited matches modifiedTime: formatTimestamp(mtime, fetchStatsContext), // Format mtime createdTime: formatTimestamp(ctime, fetchStatsContext), // Format ctime numericMtime: mtime, // Store numeric mtime }); totalMatchesCount += transformedMatches.length; // Count *all* matches before limiting for total count processedCount++; } } strategyMessage += `Processed ${processedCount} files matching all filters (including path: '${searchPathPrefix || "entire vault"}'). `; } catch (apiError) { // API call failed or timed out internally apiFailedOrTimedOut = true; strategyMessage += `API search failed or timed out (${apiError instanceof Error ? apiError.message : String(apiError)}). `; logger.warning(strategyMessage, { ...opContext, subOperation: "apiSearchFailedOrTimedOut", }); } // 3. Fallback to Cache if API Failed/Timed Out if (apiFailedOrTimedOut) { if (vaultCacheService && vaultCacheService.isReady()) { strategyMessage += "Falling back to in-memory cache. "; logger.info( "API search failed/timed out. Falling back to in-memory cache.", opContext, ); const cache = vaultCacheService.getCache(); const cacheSearchContext = { ...opContext, subOperation: "searchCacheFallback", }; allFilteredResults = []; // Reset results for cache search totalMatchesCount = 0; let processedCount = 0; for (const [filePath, cacheEntry] of cache.entries()) { // Apply path filter if (searchPathPrefix && !filePath.startsWith(searchPathPrefix)) { continue; // Skip if file is not in the specified path } const mtime = cacheEntry.mtime; // Get mtime from cache // Apply date filtering if ( (sinceDate && mtime < sinceDate.getTime()) || (untilDate && mtime > untilDate.getTime()) ) { continue; } try { const matches = findMatchesInContent( cacheEntry.content, params.query, params.useRegex!, params.caseSensitive!, params.contextLength!, cacheSearchContext, ); // Apply match limit per file const limitedMatches = matches.slice(0, params.maxMatchesPerFile); if (limitedMatches.length > 0) { let ctime: number | null = null; // Attempt to fetch ctime as cache likely doesn't have it try { const noteJson = (await obsidianService.getFileContent( filePath, "json", cacheSearchContext, )) as NoteJson; ctime = noteJson.stat.ctime; } catch (statError) { logger.warning( `Failed to fetch ctime for cached file ${filePath} during fallback. Error: ${statError instanceof Error ? statError.message : String(statError)}`, cacheSearchContext, ); // Proceed without ctime if fetch fails } allFilteredResults.push({ // Add to unfiltered list path: filePath, filename: path.basename(filePath), modifiedTime: formatTimestamp(mtime, cacheSearchContext), // Format mtime createdTime: formatTimestamp(ctime ?? mtime, cacheSearchContext), // Format ctime (or mtime fallback) matches: limitedMatches, // Use limited matches numericMtime: mtime, // Store numeric mtime from cache }); totalMatchesCount += matches.length; // Count *all* matches before limiting processedCount++; } } catch (matchError) { logger.warning( `Error matching content in cached file ${filePath} during fallback: ${matchError instanceof Error ? matchError.message : String(matchError)}`, cacheSearchContext, ); } } strategyMessage += `Searched ${cache.size} cached files, processed ${processedCount} matching all filters (including path: '${searchPathPrefix || "entire vault"}'). `; } else { // This block now handles both "cache disabled" and "cache not ready" const reason = vaultCacheService ? "is not ready" : "is disabled"; strategyMessage += `Cache not available (${reason}), unable to fallback. `; logger.error( `API search failed and cache ${reason}. Cannot perform search.`, opContext, ); // Throw a specific error because the tool cannot function without a data source. throw new McpError( BaseErrorCode.SERVICE_UNAVAILABLE, `Live API search failed and the cache is currently ${reason}. Please ensure the Obsidian REST API is running and reachable, and that the cache is enabled and has had time to build.`, opContext, ); } } // 4. Apply Pagination and Sorting const totalFilesFound = allFilteredResults.length; const pageSize = params.pageSize!; const currentPage = params.page!; const totalPages = Math.ceil(totalFilesFound / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; // Sort results by numeric modified time (descending) *before* pagination allFilteredResults.sort((a, b) => { return b.numericMtime - a.numericMtime; // Descending }); const paginatedResults = allFilteredResults.slice(startIndex, endIndex); // 5. Determine alsoFoundInFiles let alsoFoundInFiles: string[] | undefined = undefined; if (totalPages > 1) { const paginatedFilePaths = new Set(paginatedResults.map((r) => r.path)); alsoFoundInFiles = allFilteredResults .filter((r) => !paginatedFilePaths.has(r.path)) // Get files not on the current page .map((r) => r.filename); // Then get their filenames alsoFoundInFiles = [...new Set(alsoFoundInFiles)]; // Ensure unique filenames in the final list } // 6. Construct Final Response const finalMessage = `${strategyMessage}Found ${totalMatchesCount} matches across ${totalFilesFound} files matching all criteria. Returning page ${currentPage} of ${totalPages} (${paginatedResults.length} files on this page, page size ${pageSize}, max matches per file ${params.maxMatchesPerFile}). Results sorted by modification date (newest first).`; const response: ObsidianGlobalSearchResponse = { success: true, // Indicate overall tool success, even if fallback was used or results are empty message: finalMessage, results: paginatedResults, totalFilesFound: totalFilesFound, totalMatchesFound: totalMatchesCount, currentPage: currentPage, pageSize: pageSize, totalPages: totalPages, alsoFoundInFiles: alsoFoundInFiles, // Add the list here }; logger.info(`Global search processing completed. ${finalMessage}`, opContext); return response; }; ``` -------------------------------------------------------------------------------- /src/services/obsidianRestAPI/service.ts: -------------------------------------------------------------------------------- ```typescript /** * @module ObsidianRestApiService * @description * This module provides the core implementation for the Obsidian REST API service. * It encapsulates the logic for making authenticated requests to the API endpoints. */ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import https from "node:https"; // Import the https module for Agent configuration import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { ErrorHandler, logger, RequestContext, requestContextService, } from "../../utils/index.js"; // Added requestContextService import * as activeFileMethods from "./methods/activeFileMethods.js"; import * as commandMethods from "./methods/commandMethods.js"; import * as openMethods from "./methods/openMethods.js"; import * as patchMethods from "./methods/patchMethods.js"; import * as periodicNoteMethods from "./methods/periodicNoteMethods.js"; import * as searchMethods from "./methods/searchMethods.js"; import * as vaultMethods from "./methods/vaultMethods.js"; import { ApiStatusResponse, // Import PatchOptions type ComplexSearchResult, NoteJson, NoteStat, ObsidianCommand, PatchOptions, Period, SimpleSearchResult, } from "./types.js"; // Import types from the new file export class ObsidianRestApiService { private axiosInstance: AxiosInstance; private apiKey: string; constructor() { this.apiKey = config.obsidianApiKey; // Get from central config if (!this.apiKey) { // Config validation should prevent this, but double-check throw new McpError( BaseErrorCode.CONFIGURATION_ERROR, "Obsidian API Key is missing in configuration.", {}, ); } const httpsAgent = new https.Agent({ rejectUnauthorized: config.obsidianVerifySsl, }); this.axiosInstance = axios.create({ baseURL: config.obsidianBaseUrl.replace(/\/$/, ""), // Remove trailing slash headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", // Default accept type }, timeout: 60000, // Increased timeout to 60 seconds (was 15000) httpsAgent, }); logger.info( `ObsidianRestApiService initialized with base URL: ${this.axiosInstance.defaults.baseURL}, Verify SSL: ${config.obsidianVerifySsl}`, requestContextService.createRequestContext({ operation: "ObsidianServiceInit", }), ); } /** * Private helper to make requests and handle common errors. * @param config - Axios request configuration. * @param context - Request context for logging. * @param operationName - Name of the operation for logging context. * @returns The response data. * @throws {McpError} If the request fails. */ private async _request<T = any>( requestConfig: AxiosRequestConfig, context: RequestContext, operationName: string, ): Promise<T> { const operationContext = { ...context, operation: `ObsidianAPI_${operationName}`, }; logger.debug( `Making Obsidian API request: ${requestConfig.method} ${requestConfig.url}`, operationContext, ); return await ErrorHandler.tryCatch( async () => { try { const response = await this.axiosInstance.request<T>(requestConfig); logger.debug( `Obsidian API request successful: ${requestConfig.method} ${requestConfig.url}`, { ...operationContext, status: response.status }, ); // For HEAD requests, we need the headers, so return the whole response. // For other requests, returning response.data is fine. if (requestConfig.method === "HEAD") { return response as T; } return response.data; } catch (error) { const axiosError = error as AxiosError; let errorCode = BaseErrorCode.INTERNAL_ERROR; let errorMessage = `Obsidian API request failed: ${axiosError.message}`; const errorDetails: Record<string, any> = { requestUrl: requestConfig.url, requestMethod: requestConfig.method, responseStatus: axiosError.response?.status, responseData: axiosError.response?.data, }; if (axiosError.response) { // Handle specific HTTP status codes switch (axiosError.response.status) { case 400: errorCode = BaseErrorCode.VALIDATION_ERROR; errorMessage = `Obsidian API Bad Request: ${JSON.stringify(axiosError.response.data)}`; break; case 401: errorCode = BaseErrorCode.UNAUTHORIZED; errorMessage = "Obsidian API Unauthorized: Invalid API Key."; break; case 403: errorCode = BaseErrorCode.FORBIDDEN; errorMessage = "Obsidian API Forbidden: Check permissions."; break; case 404: errorCode = BaseErrorCode.NOT_FOUND; errorMessage = `Obsidian API Not Found: ${requestConfig.url}`; // Log 404s at debug level, as they might be expected (e.g., checking existence) logger.debug(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); // NOTE: We throw immediately after logging debug for 404, skipping the general error log below. case 405: errorCode = BaseErrorCode.VALIDATION_ERROR; // Method not allowed often implies incorrect usage errorMessage = `Obsidian API Method Not Allowed: ${requestConfig.method} on ${requestConfig.url}`; break; case 503: errorCode = BaseErrorCode.SERVICE_UNAVAILABLE; errorMessage = "Obsidian API Service Unavailable."; break; } // General error logging for non-404 client/server errors handled above logger.error(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); } else if (axiosError.request) { // Network error (no response received) errorCode = BaseErrorCode.SERVICE_UNAVAILABLE; errorMessage = `Obsidian API Network Error: No response received from ${requestConfig.url}. This may be due to Obsidian not running, the Local REST API plugin being disabled, or a network issue.`; logger.error(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); } else { // Other errors (e.g., setup issues) // Pass error object correctly if it's an Error instance logger.error( errorMessage, error instanceof Error ? error : undefined, { ...operationContext, ...errorDetails, originalError: String(error), }, ); throw new McpError(errorCode, errorMessage, operationContext); } } }, { operation: `ObsidianAPI_${operationName}_Wrapper`, context: context, input: requestConfig, // Log request config (sanitized by ErrorHandler) errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if wrapper itself fails }, ); } // --- API Methods --- /** * Checks the status and authentication of the Obsidian Local REST API. * @param context - The request context for logging and correlation. * @returns {Promise<ApiStatusResponse>} - The status object from the API. */ async checkStatus(context: RequestContext): Promise<ApiStatusResponse> { // Note: This is the only endpoint that doesn't strictly require auth, // but sending the key helps check if it's valid. // This one is simple enough to keep inline or could be extracted too. return this._request<ApiStatusResponse>( { method: "GET", url: "/", }, context, "checkStatus", ); } // --- Vault Methods --- /** * Gets the content of a specific file in the vault. * @param filePath - Vault-relative path to the file. * @param format - 'markdown' or 'json' (for NoteJson). * @param context - Request context. * @returns The file content (string) or NoteJson object. */ async getFileContent( filePath: string, format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return vaultMethods.getFileContent( this._request.bind(this), filePath, format, context, ); } /** * Updates (overwrites) the content of a file or creates it if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param content - The new content for the file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updateFileContent( filePath: string, content: string, context: RequestContext, ): Promise<void> { return vaultMethods.updateFileContent( this._request.bind(this), filePath, content, context, ); } /** * Appends content to the end of a file. Creates the file if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendFileContent( filePath: string, content: string, context: RequestContext, ): Promise<void> { return vaultMethods.appendFileContent( this._request.bind(this), filePath, content, context, ); } /** * Deletes a specific file in the vault. * @param filePath - Vault-relative path to the file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deleteFile(filePath: string, context: RequestContext): Promise<void> { return vaultMethods.deleteFile(this._request.bind(this), filePath, context); } /** * Lists files within a specified directory in the vault. * @param dirPath - Vault-relative path to the directory. Use empty string "" or "/" for the root. * @param context - Request context. * @returns A list of file and directory names. */ async listFiles(dirPath: string, context: RequestContext): Promise<string[]> { return vaultMethods.listFiles(this._request.bind(this), dirPath, context); } /** * Gets the metadata (stat) of a specific file using a lightweight HEAD request. * @param filePath - Vault-relative path to the file. * @param context - Request context. * @returns The file's metadata. */ async getFileMetadata( filePath: string, context: RequestContext, ): Promise<NoteStat | null> { return vaultMethods.getFileMetadata( this._request.bind(this), filePath, context, ); } // --- Search Methods --- /** * Performs a simple text search across the vault. * @param query - The text query string. * @param contextLength - Number of characters surrounding each match (default 100). * @param context - Request context. * @returns An array of search results. */ async searchSimple( query: string, contextLength: number = 100, context: RequestContext, ): Promise<SimpleSearchResult[]> { return searchMethods.searchSimple( this._request.bind(this), query, contextLength, context, ); } /** * Performs a complex search using Dataview DQL or JsonLogic. * @param query - The query string (DQL) or JSON object (JsonLogic). * @param contentType - The content type header indicating the query format. * @param context - Request context. * @returns An array of search results. */ async searchComplex( query: string | object, contentType: | "application/vnd.olrapi.dataview.dql+txt" | "application/vnd.olrapi.jsonlogic+json", context: RequestContext, ): Promise<ComplexSearchResult[]> { return searchMethods.searchComplex( this._request.bind(this), query, contentType, context, ); } // --- Command Methods --- /** * Executes a registered Obsidian command by its ID. * @param commandId - The ID of the command (e.g., "app:go-back"). * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async executeCommand( commandId: string, context: RequestContext, ): Promise<void> { return commandMethods.executeCommand( this._request.bind(this), commandId, context, ); } /** * Lists all available Obsidian commands. * @param context - Request context. * @returns A list of available commands. */ async listCommands(context: RequestContext): Promise<ObsidianCommand[]> { return commandMethods.listCommands(this._request.bind(this), context); } // --- Open Methods --- /** * Opens a specific file in Obsidian. Creates the file if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param newLeaf - Whether to open the file in a new editor tab (leaf). * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK, but no body expected). */ async openFile( filePath: string, newLeaf: boolean = false, context: RequestContext, ): Promise<void> { return openMethods.openFile( this._request.bind(this), filePath, newLeaf, context, ); } // --- Active File Methods --- /** * Gets the content of the currently active file in Obsidian. * @param format - 'markdown' or 'json' (for NoteJson). * @param context - Request context. * @returns The file content (string) or NoteJson object. */ async getActiveFile( format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return activeFileMethods.getActiveFile( this._request.bind(this), format, context, ); } /** * Updates (overwrites) the content of the currently active file. * @param content - The new content. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updateActiveFile( content: string, context: RequestContext, ): Promise<void> { return activeFileMethods.updateActiveFile( this._request.bind(this), content, context, ); } /** * Appends content to the end of the currently active file. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendActiveFile( content: string, context: RequestContext, ): Promise<void> { return activeFileMethods.appendActiveFile( this._request.bind(this), content, context, ); } /** * Deletes the currently active file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deleteActiveFile(context: RequestContext): Promise<void> { return activeFileMethods.deleteActiveFile( this._request.bind(this), context, ); } // --- Periodic Notes Methods --- // PATCH methods for periodic notes are complex and omitted for brevity /** * Gets the content of a periodic note (daily, weekly, etc.). * @param period - The period type ('daily', 'weekly', 'monthly', 'quarterly', 'yearly'). * @param format - 'markdown' or 'json'. * @param context - Request context. * @returns The note content or NoteJson. */ async getPeriodicNote( period: Period, format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return periodicNoteMethods.getPeriodicNote( this._request.bind(this), period, format, context, ); } /** * Updates (overwrites) the content of a periodic note. Creates if needed. * @param period - The period type. * @param content - The new content. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updatePeriodicNote( period: Period, content: string, context: RequestContext, ): Promise<void> { return periodicNoteMethods.updatePeriodicNote( this._request.bind(this), period, content, context, ); } /** * Appends content to a periodic note. Creates if needed. * @param period - The period type. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendPeriodicNote( period: Period, content: string, context: RequestContext, ): Promise<void> { return periodicNoteMethods.appendPeriodicNote( this._request.bind(this), period, content, context, ); } /** * Deletes a periodic note. * @param period - The period type. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deletePeriodicNote( period: Period, context: RequestContext, ): Promise<void> { return periodicNoteMethods.deletePeriodicNote( this._request.bind(this), period, context, ); } // --- Patch Methods --- /** * Patches a specific file in the vault using granular controls. * @param filePath - Vault-relative path to the file. * @param content - The content to insert/replace (string or JSON for tables/frontmatter). * @param options - Patch operation details (operation, targetType, target, etc.). * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchFile( filePath: string, content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchFile( this._request.bind(this), filePath, content, options, context, ); } /** * Patches the currently active file in Obsidian using granular controls. * @param content - The content to insert/replace. * @param options - Patch operation details. * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchActiveFile( content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchActiveFile( this._request.bind(this), content, options, context, ); } /** * Patches a periodic note using granular controls. * @param period - The period type ('daily', 'weekly', etc.). * @param content - The content to insert/replace. * @param options - Patch operation details. * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchPeriodicNote( period: Period, content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchPeriodicNote( this._request.bind(this), period, content, options, context, ); } } ``` -------------------------------------------------------------------------------- /src/utils/security/sanitization.ts: -------------------------------------------------------------------------------- ```typescript /** * @fileoverview Provides a comprehensive sanitization utility class for various input types, * including HTML, strings, URLs, file paths, JSON, and numbers. It also includes * functionality for redacting sensitive information from objects for safe logging. * @module src/utils/security/sanitization */ import path from "path"; import sanitizeHtml from "sanitize-html"; import validator from "validator"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { logger, RequestContext, requestContextService, } from "../internal/index.js"; // Use internal index /** * Options for path sanitization, controlling how file paths are cleaned and validated. */ export interface PathSanitizeOptions { /** * If provided, restricts sanitized paths to be relative to this root directory. * Attempts to traverse above this root (e.g., using `../`) will result in an error. * The final sanitized path will be relative to this `rootDir`. */ rootDir?: string; /** * If `true`, normalizes Windows-style backslashes (`\\`) to POSIX-style forward slashes (`/`). * Defaults to `false`. */ toPosix?: boolean; /** * If `true`, allows absolute paths, subject to `rootDir` constraints if `rootDir` is also provided. * If `false` (default), absolute paths are converted to relative paths by removing leading slashes or drive letters. */ allowAbsolute?: boolean; } /** * Information returned by the `sanitizePath` method, providing details about * the sanitization process and its outcome. */ export interface SanitizedPathInfo { /** The final sanitized and normalized path string. */ sanitizedPath: string; /** The original path string passed to the function before any normalization or sanitization. */ originalInput: string; /** Indicates if the input path was determined to be absolute after initial `path.normalize()`. */ wasAbsolute: boolean; /** * Indicates if an initially absolute path was converted to a relative path * (typically because `options.allowAbsolute` was `false`). */ convertedToRelative: boolean; /** The effective options (including defaults) that were used for sanitization. */ optionsUsed: PathSanitizeOptions; } /** * Options for context-specific string sanitization using `sanitizeString`. */ export interface SanitizeStringOptions { /** * Specifies the context in which the string will be used, guiding the sanitization strategy. * - `'text'`: (Default) Strips all HTML tags, suitable for plain text content. * - `'html'`: Sanitizes for safe HTML embedding, using `allowedTags` and `allowedAttributes`. * - `'attribute'`: Sanitizes for use within an HTML attribute value (strips all tags). * - `'url'`: Validates and trims the string as a URL. * - `'javascript'`: **Disallowed.** Throws an error to prevent unsafe JavaScript sanitization. */ context?: "text" | "html" | "attribute" | "url" | "javascript"; /** Custom allowed HTML tags when `context` is `'html'`. Overrides default HTML sanitization tags. */ allowedTags?: string[]; /** Custom allowed HTML attributes per tag when `context` is `'html'`. Overrides default HTML sanitization attributes. */ allowedAttributes?: Record<string, string[]>; } /** * Configuration options for HTML sanitization using `sanitizeHtml`. */ export interface HtmlSanitizeConfig { /** An array of allowed HTML tag names (e.g., `['p', 'a', 'strong']`). */ allowedTags?: string[]; /** * A map specifying allowed attributes for HTML tags. * Keys can be tag names (e.g., `'a'`) or `'*'` for global attributes. * Values are arrays of allowed attribute names (e.g., `{'a': ['href', 'title']}`). */ allowedAttributes?: sanitizeHtml.IOptions["allowedAttributes"]; /** If `true`, HTML comments (`<!-- ... -->`) are preserved. Defaults to `false`. */ preserveComments?: boolean; /** * Custom rules for transforming tags during sanitization. * See `sanitize-html` documentation for `transformTags` options. */ transformTags?: sanitizeHtml.IOptions["transformTags"]; } /** * A singleton utility class for performing various input sanitization tasks. * It provides methods to clean and validate strings, HTML, URLs, file paths, JSON, * and numbers, and to redact sensitive data for logging. */ export class Sanitization { private static instance: Sanitization; private sensitiveFields: string[] = [ "password", "token", "secret", "key", "apiKey", "auth", "credential", "jwt", "ssn", "credit", "card", "cvv", "authorization", "passphrase", "privatekey", // Added more common sensitive field names "obsidianapikey", // Specific to this project potentially ]; private defaultHtmlSanitizeConfig: HtmlSanitizeConfig = { allowedTags: [ "h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "ul", "ol", "li", "b", "i", "strong", "em", "strike", "code", "hr", "br", "div", "table", "thead", "tbody", "tr", "th", "td", "pre", "blockquote", // Added blockquote ], allowedAttributes: { a: ["href", "name", "target", "title"], // Added title for links img: ["src", "alt", "title", "width", "height"], "*": ["class", "id", "style", "data-*"], // Allow data-* attributes }, preserveComments: false, }; private constructor() { // Singleton constructor } /** * Gets the singleton instance of the `Sanitization` class. * @returns {Sanitization} The singleton instance. */ public static getInstance(): Sanitization { if (!Sanitization.instance) { Sanitization.instance = new Sanitization(); } return Sanitization.instance; } /** * Sets or extends the list of field names considered sensitive for log redaction. * Field names are matched case-insensitively. * @param {string[]} fields - An array of field names to add to the sensitive list. * @param {RequestContext} [context] - Optional context for logging this configuration change. */ public setSensitiveFields(fields: string[], context?: RequestContext): void { const opContext = context || requestContextService.createRequestContext({ operation: "Sanitization.setSensitiveFields", }); this.sensitiveFields = [ ...new Set([ ...this.sensitiveFields, ...fields.map((f) => f.toLowerCase()), ]), ]; logger.debug("Updated sensitive fields list for log redaction.", { ...opContext, newCount: this.sensitiveFields.length, }); } /** * Retrieves a copy of the current list of sensitive field names used for log redaction. * @returns {string[]} An array of sensitive field names (all lowercase). */ public getSensitiveFields(): string[] { return [...this.sensitiveFields]; } /** * Sanitizes an HTML string by removing potentially malicious tags and attributes, * based on a configurable allow-list. * @param {string} input - The HTML string to sanitize. * @param {HtmlSanitizeConfig} [config] - Optional custom configuration for HTML sanitization. * Overrides defaults for `allowedTags`, `allowedAttributes`, etc. * @returns {string} The sanitized HTML string. Returns an empty string if input is falsy. */ public sanitizeHtml(input: string, config?: HtmlSanitizeConfig): string { if (!input) return ""; const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config }; const options: sanitizeHtml.IOptions = { allowedTags: effectiveConfig.allowedTags, allowedAttributes: effectiveConfig.allowedAttributes, transformTags: effectiveConfig.transformTags, }; if (effectiveConfig.preserveComments) { // Ensure '!--' is not duplicated if already present options.allowedTags = [ ...new Set([...(options.allowedTags || []), "!--"]), ]; } return sanitizeHtml(input, options); } /** * Sanitizes a tag name by removing the leading '#' and replacing invalid characters. * @param {string} input - The tag string to sanitize. * @returns {string} The sanitized tag name. */ public sanitizeTagName(input: string): string { if (!input) return ""; // Remove leading '#' and replace spaces/invalid characters with nothing return input.replace(/^#/, "").replace(/[\s#,\\?%*:|"<>]/g, ""); } /** >>>>>>> REPLACE * Sanitizes a string based on its intended usage context (e.g., HTML, URL, plain text). * * **Security Note:** Using `context: 'javascript'` is explicitly disallowed and will throw an `McpError`. * This is to prevent accidental introduction of XSS vulnerabilities through ineffective sanitization * of JavaScript code. Proper contextual encoding or safer methods should be used for JavaScript. * * @param {string} input - The string to sanitize. * @param {SanitizeStringOptions} [options={}] - Options specifying the sanitization context * and any context-specific parameters (like `allowedTags` for HTML). * @param {RequestContext} [contextForLogging] - Optional context for logging warnings or errors. * @returns {string} The sanitized string. Returns an empty string if input is falsy. * @throws {McpError} If `options.context` is `'javascript'`. */ public sanitizeString( input: string, options: SanitizeStringOptions = {}, contextForLogging?: RequestContext, ): string { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizeString", inputContext: options.context, }); if (!input) return ""; switch (options.context) { case "html": return this.sanitizeHtml(input, { allowedTags: options.allowedTags, allowedAttributes: options.allowedAttributes ? this.convertAttributesFormat(options.allowedAttributes) : undefined, }); case "attribute": // For HTML attributes, strip all tags. Values should be further encoded by the templating engine. return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }); case "url": // Validate and trim. Throws McpError on failure. try { return this.sanitizeUrl(input, ["http", "https"], opContext); // Use sanitizeUrl for consistent validation } catch (urlError) { logger.warning( "Invalid URL detected during string sanitization (context: url).", { ...opContext, input, error: urlError instanceof Error ? urlError.message : String(urlError), }, ); return ""; // Return empty or rethrow, depending on desired strictness. Empty for now. } case "javascript": logger.error( "Attempted JavaScript sanitization via sanitizeString, which is disallowed.", { ...opContext, inputPreview: input.substring(0, 100) }, ); throw new McpError( BaseErrorCode.VALIDATION_ERROR, "JavaScript sanitization is not supported via sanitizeString due to security risks. Use appropriate contextual encoding or safer alternatives.", opContext, ); case "text": default: // Default to stripping all HTML for plain text contexts. return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }); } } /** * Sanitizes a URL string by validating its format and protocol. * @param {string} input - The URL string to sanitize. * @param {string[]} [allowedProtocols=['http', 'https']] - An array of allowed URL protocols (e.g., 'http', 'https', 'ftp'). * @param {RequestContext} [contextForLogging] - Optional context for logging errors. * @returns {string} The sanitized and trimmed URL string. * @throws {McpError} If the URL is invalid, uses a disallowed protocol, or contains 'javascript:'. */ public sanitizeUrl( input: string, allowedProtocols: string[] = ["http", "https"], contextForLogging?: RequestContext, ): string { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizeUrl" }); try { if (!input || typeof input !== "string") { throw new Error("Invalid URL input: must be a non-empty string."); } const trimmedInput = input.trim(); // Stricter check for 'javascript:' regardless of validator's protocol check if (trimmedInput.toLowerCase().startsWith("javascript:")) { throw new Error("JavaScript pseudo-protocol is explicitly disallowed."); } if ( !validator.isURL(trimmedInput, { protocols: allowedProtocols, require_protocol: true, }) ) { throw new Error( `Invalid URL format or protocol not in allowed list: [${allowedProtocols.join(", ")}].`, ); } return trimmedInput; } catch (error) { const message = error instanceof Error ? error.message : "Invalid or disallowed URL."; logger.warning(`URL sanitization failed: ${message}`, { ...opContext, input, }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, { ...opContext, input, }); } } /** * Sanitizes a file path to prevent path traversal attacks and normalize its format. * * @param {string} input - The file path string to sanitize. * @param {PathSanitizeOptions} [options={}] - Options to control sanitization behavior (e.g., `rootDir`, `toPosix`). * @param {RequestContext} [contextForLogging] - Optional context for logging warnings or errors. * @returns {SanitizedPathInfo} An object containing the sanitized path and metadata about the sanitization. * @throws {McpError} If the path is invalid (e.g., empty, contains null bytes) or determined to be unsafe * (e.g., attempts to traverse outside `rootDir` or current working directory if no `rootDir`). */ public sanitizePath( input: string, options: PathSanitizeOptions = {}, contextForLogging?: RequestContext, ): SanitizedPathInfo { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizePath" }); const originalInput = input; const effectiveOptions: PathSanitizeOptions = { toPosix: options.toPosix ?? false, allowAbsolute: options.allowAbsolute ?? false, rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined, // Resolve rootDir upfront }; let wasAbsoluteInitially = false; let convertedToRelative = false; try { if (!input || typeof input !== "string") { throw new Error("Invalid path input: must be a non-empty string."); } if (input.includes("\0")) { throw new Error("Path contains null byte, which is disallowed."); } let normalized = path.normalize(input); // Normalize first (e.g., 'a/b/../c' -> 'a/c') wasAbsoluteInitially = path.isAbsolute(normalized); if (effectiveOptions.toPosix) { normalized = normalized.replace(/\\/g, "/"); } let finalSanitizedPath: string; if (effectiveOptions.rootDir) { // Resolve the input path against the root directory. // If 'normalized' is absolute, path.resolve treats it as the new root. // To correctly join, ensure 'normalized' is treated as relative to 'rootDir' if it's not already escaping. let tempPathForResolve = normalized; if (path.isAbsolute(normalized) && !effectiveOptions.allowAbsolute) { // If absolute paths are not allowed, make it relative before resolving with rootDir tempPathForResolve = normalized.replace(/^(?:[A-Za-z]:)?[/\\]+/, ""); convertedToRelative = true; } else if ( path.isAbsolute(normalized) && effectiveOptions.allowAbsolute ) { // Absolute path is allowed, check if it's within rootDir if ( !normalized.startsWith(effectiveOptions.rootDir + path.sep) && normalized !== effectiveOptions.rootDir ) { throw new Error( "Absolute path is outside the specified root directory.", ); } finalSanitizedPath = path.relative( effectiveOptions.rootDir, normalized, ); finalSanitizedPath = finalSanitizedPath === "" ? "." : finalSanitizedPath; // Handle case where path is rootDir itself // Early return if absolute path is allowed and within root. return { sanitizedPath: finalSanitizedPath, originalInput, wasAbsolute: wasAbsoluteInitially, convertedToRelative, optionsUsed: effectiveOptions, }; } // If path was relative or made relative, join with rootDir const fullPath = path.resolve( effectiveOptions.rootDir, tempPathForResolve, ); if ( !fullPath.startsWith(effectiveOptions.rootDir + path.sep) && fullPath !== effectiveOptions.rootDir ) { throw new Error( "Path traversal detected: sanitized path escapes root directory.", ); } finalSanitizedPath = path.relative(effectiveOptions.rootDir, fullPath); finalSanitizedPath = finalSanitizedPath === "" ? "." : finalSanitizedPath; } else { // No rootDir specified if (path.isAbsolute(normalized)) { if (effectiveOptions.allowAbsolute) { finalSanitizedPath = normalized; // Absolute path allowed } else { // Convert to relative (strip leading slash/drive) finalSanitizedPath = normalized.replace( /^(?:[A-Za-z]:)?[/\\]+/, "", ); convertedToRelative = true; } } else { // Path is relative, and no rootDir // For relative paths without a rootDir, ensure they don't traverse "above" the conceptual CWD. // path.resolve('.') gives current working directory. const resolvedAgainstCwd = path.resolve(normalized); if (!resolvedAgainstCwd.startsWith(path.resolve("."))) { // This check is a bit tricky because '..' is valid if it stays within CWD's subtree. // A more robust check might involve comparing segments or ensuring it doesn't go "too high". // For simplicity, if it resolves outside CWD's prefix, consider it traversal. // This might be too strict for some use cases but safer for general utility. // A common pattern is to check if path.relative(cwd, resolvedPath) starts with '..'. if ( path .relative(path.resolve("."), resolvedAgainstCwd) .startsWith("..") ) { throw new Error( "Relative path traversal detected (escapes current working directory context).", ); } } finalSanitizedPath = normalized; } } return { sanitizedPath: finalSanitizedPath, originalInput, wasAbsolute: wasAbsoluteInitially, convertedToRelative, optionsUsed: effectiveOptions, }; } catch (error) { const message = error instanceof Error ? error.message : "Invalid or unsafe path."; logger.warning(`Path sanitization error: ${message}`, { ...opContext, input: originalInput, options: effectiveOptions, errorDetails: String(error), }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, { ...opContext, input: originalInput, }); } } /** * Sanitizes a JSON string by parsing it to validate its format. * Optionally checks if the JSON string's byte size exceeds a maximum limit. * * @template T The expected type of the parsed JSON object. Defaults to `unknown`. * @param {string} input - The JSON string to sanitize/validate. * @param {number} [maxSizeBytes] - Optional maximum allowed size of the JSON string in bytes. * @param {RequestContext} [contextForLogging] - Optional context for logging errors. * @returns {T} The parsed JavaScript object. * @throws {McpError} If the input is not a string, is not valid JSON, or exceeds `maxSizeBytes`. */ public sanitizeJson<T = unknown>( input: string, maxSizeBytes?: number, contextForLogging?: RequestContext, ): T { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizeJson" }); try { if (typeof input !== "string") { throw new Error("Invalid input: expected a JSON string."); } if ( maxSizeBytes !== undefined && Buffer.byteLength(input, "utf8") > maxSizeBytes ) { throw new McpError( // Throw McpError directly BaseErrorCode.VALIDATION_ERROR, `JSON content exceeds maximum allowed size of ${maxSizeBytes} bytes. Actual size: ${Buffer.byteLength(input, "utf8")} bytes.`, { ...opContext, size: Buffer.byteLength(input, "utf8"), maxSize: maxSizeBytes, }, ); } const parsed = JSON.parse(input); // Note: This function only validates JSON structure. It does not sanitize content within the JSON. // For deep sanitization of object values, additional logic would be needed. return parsed as T; } catch (error) { if (error instanceof McpError) throw error; // Re-throw if already McpError (e.g., size limit) const message = error instanceof Error ? error.message : "Invalid JSON format."; logger.warning(`JSON sanitization failed: ${message}`, { ...opContext, inputPreview: input.substring(0, 100), errorDetails: String(error), }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, { ...opContext, inputPreview: input.length > 100 ? `${input.substring(0, 100)}...` : input, }); } } /** * Sanitizes a numeric input (number or string) by converting it to a number * and optionally clamping it within a specified min/max range. * * @param {number | string} input - The numeric value or string representation of a number. * @param {number} [min] - Optional minimum allowed value (inclusive). * @param {number} [max] - Optional maximum allowed value (inclusive). * @param {RequestContext} [contextForLogging] - Optional context for logging clamping or errors. * @returns {number} The sanitized (and potentially clamped) number. * @throws {McpError} If the input cannot be parsed into a valid, finite number. */ public sanitizeNumber( input: number | string, min?: number, max?: number, contextForLogging?: RequestContext, ): number { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizeNumber", }); let value: number; if (typeof input === "string") { const trimmedInput = input.trim(); // Validator's isNumeric allows empty strings, so check explicitly. if (trimmedInput === "" || !validator.isNumeric(trimmedInput)) { throw new McpError( BaseErrorCode.VALIDATION_ERROR, "Invalid number format: string is not numeric or is empty.", { ...opContext, input }, ); } value = parseFloat(trimmedInput); } else if (typeof input === "number") { value = input; } else { throw new McpError( BaseErrorCode.VALIDATION_ERROR, "Invalid input type: expected number or string.", { ...opContext, input: String(input) }, ); } if (isNaN(value) || !isFinite(value)) { throw new McpError( BaseErrorCode.VALIDATION_ERROR, "Invalid number value (NaN or Infinity).", { ...opContext, input }, ); } let clamped = false; let originalValueForLog = value; // Store original before clamping for logging if (min !== undefined && value < min) { value = min; clamped = true; } if (max !== undefined && value > max) { value = max; clamped = true; } if (clamped) { logger.debug("Number clamped to range.", { ...opContext, originalValue: originalValueForLog, min, max, finalValue: value, }); } return value; } /** * Sanitizes an object or array for logging by deep cloning it and redacting fields * whose names (case-insensitively) match any of the configured sensitive field names. * Redacted fields are replaced with the string `'[REDACTED]'`. * * @param {unknown} input - The object, array, or other value to sanitize for logging. * If input is not an object or array, it's returned as is. * @param {RequestContext} [contextForLogging] - Optional context for logging errors during sanitization. * @returns {unknown} A sanitized copy of the input, safe for logging. * Returns `'[Log Sanitization Failed]'` if an unexpected error occurs during sanitization. */ public sanitizeForLogging( input: unknown, contextForLogging?: RequestContext, ): unknown { const opContext = contextForLogging || requestContextService.createRequestContext({ operation: "sanitizeForLogging", }); try { // Primitives and null are returned as is. if (input === null || typeof input !== "object") { return input; } // Use structuredClone if available (Node.js >= 17), otherwise fallback to JSON parse/stringify. // JSON.parse(JSON.stringify(obj)) is a common way to deep clone, but has limitations // (e.g., loses functions, undefined, Date objects become strings). // For logging, this is often acceptable. const clonedInput = typeof structuredClone === "function" ? structuredClone(input) : JSON.parse(JSON.stringify(input)); this.redactSensitiveFields(clonedInput); return clonedInput; } catch (error) { logger.error( "Error during log sanitization process.", error instanceof Error ? error : undefined, { ...opContext, errorDetails: error instanceof Error ? error.message : String(error), }, ); return "[Log Sanitization Failed]"; // Fallback string indicating sanitization failure } } /** * Helper to convert attribute format for sanitize-html. * `sanitize-html` expects `allowedAttributes` in a specific format. * This method assumes the input `attrs` (from `SanitizeStringOptions`) * is already in the correct format or a compatible one. * @param {Record<string, string[]>} attrs - Attributes configuration. * @returns {sanitizeHtml.IOptions['allowedAttributes']} Attributes in `sanitize-html` format. * @private */ private convertAttributesFormat( attrs: Record<string, string[]>, ): sanitizeHtml.IOptions["allowedAttributes"] { // The type Record<string, string[]> is compatible with sanitizeHtml.IOptions['allowedAttributes'] // which can be Record<string, Array<string | RegExp>> or boolean. // No complex conversion needed if options.allowedAttributes is already Record<string, string[]>. return attrs; } /** * Recursively redacts sensitive fields within an object or array. * This method modifies the input object/array in place. * @param {unknown} obj - The object or array to redact sensitive fields from. * @private */ private redactSensitiveFields(obj: unknown): void { if (!obj || typeof obj !== "object") { return; // Not an object or array, or null } if (Array.isArray(obj)) { obj.forEach((item) => { // Recurse only if the item is an object (including nested arrays) if (item && typeof item === "object") { this.redactSensitiveFields(item); } }); return; } // It's an object (but not an array) for (const key in obj) { // Check if the property belongs to the object itself, not its prototype if (Object.prototype.hasOwnProperty.call(obj, key)) { const value = (obj as Record<string, unknown>)[key]; const lowerKey = key.toLowerCase(); // Special handling for non-serializable but non-sensitive objects if (key === "httpsAgent") { (obj as Record<string, unknown>)[key] = "[HttpAgent Instance]"; continue; // Skip further processing for this key } // Check if the lowercase key includes any of the lowercase sensitive field terms const isSensitive = this.sensitiveFields.some( (field) => lowerKey.includes(field), // sensitiveFields are already stored as lowercase ); if (isSensitive) { (obj as Record<string, unknown>)[key] = "[REDACTED]"; } else if (value && typeof value === "object") { // If the value is another object or array, recurse this.redactSensitiveFields(value); } } } } } /** * A default, shared instance of the `Sanitization` class. * Use this instance for all sanitization tasks. * * Example: * ```typescript * import { sanitization, sanitizeInputForLogging } from './sanitization'; * * const unsafeHtml = "<script>alert('xss')</script><p>Safe</p>"; * const safeHtml = sanitization.sanitizeHtml(unsafeHtml); * * const sensitiveData = { password: '123', username: 'user' }; * const safeLogData = sanitizeInputForLogging(sensitiveData); * // safeLogData will be { password: '[REDACTED]', username: 'user' } * ``` */ export const sanitization = Sanitization.getInstance(); /** * A convenience function that wraps `sanitization.sanitizeForLogging`. * Sanitizes an object or array for logging by redacting sensitive fields. * * @param {unknown} input - The data to sanitize for logging. * @param {RequestContext} [contextForLogging] - Optional context for logging errors during sanitization. * @returns {unknown} A sanitized copy of the input, safe for logging. */ export const sanitizeInputForLogging = ( input: unknown, contextForLogging?: RequestContext, ): unknown => sanitization.sanitizeForLogging(input, contextForLogging); ``` -------------------------------------------------------------------------------- /src/mcp-server/tools/obsidianUpdateNoteTool/logic.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { NoteJson, ObsidianRestApiService, VaultCacheService, } from "../../../services/obsidianRestAPI/index.js"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { createFormattedStatWithTokenCount, logger, RequestContext, retryWithDelay, } from "../../../utils/index.js"; // ==================================================================================== // Schema Definitions for Input Validation // ==================================================================================== /** Defines the possible types of targets for the update operation. */ const TargetTypeSchema = z .enum(["filePath", "activeFile", "periodicNote"]) .describe( "Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.", ); /** Defines the only allowed modification type for this tool implementation. */ const ModificationTypeSchema = z .literal("wholeFile") .describe( "Determines the modification strategy: must be 'wholeFile' for this tool.", ); /** Defines the specific whole-file operations supported. */ const WholeFileModeSchema = z .enum(["append", "prepend", "overwrite"]) .describe( "Specifies the whole-file operation: 'append', 'prepend', or 'overwrite'.", ); /** Defines the valid periods for periodic notes. */ const PeriodicNotePeriodSchema = z .enum(["daily", "weekly", "monthly", "quarterly", "yearly"]) .describe("Valid periods for 'periodicNote' target type."); /** * Base Zod schema containing fields common to all update operations within this tool. * Currently, only 'wholeFile' is supported, so this forms the basis for that mode. */ const BaseUpdateSchema = z.object({ /** Specifies the type of target note. */ targetType: TargetTypeSchema, /** The content to use for the modification. Must be a string for whole-file operations. */ content: z .string() .describe( "The content for the modification (must be a string for whole-file operations).", ), /** * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'. * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'. * Not used if targetType is 'activeFile'. */ targetIdentifier: z .string() .optional() .describe( "Identifier for 'filePath' (vault-relative path) or 'periodicNote' (period string). Not used for 'activeFile'.", ), }); /** * Zod schema specifically for the 'wholeFile' modification type, extending the base schema. * Includes mode-specific options like createIfNeeded and overwriteIfExists. */ const WholeFileUpdateSchema = BaseUpdateSchema.extend({ /** The modification type, fixed to 'wholeFile'. */ modificationType: ModificationTypeSchema, /** The specific whole-file operation ('append', 'prepend', 'overwrite'). */ wholeFileMode: WholeFileModeSchema, /** If true (default), creates the target file/note if it doesn't exist before applying the modification. If false, the operation fails if the target doesn't exist. */ createIfNeeded: z .boolean() .optional() .default(true) .describe( "If true (default), creates the target if it doesn't exist. If false, fails if target is missing.", ), /** Only relevant for 'overwrite' mode. If true, allows overwriting an existing file. If false (default) and the file exists, the 'overwrite' operation fails. */ overwriteIfExists: z .boolean() .optional() .default(false) .describe( "For 'overwrite' mode: If true, allows overwriting. If false (default) and file exists, operation fails.", ), /** If true, includes the final content of the modified file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe("If true, returns the final file content in the response."), }); // ==================================================================================== // Schema for SDK Registration (Flattened for Tool Definition) // ==================================================================================== /** * Zod schema used for registering the tool with the MCP SDK (`server.tool`). * This schema defines the expected input structure from the client's perspective. * It flattens the structure slightly by making mode-specific fields optional at this stage, * relying on the refined schema (`ObsidianUpdateFileInputSchema`) for stricter validation * within the handler logic. */ const ObsidianUpdateNoteRegistrationSchema = z .object({ /** Specifies the target note: 'filePath' (requires targetIdentifier), 'activeFile' (currently open file), or 'periodicNote' (requires targetIdentifier with period like 'daily'). */ targetType: TargetTypeSchema, /** The content for the modification. Must be a string for whole-file operations. */ content: z .string() .describe("The content for the modification (must be a string)."), /** Identifier for the target when targetType is 'filePath' (vault-relative path, e.g., 'Notes/My File.md') or 'periodicNote' (period string: 'daily', 'weekly', etc.). Not used for 'activeFile'. */ targetIdentifier: z .string() .optional() .describe( "Identifier for 'filePath' (path) or 'periodicNote' (period). Not used for 'activeFile'.", ), /** Determines the modification strategy: must be 'wholeFile'. */ modificationType: ModificationTypeSchema, // --- WholeFile Mode Parameters (Marked optional here, refined schema enforces if modificationType is 'wholeFile') --- /** For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'. */ wholeFileMode: WholeFileModeSchema.optional() // Made optional here, refined schema handles requirement .describe( "For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'.", ), /** For 'wholeFile' mode: If true (default), creates the target file/note if it doesn't exist before modifying. If false, fails if the target doesn't exist. */ createIfNeeded: z .boolean() .optional() .default(true) .describe( "For 'wholeFile' mode: If true (default), creates target if needed. If false, fails if missing.", ), /** For 'wholeFile' mode with 'overwrite': If false (default), the operation fails if the target file already exists. If true, allows overwriting the existing file. */ overwriteIfExists: z .boolean() .optional() .default(false) .describe( "For 'wholeFile'/'overwrite' mode: If false (default), fails if target exists. If true, allows overwrite.", ), /** If true, returns the final content of the file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe("If true, returns the final file content in the response."), }) .describe( "Tool to modify Obsidian notes (specified by file path, active file, or periodic note) using whole-file operations: 'append', 'prepend', or 'overwrite'. Options control creation and overwrite behavior.", ); /** * The shape of the registration schema, used by `server.tool` for basic validation. * @see ObsidianUpdateFileRegistrationSchema */ export const ObsidianUpdateNoteInputSchemaShape = ObsidianUpdateNoteRegistrationSchema.shape; /** * TypeScript type inferred from the registration schema. Represents the raw input * received by the tool handler *before* refinement. * @see ObsidianUpdateFileRegistrationSchema */ export type ObsidianUpdateNoteRegistrationInput = z.infer< typeof ObsidianUpdateNoteRegistrationSchema >; // ==================================================================================== // Refined Schema for Internal Logic and Strict Validation // ==================================================================================== /** * Refined Zod schema used internally within the tool's logic for strict validation. * It builds upon `WholeFileUpdateSchema` and adds cross-field validation rules using `.refine()`. * This ensures that `targetIdentifier` is provided and valid when required by `targetType`. */ export const ObsidianUpdateNoteInputSchema = WholeFileUpdateSchema.refine( (data) => { // Rule 1: If targetType is 'filePath' or 'periodicNote', targetIdentifier must be provided. if ( (data.targetType === "filePath" || data.targetType === "periodicNote") && !data.targetIdentifier ) { return false; } // Rule 2: If targetType is 'periodicNote', targetIdentifier must be a valid period string. if ( data.targetType === "periodicNote" && data.targetIdentifier && !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success ) { return false; } // All checks passed return true; }, { // Custom error message for refinement failure. message: "targetIdentifier is required and must be a valid path for targetType 'filePath', or a valid period ('daily', 'weekly', etc.) for targetType 'periodicNote'.", path: ["targetIdentifier"], // Associate the error with the targetIdentifier field. }, ); /** * TypeScript type inferred from the *refined* input schema (`ObsidianUpdateFileInputSchema`). * This type represents the validated and structured input used within the core processing logic. */ export type ObsidianUpdateNoteInput = z.infer< typeof ObsidianUpdateNoteInputSchema >; // ==================================================================================== // Response Type Definition // ==================================================================================== /** * Represents the structure of file statistics after formatting, including * human-readable timestamps and an estimated token count. */ type FormattedStat = { /** Creation time formatted as a standard date-time string. */ createdTime: string; /** Last modified time formatted as a standard date-time string. */ modifiedTime: string; /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */ tokenCountEstimate: number; }; /** * Defines the structure of the successful response returned by the `processObsidianUpdateFile` function. * This object is typically serialized to JSON and sent back to the client. */ export interface ObsidianUpdateNoteResponse { /** Indicates whether the operation was successful. */ success: boolean; /** A human-readable message describing the outcome of the operation. */ message: string; /** Optional file statistics (creation/modification times, token count) if the file could be read after the update. */ stats?: FormattedStat; // Renamed from stat /** Optional final content of the file, included only if `returnContent` was true in the request and the file could be read. */ finalContent?: string; } // ==================================================================================== // Helper Functions // ==================================================================================== /** * Attempts to retrieve the final state (content and stats) of the target note after an update operation. * Uses the appropriate Obsidian API method based on the target type. * Logs a warning and returns null if fetching the final state fails, to avoid failing the entire update operation. * * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note. * @param {string | undefined} targetIdentifier - The identifier (path or period) if applicable. * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'. * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance. * @param {RequestContext} context - The request context for logging and correlation. * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails. */ async function getFinalState( targetType: z.infer<typeof TargetTypeSchema>, targetIdentifier: string | undefined, period: z.infer<typeof PeriodicNotePeriodSchema> | undefined, obsidianService: ObsidianRestApiService, context: RequestContext, ): Promise<NoteJson | null> { const operation = "getFinalState"; logger.debug( `Attempting to retrieve final state for target: ${targetType} ${targetIdentifier ?? "(active)"}`, { ...context, operation }, ); try { let noteJson: NoteJson | null = null; // Call the appropriate API method based on target type if (targetType === "filePath" && targetIdentifier) { noteJson = (await obsidianService.getFileContent( targetIdentifier, "json", context, )) as NoteJson; } else if (targetType === "activeFile") { noteJson = (await obsidianService.getActiveFile( "json", context, )) as NoteJson; } else if (targetType === "periodicNote" && period) { noteJson = (await obsidianService.getPeriodicNote( period, "json", context, )) as NoteJson; } logger.debug(`Successfully retrieved final state`, { ...context, operation, }); return noteJson; } catch (error) { // Log the error but don't let it fail the main update operation. const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `Could not retrieve final state after update for target: ${targetType} ${targetIdentifier ?? "(active)"}. Error: ${errorMsg}`, { ...context, operation, error: errorMsg }, ); return null; // Return null to indicate failure without throwing } } // ==================================================================================== // Core Logic Function // ==================================================================================== /** * Processes the core logic for the 'obsidian_update_file' tool when using the 'wholeFile' * modification type (append, prepend, overwrite). It handles pre-checks, performs the * update via the Obsidian REST API, retrieves the final state, and constructs the response. * * @param {ObsidianUpdateFileInput} params - The validated input parameters conforming to the refined schema. * @param {RequestContext} context - The request context for logging and correlation. * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service. * @returns {Promise<ObsidianUpdateFileResponse>} A promise resolving to the structured success response. * @throws {McpError} Throws an McpError if validation fails or the API interaction results in an error. */ export const processObsidianUpdateNote = async ( params: ObsidianUpdateNoteInput, // Use the refined, validated type context: RequestContext, obsidianService: ObsidianRestApiService, vaultCacheService: VaultCacheService | undefined, ): Promise<ObsidianUpdateNoteResponse> => { logger.debug(`Processing obsidian_update_note request (wholeFile mode)`, { ...context, targetType: params.targetType, wholeFileMode: params.wholeFileMode, }); const targetId = params.targetIdentifier; // Alias for clarity const contentString = params.content; const mode = params.wholeFileMode; let wasCreated = false; // Flag to track if the file was newly created by the operation let targetPeriod: z.infer<typeof PeriodicNotePeriodSchema> | undefined; // Parse the period if the target is a periodic note if (params.targetType === "periodicNote" && targetId) { // Use safeParse for robustness, though refined schema should guarantee validity const parseResult = PeriodicNotePeriodSchema.safeParse(targetId); if (!parseResult.success) { // This should ideally not happen due to the refined schema, but handle defensively throw new McpError( BaseErrorCode.VALIDATION_ERROR, `Invalid period provided for periodicNote: ${targetId}`, context, ); } targetPeriod = parseResult.data; } try { // --- Step 1: Pre-operation Existence Check --- // Determine if the target file/note exists before attempting modification. // This is crucial for overwrite safety checks and createIfNeeded logic. let existsBefore = false; const checkContext = { ...context, operation: "existenceCheck" }; logger.debug( `Checking existence of target: ${params.targetType} ${targetId ?? "(active)"}`, checkContext, ); try { await retryWithDelay( async () => { if (params.targetType === "filePath" && targetId) { await obsidianService.getFileContent( targetId, "json", checkContext, ); } else if (params.targetType === "activeFile") { await obsidianService.getActiveFile("json", checkContext); } else if (params.targetType === "periodicNote" && targetPeriod) { await obsidianService.getPeriodicNote( targetPeriod, "json", checkContext, ); } // If any of the above succeed without throwing, the target exists. existsBefore = true; logger.debug(`Target exists before operation.`, checkContext); }, { operationName: "existenceCheckObsidianUpdateNote", context: checkContext, maxRetries: 3, // Total attempts: 1 initial + 2 retries delayMs: 250, shouldRetry: (error: unknown) => { // Only retry if it's a NOT_FOUND error AND createIfNeeded is true. // If createIfNeeded is false, a NOT_FOUND error means we shouldn't proceed, so don't retry. const should = error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND && params.createIfNeeded; if ( error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND ) { logger.debug( `existenceCheckObsidianUpdateNote: shouldRetry=${should} for NOT_FOUND (createIfNeeded: ${params.createIfNeeded})`, checkContext, ); } return should; }, onRetry: (attempt, error) => { const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `Existence check (attempt ${attempt}) failed for target '${params.targetType} ${targetId ?? ""}'. Error: ${errorMsg}. Retrying as createIfNeeded is true...`, checkContext, ); }, }, ); } catch (error) { // This catch block is primarily for the case where retryWithDelay itself throws // (e.g., all retries exhausted for NOT_FOUND with createIfNeeded=true, or an unretryable error occurred). if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) { // If it's still NOT_FOUND after retries (or if createIfNeeded was false and it failed the first time), // then existsBefore should definitely be false. existsBefore = false; logger.debug( `Target confirmed not to exist after existence check attempts (createIfNeeded: ${params.createIfNeeded}).`, checkContext, ); } else { // For any other error type, re-throw it as it's unexpected here. logger.error( `Unexpected error after existence check retries`, error instanceof Error ? error : undefined, checkContext, ); throw error; } } // --- Step 2: Perform Safety and Configuration Checks --- const safetyCheckContext = { ...context, operation: "safetyChecks", existsBefore, }; // Check 2a: Overwrite safety if (mode === "overwrite" && existsBefore && !params.overwriteIfExists) { logger.warning( `Overwrite attempt failed: Target exists and overwriteIfExists is false.`, safetyCheckContext, ); throw new McpError( BaseErrorCode.CONFLICT, // Use CONFLICT as it clashes with existing state + config `Target ${params.targetType} '${targetId ?? "(active)"}' exists, and 'overwriteIfExists' is set to false. Cannot overwrite.`, safetyCheckContext, ); } // Check 2b: Not Found when creation is disabled if (!existsBefore && !params.createIfNeeded) { logger.warning( `Update attempt failed: Target not found and createIfNeeded is false.`, safetyCheckContext, ); throw new McpError( BaseErrorCode.NOT_FOUND, `Target ${params.targetType} '${targetId ?? "(active)"}' not found, and 'createIfNeeded' is set to false. Cannot update.`, safetyCheckContext, ); } // Determine if the operation will result in file creation wasCreated = !existsBefore && params.createIfNeeded; logger.debug( `Operation will proceed. File creation needed: ${wasCreated}`, safetyCheckContext, ); // --- Step 3: Perform the Update Operation via Obsidian API --- const updateContext = { ...context, operation: `performUpdate:${mode}`, wasCreated, }; logger.debug(`Performing update operation: ${mode}`, updateContext); // Handle 'prepend' and 'append' manually as Obsidian API might not directly support them atomically. if (mode === "prepend" || mode === "append") { let existingContent = ""; // Only read existing content if the file existed before the operation. if (existsBefore) { const readContext = { ...updateContext, subOperation: "readForModify" }; logger.debug(`Reading existing content for ${mode}`, readContext); try { if (params.targetType === "filePath" && targetId) { existingContent = (await obsidianService.getFileContent( targetId, "markdown", readContext, )) as string; } else if (params.targetType === "activeFile") { existingContent = (await obsidianService.getActiveFile( "markdown", readContext, )) as string; } else if (params.targetType === "periodicNote" && targetPeriod) { existingContent = (await obsidianService.getPeriodicNote( targetPeriod, "markdown", readContext, )) as string; } logger.debug( `Successfully read existing content. Length: ${existingContent.length}`, readContext, ); } catch (readError) { // This should ideally not happen if existsBefore is true, but handle defensively. const errorMsg = readError instanceof Error ? readError.message : String(readError); logger.error( `Error reading existing content for ${mode} despite existence check.`, readError instanceof Error ? readError : undefined, readContext, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `Failed to read existing content for ${mode} operation. Error: ${errorMsg}`, readContext, ); } } else { logger.debug( `Target did not exist before, skipping read for ${mode}.`, updateContext, ); } // Combine content based on the mode. const newContent = mode === "prepend" ? contentString + existingContent : existingContent + contentString; logger.debug( `Combined content length for ${mode}: ${newContent.length}`, updateContext, ); // Overwrite the target with the newly combined content. const writeContext = { ...updateContext, subOperation: "writeCombined" }; logger.debug(`Writing combined content back to target`, writeContext); if (params.targetType === "filePath" && targetId) { await obsidianService.updateFileContent( targetId, newContent, writeContext, ); } else if (params.targetType === "activeFile") { await obsidianService.updateActiveFile(newContent, writeContext); } else if (params.targetType === "periodicNote" && targetPeriod) { await obsidianService.updatePeriodicNote( targetPeriod, newContent, writeContext, ); } logger.debug( `Successfully wrote combined content for ${mode}`, writeContext, ); if (params.targetType === "filePath" && targetId && vaultCacheService) { await vaultCacheService.updateCacheForFile(targetId, writeContext); } } else { // Handle 'overwrite' mode directly. switch (params.targetType) { case "filePath": // targetId is guaranteed by refined schema check await obsidianService.updateFileContent( targetId!, contentString, updateContext, ); break; case "activeFile": await obsidianService.updateActiveFile(contentString, updateContext); break; case "periodicNote": // targetPeriod is guaranteed by refined schema check await obsidianService.updatePeriodicNote( targetPeriod!, contentString, updateContext, ); break; } logger.debug( `Successfully performed overwrite on target: ${params.targetType} ${targetId ?? "(active)"}`, updateContext, ); if (params.targetType === "filePath" && targetId && vaultCacheService) { await vaultCacheService.updateCacheForFile(targetId, updateContext); } } // --- Step 4: Get Final State (Stat and Optional Content) --- // Add a small delay before attempting to get the final state, to allow Obsidian API to stabilize after write. const POST_UPDATE_DELAY_MS = 250; logger.debug( `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state...`, { ...context, operation: "postUpdateDelay" }, ); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); // Attempt to retrieve the file's state *after* the modification. let finalState: NoteJson | null = null; // Initialize to null try { finalState = await retryWithDelay( async () => getFinalState( params.targetType, targetId, targetPeriod, obsidianService, context, ), { operationName: "getFinalStateAfterUpdate", context: { ...context, operation: "getFinalStateAfterUpdateAttempt" }, // Use a distinct context for retry logs maxRetries: 3, // Total attempts: 1 initial + 2 retries delayMs: 250, // Shorter delay shouldRetry: (error: unknown) => { // Retry on common transient issues or if the file might not be immediately available const should = error instanceof McpError && (error.code === BaseErrorCode.NOT_FOUND || // File might not be indexed immediately error.code === BaseErrorCode.SERVICE_UNAVAILABLE || // API temporarily busy error.code === BaseErrorCode.TIMEOUT); // API call timed out if (should) { logger.debug( `getFinalStateAfterUpdate: shouldRetry=true for error code ${(error as McpError).code}`, context, ); } return should; }, onRetry: (attempt, error) => { const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `getFinalState (attempt ${attempt}) failed. Error: ${errorMsg}. Retrying...`, { ...context, operation: "getFinalStateRetry" }, ); }, }, ); } catch (error) { // If retryWithDelay throws after all attempts, getFinalState effectively failed. // The original getFinalState already logs a warning and returns null if it encounters an error internally // and is designed not to let its failure stop the main operation. // So, if retryWithDelay throws, it means even retries didn't help. finalState = null; // Ensure finalState remains null const errorMsg = error instanceof Error ? error.message : String(error); logger.error( `Failed to retrieve final state for target '${params.targetType} ${targetId ?? ""}' even after retries. Error: ${errorMsg}`, error instanceof Error ? error : undefined, context, ); // Do not re-throw here, allow the main process to construct a response with a warning. } // --- Step 5: Construct Success Message --- // Create a user-friendly message indicating what happened. let messageAction: string; if (wasCreated) { // Use past tense for creation events messageAction = mode === "overwrite" ? "created" : `${mode}d (and created)`; } else { // Use past tense for modifications of existing files messageAction = mode === "overwrite" ? "overwritten" : `${mode}ed`; } const targetName = params.targetType === "filePath" ? `'${targetId}'` : params.targetType === "periodicNote" ? `'${targetId}' note` : "the active file"; let successMessage = `File content successfully ${messageAction} for ${targetName}.`; // Use let logger.info(successMessage, context); // Log initial success message // Append a warning if the final state couldn't be retrieved if (finalState === null) { const warningMsg = " (Warning: Could not retrieve final file stats/content after update.)"; successMessage += warningMsg; logger.warning( `Appending warning to response message: ${warningMsg}`, context, ); } // --- Step 6: Build and Return Response --- // Format the file statistics (if available) using the shared utility. const finalContentForStat = finalState?.content ?? ""; // Provide content for token counting const formattedStatResult = finalState?.stat ? await createFormattedStatWithTokenCount( finalState.stat, finalContentForStat, context, ) // Await the async utility : undefined; // Ensure stat is undefined if the utility returned null (e.g., token counting failed) const formattedStat = formattedStatResult === null ? undefined : formattedStatResult; // Construct the final response object. const response: ObsidianUpdateNoteResponse = { success: true, message: successMessage, stats: formattedStat, }; // Include final content if requested and available. if (params.returnContent) { response.finalContent = finalState?.content; // Assign content if available, otherwise undefined logger.debug( `Including final content in response as requested.`, context, ); } return response; } catch (error) { // Handle errors, ensuring they are McpError instances before re-throwing. // Errors from obsidianService calls should already be McpErrors and logged by the service. if (error instanceof McpError) { // Log McpErrors specifically from this level if needed, though lower levels might have logged already logger.error( `McpError during file update: ${error.message}`, error, context, ); throw error; // Re-throw known McpError } else { // Catch unexpected errors, log them, and wrap in a generic McpError. const errorMessage = `Unexpected error updating Obsidian file/note`; logger.error( errorMessage, error instanceof Error ? error : undefined, context, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, context, ); } } }; ``` -------------------------------------------------------------------------------- /src/mcp-server/tools/obsidianSearchReplaceTool/logic.ts: -------------------------------------------------------------------------------- ```typescript import path from "node:path"; // For file path fallback logic using POSIX separators import { z } from "zod"; import { NoteJson, ObsidianRestApiService, VaultCacheService, } from "../../../services/obsidianRestAPI/index.js"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { createFormattedStatWithTokenCount, logger, RequestContext, retryWithDelay, } from "../../../utils/index.js"; // ==================================================================================== // Schema Definitions for Input Validation // ==================================================================================== /** Defines the possible types of targets for the search/replace operation. */ const TargetTypeSchema = z .enum(["filePath", "activeFile", "periodicNote"]) .describe( "Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.", ); /** Defines the valid periods for periodic notes. */ const PeriodicNotePeriodSchema = z .enum(["daily", "weekly", "monthly", "quarterly", "yearly"]) .describe("Valid periods for 'periodicNote' target type."); /** * Defines the structure for a single search and replace operation block. */ const ReplacementBlockSchema = z.object({ /** The exact string or regex pattern to search for within the note content. Cannot be empty. */ search: z .string() .min(1, "Search pattern cannot be empty.") .describe("The exact string or regex pattern to search for."), /** The string to replace each match with. An empty string effectively deletes the matched text. */ replace: z.string().describe("The string to replace matches with."), }); /** * Base Zod schema object containing fields common to the search/replace tool input. * This is used as the foundation for both the registration shape and the refined internal schema. */ const BaseObsidianSearchReplaceInputSchema = z.object({ /** Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'. */ targetType: TargetTypeSchema, /** * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'. * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'. * Not used if targetType is 'activeFile'. The tool attempts a case-insensitive fallback if the exact filePath is not found. */ targetIdentifier: z .string() .optional() .describe( "Required if targetType is 'filePath' (vault-relative path) or 'periodicNote' (period string: 'daily', etc.). Tries case-insensitive fallback for filePath.", ), /** An array of one or more search/replace operations to perform sequentially on the note content. */ replacements: z .array(ReplacementBlockSchema) .min(1, "Replacements array cannot be empty.") .describe("An array of search/replace operations to perform sequentially."), /** If true, treats the 'search' field in each replacement block as a JavaScript regular expression pattern. Defaults to false (exact string matching). */ useRegex: z .boolean() .optional() .default(false) .describe( "If true, treat the 'search' field in replacements as JavaScript regex patterns. Defaults to false (exact string matching).", ), /** If true (default), replaces all occurrences matching each search pattern within the note. If false, replaces only the first occurrence of each pattern. */ replaceAll: z .boolean() .optional() .default(true) .describe( "If true (default), replace all occurrences for each search pattern. If false, replace only the first occurrence.", ), /** If true (default), the search operation is case-sensitive. If false, it's case-insensitive. Applies to both string and regex searches. */ caseSensitive: z .boolean() .optional() .default(true) .describe( "If true (default), the search is case-sensitive. If false, it's case-insensitive. Applies to both string and regex search.", ), /** If true (and useRegex is false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\s+). Defaults to false. Cannot be true if useRegex is true. */ flexibleWhitespace: z .boolean() .optional() .default(false) .describe( "If true (and useRegex=false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\\s+). Defaults to false.", ), /** If true, ensures the search term matches only whole words by implicitly adding word boundaries (\b) around the pattern (unless boundaries already exist in regex). Applies to both regex and non-regex modes. Defaults to false. */ wholeWord: z .boolean() .optional() .default(false) .describe( "If true, ensures the search term matches only whole words using word boundaries (\\b). Applies to both regex and non-regex modes. Defaults to false.", ), /** If true, includes the final content of the modified file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe( "If true, returns the final content of the file in the response. Defaults to false.", ), }); // ==================================================================================== // Refined Schema for Internal Logic and Strict Validation // ==================================================================================== /** * Refined Zod schema used internally within the tool's logic for strict validation. * It builds upon `BaseObsidianSearchReplaceInputSchema` and adds cross-field validation rules: * 1. Ensures `targetIdentifier` is provided and valid when required by `targetType`. * 2. Ensures `flexibleWhitespace` is not used concurrently with `useRegex`. */ export const ObsidianSearchReplaceInputSchema = BaseObsidianSearchReplaceInputSchema.refine( (data) => { // Rule 1: Validate targetIdentifier based on targetType if ( (data.targetType === "filePath" || data.targetType === "periodicNote") && !data.targetIdentifier ) { return false; // Missing targetIdentifier } if ( data.targetType === "periodicNote" && data.targetIdentifier && !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success ) { return false; // Invalid period } // Rule 2: flexibleWhitespace cannot be true if useRegex is true if (data.flexibleWhitespace && data.useRegex) { return false; // Conflicting options } return true; // All checks passed }, { // Custom error message for refinement failures message: "Validation failed: targetIdentifier is required and must be valid for 'filePath' or 'periodicNote'. Also, 'flexibleWhitespace' cannot be true if 'useRegex' is true.", // Point error reporting to potentially problematic fields path: ["targetIdentifier", "flexibleWhitespace", "useRegex"], }, ).describe( "Performs one or more search-and-replace operations within a target Obsidian note (file path, active, or periodic). Reads the file, applies replacements sequentially in memory, and writes the modified content back, overwriting the original. Supports string/regex search, case sensitivity toggle, replacing first/all occurrences, flexible whitespace matching (non-regex), and whole word matching.", ); // ==================================================================================== // Schema Shape and Type Exports for Registration and Logic // ==================================================================================== /** * The shape of the base input schema, used by `server.tool` for registration and initial validation. * @see BaseObsidianSearchReplaceInputSchema */ export const ObsidianSearchReplaceInputSchemaShape = BaseObsidianSearchReplaceInputSchema.shape; /** * TypeScript type inferred from the base registration schema. Represents the raw input * received by the tool handler *before* refinement. * @see BaseObsidianSearchReplaceInputSchema */ export type ObsidianSearchReplaceRegistrationInput = z.infer< typeof BaseObsidianSearchReplaceInputSchema >; /** * TypeScript type inferred from the *refined* input schema (`ObsidianSearchReplaceInputSchema`). * This type represents the validated and structured input used within the core processing logic. */ export type ObsidianSearchReplaceInput = z.infer< typeof ObsidianSearchReplaceInputSchema >; // ==================================================================================== // Response Type Definition // ==================================================================================== /** * Represents the structure of file statistics after formatting, including * human-readable timestamps and an estimated token count. */ type FormattedStat = { /** Creation time formatted as a standard date-time string. */ createdTime: string; /** Last modified time formatted as a standard date-time string. */ modifiedTime: string; /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */ tokenCountEstimate: number; }; /** * Defines the structure of the successful response returned by the `processObsidianSearchReplace` function. * This object is typically serialized to JSON and sent back to the client. */ export interface ObsidianSearchReplaceResponse { /** Indicates whether the operation was successful. */ success: boolean; /** A human-readable message describing the outcome of the operation (e.g., number of replacements). */ message: string; /** The total number of replacements made across all search/replace blocks. */ totalReplacementsMade: number; /** Optional file statistics (creation/modification times, token count) if the file could be read after the update. */ stats?: FormattedStat; /** Optional final content of the file, included only if `returnContent` was true in the request. */ finalContent?: string; } // ==================================================================================== // Helper Functions // ==================================================================================== /** * Escapes characters that have special meaning in regular expressions. * This allows treating a literal string as a pattern in a regex. * * @param {string} str - The input string to escape. * @returns {string} The string with regex special characters escaped. */ function escapeRegex(str: string): string { // Escape characters: . * + ? ^ $ { } ( ) | [ ] \ - return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); // $& inserts the matched character } /** * Attempts to retrieve the final state (content and stats) of the target note after a search/replace operation. * Uses the appropriate Obsidian API method based on the target type and the potentially corrected file path. * Logs a warning and returns null if fetching the final state fails, preventing failure of the entire operation. * * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note. * @param {string | undefined} effectiveFilePath - The vault-relative path (potentially corrected by case-insensitive fallback). Undefined for non-filePath targets. * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'. * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance. * @param {RequestContext} context - The request context for logging and correlation. * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails. */ async function getFinalState( targetType: z.infer<typeof TargetTypeSchema>, effectiveFilePath: string | undefined, period: z.infer<typeof PeriodicNotePeriodSchema> | undefined, obsidianService: ObsidianRestApiService, context: RequestContext, ): Promise<NoteJson | null> { const operation = "getFinalStateAfterSearchReplace"; const targetDesc = effectiveFilePath ?? (period ? `periodic ${period}` : "active file"); logger.debug(`Attempting to retrieve final state for target: ${targetDesc}`, { ...context, operation, }); try { let noteJson: NoteJson | null = null; // Call the appropriate API method if (targetType === "filePath" && effectiveFilePath) { noteJson = (await obsidianService.getFileContent( effectiveFilePath, "json", context, )) as NoteJson; } else if (targetType === "activeFile") { noteJson = (await obsidianService.getActiveFile( "json", context, )) as NoteJson; } else if (targetType === "periodicNote" && period) { noteJson = (await obsidianService.getPeriodicNote( period, "json", context, )) as NoteJson; } logger.debug(`Successfully retrieved final state for ${targetDesc}`, { ...context, operation, }); return noteJson; } catch (error) { // Log the error but return null to avoid failing the main operation. const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `Could not retrieve final state after search/replace for target: ${targetDesc}. Error: ${errorMsg}`, { ...context, operation, error: errorMsg }, ); return null; } } // ==================================================================================== // Core Logic Function // ==================================================================================== /** * Processes the core logic for the 'obsidian_search_replace' tool. * Reads the target note content, performs a sequence of search and replace operations * based on the provided parameters (handling regex, case sensitivity, etc.), * writes the modified content back to Obsidian, retrieves the final state, * and constructs the response object. * * @param {ObsidianSearchReplaceInput} params - The validated input parameters conforming to the refined schema. * @param {RequestContext} context - The request context for logging and correlation. * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service. * @returns {Promise<ObsidianSearchReplaceResponse>} A promise resolving to the structured success response. * @throws {McpError} Throws an McpError if validation fails, reading/writing fails, or an unexpected error occurs during processing. */ export const processObsidianSearchReplace = async ( params: ObsidianSearchReplaceInput, // Use the refined, validated type context: RequestContext, obsidianService: ObsidianRestApiService, vaultCacheService: VaultCacheService | undefined, ): Promise<ObsidianSearchReplaceResponse> => { // Destructure validated parameters for easier access const { targetType, targetIdentifier, replacements, useRegex: initialUseRegex, // Rename to avoid shadowing loop variable replaceAll, caseSensitive, flexibleWhitespace, // Note: Cannot be true if initialUseRegex is true (enforced by schema) wholeWord, returnContent, } = params; let effectiveFilePath = targetIdentifier; // Store the path used (might be updated by fallback) let targetDescription = targetIdentifier ?? "active file"; // For logging and error messages let targetPeriod: z.infer<typeof PeriodicNotePeriodSchema> | undefined; logger.debug(`Processing obsidian_search_replace request`, { ...context, targetType, targetIdentifier, initialUseRegex, flexibleWhitespace, wholeWord, returnContent, }); // --- Step 1: Read Initial Content (with case-insensitive fallback for filePath) --- let originalContent: string; const readContext = { ...context, operation: "readFileContent" }; try { if (targetType === "filePath") { if (!targetIdentifier) { // Should be caught by schema, but double-check throw new McpError( BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'filePath'.", readContext, ); } targetDescription = targetIdentifier; // Initial description try { // Attempt 1: Case-sensitive read logger.debug( `Attempting to read file (case-sensitive): ${targetIdentifier}`, readContext, ); originalContent = (await obsidianService.getFileContent( targetIdentifier, "markdown", readContext, )) as string; effectiveFilePath = targetIdentifier; // Confirm exact path worked logger.debug( `Successfully read file using exact path: ${targetIdentifier}`, readContext, ); } catch (readError) { // Attempt 2: Case-insensitive fallback if NOT_FOUND if ( readError instanceof McpError && readError.code === BaseErrorCode.NOT_FOUND ) { logger.info( `File not found with exact path: ${targetIdentifier}. Attempting case-insensitive fallback.`, readContext, ); const dirname = path.posix.dirname(targetIdentifier); const filenameLower = path.posix .basename(targetIdentifier) .toLowerCase(); // List directory contents (use root '/' if dirname is '.') const dirToList = dirname === "." ? "/" : dirname; const filesInDir = await obsidianService.listFiles( dirToList, readContext, ); // Filter for files matching the lowercase basename const matches = filesInDir.filter( (f) => !f.endsWith("/") && path.posix.basename(f).toLowerCase() === filenameLower, ); if (matches.length === 1) { // Found exactly one match const correctFilename = path.posix.basename(matches[0]); effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path targetDescription = effectiveFilePath; // Update description for subsequent logs/errors logger.info( `Found case-insensitive match: ${effectiveFilePath}. Reading content.`, readContext, ); originalContent = (await obsidianService.getFileContent( effectiveFilePath, "markdown", readContext, )) as string; logger.debug( `Successfully read file using fallback path: ${effectiveFilePath}`, readContext, ); } else { // Handle ambiguous (multiple matches) or no match found const errorMsg = matches.length > 1 ? `Read failed: Ambiguous case-insensitive matches found for '${targetIdentifier}' in directory '${dirToList}'. Matches: [${matches.join(", ")}]` : `Read failed: File not found for '${targetIdentifier}' (case-insensitive fallback also failed in directory '${dirToList}').`; logger.error(errorMsg, { ...readContext, matches }); // Use NOT_FOUND for no match, CONFLICT for ambiguity throw new McpError( matches.length > 1 ? BaseErrorCode.CONFLICT : BaseErrorCode.NOT_FOUND, errorMsg, readContext, ); } } else { // Re-throw errors other than NOT_FOUND during the initial read attempt throw readError; } } } else if (targetType === "activeFile") { logger.debug(`Reading content from active file.`, readContext); originalContent = (await obsidianService.getActiveFile( "markdown", readContext, )) as string; targetDescription = "the active file"; effectiveFilePath = undefined; // Not applicable logger.debug(`Successfully read active file content.`, readContext); } else { // periodicNote if (!targetIdentifier) { // Should be caught by schema throw new McpError( BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'periodicNote'.", readContext, ); } // Parse period (already validated by refined schema) targetPeriod = PeriodicNotePeriodSchema.parse(targetIdentifier); targetDescription = `periodic note '${targetPeriod}'`; effectiveFilePath = undefined; // Not applicable logger.debug(`Reading content from ${targetDescription}.`, readContext); originalContent = (await obsidianService.getPeriodicNote( targetPeriod, "markdown", readContext, )) as string; logger.debug( `Successfully read ${targetDescription} content.`, readContext, ); } } catch (error) { // Catch and handle errors during the initial read phase if (error instanceof McpError) throw error; // Re-throw known McpErrors const errorMessage = `Unexpected error reading target ${targetDescription} before search/replace.`; logger.error( errorMessage, error instanceof Error ? error : undefined, readContext, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, readContext, ); } // --- Step 2: Perform Sequential Replacements --- let modifiedContent = originalContent; let totalReplacementsMade = 0; const replaceContext = { ...context, operation: "performReplacements" }; logger.debug( `Starting ${replacements.length} replacement operations.`, replaceContext, ); for (let i = 0; i < replacements.length; i++) { const rep = replacements[i]; const repContext = { ...replaceContext, replacementIndex: i, searchPattern: rep.search, }; let currentReplacementsInBlock = 0; let finalSearchPattern: string | RegExp = rep.search; // Start with the raw search string let useRegexForThisRep = initialUseRegex; // Use the overall setting initially try { // --- 2a: Prepare the Search Pattern (Apply options) --- const patternPrepContext = { ...repContext, subOperation: "prepareSearchPattern", }; if (!initialUseRegex) { // Handle non-regex specific options: flexibleWhitespace and wholeWord let searchStr = rep.search; // Work with a mutable string if (flexibleWhitespace) { // Convert to a regex string: escape special chars, then replace whitespace sequences with \s+ searchStr = escapeRegex(searchStr).replace(/\s+/g, "\\s+"); useRegexForThisRep = true; // Now treat it as a regex pattern string logger.debug( `Applying flexibleWhitespace: "${rep.search}" -> /${searchStr}/`, patternPrepContext, ); } if (wholeWord) { // Add word boundaries (\b) to the pattern string // If flexibleWhitespace was applied, searchStr is already a regex string. // Otherwise, escape the original search string first. const basePattern = useRegexForThisRep ? searchStr : escapeRegex(searchStr); searchStr = `\\b${basePattern}\\b`; useRegexForThisRep = true; // Definitely treat as a regex pattern string now logger.debug( `Applying wholeWord: "${rep.search}" -> /${searchStr}/`, patternPrepContext, ); } finalSearchPattern = searchStr; // Update the pattern to use } else if (wholeWord) { // Initial useRegex is true, but wholeWord is also requested. // Apply wholeWord boundaries if the user's regex doesn't obviously have them. let searchStr = rep.search; // Heuristic check: Does the pattern likely already account for boundaries? // Looks for ^, $, \b at start/end, or patterns matching full lines/non-whitespace sequences. const hasBoundary = /(?:^|\\b)\S.*\S(?:$|\\b)|^\S$|^\S.*\S$|^$/.test(searchStr) || /^\^|\\b/.test(searchStr) || /\$|\\b$/.test(searchStr); if (!hasBoundary) { searchStr = `\\b${searchStr}\\b`; // Log a warning as this might interfere with complex user regex. logger.warning( `Applying wholeWord=true to user-provided regex. Original: /${rep.search}/, Modified: /${searchStr}/. This might affect complex regex behavior.`, patternPrepContext, ); finalSearchPattern = searchStr; // Update the pattern string } else { logger.debug( `wholeWord=true requested, but user regex /${searchStr}/ appears to already contain boundary anchors. Using original regex.`, patternPrepContext, ); finalSearchPattern = rep.search; // Keep original regex string } } // If it's still not treated as regex, finalSearchPattern remains the original rep.search string. // --- 2b: Execute the Replacement --- const execContext = { ...repContext, subOperation: "executeReplacement", isRegex: useRegexForThisRep, }; if (useRegexForThisRep) { // --- Regex Replacement --- let flags = ""; if (replaceAll) flags += "g"; // Global flag for all matches if (!caseSensitive) flags += "i"; // Ignore case flag const regex = new RegExp(finalSearchPattern as string, flags); // Create RegExp object logger.debug( `Executing regex replacement: /${finalSearchPattern}/${flags}`, execContext, ); // Count matches *before* replacing to report accurately const matches = modifiedContent.match(regex); currentReplacementsInBlock = matches ? matches.length : 0; // If replaceAll is false, we only perform/count one replacement, even if regex matches more if (!replaceAll && currentReplacementsInBlock > 0) { currentReplacementsInBlock = 1; } // Perform the replacement if (currentReplacementsInBlock > 0) { if (replaceAll) { modifiedContent = modifiedContent.replace(regex, rep.replace); } else { // Replace only the first occurrence found by the regex modifiedContent = modifiedContent.replace(regex, rep.replace); } } } else { // --- Simple String Replacement --- // Note: wholeWord and flexibleWhitespace would have set useRegexForThisRep = true const searchString = finalSearchPattern as string; // It's just a string here const comparisonString = caseSensitive ? searchString : searchString.toLowerCase(); let startIndex = 0; logger.debug( `Executing string replacement: "${searchString}" (caseSensitive: ${caseSensitive})`, execContext, ); while (true) { const contentToSearch = caseSensitive ? modifiedContent : modifiedContent.toLowerCase(); const index = contentToSearch.indexOf(comparisonString, startIndex); if (index === -1) { break; // No more occurrences found } currentReplacementsInBlock++; // Perform replacement using original indices and search string length modifiedContent = modifiedContent.substring(0, index) + rep.replace + modifiedContent.substring(index + searchString.length); if (!replaceAll) { break; // Stop after the first replacement } // Move start index past the inserted replacement to find the next match startIndex = index + rep.replace.length; // Safety break for empty search string or potential infinite loops if (searchString.length === 0) { logger.warning( `Search string is empty. Breaking replacement loop to prevent infinite execution.`, execContext, ); break; } // Basic check if replacement could cause infinite loop (e.g., replacing 'a' with 'ba') if ( rep.replace.includes(searchString) && rep.replace.length >= searchString.length ) { // This is a heuristic, might not catch all cases but prevents common ones. logger.warning( `Replacement string "${rep.replace}" contains search string "${searchString}". Potential infinite loop detected. Breaking loop for this block.`, execContext, ); break; } } } totalReplacementsMade += currentReplacementsInBlock; logger.debug( `Block ${i}: Performed ${currentReplacementsInBlock} replacements for search: "${rep.search}"`, repContext, ); } catch (error) { // Catch errors during a specific replacement block const errorMessage = `Error during replacement block ${i} (search: "${rep.search}")`; logger.error( errorMessage, error instanceof Error ? error : undefined, repContext, ); // Fail fast: Stop processing further replacements if one block fails. throw new McpError( BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : "Unknown error"}`, repContext, ); } } // End of replacements loop logger.debug( `Finished all replacement operations. Total replacements made: ${totalReplacementsMade}`, replaceContext, ); // --- Step 3: Write Modified Content Back to Obsidian --- let finalState: NoteJson | null = null; const POST_UPDATE_DELAY_MS = 500; // Delay before trying to read the file back // Only write back if the content actually changed to avoid unnecessary file operations. if (modifiedContent !== originalContent) { const writeContext = { ...context, operation: "writeFileContent" }; try { logger.debug( `Content changed. Writing modified content back to ${targetDescription}`, writeContext, ); // Use the effectiveFilePath determined during the read phase for filePath targets if (targetType === "filePath") { await obsidianService.updateFileContent( effectiveFilePath!, modifiedContent, writeContext, ); if (vaultCacheService) { await vaultCacheService.updateCacheForFile( effectiveFilePath!, writeContext, ); } } else if (targetType === "activeFile") { await obsidianService.updateActiveFile(modifiedContent, writeContext); } else { // periodicNote await obsidianService.updatePeriodicNote( targetPeriod!, modifiedContent, writeContext, ); } logger.info( `Successfully updated ${targetDescription} with ${totalReplacementsMade} replacement(s).`, writeContext, ); // Attempt to get the final state *after* successfully writing. logger.debug( `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state after write...`, { ...writeContext, subOperation: "postWriteDelay" }, ); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); try { finalState = await retryWithDelay( async () => getFinalState( targetType, effectiveFilePath, targetPeriod, obsidianService, context, ), { operationName: "getFinalStateAfterSearchReplaceWrite", context: { ...context, operation: "getFinalStateAfterSearchReplaceWriteAttempt", }, maxRetries: 3, delayMs: 300, shouldRetry: (err: unknown) => err instanceof McpError && (err.code === BaseErrorCode.NOT_FOUND || err.code === BaseErrorCode.SERVICE_UNAVAILABLE || err.code === BaseErrorCode.TIMEOUT), onRetry: (attempt, err) => logger.warning( `getFinalStateAfterSearchReplaceWrite (attempt ${attempt}) failed. Error: ${(err as Error).message}. Retrying...`, writeContext, ), }, ); } catch (retryError) { finalState = null; logger.error( `Failed to retrieve final state for ${targetDescription} after write, even after retries. Error: ${(retryError as Error).message}`, retryError instanceof Error ? retryError : undefined, writeContext, ); } } catch (error) { // Handle errors during the write phase if (error instanceof McpError) throw error; // Re-throw known McpErrors const errorMessage = `Unexpected error writing modified content to ${targetDescription}.`; logger.error( errorMessage, error instanceof Error ? error : undefined, writeContext, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, writeContext, ); } } else { // Content did not change, no need to write. logger.info( `No changes detected in ${targetDescription} after search/replace operations. Skipping write.`, context, ); // Still attempt to get the state, as the user might want stats even if content is unchanged. logger.debug( `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state (no change)...`, { ...context, subOperation: "postNoChangeDelay" }, ); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); try { finalState = await retryWithDelay( async () => getFinalState( targetType, effectiveFilePath, targetPeriod, obsidianService, context, ), { operationName: "getFinalStateAfterSearchReplaceNoChange", context: { ...context, operation: "getFinalStateAfterSearchReplaceNoChangeAttempt", }, maxRetries: 3, delayMs: 300, shouldRetry: (err: unknown) => err instanceof McpError && (err.code === BaseErrorCode.NOT_FOUND || err.code === BaseErrorCode.SERVICE_UNAVAILABLE || err.code === BaseErrorCode.TIMEOUT), onRetry: (attempt, err) => logger.warning( `getFinalStateAfterSearchReplaceNoChange (attempt ${attempt}) failed. Error: ${(err as Error).message}. Retrying...`, context, ), }, ); } catch (retryError) { finalState = null; logger.error( `Failed to retrieve final state for ${targetDescription} (no change), even after retries. Error: ${(retryError as Error).message}`, retryError instanceof Error ? retryError : undefined, context, ); } } // --- Step 4: Construct and Return the Response --- const responseContext = { ...context, operation: "buildResponse" }; let message: string; if (totalReplacementsMade > 0) { message = `Search/replace completed on ${targetDescription}. Successfully made ${totalReplacementsMade} replacement(s).`; } else if (modifiedContent !== originalContent) { // This case should ideally not happen if totalReplacementsMade is 0, but as a safeguard: message = `Search/replace completed on ${targetDescription}. Content was modified, but replacement count is zero. Please review.`; } else { message = `Search/replace completed on ${targetDescription}. No matching text was found, so no replacements were made.`; } // Append a warning if the final state couldn't be retrieved if (finalState === null) { const warningMsg = " (Warning: Could not retrieve final file stats/content after update.)"; message += warningMsg; logger.warning( `Appending warning to response message: ${warningMsg}`, responseContext, ); } // Format the file statistics using the shared utility. // Use final state content if available, otherwise use the (potentially modified) content in memory for token count. const finalContentForStat = finalState?.content ?? modifiedContent; const formattedStatResult = finalState?.stat ? await createFormattedStatWithTokenCount( finalState.stat, finalContentForStat, responseContext, ) // Await the async utility : undefined; // Ensure stat is undefined if the utility returned null (e.g., token counting failed) const formattedStat = formattedStatResult === null ? undefined : formattedStatResult; // Build the final response object const response: ObsidianSearchReplaceResponse = { success: true, message: message, totalReplacementsMade, stats: formattedStat, }; // Include final content if requested and available. if (returnContent) { // Prefer content from final state read, fallback to in-memory modified content. response.finalContent = finalState?.content ?? modifiedContent; logger.debug( `Including final content in response as requested.`, responseContext, ); } logger.debug( `Search/replace process completed successfully.`, responseContext, ); return response; }; ```