This is page 3 of 4. Use http://codebase.md/cyanheads/obsidian-mcp-server?lines=false&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;
};
```