#
tokens: 39558/50000 7/89 files (page 3/4)
lines: off (toggle) GitHub
raw markdown copy
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;
};

```
Page 3/4FirstPrevNextLast