#
tokens: 33978/50000 4/89 files (page 4/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 4 of 5. Use http://codebase.md/cyanheads/obsidian-mcp-server?lines=true&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/services/obsidianRestAPI/service.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @module ObsidianRestApiService
  3 |  * @description
  4 |  * This module provides the core implementation for the Obsidian REST API service.
  5 |  * It encapsulates the logic for making authenticated requests to the API endpoints.
  6 |  */
  7 | 
  8 | import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
  9 | import https from "node:https"; // Import the https module for Agent configuration
 10 | import { config } from "../../config/index.js";
 11 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 12 | import {
 13 |   ErrorHandler,
 14 |   logger,
 15 |   RequestContext,
 16 |   requestContextService,
 17 | } from "../../utils/index.js"; // Added requestContextService
 18 | import * as activeFileMethods from "./methods/activeFileMethods.js";
 19 | import * as commandMethods from "./methods/commandMethods.js";
 20 | import * as openMethods from "./methods/openMethods.js";
 21 | import * as patchMethods from "./methods/patchMethods.js";
 22 | import * as periodicNoteMethods from "./methods/periodicNoteMethods.js";
 23 | import * as searchMethods from "./methods/searchMethods.js";
 24 | import * as vaultMethods from "./methods/vaultMethods.js";
 25 | import {
 26 |   ApiStatusResponse, // Import PatchOptions type
 27 |   ComplexSearchResult,
 28 |   NoteJson,
 29 |   NoteStat,
 30 |   ObsidianCommand,
 31 |   PatchOptions,
 32 |   Period,
 33 |   SimpleSearchResult,
 34 | } from "./types.js"; // Import types from the new file
 35 | 
 36 | export class ObsidianRestApiService {
 37 |   private axiosInstance: AxiosInstance;
 38 |   private apiKey: string;
 39 | 
 40 |   constructor() {
 41 |     this.apiKey = config.obsidianApiKey; // Get from central config
 42 |     if (!this.apiKey) {
 43 |       // Config validation should prevent this, but double-check
 44 |       throw new McpError(
 45 |         BaseErrorCode.CONFIGURATION_ERROR,
 46 |         "Obsidian API Key is missing in configuration.",
 47 |         {},
 48 |       );
 49 |     }
 50 | 
 51 |     const httpsAgent = new https.Agent({
 52 |       rejectUnauthorized: config.obsidianVerifySsl,
 53 |     });
 54 | 
 55 |     this.axiosInstance = axios.create({
 56 |       baseURL: config.obsidianBaseUrl.replace(/\/$/, ""), // Remove trailing slash
 57 |       headers: {
 58 |         Authorization: `Bearer ${this.apiKey}`,
 59 |         Accept: "application/json", // Default accept type
 60 |       },
 61 |       timeout: 60000, // Increased timeout to 60 seconds (was 15000)
 62 |       httpsAgent,
 63 |     });
 64 | 
 65 |     logger.info(
 66 |       `ObsidianRestApiService initialized with base URL: ${this.axiosInstance.defaults.baseURL}, Verify SSL: ${config.obsidianVerifySsl}`,
 67 |       requestContextService.createRequestContext({
 68 |         operation: "ObsidianServiceInit",
 69 |       }),
 70 |     );
 71 |   }
 72 | 
 73 |   /**
 74 |    * Private helper to make requests and handle common errors.
 75 |    * @param config - Axios request configuration.
 76 |    * @param context - Request context for logging.
 77 |    * @param operationName - Name of the operation for logging context.
 78 |    * @returns The response data.
 79 |    * @throws {McpError} If the request fails.
 80 |    */
 81 |   private async _request<T = any>(
 82 |     requestConfig: AxiosRequestConfig,
 83 |     context: RequestContext,
 84 |     operationName: string,
 85 |   ): Promise<T> {
 86 |     const operationContext = {
 87 |       ...context,
 88 |       operation: `ObsidianAPI_${operationName}`,
 89 |     };
 90 |     logger.debug(
 91 |       `Making Obsidian API request: ${requestConfig.method} ${requestConfig.url}`,
 92 |       operationContext,
 93 |     );
 94 | 
 95 |     return await ErrorHandler.tryCatch(
 96 |       async () => {
 97 |         try {
 98 |           const response = await this.axiosInstance.request<T>(requestConfig);
 99 |           logger.debug(
100 |             `Obsidian API request successful: ${requestConfig.method} ${requestConfig.url}`,
101 |             { ...operationContext, status: response.status },
102 |           );
103 |           // For HEAD requests, we need the headers, so return the whole response.
104 |           // For other requests, returning response.data is fine.
105 |           if (requestConfig.method === "HEAD") {
106 |             return response as T;
107 |           }
108 |           return response.data;
109 |         } catch (error) {
110 |           const axiosError = error as AxiosError;
111 |           let errorCode = BaseErrorCode.INTERNAL_ERROR;
112 |           let errorMessage = `Obsidian API request failed: ${axiosError.message}`;
113 |           const errorDetails: Record<string, any> = {
114 |             requestUrl: requestConfig.url,
115 |             requestMethod: requestConfig.method,
116 |             responseStatus: axiosError.response?.status,
117 |             responseData: axiosError.response?.data,
118 |           };
119 | 
120 |           if (axiosError.response) {
121 |             // Handle specific HTTP status codes
122 |             switch (axiosError.response.status) {
123 |               case 400:
124 |                 errorCode = BaseErrorCode.VALIDATION_ERROR;
125 |                 errorMessage = `Obsidian API Bad Request: ${JSON.stringify(axiosError.response.data)}`;
126 |                 break;
127 |               case 401:
128 |                 errorCode = BaseErrorCode.UNAUTHORIZED;
129 |                 errorMessage = "Obsidian API Unauthorized: Invalid API Key.";
130 |                 break;
131 |               case 403:
132 |                 errorCode = BaseErrorCode.FORBIDDEN;
133 |                 errorMessage = "Obsidian API Forbidden: Check permissions.";
134 |                 break;
135 |               case 404:
136 |                 errorCode = BaseErrorCode.NOT_FOUND;
137 |                 errorMessage = `Obsidian API Not Found: ${requestConfig.url}`;
138 |                 // Log 404s at debug level, as they might be expected (e.g., checking existence)
139 |                 logger.debug(errorMessage, {
140 |                   ...operationContext,
141 |                   ...errorDetails,
142 |                 });
143 |                 throw new McpError(errorCode, errorMessage, operationContext);
144 |               // NOTE: We throw immediately after logging debug for 404, skipping the general error log below.
145 |               case 405:
146 |                 errorCode = BaseErrorCode.VALIDATION_ERROR; // Method not allowed often implies incorrect usage
147 |                 errorMessage = `Obsidian API Method Not Allowed: ${requestConfig.method} on ${requestConfig.url}`;
148 |                 break;
149 |               case 503:
150 |                 errorCode = BaseErrorCode.SERVICE_UNAVAILABLE;
151 |                 errorMessage = "Obsidian API Service Unavailable.";
152 |                 break;
153 |             }
154 |             // General error logging for non-404 client/server errors handled above
155 |             logger.error(errorMessage, {
156 |               ...operationContext,
157 |               ...errorDetails,
158 |             });
159 |             throw new McpError(errorCode, errorMessage, operationContext);
160 |           } else if (axiosError.request) {
161 |             // Network error (no response received)
162 |             errorCode = BaseErrorCode.SERVICE_UNAVAILABLE;
163 |             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.`;
164 |             logger.error(errorMessage, {
165 |               ...operationContext,
166 |               ...errorDetails,
167 |             });
168 |             throw new McpError(errorCode, errorMessage, operationContext);
169 |           } else {
170 |             // Other errors (e.g., setup issues)
171 |             // Pass error object correctly if it's an Error instance
172 |             logger.error(
173 |               errorMessage,
174 |               error instanceof Error ? error : undefined,
175 |               {
176 |                 ...operationContext,
177 |                 ...errorDetails,
178 |                 originalError: String(error),
179 |               },
180 |             );
181 |             throw new McpError(errorCode, errorMessage, operationContext);
182 |           }
183 |         }
184 |       },
185 |       {
186 |         operation: `ObsidianAPI_${operationName}_Wrapper`,
187 |         context: context,
188 |         input: requestConfig, // Log request config (sanitized by ErrorHandler)
189 |         errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if wrapper itself fails
190 |       },
191 |     );
192 |   }
193 | 
194 |   // --- API Methods ---
195 | 
196 |   /**
197 |    * Checks the status and authentication of the Obsidian Local REST API.
198 |    * @param context - The request context for logging and correlation.
199 |    * @returns {Promise<ApiStatusResponse>} - The status object from the API.
200 |    */
201 |   async checkStatus(context: RequestContext): Promise<ApiStatusResponse> {
202 |     // Note: This is the only endpoint that doesn't strictly require auth,
203 |     // but sending the key helps check if it's valid.
204 |     // This one is simple enough to keep inline or could be extracted too.
205 |     return this._request<ApiStatusResponse>(
206 |       {
207 |         method: "GET",
208 |         url: "/",
209 |       },
210 |       context,
211 |       "checkStatus",
212 |     );
213 |   }
214 | 
215 |   // --- Vault Methods ---
216 | 
217 |   /**
218 |    * Gets the content of a specific file in the vault.
219 |    * @param filePath - Vault-relative path to the file.
220 |    * @param format - 'markdown' or 'json' (for NoteJson).
221 |    * @param context - Request context.
222 |    * @returns The file content (string) or NoteJson object.
223 |    */
224 |   async getFileContent(
225 |     filePath: string,
226 |     format: "markdown" | "json" = "markdown",
227 |     context: RequestContext,
228 |   ): Promise<string | NoteJson> {
229 |     return vaultMethods.getFileContent(
230 |       this._request.bind(this),
231 |       filePath,
232 |       format,
233 |       context,
234 |     );
235 |   }
236 | 
237 |   /**
238 |    * Updates (overwrites) the content of a file or creates it if it doesn't exist.
239 |    * @param filePath - Vault-relative path to the file.
240 |    * @param content - The new content for the file.
241 |    * @param context - Request context.
242 |    * @returns {Promise<void>} Resolves on success (204 No Content).
243 |    */
244 |   async updateFileContent(
245 |     filePath: string,
246 |     content: string,
247 |     context: RequestContext,
248 |   ): Promise<void> {
249 |     return vaultMethods.updateFileContent(
250 |       this._request.bind(this),
251 |       filePath,
252 |       content,
253 |       context,
254 |     );
255 |   }
256 | 
257 |   /**
258 |    * Appends content to the end of a file. Creates the file if it doesn't exist.
259 |    * @param filePath - Vault-relative path to the file.
260 |    * @param content - The content to append.
261 |    * @param context - Request context.
262 |    * @returns {Promise<void>} Resolves on success (204 No Content).
263 |    */
264 |   async appendFileContent(
265 |     filePath: string,
266 |     content: string,
267 |     context: RequestContext,
268 |   ): Promise<void> {
269 |     return vaultMethods.appendFileContent(
270 |       this._request.bind(this),
271 |       filePath,
272 |       content,
273 |       context,
274 |     );
275 |   }
276 | 
277 |   /**
278 |    * Deletes a specific file in the vault.
279 |    * @param filePath - Vault-relative path to the file.
280 |    * @param context - Request context.
281 |    * @returns {Promise<void>} Resolves on success (204 No Content).
282 |    */
283 |   async deleteFile(filePath: string, context: RequestContext): Promise<void> {
284 |     return vaultMethods.deleteFile(this._request.bind(this), filePath, context);
285 |   }
286 | 
287 |   /**
288 |    * Lists files within a specified directory in the vault.
289 |    * @param dirPath - Vault-relative path to the directory. Use empty string "" or "/" for the root.
290 |    * @param context - Request context.
291 |    * @returns A list of file and directory names.
292 |    */
293 |   async listFiles(dirPath: string, context: RequestContext): Promise<string[]> {
294 |     return vaultMethods.listFiles(this._request.bind(this), dirPath, context);
295 |   }
296 | 
297 |   /**
298 |    * Gets the metadata (stat) of a specific file using a lightweight HEAD request.
299 |    * @param filePath - Vault-relative path to the file.
300 |    * @param context - Request context.
301 |    * @returns The file's metadata.
302 |    */
303 |   async getFileMetadata(
304 |     filePath: string,
305 |     context: RequestContext,
306 |   ): Promise<NoteStat | null> {
307 |     return vaultMethods.getFileMetadata(
308 |       this._request.bind(this),
309 |       filePath,
310 |       context,
311 |     );
312 |   }
313 | 
314 |   // --- Search Methods ---
315 | 
316 |   /**
317 |    * Performs a simple text search across the vault.
318 |    * @param query - The text query string.
319 |    * @param contextLength - Number of characters surrounding each match (default 100).
320 |    * @param context - Request context.
321 |    * @returns An array of search results.
322 |    */
323 |   async searchSimple(
324 |     query: string,
325 |     contextLength: number = 100,
326 |     context: RequestContext,
327 |   ): Promise<SimpleSearchResult[]> {
328 |     return searchMethods.searchSimple(
329 |       this._request.bind(this),
330 |       query,
331 |       contextLength,
332 |       context,
333 |     );
334 |   }
335 | 
336 |   /**
337 |    * Performs a complex search using Dataview DQL or JsonLogic.
338 |    * @param query - The query string (DQL) or JSON object (JsonLogic).
339 |    * @param contentType - The content type header indicating the query format.
340 |    * @param context - Request context.
341 |    * @returns An array of search results.
342 |    */
343 |   async searchComplex(
344 |     query: string | object,
345 |     contentType:
346 |       | "application/vnd.olrapi.dataview.dql+txt"
347 |       | "application/vnd.olrapi.jsonlogic+json",
348 |     context: RequestContext,
349 |   ): Promise<ComplexSearchResult[]> {
350 |     return searchMethods.searchComplex(
351 |       this._request.bind(this),
352 |       query,
353 |       contentType,
354 |       context,
355 |     );
356 |   }
357 | 
358 |   // --- Command Methods ---
359 | 
360 |   /**
361 |    * Executes a registered Obsidian command by its ID.
362 |    * @param commandId - The ID of the command (e.g., "app:go-back").
363 |    * @param context - Request context.
364 |    * @returns {Promise<void>} Resolves on success (204 No Content).
365 |    */
366 |   async executeCommand(
367 |     commandId: string,
368 |     context: RequestContext,
369 |   ): Promise<void> {
370 |     return commandMethods.executeCommand(
371 |       this._request.bind(this),
372 |       commandId,
373 |       context,
374 |     );
375 |   }
376 | 
377 |   /**
378 |    * Lists all available Obsidian commands.
379 |    * @param context - Request context.
380 |    * @returns A list of available commands.
381 |    */
382 |   async listCommands(context: RequestContext): Promise<ObsidianCommand[]> {
383 |     return commandMethods.listCommands(this._request.bind(this), context);
384 |   }
385 | 
386 |   // --- Open Methods ---
387 | 
388 |   /**
389 |    * Opens a specific file in Obsidian. Creates the file if it doesn't exist.
390 |    * @param filePath - Vault-relative path to the file.
391 |    * @param newLeaf - Whether to open the file in a new editor tab (leaf).
392 |    * @param context - Request context.
393 |    * @returns {Promise<void>} Resolves on success (200 OK, but no body expected).
394 |    */
395 |   async openFile(
396 |     filePath: string,
397 |     newLeaf: boolean = false,
398 |     context: RequestContext,
399 |   ): Promise<void> {
400 |     return openMethods.openFile(
401 |       this._request.bind(this),
402 |       filePath,
403 |       newLeaf,
404 |       context,
405 |     );
406 |   }
407 | 
408 |   // --- Active File Methods ---
409 | 
410 |   /**
411 |    * Gets the content of the currently active file in Obsidian.
412 |    * @param format - 'markdown' or 'json' (for NoteJson).
413 |    * @param context - Request context.
414 |    * @returns The file content (string) or NoteJson object.
415 |    */
416 |   async getActiveFile(
417 |     format: "markdown" | "json" = "markdown",
418 |     context: RequestContext,
419 |   ): Promise<string | NoteJson> {
420 |     return activeFileMethods.getActiveFile(
421 |       this._request.bind(this),
422 |       format,
423 |       context,
424 |     );
425 |   }
426 | 
427 |   /**
428 |    * Updates (overwrites) the content of the currently active file.
429 |    * @param content - The new content.
430 |    * @param context - Request context.
431 |    * @returns {Promise<void>} Resolves on success (204 No Content).
432 |    */
433 |   async updateActiveFile(
434 |     content: string,
435 |     context: RequestContext,
436 |   ): Promise<void> {
437 |     return activeFileMethods.updateActiveFile(
438 |       this._request.bind(this),
439 |       content,
440 |       context,
441 |     );
442 |   }
443 | 
444 |   /**
445 |    * Appends content to the end of the currently active file.
446 |    * @param content - The content to append.
447 |    * @param context - Request context.
448 |    * @returns {Promise<void>} Resolves on success (204 No Content).
449 |    */
450 |   async appendActiveFile(
451 |     content: string,
452 |     context: RequestContext,
453 |   ): Promise<void> {
454 |     return activeFileMethods.appendActiveFile(
455 |       this._request.bind(this),
456 |       content,
457 |       context,
458 |     );
459 |   }
460 | 
461 |   /**
462 |    * Deletes the currently active file.
463 |    * @param context - Request context.
464 |    * @returns {Promise<void>} Resolves on success (204 No Content).
465 |    */
466 |   async deleteActiveFile(context: RequestContext): Promise<void> {
467 |     return activeFileMethods.deleteActiveFile(
468 |       this._request.bind(this),
469 |       context,
470 |     );
471 |   }
472 | 
473 |   // --- Periodic Notes Methods ---
474 |   // PATCH methods for periodic notes are complex and omitted for brevity
475 | 
476 |   /**
477 |    * Gets the content of a periodic note (daily, weekly, etc.).
478 |    * @param period - The period type ('daily', 'weekly', 'monthly', 'quarterly', 'yearly').
479 |    * @param format - 'markdown' or 'json'.
480 |    * @param context - Request context.
481 |    * @returns The note content or NoteJson.
482 |    */
483 |   async getPeriodicNote(
484 |     period: Period,
485 |     format: "markdown" | "json" = "markdown",
486 |     context: RequestContext,
487 |   ): Promise<string | NoteJson> {
488 |     return periodicNoteMethods.getPeriodicNote(
489 |       this._request.bind(this),
490 |       period,
491 |       format,
492 |       context,
493 |     );
494 |   }
495 | 
496 |   /**
497 |    * Updates (overwrites) the content of a periodic note. Creates if needed.
498 |    * @param period - The period type.
499 |    * @param content - The new content.
500 |    * @param context - Request context.
501 |    * @returns {Promise<void>} Resolves on success (204 No Content).
502 |    */
503 |   async updatePeriodicNote(
504 |     period: Period,
505 |     content: string,
506 |     context: RequestContext,
507 |   ): Promise<void> {
508 |     return periodicNoteMethods.updatePeriodicNote(
509 |       this._request.bind(this),
510 |       period,
511 |       content,
512 |       context,
513 |     );
514 |   }
515 | 
516 |   /**
517 |    * Appends content to a periodic note. Creates if needed.
518 |    * @param period - The period type.
519 |    * @param content - The content to append.
520 |    * @param context - Request context.
521 |    * @returns {Promise<void>} Resolves on success (204 No Content).
522 |    */
523 |   async appendPeriodicNote(
524 |     period: Period,
525 |     content: string,
526 |     context: RequestContext,
527 |   ): Promise<void> {
528 |     return periodicNoteMethods.appendPeriodicNote(
529 |       this._request.bind(this),
530 |       period,
531 |       content,
532 |       context,
533 |     );
534 |   }
535 | 
536 |   /**
537 |    * Deletes a periodic note.
538 |    * @param period - The period type.
539 |    * @param context - Request context.
540 |    * @returns {Promise<void>} Resolves on success (204 No Content).
541 |    */
542 |   async deletePeriodicNote(
543 |     period: Period,
544 |     context: RequestContext,
545 |   ): Promise<void> {
546 |     return periodicNoteMethods.deletePeriodicNote(
547 |       this._request.bind(this),
548 |       period,
549 |       context,
550 |     );
551 |   }
552 | 
553 |   // --- Patch Methods ---
554 | 
555 |   /**
556 |    * Patches a specific file in the vault using granular controls.
557 |    * @param filePath - Vault-relative path to the file.
558 |    * @param content - The content to insert/replace (string or JSON for tables/frontmatter).
559 |    * @param options - Patch operation details (operation, targetType, target, etc.).
560 |    * @param context - Request context.
561 |    * @returns {Promise<void>} Resolves on success (200 OK).
562 |    */
563 |   async patchFile(
564 |     filePath: string,
565 |     content: string | object,
566 |     options: PatchOptions,
567 |     context: RequestContext,
568 |   ): Promise<void> {
569 |     return patchMethods.patchFile(
570 |       this._request.bind(this),
571 |       filePath,
572 |       content,
573 |       options,
574 |       context,
575 |     );
576 |   }
577 | 
578 |   /**
579 |    * Patches the currently active file in Obsidian using granular controls.
580 |    * @param content - The content to insert/replace.
581 |    * @param options - Patch operation details.
582 |    * @param context - Request context.
583 |    * @returns {Promise<void>} Resolves on success (200 OK).
584 |    */
585 |   async patchActiveFile(
586 |     content: string | object,
587 |     options: PatchOptions,
588 |     context: RequestContext,
589 |   ): Promise<void> {
590 |     return patchMethods.patchActiveFile(
591 |       this._request.bind(this),
592 |       content,
593 |       options,
594 |       context,
595 |     );
596 |   }
597 | 
598 |   /**
599 |    * Patches a periodic note using granular controls.
600 |    * @param period - The period type ('daily', 'weekly', etc.).
601 |    * @param content - The content to insert/replace.
602 |    * @param options - Patch operation details.
603 |    * @param context - Request context.
604 |    * @returns {Promise<void>} Resolves on success (200 OK).
605 |    */
606 |   async patchPeriodicNote(
607 |     period: Period,
608 |     content: string | object,
609 |     options: PatchOptions,
610 |     context: RequestContext,
611 |   ): Promise<void> {
612 |     return patchMethods.patchPeriodicNote(
613 |       this._request.bind(this),
614 |       period,
615 |       content,
616 |       options,
617 |       context,
618 |     );
619 |   }
620 | }
621 | 
```

--------------------------------------------------------------------------------
/src/utils/security/sanitization.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Provides a comprehensive sanitization utility class for various input types,
  3 |  * including HTML, strings, URLs, file paths, JSON, and numbers. It also includes
  4 |  * functionality for redacting sensitive information from objects for safe logging.
  5 |  * @module src/utils/security/sanitization
  6 |  */
  7 | 
  8 | import path from "path";
  9 | import sanitizeHtml from "sanitize-html";
 10 | import validator from "validator";
 11 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 12 | import {
 13 |   logger,
 14 |   RequestContext,
 15 |   requestContextService,
 16 | } from "../internal/index.js"; // Use internal index
 17 | 
 18 | /**
 19 |  * Options for path sanitization, controlling how file paths are cleaned and validated.
 20 |  */
 21 | export interface PathSanitizeOptions {
 22 |   /**
 23 |    * If provided, restricts sanitized paths to be relative to this root directory.
 24 |    * Attempts to traverse above this root (e.g., using `../`) will result in an error.
 25 |    * The final sanitized path will be relative to this `rootDir`.
 26 |    */
 27 |   rootDir?: string;
 28 |   /**
 29 |    * If `true`, normalizes Windows-style backslashes (`\\`) to POSIX-style forward slashes (`/`).
 30 |    * Defaults to `false`.
 31 |    */
 32 |   toPosix?: boolean;
 33 |   /**
 34 |    * If `true`, allows absolute paths, subject to `rootDir` constraints if `rootDir` is also provided.
 35 |    * If `false` (default), absolute paths are converted to relative paths by removing leading slashes or drive letters.
 36 |    */
 37 |   allowAbsolute?: boolean;
 38 | }
 39 | 
 40 | /**
 41 |  * Information returned by the `sanitizePath` method, providing details about
 42 |  * the sanitization process and its outcome.
 43 |  */
 44 | export interface SanitizedPathInfo {
 45 |   /** The final sanitized and normalized path string. */
 46 |   sanitizedPath: string;
 47 |   /** The original path string passed to the function before any normalization or sanitization. */
 48 |   originalInput: string;
 49 |   /** Indicates if the input path was determined to be absolute after initial `path.normalize()`. */
 50 |   wasAbsolute: boolean;
 51 |   /**
 52 |    * Indicates if an initially absolute path was converted to a relative path
 53 |    * (typically because `options.allowAbsolute` was `false`).
 54 |    */
 55 |   convertedToRelative: boolean;
 56 |   /** The effective options (including defaults) that were used for sanitization. */
 57 |   optionsUsed: PathSanitizeOptions;
 58 | }
 59 | 
 60 | /**
 61 |  * Options for context-specific string sanitization using `sanitizeString`.
 62 |  */
 63 | export interface SanitizeStringOptions {
 64 |   /**
 65 |    * Specifies the context in which the string will be used, guiding the sanitization strategy.
 66 |    * - `'text'`: (Default) Strips all HTML tags, suitable for plain text content.
 67 |    * - `'html'`: Sanitizes for safe HTML embedding, using `allowedTags` and `allowedAttributes`.
 68 |    * - `'attribute'`: Sanitizes for use within an HTML attribute value (strips all tags).
 69 |    * - `'url'`: Validates and trims the string as a URL.
 70 |    * - `'javascript'`: **Disallowed.** Throws an error to prevent unsafe JavaScript sanitization.
 71 |    */
 72 |   context?: "text" | "html" | "attribute" | "url" | "javascript";
 73 |   /** Custom allowed HTML tags when `context` is `'html'`. Overrides default HTML sanitization tags. */
 74 |   allowedTags?: string[];
 75 |   /** Custom allowed HTML attributes per tag when `context` is `'html'`. Overrides default HTML sanitization attributes. */
 76 |   allowedAttributes?: Record<string, string[]>;
 77 | }
 78 | 
 79 | /**
 80 |  * Configuration options for HTML sanitization using `sanitizeHtml`.
 81 |  */
 82 | export interface HtmlSanitizeConfig {
 83 |   /** An array of allowed HTML tag names (e.g., `['p', 'a', 'strong']`). */
 84 |   allowedTags?: string[];
 85 |   /**
 86 |    * A map specifying allowed attributes for HTML tags.
 87 |    * Keys can be tag names (e.g., `'a'`) or `'*'` for global attributes.
 88 |    * Values are arrays of allowed attribute names (e.g., `{'a': ['href', 'title']}`).
 89 |    */
 90 |   allowedAttributes?: sanitizeHtml.IOptions["allowedAttributes"];
 91 |   /** If `true`, HTML comments (`<!-- ... -->`) are preserved. Defaults to `false`. */
 92 |   preserveComments?: boolean;
 93 |   /**
 94 |    * Custom rules for transforming tags during sanitization.
 95 |    * See `sanitize-html` documentation for `transformTags` options.
 96 |    */
 97 |   transformTags?: sanitizeHtml.IOptions["transformTags"];
 98 | }
 99 | 
100 | /**
101 |  * A singleton utility class for performing various input sanitization tasks.
102 |  * It provides methods to clean and validate strings, HTML, URLs, file paths, JSON,
103 |  * and numbers, and to redact sensitive data for logging.
104 |  */
105 | export class Sanitization {
106 |   private static instance: Sanitization;
107 | 
108 |   private sensitiveFields: string[] = [
109 |     "password",
110 |     "token",
111 |     "secret",
112 |     "key",
113 |     "apiKey",
114 |     "auth",
115 |     "credential",
116 |     "jwt",
117 |     "ssn",
118 |     "credit",
119 |     "card",
120 |     "cvv",
121 |     "authorization",
122 |     "passphrase",
123 |     "privatekey", // Added more common sensitive field names
124 |     "obsidianapikey", // Specific to this project potentially
125 |   ];
126 | 
127 |   private defaultHtmlSanitizeConfig: HtmlSanitizeConfig = {
128 |     allowedTags: [
129 |       "h1",
130 |       "h2",
131 |       "h3",
132 |       "h4",
133 |       "h5",
134 |       "h6",
135 |       "p",
136 |       "a",
137 |       "ul",
138 |       "ol",
139 |       "li",
140 |       "b",
141 |       "i",
142 |       "strong",
143 |       "em",
144 |       "strike",
145 |       "code",
146 |       "hr",
147 |       "br",
148 |       "div",
149 |       "table",
150 |       "thead",
151 |       "tbody",
152 |       "tr",
153 |       "th",
154 |       "td",
155 |       "pre",
156 |       "blockquote", // Added blockquote
157 |     ],
158 |     allowedAttributes: {
159 |       a: ["href", "name", "target", "title"], // Added title for links
160 |       img: ["src", "alt", "title", "width", "height"],
161 |       "*": ["class", "id", "style", "data-*"], // Allow data-* attributes
162 |     },
163 |     preserveComments: false,
164 |   };
165 | 
166 |   private constructor() {
167 |     // Singleton constructor
168 |   }
169 | 
170 |   /**
171 |    * Gets the singleton instance of the `Sanitization` class.
172 |    * @returns {Sanitization} The singleton instance.
173 |    */
174 |   public static getInstance(): Sanitization {
175 |     if (!Sanitization.instance) {
176 |       Sanitization.instance = new Sanitization();
177 |     }
178 |     return Sanitization.instance;
179 |   }
180 | 
181 |   /**
182 |    * Sets or extends the list of field names considered sensitive for log redaction.
183 |    * Field names are matched case-insensitively.
184 |    * @param {string[]} fields - An array of field names to add to the sensitive list.
185 |    * @param {RequestContext} [context] - Optional context for logging this configuration change.
186 |    */
187 |   public setSensitiveFields(fields: string[], context?: RequestContext): void {
188 |     const opContext =
189 |       context ||
190 |       requestContextService.createRequestContext({
191 |         operation: "Sanitization.setSensitiveFields",
192 |       });
193 |     this.sensitiveFields = [
194 |       ...new Set([
195 |         ...this.sensitiveFields,
196 |         ...fields.map((f) => f.toLowerCase()),
197 |       ]),
198 |     ];
199 |     logger.debug("Updated sensitive fields list for log redaction.", {
200 |       ...opContext,
201 |       newCount: this.sensitiveFields.length,
202 |     });
203 |   }
204 | 
205 |   /**
206 |    * Retrieves a copy of the current list of sensitive field names used for log redaction.
207 |    * @returns {string[]} An array of sensitive field names (all lowercase).
208 |    */
209 |   public getSensitiveFields(): string[] {
210 |     return [...this.sensitiveFields];
211 |   }
212 | 
213 |   /**
214 |    * Sanitizes an HTML string by removing potentially malicious tags and attributes,
215 |    * based on a configurable allow-list.
216 |    * @param {string} input - The HTML string to sanitize.
217 |    * @param {HtmlSanitizeConfig} [config] - Optional custom configuration for HTML sanitization.
218 |    *   Overrides defaults for `allowedTags`, `allowedAttributes`, etc.
219 |    * @returns {string} The sanitized HTML string. Returns an empty string if input is falsy.
220 |    */
221 |   public sanitizeHtml(input: string, config?: HtmlSanitizeConfig): string {
222 |     if (!input) return "";
223 | 
224 |     const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
225 |     const options: sanitizeHtml.IOptions = {
226 |       allowedTags: effectiveConfig.allowedTags,
227 |       allowedAttributes: effectiveConfig.allowedAttributes,
228 |       transformTags: effectiveConfig.transformTags,
229 |     };
230 | 
231 |     if (effectiveConfig.preserveComments) {
232 |       // Ensure '!--' is not duplicated if already present
233 |       options.allowedTags = [
234 |         ...new Set([...(options.allowedTags || []), "!--"]),
235 |       ];
236 |     }
237 |     return sanitizeHtml(input, options);
238 |   }
239 | 
240 |   /**
241 |    * Sanitizes a tag name by removing the leading '#' and replacing invalid characters.
242 |    * @param {string} input - The tag string to sanitize.
243 |    * @returns {string} The sanitized tag name.
244 |    */
245 |   public sanitizeTagName(input: string): string {
246 |     if (!input) return "";
247 |     // Remove leading '#' and replace spaces/invalid characters with nothing
248 |     return input.replace(/^#/, "").replace(/[\s#,\\?%*:|"<>]/g, "");
249 |   }
250 | 
251 |   /**
252 | >>>>>>> REPLACE
253 |    * Sanitizes a string based on its intended usage context (e.g., HTML, URL, plain text).
254 |    *
255 |    * **Security Note:** Using `context: 'javascript'` is explicitly disallowed and will throw an `McpError`.
256 |    * This is to prevent accidental introduction of XSS vulnerabilities through ineffective sanitization
257 |    * of JavaScript code. Proper contextual encoding or safer methods should be used for JavaScript.
258 |    *
259 |    * @param {string} input - The string to sanitize.
260 |    * @param {SanitizeStringOptions} [options={}] - Options specifying the sanitization context
261 |    *   and any context-specific parameters (like `allowedTags` for HTML).
262 |    * @param {RequestContext} [contextForLogging] - Optional context for logging warnings or errors.
263 |    * @returns {string} The sanitized string. Returns an empty string if input is falsy.
264 |    * @throws {McpError} If `options.context` is `'javascript'`.
265 |    */
266 |   public sanitizeString(
267 |     input: string,
268 |     options: SanitizeStringOptions = {},
269 |     contextForLogging?: RequestContext,
270 |   ): string {
271 |     const opContext =
272 |       contextForLogging ||
273 |       requestContextService.createRequestContext({
274 |         operation: "sanitizeString",
275 |         inputContext: options.context,
276 |       });
277 |     if (!input) return "";
278 | 
279 |     switch (options.context) {
280 |       case "html":
281 |         return this.sanitizeHtml(input, {
282 |           allowedTags: options.allowedTags,
283 |           allowedAttributes: options.allowedAttributes
284 |             ? this.convertAttributesFormat(options.allowedAttributes)
285 |             : undefined,
286 |         });
287 |       case "attribute":
288 |         // For HTML attributes, strip all tags. Values should be further encoded by the templating engine.
289 |         return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
290 |       case "url":
291 |         // Validate and trim. Throws McpError on failure.
292 |         try {
293 |           return this.sanitizeUrl(input, ["http", "https"], opContext); // Use sanitizeUrl for consistent validation
294 |         } catch (urlError) {
295 |           logger.warning(
296 |             "Invalid URL detected during string sanitization (context: url).",
297 |             {
298 |               ...opContext,
299 |               input,
300 |               error:
301 |                 urlError instanceof Error ? urlError.message : String(urlError),
302 |             },
303 |           );
304 |           return ""; // Return empty or rethrow, depending on desired strictness. Empty for now.
305 |         }
306 |       case "javascript":
307 |         logger.error(
308 |           "Attempted JavaScript sanitization via sanitizeString, which is disallowed.",
309 |           { ...opContext, inputPreview: input.substring(0, 100) },
310 |         );
311 |         throw new McpError(
312 |           BaseErrorCode.VALIDATION_ERROR,
313 |           "JavaScript sanitization is not supported via sanitizeString due to security risks. Use appropriate contextual encoding or safer alternatives.",
314 |           opContext,
315 |         );
316 |       case "text":
317 |       default:
318 |         // Default to stripping all HTML for plain text contexts.
319 |         return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
320 |     }
321 |   }
322 | 
323 |   /**
324 |    * Sanitizes a URL string by validating its format and protocol.
325 |    * @param {string} input - The URL string to sanitize.
326 |    * @param {string[]} [allowedProtocols=['http', 'https']] - An array of allowed URL protocols (e.g., 'http', 'https', 'ftp').
327 |    * @param {RequestContext} [contextForLogging] - Optional context for logging errors.
328 |    * @returns {string} The sanitized and trimmed URL string.
329 |    * @throws {McpError} If the URL is invalid, uses a disallowed protocol, or contains 'javascript:'.
330 |    */
331 |   public sanitizeUrl(
332 |     input: string,
333 |     allowedProtocols: string[] = ["http", "https"],
334 |     contextForLogging?: RequestContext,
335 |   ): string {
336 |     const opContext =
337 |       contextForLogging ||
338 |       requestContextService.createRequestContext({ operation: "sanitizeUrl" });
339 |     try {
340 |       if (!input || typeof input !== "string") {
341 |         throw new Error("Invalid URL input: must be a non-empty string.");
342 |       }
343 |       const trimmedInput = input.trim();
344 |       // Stricter check for 'javascript:' regardless of validator's protocol check
345 |       if (trimmedInput.toLowerCase().startsWith("javascript:")) {
346 |         throw new Error("JavaScript pseudo-protocol is explicitly disallowed.");
347 |       }
348 |       if (
349 |         !validator.isURL(trimmedInput, {
350 |           protocols: allowedProtocols,
351 |           require_protocol: true,
352 |         })
353 |       ) {
354 |         throw new Error(
355 |           `Invalid URL format or protocol not in allowed list: [${allowedProtocols.join(", ")}].`,
356 |         );
357 |       }
358 |       return trimmedInput;
359 |     } catch (error) {
360 |       const message =
361 |         error instanceof Error ? error.message : "Invalid or disallowed URL.";
362 |       logger.warning(`URL sanitization failed: ${message}`, {
363 |         ...opContext,
364 |         input,
365 |       });
366 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, {
367 |         ...opContext,
368 |         input,
369 |       });
370 |     }
371 |   }
372 | 
373 |   /**
374 |    * Sanitizes a file path to prevent path traversal attacks and normalize its format.
375 |    *
376 |    * @param {string} input - The file path string to sanitize.
377 |    * @param {PathSanitizeOptions} [options={}] - Options to control sanitization behavior (e.g., `rootDir`, `toPosix`).
378 |    * @param {RequestContext} [contextForLogging] - Optional context for logging warnings or errors.
379 |    * @returns {SanitizedPathInfo} An object containing the sanitized path and metadata about the sanitization.
380 |    * @throws {McpError} If the path is invalid (e.g., empty, contains null bytes) or determined to be unsafe
381 |    *   (e.g., attempts to traverse outside `rootDir` or current working directory if no `rootDir`).
382 |    */
383 |   public sanitizePath(
384 |     input: string,
385 |     options: PathSanitizeOptions = {},
386 |     contextForLogging?: RequestContext,
387 |   ): SanitizedPathInfo {
388 |     const opContext =
389 |       contextForLogging ||
390 |       requestContextService.createRequestContext({ operation: "sanitizePath" });
391 |     const originalInput = input;
392 |     const effectiveOptions: PathSanitizeOptions = {
393 |       toPosix: options.toPosix ?? false,
394 |       allowAbsolute: options.allowAbsolute ?? false,
395 |       rootDir: options.rootDir ? path.resolve(options.rootDir) : undefined, // Resolve rootDir upfront
396 |     };
397 | 
398 |     let wasAbsoluteInitially = false;
399 |     let convertedToRelative = false;
400 | 
401 |     try {
402 |       if (!input || typeof input !== "string") {
403 |         throw new Error("Invalid path input: must be a non-empty string.");
404 |       }
405 |       if (input.includes("\0")) {
406 |         throw new Error("Path contains null byte, which is disallowed.");
407 |       }
408 | 
409 |       let normalized = path.normalize(input); // Normalize first (e.g., 'a/b/../c' -> 'a/c')
410 |       wasAbsoluteInitially = path.isAbsolute(normalized);
411 | 
412 |       if (effectiveOptions.toPosix) {
413 |         normalized = normalized.replace(/\\/g, "/");
414 |       }
415 | 
416 |       let finalSanitizedPath: string;
417 | 
418 |       if (effectiveOptions.rootDir) {
419 |         // Resolve the input path against the root directory.
420 |         // If 'normalized' is absolute, path.resolve treats it as the new root.
421 |         // To correctly join, ensure 'normalized' is treated as relative to 'rootDir' if it's not already escaping.
422 |         let tempPathForResolve = normalized;
423 |         if (path.isAbsolute(normalized) && !effectiveOptions.allowAbsolute) {
424 |           // If absolute paths are not allowed, make it relative before resolving with rootDir
425 |           tempPathForResolve = normalized.replace(/^(?:[A-Za-z]:)?[/\\]+/, "");
426 |           convertedToRelative = true;
427 |         } else if (
428 |           path.isAbsolute(normalized) &&
429 |           effectiveOptions.allowAbsolute
430 |         ) {
431 |           // Absolute path is allowed, check if it's within rootDir
432 |           if (
433 |             !normalized.startsWith(effectiveOptions.rootDir + path.sep) &&
434 |             normalized !== effectiveOptions.rootDir
435 |           ) {
436 |             throw new Error(
437 |               "Absolute path is outside the specified root directory.",
438 |             );
439 |           }
440 |           finalSanitizedPath = path.relative(
441 |             effectiveOptions.rootDir,
442 |             normalized,
443 |           );
444 |           finalSanitizedPath =
445 |             finalSanitizedPath === "" ? "." : finalSanitizedPath; // Handle case where path is rootDir itself
446 |           // Early return if absolute path is allowed and within root.
447 |           return {
448 |             sanitizedPath: finalSanitizedPath,
449 |             originalInput,
450 |             wasAbsolute: wasAbsoluteInitially,
451 |             convertedToRelative,
452 |             optionsUsed: effectiveOptions,
453 |           };
454 |         }
455 |         // If path was relative or made relative, join with rootDir
456 |         const fullPath = path.resolve(
457 |           effectiveOptions.rootDir,
458 |           tempPathForResolve,
459 |         );
460 | 
461 |         if (
462 |           !fullPath.startsWith(effectiveOptions.rootDir + path.sep) &&
463 |           fullPath !== effectiveOptions.rootDir
464 |         ) {
465 |           throw new Error(
466 |             "Path traversal detected: sanitized path escapes root directory.",
467 |           );
468 |         }
469 |         finalSanitizedPath = path.relative(effectiveOptions.rootDir, fullPath);
470 |         finalSanitizedPath =
471 |           finalSanitizedPath === "" ? "." : finalSanitizedPath;
472 |       } else {
473 |         // No rootDir specified
474 |         if (path.isAbsolute(normalized)) {
475 |           if (effectiveOptions.allowAbsolute) {
476 |             finalSanitizedPath = normalized; // Absolute path allowed
477 |           } else {
478 |             // Convert to relative (strip leading slash/drive)
479 |             finalSanitizedPath = normalized.replace(
480 |               /^(?:[A-Za-z]:)?[/\\]+/,
481 |               "",
482 |             );
483 |             convertedToRelative = true;
484 |           }
485 |         } else {
486 |           // Path is relative, and no rootDir
487 |           // For relative paths without a rootDir, ensure they don't traverse "above" the conceptual CWD.
488 |           // path.resolve('.') gives current working directory.
489 |           const resolvedAgainstCwd = path.resolve(normalized);
490 |           if (!resolvedAgainstCwd.startsWith(path.resolve("."))) {
491 |             // This check is a bit tricky because '..' is valid if it stays within CWD's subtree.
492 |             // A more robust check might involve comparing segments or ensuring it doesn't go "too high".
493 |             // For simplicity, if it resolves outside CWD's prefix, consider it traversal.
494 |             // This might be too strict for some use cases but safer for general utility.
495 |             // A common pattern is to check if path.relative(cwd, resolvedPath) starts with '..'.
496 |             if (
497 |               path
498 |                 .relative(path.resolve("."), resolvedAgainstCwd)
499 |                 .startsWith("..")
500 |             ) {
501 |               throw new Error(
502 |                 "Relative path traversal detected (escapes current working directory context).",
503 |               );
504 |             }
505 |           }
506 |           finalSanitizedPath = normalized;
507 |         }
508 |       }
509 |       return {
510 |         sanitizedPath: finalSanitizedPath,
511 |         originalInput,
512 |         wasAbsolute: wasAbsoluteInitially,
513 |         convertedToRelative,
514 |         optionsUsed: effectiveOptions,
515 |       };
516 |     } catch (error) {
517 |       const message =
518 |         error instanceof Error ? error.message : "Invalid or unsafe path.";
519 |       logger.warning(`Path sanitization error: ${message}`, {
520 |         ...opContext,
521 |         input: originalInput,
522 |         options: effectiveOptions,
523 |         errorDetails: String(error),
524 |       });
525 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, {
526 |         ...opContext,
527 |         input: originalInput,
528 |       });
529 |     }
530 |   }
531 | 
532 |   /**
533 |    * Sanitizes a JSON string by parsing it to validate its format.
534 |    * Optionally checks if the JSON string's byte size exceeds a maximum limit.
535 |    *
536 |    * @template T The expected type of the parsed JSON object. Defaults to `unknown`.
537 |    * @param {string} input - The JSON string to sanitize/validate.
538 |    * @param {number} [maxSizeBytes] - Optional maximum allowed size of the JSON string in bytes.
539 |    * @param {RequestContext} [contextForLogging] - Optional context for logging errors.
540 |    * @returns {T} The parsed JavaScript object.
541 |    * @throws {McpError} If the input is not a string, is not valid JSON, or exceeds `maxSizeBytes`.
542 |    */
543 |   public sanitizeJson<T = unknown>(
544 |     input: string,
545 |     maxSizeBytes?: number,
546 |     contextForLogging?: RequestContext,
547 |   ): T {
548 |     const opContext =
549 |       contextForLogging ||
550 |       requestContextService.createRequestContext({ operation: "sanitizeJson" });
551 |     try {
552 |       if (typeof input !== "string") {
553 |         throw new Error("Invalid input: expected a JSON string.");
554 |       }
555 |       if (
556 |         maxSizeBytes !== undefined &&
557 |         Buffer.byteLength(input, "utf8") > maxSizeBytes
558 |       ) {
559 |         throw new McpError( // Throw McpError directly
560 |           BaseErrorCode.VALIDATION_ERROR,
561 |           `JSON content exceeds maximum allowed size of ${maxSizeBytes} bytes. Actual size: ${Buffer.byteLength(input, "utf8")} bytes.`,
562 |           {
563 |             ...opContext,
564 |             size: Buffer.byteLength(input, "utf8"),
565 |             maxSize: maxSizeBytes,
566 |           },
567 |         );
568 |       }
569 |       const parsed = JSON.parse(input);
570 |       // Note: This function only validates JSON structure. It does not sanitize content within the JSON.
571 |       // For deep sanitization of object values, additional logic would be needed.
572 |       return parsed as T;
573 |     } catch (error) {
574 |       if (error instanceof McpError) throw error; // Re-throw if already McpError (e.g., size limit)
575 |       const message =
576 |         error instanceof Error ? error.message : "Invalid JSON format.";
577 |       logger.warning(`JSON sanitization failed: ${message}`, {
578 |         ...opContext,
579 |         inputPreview: input.substring(0, 100),
580 |         errorDetails: String(error),
581 |       });
582 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, message, {
583 |         ...opContext,
584 |         inputPreview:
585 |           input.length > 100 ? `${input.substring(0, 100)}...` : input,
586 |       });
587 |     }
588 |   }
589 | 
590 |   /**
591 |    * Sanitizes a numeric input (number or string) by converting it to a number
592 |    * and optionally clamping it within a specified min/max range.
593 |    *
594 |    * @param {number | string} input - The numeric value or string representation of a number.
595 |    * @param {number} [min] - Optional minimum allowed value (inclusive).
596 |    * @param {number} [max] - Optional maximum allowed value (inclusive).
597 |    * @param {RequestContext} [contextForLogging] - Optional context for logging clamping or errors.
598 |    * @returns {number} The sanitized (and potentially clamped) number.
599 |    * @throws {McpError} If the input cannot be parsed into a valid, finite number.
600 |    */
601 |   public sanitizeNumber(
602 |     input: number | string,
603 |     min?: number,
604 |     max?: number,
605 |     contextForLogging?: RequestContext,
606 |   ): number {
607 |     const opContext =
608 |       contextForLogging ||
609 |       requestContextService.createRequestContext({
610 |         operation: "sanitizeNumber",
611 |       });
612 |     let value: number;
613 | 
614 |     if (typeof input === "string") {
615 |       const trimmedInput = input.trim();
616 |       // Validator's isNumeric allows empty strings, so check explicitly.
617 |       if (trimmedInput === "" || !validator.isNumeric(trimmedInput)) {
618 |         throw new McpError(
619 |           BaseErrorCode.VALIDATION_ERROR,
620 |           "Invalid number format: string is not numeric or is empty.",
621 |           { ...opContext, input },
622 |         );
623 |       }
624 |       value = parseFloat(trimmedInput);
625 |     } else if (typeof input === "number") {
626 |       value = input;
627 |     } else {
628 |       throw new McpError(
629 |         BaseErrorCode.VALIDATION_ERROR,
630 |         "Invalid input type: expected number or string.",
631 |         { ...opContext, input: String(input) },
632 |       );
633 |     }
634 | 
635 |     if (isNaN(value) || !isFinite(value)) {
636 |       throw new McpError(
637 |         BaseErrorCode.VALIDATION_ERROR,
638 |         "Invalid number value (NaN or Infinity).",
639 |         { ...opContext, input },
640 |       );
641 |     }
642 | 
643 |     let clamped = false;
644 |     let originalValueForLog = value; // Store original before clamping for logging
645 |     if (min !== undefined && value < min) {
646 |       value = min;
647 |       clamped = true;
648 |     }
649 |     if (max !== undefined && value > max) {
650 |       value = max;
651 |       clamped = true;
652 |     }
653 |     if (clamped) {
654 |       logger.debug("Number clamped to range.", {
655 |         ...opContext,
656 |         originalValue: originalValueForLog,
657 |         min,
658 |         max,
659 |         finalValue: value,
660 |       });
661 |     }
662 |     return value;
663 |   }
664 | 
665 |   /**
666 |    * Sanitizes an object or array for logging by deep cloning it and redacting fields
667 |    * whose names (case-insensitively) match any of the configured sensitive field names.
668 |    * Redacted fields are replaced with the string `'[REDACTED]'`.
669 |    *
670 |    * @param {unknown} input - The object, array, or other value to sanitize for logging.
671 |    *   If input is not an object or array, it's returned as is.
672 |    * @param {RequestContext} [contextForLogging] - Optional context for logging errors during sanitization.
673 |    * @returns {unknown} A sanitized copy of the input, safe for logging.
674 |    *   Returns `'[Log Sanitization Failed]'` if an unexpected error occurs during sanitization.
675 |    */
676 |   public sanitizeForLogging(
677 |     input: unknown,
678 |     contextForLogging?: RequestContext,
679 |   ): unknown {
680 |     const opContext =
681 |       contextForLogging ||
682 |       requestContextService.createRequestContext({
683 |         operation: "sanitizeForLogging",
684 |       });
685 |     try {
686 |       // Primitives and null are returned as is.
687 |       if (input === null || typeof input !== "object") {
688 |         return input;
689 |       }
690 | 
691 |       // Use structuredClone if available (Node.js >= 17), otherwise fallback to JSON parse/stringify.
692 |       // JSON.parse(JSON.stringify(obj)) is a common way to deep clone, but has limitations
693 |       // (e.g., loses functions, undefined, Date objects become strings).
694 |       // For logging, this is often acceptable.
695 |       const clonedInput =
696 |         typeof structuredClone === "function"
697 |           ? structuredClone(input)
698 |           : JSON.parse(JSON.stringify(input));
699 | 
700 |       this.redactSensitiveFields(clonedInput);
701 |       return clonedInput;
702 |     } catch (error) {
703 |       logger.error(
704 |         "Error during log sanitization process.",
705 |         error instanceof Error ? error : undefined,
706 |         {
707 |           ...opContext,
708 |           errorDetails: error instanceof Error ? error.message : String(error),
709 |         },
710 |       );
711 |       return "[Log Sanitization Failed]"; // Fallback string indicating sanitization failure
712 |     }
713 |   }
714 | 
715 |   /**
716 |    * Helper to convert attribute format for sanitize-html.
717 |    * `sanitize-html` expects `allowedAttributes` in a specific format.
718 |    * This method assumes the input `attrs` (from `SanitizeStringOptions`)
719 |    * is already in the correct format or a compatible one.
720 |    * @param {Record<string, string[]>} attrs - Attributes configuration.
721 |    * @returns {sanitizeHtml.IOptions['allowedAttributes']} Attributes in `sanitize-html` format.
722 |    * @private
723 |    */
724 |   private convertAttributesFormat(
725 |     attrs: Record<string, string[]>,
726 |   ): sanitizeHtml.IOptions["allowedAttributes"] {
727 |     // The type Record<string, string[]> is compatible with sanitizeHtml.IOptions['allowedAttributes']
728 |     // which can be Record<string, Array<string | RegExp>> or boolean.
729 |     // No complex conversion needed if options.allowedAttributes is already Record<string, string[]>.
730 |     return attrs;
731 |   }
732 | 
733 |   /**
734 |    * Recursively redacts sensitive fields within an object or array.
735 |    * This method modifies the input object/array in place.
736 |    * @param {unknown} obj - The object or array to redact sensitive fields from.
737 |    * @private
738 |    */
739 |   private redactSensitiveFields(obj: unknown): void {
740 |     if (!obj || typeof obj !== "object") {
741 |       return; // Not an object or array, or null
742 |     }
743 | 
744 |     if (Array.isArray(obj)) {
745 |       obj.forEach((item) => {
746 |         // Recurse only if the item is an object (including nested arrays)
747 |         if (item && typeof item === "object") {
748 |           this.redactSensitiveFields(item);
749 |         }
750 |       });
751 |       return;
752 |     }
753 | 
754 |     // It's an object (but not an array)
755 |     for (const key in obj) {
756 |       // Check if the property belongs to the object itself, not its prototype
757 |       if (Object.prototype.hasOwnProperty.call(obj, key)) {
758 |         const value = (obj as Record<string, unknown>)[key];
759 |         const lowerKey = key.toLowerCase();
760 | 
761 |         // Special handling for non-serializable but non-sensitive objects
762 |         if (key === "httpsAgent") {
763 |           (obj as Record<string, unknown>)[key] = "[HttpAgent Instance]";
764 |           continue; // Skip further processing for this key
765 |         }
766 | 
767 |         // Check if the lowercase key includes any of the lowercase sensitive field terms
768 |         const isSensitive = this.sensitiveFields.some(
769 |           (field) => lowerKey.includes(field), // sensitiveFields are already stored as lowercase
770 |         );
771 | 
772 |         if (isSensitive) {
773 |           (obj as Record<string, unknown>)[key] = "[REDACTED]";
774 |         } else if (value && typeof value === "object") {
775 |           // If the value is another object or array, recurse
776 |           this.redactSensitiveFields(value);
777 |         }
778 |       }
779 |     }
780 |   }
781 | }
782 | 
783 | /**
784 |  * A default, shared instance of the `Sanitization` class.
785 |  * Use this instance for all sanitization tasks.
786 |  *
787 |  * Example:
788 |  * ```typescript
789 |  * import { sanitization, sanitizeInputForLogging } from './sanitization';
790 |  *
791 |  * const unsafeHtml = "<script>alert('xss')</script><p>Safe</p>";
792 |  * const safeHtml = sanitization.sanitizeHtml(unsafeHtml);
793 |  *
794 |  * const sensitiveData = { password: '123', username: 'user' };
795 |  * const safeLogData = sanitizeInputForLogging(sensitiveData);
796 |  * // safeLogData will be { password: '[REDACTED]', username: 'user' }
797 |  * ```
798 |  */
799 | export const sanitization = Sanitization.getInstance();
800 | 
801 | /**
802 |  * A convenience function that wraps `sanitization.sanitizeForLogging`.
803 |  * Sanitizes an object or array for logging by redacting sensitive fields.
804 |  *
805 |  * @param {unknown} input - The data to sanitize for logging.
806 |  * @param {RequestContext} [contextForLogging] - Optional context for logging errors during sanitization.
807 |  * @returns {unknown} A sanitized copy of the input, safe for logging.
808 |  */
809 | export const sanitizeInputForLogging = (
810 |   input: unknown,
811 |   contextForLogging?: RequestContext,
812 | ): unknown => sanitization.sanitizeForLogging(input, contextForLogging);
813 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/tools/obsidianUpdateNoteTool/logic.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { z } from "zod";
  2 | import {
  3 |   NoteJson,
  4 |   ObsidianRestApiService,
  5 |   VaultCacheService,
  6 | } from "../../../services/obsidianRestAPI/index.js";
  7 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  8 | import {
  9 |   createFormattedStatWithTokenCount,
 10 |   logger,
 11 |   RequestContext,
 12 |   retryWithDelay,
 13 | } from "../../../utils/index.js";
 14 | 
 15 | // ====================================================================================
 16 | // Schema Definitions for Input Validation
 17 | // ====================================================================================
 18 | 
 19 | /** Defines the possible types of targets for the update operation. */
 20 | const TargetTypeSchema = z
 21 |   .enum(["filePath", "activeFile", "periodicNote"])
 22 |   .describe(
 23 |     "Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.",
 24 |   );
 25 | 
 26 | /** Defines the only allowed modification type for this tool implementation. */
 27 | const ModificationTypeSchema = z
 28 |   .literal("wholeFile")
 29 |   .describe(
 30 |     "Determines the modification strategy: must be 'wholeFile' for this tool.",
 31 |   );
 32 | 
 33 | /** Defines the specific whole-file operations supported. */
 34 | const WholeFileModeSchema = z
 35 |   .enum(["append", "prepend", "overwrite"])
 36 |   .describe(
 37 |     "Specifies the whole-file operation: 'append', 'prepend', or 'overwrite'.",
 38 |   );
 39 | 
 40 | /** Defines the valid periods for periodic notes. */
 41 | const PeriodicNotePeriodSchema = z
 42 |   .enum(["daily", "weekly", "monthly", "quarterly", "yearly"])
 43 |   .describe("Valid periods for 'periodicNote' target type.");
 44 | 
 45 | /**
 46 |  * Base Zod schema containing fields common to all update operations within this tool.
 47 |  * Currently, only 'wholeFile' is supported, so this forms the basis for that mode.
 48 |  */
 49 | const BaseUpdateSchema = z.object({
 50 |   /** Specifies the type of target note. */
 51 |   targetType: TargetTypeSchema,
 52 |   /** The content to use for the modification. Must be a string for whole-file operations. */
 53 |   content: z
 54 |     .string()
 55 |     .describe(
 56 |       "The content for the modification (must be a string for whole-file operations).",
 57 |     ),
 58 |   /**
 59 |    * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'.
 60 |    * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'.
 61 |    * Not used if targetType is 'activeFile'.
 62 |    */
 63 |   targetIdentifier: z
 64 |     .string()
 65 |     .optional()
 66 |     .describe(
 67 |       "Identifier for 'filePath' (vault-relative path) or 'periodicNote' (period string). Not used for 'activeFile'.",
 68 |     ),
 69 | });
 70 | 
 71 | /**
 72 |  * Zod schema specifically for the 'wholeFile' modification type, extending the base schema.
 73 |  * Includes mode-specific options like createIfNeeded and overwriteIfExists.
 74 |  */
 75 | const WholeFileUpdateSchema = BaseUpdateSchema.extend({
 76 |   /** The modification type, fixed to 'wholeFile'. */
 77 |   modificationType: ModificationTypeSchema,
 78 |   /** The specific whole-file operation ('append', 'prepend', 'overwrite'). */
 79 |   wholeFileMode: WholeFileModeSchema,
 80 |   /** 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. */
 81 |   createIfNeeded: z
 82 |     .boolean()
 83 |     .optional()
 84 |     .default(true)
 85 |     .describe(
 86 |       "If true (default), creates the target if it doesn't exist. If false, fails if target is missing.",
 87 |     ),
 88 |   /** Only relevant for 'overwrite' mode. If true, allows overwriting an existing file. If false (default) and the file exists, the 'overwrite' operation fails. */
 89 |   overwriteIfExists: z
 90 |     .boolean()
 91 |     .optional()
 92 |     .default(false)
 93 |     .describe(
 94 |       "For 'overwrite' mode: If true, allows overwriting. If false (default) and file exists, operation fails.",
 95 |     ),
 96 |   /** If true, includes the final content of the modified file in the response. Defaults to false. */
 97 |   returnContent: z
 98 |     .boolean()
 99 |     .optional()
100 |     .default(false)
101 |     .describe("If true, returns the final file content in the response."),
102 | });
103 | 
104 | // ====================================================================================
105 | // Schema for SDK Registration (Flattened for Tool Definition)
106 | // ====================================================================================
107 | 
108 | /**
109 |  * Zod schema used for registering the tool with the MCP SDK (`server.tool`).
110 |  * This schema defines the expected input structure from the client's perspective.
111 |  * It flattens the structure slightly by making mode-specific fields optional at this stage,
112 |  * relying on the refined schema (`ObsidianUpdateFileInputSchema`) for stricter validation
113 |  * within the handler logic.
114 |  */
115 | const ObsidianUpdateNoteRegistrationSchema = z
116 |   .object({
117 |     /** Specifies the target note: 'filePath' (requires targetIdentifier), 'activeFile' (currently open file), or 'periodicNote' (requires targetIdentifier with period like 'daily'). */
118 |     targetType: TargetTypeSchema,
119 |     /** The content for the modification. Must be a string for whole-file operations. */
120 |     content: z
121 |       .string()
122 |       .describe("The content for the modification (must be a string)."),
123 |     /** 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'. */
124 |     targetIdentifier: z
125 |       .string()
126 |       .optional()
127 |       .describe(
128 |         "Identifier for 'filePath' (path) or 'periodicNote' (period). Not used for 'activeFile'.",
129 |       ),
130 |     /** Determines the modification strategy: must be 'wholeFile'. */
131 |     modificationType: ModificationTypeSchema,
132 | 
133 |     // --- WholeFile Mode Parameters (Marked optional here, refined schema enforces if modificationType is 'wholeFile') ---
134 |     /** For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'. */
135 |     wholeFileMode: WholeFileModeSchema.optional() // Made optional here, refined schema handles requirement
136 |       .describe(
137 |         "For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'.",
138 |       ),
139 |     /** 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. */
140 |     createIfNeeded: z
141 |       .boolean()
142 |       .optional()
143 |       .default(true)
144 |       .describe(
145 |         "For 'wholeFile' mode: If true (default), creates target if needed. If false, fails if missing.",
146 |       ),
147 |     /** For 'wholeFile' mode with 'overwrite': If false (default), the operation fails if the target file already exists. If true, allows overwriting the existing file. */
148 |     overwriteIfExists: z
149 |       .boolean()
150 |       .optional()
151 |       .default(false)
152 |       .describe(
153 |         "For 'wholeFile'/'overwrite' mode: If false (default), fails if target exists. If true, allows overwrite.",
154 |       ),
155 |     /** If true, returns the final content of the file in the response. Defaults to false. */
156 |     returnContent: z
157 |       .boolean()
158 |       .optional()
159 |       .default(false)
160 |       .describe("If true, returns the final file content in the response."),
161 |   })
162 |   .describe(
163 |     "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.",
164 |   );
165 | 
166 | /**
167 |  * The shape of the registration schema, used by `server.tool` for basic validation.
168 |  * @see ObsidianUpdateFileRegistrationSchema
169 |  */
170 | export const ObsidianUpdateNoteInputSchemaShape =
171 |   ObsidianUpdateNoteRegistrationSchema.shape;
172 | 
173 | /**
174 |  * TypeScript type inferred from the registration schema. Represents the raw input
175 |  * received by the tool handler *before* refinement.
176 |  * @see ObsidianUpdateFileRegistrationSchema
177 |  */
178 | export type ObsidianUpdateNoteRegistrationInput = z.infer<
179 |   typeof ObsidianUpdateNoteRegistrationSchema
180 | >;
181 | 
182 | // ====================================================================================
183 | // Refined Schema for Internal Logic and Strict Validation
184 | // ====================================================================================
185 | 
186 | /**
187 |  * Refined Zod schema used internally within the tool's logic for strict validation.
188 |  * It builds upon `WholeFileUpdateSchema` and adds cross-field validation rules using `.refine()`.
189 |  * This ensures that `targetIdentifier` is provided and valid when required by `targetType`.
190 |  */
191 | export const ObsidianUpdateNoteInputSchema = WholeFileUpdateSchema.refine(
192 |   (data) => {
193 |     // Rule 1: If targetType is 'filePath' or 'periodicNote', targetIdentifier must be provided.
194 |     if (
195 |       (data.targetType === "filePath" || data.targetType === "periodicNote") &&
196 |       !data.targetIdentifier
197 |     ) {
198 |       return false;
199 |     }
200 |     // Rule 2: If targetType is 'periodicNote', targetIdentifier must be a valid period string.
201 |     if (
202 |       data.targetType === "periodicNote" &&
203 |       data.targetIdentifier &&
204 |       !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success
205 |     ) {
206 |       return false;
207 |     }
208 |     // All checks passed
209 |     return true;
210 |   },
211 |   {
212 |     // Custom error message for refinement failure.
213 |     message:
214 |       "targetIdentifier is required and must be a valid path for targetType 'filePath', or a valid period ('daily', 'weekly', etc.) for targetType 'periodicNote'.",
215 |     path: ["targetIdentifier"], // Associate the error with the targetIdentifier field.
216 |   },
217 | );
218 | 
219 | /**
220 |  * TypeScript type inferred from the *refined* input schema (`ObsidianUpdateFileInputSchema`).
221 |  * This type represents the validated and structured input used within the core processing logic.
222 |  */
223 | export type ObsidianUpdateNoteInput = z.infer<
224 |   typeof ObsidianUpdateNoteInputSchema
225 | >;
226 | 
227 | // ====================================================================================
228 | // Response Type Definition
229 | // ====================================================================================
230 | 
231 | /**
232 |  * Represents the structure of file statistics after formatting, including
233 |  * human-readable timestamps and an estimated token count.
234 |  */
235 | type FormattedStat = {
236 |   /** Creation time formatted as a standard date-time string. */
237 |   createdTime: string;
238 |   /** Last modified time formatted as a standard date-time string. */
239 |   modifiedTime: string;
240 |   /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */
241 |   tokenCountEstimate: number;
242 | };
243 | 
244 | /**
245 |  * Defines the structure of the successful response returned by the `processObsidianUpdateFile` function.
246 |  * This object is typically serialized to JSON and sent back to the client.
247 |  */
248 | export interface ObsidianUpdateNoteResponse {
249 |   /** Indicates whether the operation was successful. */
250 |   success: boolean;
251 |   /** A human-readable message describing the outcome of the operation. */
252 |   message: string;
253 |   /** Optional file statistics (creation/modification times, token count) if the file could be read after the update. */
254 |   stats?: FormattedStat; // Renamed from stat
255 |   /** Optional final content of the file, included only if `returnContent` was true in the request and the file could be read. */
256 |   finalContent?: string;
257 | }
258 | 
259 | // ====================================================================================
260 | // Helper Functions
261 | // ====================================================================================
262 | 
263 | /**
264 |  * Attempts to retrieve the final state (content and stats) of the target note after an update operation.
265 |  * Uses the appropriate Obsidian API method based on the target type.
266 |  * Logs a warning and returns null if fetching the final state fails, to avoid failing the entire update operation.
267 |  *
268 |  * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note.
269 |  * @param {string | undefined} targetIdentifier - The identifier (path or period) if applicable.
270 |  * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'.
271 |  * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
272 |  * @param {RequestContext} context - The request context for logging and correlation.
273 |  * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails.
274 |  */
275 | async function getFinalState(
276 |   targetType: z.infer<typeof TargetTypeSchema>,
277 |   targetIdentifier: string | undefined,
278 |   period: z.infer<typeof PeriodicNotePeriodSchema> | undefined,
279 |   obsidianService: ObsidianRestApiService,
280 |   context: RequestContext,
281 | ): Promise<NoteJson | null> {
282 |   const operation = "getFinalState";
283 |   logger.debug(
284 |     `Attempting to retrieve final state for target: ${targetType} ${targetIdentifier ?? "(active)"}`,
285 |     { ...context, operation },
286 |   );
287 |   try {
288 |     let noteJson: NoteJson | null = null;
289 |     // Call the appropriate API method based on target type
290 |     if (targetType === "filePath" && targetIdentifier) {
291 |       noteJson = (await obsidianService.getFileContent(
292 |         targetIdentifier,
293 |         "json",
294 |         context,
295 |       )) as NoteJson;
296 |     } else if (targetType === "activeFile") {
297 |       noteJson = (await obsidianService.getActiveFile(
298 |         "json",
299 |         context,
300 |       )) as NoteJson;
301 |     } else if (targetType === "periodicNote" && period) {
302 |       noteJson = (await obsidianService.getPeriodicNote(
303 |         period,
304 |         "json",
305 |         context,
306 |       )) as NoteJson;
307 |     }
308 |     logger.debug(`Successfully retrieved final state`, {
309 |       ...context,
310 |       operation,
311 |     });
312 |     return noteJson;
313 |   } catch (error) {
314 |     // Log the error but don't let it fail the main update operation.
315 |     const errorMsg = error instanceof Error ? error.message : String(error);
316 |     logger.warning(
317 |       `Could not retrieve final state after update for target: ${targetType} ${targetIdentifier ?? "(active)"}. Error: ${errorMsg}`,
318 |       { ...context, operation, error: errorMsg },
319 |     );
320 |     return null; // Return null to indicate failure without throwing
321 |   }
322 | }
323 | 
324 | // ====================================================================================
325 | // Core Logic Function
326 | // ====================================================================================
327 | 
328 | /**
329 |  * Processes the core logic for the 'obsidian_update_file' tool when using the 'wholeFile'
330 |  * modification type (append, prepend, overwrite). It handles pre-checks, performs the
331 |  * update via the Obsidian REST API, retrieves the final state, and constructs the response.
332 |  *
333 |  * @param {ObsidianUpdateFileInput} params - The validated input parameters conforming to the refined schema.
334 |  * @param {RequestContext} context - The request context for logging and correlation.
335 |  * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service.
336 |  * @returns {Promise<ObsidianUpdateFileResponse>} A promise resolving to the structured success response.
337 |  * @throws {McpError} Throws an McpError if validation fails or the API interaction results in an error.
338 |  */
339 | export const processObsidianUpdateNote = async (
340 |   params: ObsidianUpdateNoteInput, // Use the refined, validated type
341 |   context: RequestContext,
342 |   obsidianService: ObsidianRestApiService,
343 |   vaultCacheService: VaultCacheService | undefined,
344 | ): Promise<ObsidianUpdateNoteResponse> => {
345 |   logger.debug(`Processing obsidian_update_note request (wholeFile mode)`, {
346 |     ...context,
347 |     targetType: params.targetType,
348 |     wholeFileMode: params.wholeFileMode,
349 |   });
350 | 
351 |   const targetId = params.targetIdentifier; // Alias for clarity
352 |   const contentString = params.content;
353 |   const mode = params.wholeFileMode;
354 |   let wasCreated = false; // Flag to track if the file was newly created by the operation
355 |   let targetPeriod: z.infer<typeof PeriodicNotePeriodSchema> | undefined;
356 | 
357 |   // Parse the period if the target is a periodic note
358 |   if (params.targetType === "periodicNote" && targetId) {
359 |     // Use safeParse for robustness, though refined schema should guarantee validity
360 |     const parseResult = PeriodicNotePeriodSchema.safeParse(targetId);
361 |     if (!parseResult.success) {
362 |       // This should ideally not happen due to the refined schema, but handle defensively
363 |       throw new McpError(
364 |         BaseErrorCode.VALIDATION_ERROR,
365 |         `Invalid period provided for periodicNote: ${targetId}`,
366 |         context,
367 |       );
368 |     }
369 |     targetPeriod = parseResult.data;
370 |   }
371 | 
372 |   try {
373 |     // --- Step 1: Pre-operation Existence Check ---
374 |     // Determine if the target file/note exists before attempting modification.
375 |     // This is crucial for overwrite safety checks and createIfNeeded logic.
376 |     let existsBefore = false;
377 |     const checkContext = { ...context, operation: "existenceCheck" };
378 |     logger.debug(
379 |       `Checking existence of target: ${params.targetType} ${targetId ?? "(active)"}`,
380 |       checkContext,
381 |     );
382 | 
383 |     try {
384 |       await retryWithDelay(
385 |         async () => {
386 |           if (params.targetType === "filePath" && targetId) {
387 |             await obsidianService.getFileContent(
388 |               targetId,
389 |               "json",
390 |               checkContext,
391 |             );
392 |           } else if (params.targetType === "activeFile") {
393 |             await obsidianService.getActiveFile("json", checkContext);
394 |           } else if (params.targetType === "periodicNote" && targetPeriod) {
395 |             await obsidianService.getPeriodicNote(
396 |               targetPeriod,
397 |               "json",
398 |               checkContext,
399 |             );
400 |           }
401 |           // If any of the above succeed without throwing, the target exists.
402 |           existsBefore = true;
403 |           logger.debug(`Target exists before operation.`, checkContext);
404 |         },
405 |         {
406 |           operationName: "existenceCheckObsidianUpdateNote",
407 |           context: checkContext,
408 |           maxRetries: 3, // Total attempts: 1 initial + 2 retries
409 |           delayMs: 250,
410 |           shouldRetry: (error: unknown) => {
411 |             // Only retry if it's a NOT_FOUND error AND createIfNeeded is true.
412 |             // If createIfNeeded is false, a NOT_FOUND error means we shouldn't proceed, so don't retry.
413 |             const should =
414 |               error instanceof McpError &&
415 |               error.code === BaseErrorCode.NOT_FOUND &&
416 |               params.createIfNeeded;
417 |             if (
418 |               error instanceof McpError &&
419 |               error.code === BaseErrorCode.NOT_FOUND
420 |             ) {
421 |               logger.debug(
422 |                 `existenceCheckObsidianUpdateNote: shouldRetry=${should} for NOT_FOUND (createIfNeeded: ${params.createIfNeeded})`,
423 |                 checkContext,
424 |               );
425 |             }
426 |             return should;
427 |           },
428 |           onRetry: (attempt, error) => {
429 |             const errorMsg =
430 |               error instanceof Error ? error.message : String(error);
431 |             logger.warning(
432 |               `Existence check (attempt ${attempt}) failed for target '${params.targetType} ${targetId ?? ""}'. Error: ${errorMsg}. Retrying as createIfNeeded is true...`,
433 |               checkContext,
434 |             );
435 |           },
436 |         },
437 |       );
438 |     } catch (error) {
439 |       // This catch block is primarily for the case where retryWithDelay itself throws
440 |       // (e.g., all retries exhausted for NOT_FOUND with createIfNeeded=true, or an unretryable error occurred).
441 |       if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
442 |         // If it's still NOT_FOUND after retries (or if createIfNeeded was false and it failed the first time),
443 |         // then existsBefore should definitely be false.
444 |         existsBefore = false;
445 |         logger.debug(
446 |           `Target confirmed not to exist after existence check attempts (createIfNeeded: ${params.createIfNeeded}).`,
447 |           checkContext,
448 |         );
449 |       } else {
450 |         // For any other error type, re-throw it as it's unexpected here.
451 |         logger.error(
452 |           `Unexpected error after existence check retries`,
453 |           error instanceof Error ? error : undefined,
454 |           checkContext,
455 |         );
456 |         throw error;
457 |       }
458 |     }
459 | 
460 |     // --- Step 2: Perform Safety and Configuration Checks ---
461 |     const safetyCheckContext = {
462 |       ...context,
463 |       operation: "safetyChecks",
464 |       existsBefore,
465 |     };
466 | 
467 |     // Check 2a: Overwrite safety
468 |     if (mode === "overwrite" && existsBefore && !params.overwriteIfExists) {
469 |       logger.warning(
470 |         `Overwrite attempt failed: Target exists and overwriteIfExists is false.`,
471 |         safetyCheckContext,
472 |       );
473 |       throw new McpError(
474 |         BaseErrorCode.CONFLICT, // Use CONFLICT as it clashes with existing state + config
475 |         `Target ${params.targetType} '${targetId ?? "(active)"}' exists, and 'overwriteIfExists' is set to false. Cannot overwrite.`,
476 |         safetyCheckContext,
477 |       );
478 |     }
479 | 
480 |     // Check 2b: Not Found when creation is disabled
481 |     if (!existsBefore && !params.createIfNeeded) {
482 |       logger.warning(
483 |         `Update attempt failed: Target not found and createIfNeeded is false.`,
484 |         safetyCheckContext,
485 |       );
486 |       throw new McpError(
487 |         BaseErrorCode.NOT_FOUND,
488 |         `Target ${params.targetType} '${targetId ?? "(active)"}' not found, and 'createIfNeeded' is set to false. Cannot update.`,
489 |         safetyCheckContext,
490 |       );
491 |     }
492 | 
493 |     // Determine if the operation will result in file creation
494 |     wasCreated = !existsBefore && params.createIfNeeded;
495 |     logger.debug(
496 |       `Operation will proceed. File creation needed: ${wasCreated}`,
497 |       safetyCheckContext,
498 |     );
499 | 
500 |     // --- Step 3: Perform the Update Operation via Obsidian API ---
501 |     const updateContext = {
502 |       ...context,
503 |       operation: `performUpdate:${mode}`,
504 |       wasCreated,
505 |     };
506 |     logger.debug(`Performing update operation: ${mode}`, updateContext);
507 | 
508 |     // Handle 'prepend' and 'append' manually as Obsidian API might not directly support them atomically.
509 |     if (mode === "prepend" || mode === "append") {
510 |       let existingContent = "";
511 |       // Only read existing content if the file existed before the operation.
512 |       if (existsBefore) {
513 |         const readContext = { ...updateContext, subOperation: "readForModify" };
514 |         logger.debug(`Reading existing content for ${mode}`, readContext);
515 |         try {
516 |           if (params.targetType === "filePath" && targetId) {
517 |             existingContent = (await obsidianService.getFileContent(
518 |               targetId,
519 |               "markdown",
520 |               readContext,
521 |             )) as string;
522 |           } else if (params.targetType === "activeFile") {
523 |             existingContent = (await obsidianService.getActiveFile(
524 |               "markdown",
525 |               readContext,
526 |             )) as string;
527 |           } else if (params.targetType === "periodicNote" && targetPeriod) {
528 |             existingContent = (await obsidianService.getPeriodicNote(
529 |               targetPeriod,
530 |               "markdown",
531 |               readContext,
532 |             )) as string;
533 |           }
534 |           logger.debug(
535 |             `Successfully read existing content. Length: ${existingContent.length}`,
536 |             readContext,
537 |           );
538 |         } catch (readError) {
539 |           // This should ideally not happen if existsBefore is true, but handle defensively.
540 |           const errorMsg =
541 |             readError instanceof Error ? readError.message : String(readError);
542 |           logger.error(
543 |             `Error reading existing content for ${mode} despite existence check.`,
544 |             readError instanceof Error ? readError : undefined,
545 |             readContext,
546 |           );
547 |           throw new McpError(
548 |             BaseErrorCode.INTERNAL_ERROR,
549 |             `Failed to read existing content for ${mode} operation. Error: ${errorMsg}`,
550 |             readContext,
551 |           );
552 |         }
553 |       } else {
554 |         logger.debug(
555 |           `Target did not exist before, skipping read for ${mode}.`,
556 |           updateContext,
557 |         );
558 |       }
559 | 
560 |       // Combine content based on the mode.
561 |       const newContent =
562 |         mode === "prepend"
563 |           ? contentString + existingContent
564 |           : existingContent + contentString;
565 |       logger.debug(
566 |         `Combined content length for ${mode}: ${newContent.length}`,
567 |         updateContext,
568 |       );
569 | 
570 |       // Overwrite the target with the newly combined content.
571 |       const writeContext = { ...updateContext, subOperation: "writeCombined" };
572 |       logger.debug(`Writing combined content back to target`, writeContext);
573 |       if (params.targetType === "filePath" && targetId) {
574 |         await obsidianService.updateFileContent(
575 |           targetId,
576 |           newContent,
577 |           writeContext,
578 |         );
579 |       } else if (params.targetType === "activeFile") {
580 |         await obsidianService.updateActiveFile(newContent, writeContext);
581 |       } else if (params.targetType === "periodicNote" && targetPeriod) {
582 |         await obsidianService.updatePeriodicNote(
583 |           targetPeriod,
584 |           newContent,
585 |           writeContext,
586 |         );
587 |       }
588 |       logger.debug(
589 |         `Successfully wrote combined content for ${mode}`,
590 |         writeContext,
591 |       );
592 |       if (params.targetType === "filePath" && targetId && vaultCacheService) {
593 |         await vaultCacheService.updateCacheForFile(targetId, writeContext);
594 |       }
595 |     } else {
596 |       // Handle 'overwrite' mode directly.
597 |       switch (params.targetType) {
598 |         case "filePath":
599 |           // targetId is guaranteed by refined schema check
600 |           await obsidianService.updateFileContent(
601 |             targetId!,
602 |             contentString,
603 |             updateContext,
604 |           );
605 |           break;
606 |         case "activeFile":
607 |           await obsidianService.updateActiveFile(contentString, updateContext);
608 |           break;
609 |         case "periodicNote":
610 |           // targetPeriod is guaranteed by refined schema check
611 |           await obsidianService.updatePeriodicNote(
612 |             targetPeriod!,
613 |             contentString,
614 |             updateContext,
615 |           );
616 |           break;
617 |       }
618 |       logger.debug(
619 |         `Successfully performed overwrite on target: ${params.targetType} ${targetId ?? "(active)"}`,
620 |         updateContext,
621 |       );
622 |       if (params.targetType === "filePath" && targetId && vaultCacheService) {
623 |         await vaultCacheService.updateCacheForFile(targetId, updateContext);
624 |       }
625 |     }
626 | 
627 |     // --- Step 4: Get Final State (Stat and Optional Content) ---
628 |     // Add a small delay before attempting to get the final state, to allow Obsidian API to stabilize after write.
629 |     const POST_UPDATE_DELAY_MS = 250;
630 |     logger.debug(
631 |       `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state...`,
632 |       { ...context, operation: "postUpdateDelay" },
633 |     );
634 |     await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS));
635 | 
636 |     // Attempt to retrieve the file's state *after* the modification.
637 |     let finalState: NoteJson | null = null; // Initialize to null
638 |     try {
639 |       finalState = await retryWithDelay(
640 |         async () =>
641 |           getFinalState(
642 |             params.targetType,
643 |             targetId,
644 |             targetPeriod,
645 |             obsidianService,
646 |             context,
647 |           ),
648 |         {
649 |           operationName: "getFinalStateAfterUpdate",
650 |           context: { ...context, operation: "getFinalStateAfterUpdateAttempt" }, // Use a distinct context for retry logs
651 |           maxRetries: 3, // Total attempts: 1 initial + 2 retries
652 |           delayMs: 250, // Shorter delay
653 |           shouldRetry: (error: unknown) => {
654 |             // Retry on common transient issues or if the file might not be immediately available
655 |             const should =
656 |               error instanceof McpError &&
657 |               (error.code === BaseErrorCode.NOT_FOUND || // File might not be indexed immediately
658 |                 error.code === BaseErrorCode.SERVICE_UNAVAILABLE || // API temporarily busy
659 |                 error.code === BaseErrorCode.TIMEOUT); // API call timed out
660 |             if (should) {
661 |               logger.debug(
662 |                 `getFinalStateAfterUpdate: shouldRetry=true for error code ${(error as McpError).code}`,
663 |                 context,
664 |               );
665 |             }
666 |             return should;
667 |           },
668 |           onRetry: (attempt, error) => {
669 |             const errorMsg =
670 |               error instanceof Error ? error.message : String(error);
671 |             logger.warning(
672 |               `getFinalState (attempt ${attempt}) failed. Error: ${errorMsg}. Retrying...`,
673 |               { ...context, operation: "getFinalStateRetry" },
674 |             );
675 |           },
676 |         },
677 |       );
678 |     } catch (error) {
679 |       // If retryWithDelay throws after all attempts, getFinalState effectively failed.
680 |       // The original getFinalState already logs a warning and returns null if it encounters an error internally
681 |       // and is designed not to let its failure stop the main operation.
682 |       // So, if retryWithDelay throws, it means even retries didn't help.
683 |       finalState = null; // Ensure finalState remains null
684 |       const errorMsg = error instanceof Error ? error.message : String(error);
685 |       logger.error(
686 |         `Failed to retrieve final state for target '${params.targetType} ${targetId ?? ""}' even after retries. Error: ${errorMsg}`,
687 |         error instanceof Error ? error : undefined,
688 |         context,
689 |       );
690 |       // Do not re-throw here, allow the main process to construct a response with a warning.
691 |     }
692 | 
693 |     // --- Step 5: Construct Success Message ---
694 |     // Create a user-friendly message indicating what happened.
695 |     let messageAction: string;
696 |     if (wasCreated) {
697 |       // Use past tense for creation events
698 |       messageAction =
699 |         mode === "overwrite" ? "created" : `${mode}d (and created)`;
700 |     } else {
701 |       // Use past tense for modifications of existing files
702 |       messageAction = mode === "overwrite" ? "overwritten" : `${mode}ed`;
703 |     }
704 |     const targetName =
705 |       params.targetType === "filePath"
706 |         ? `'${targetId}'`
707 |         : params.targetType === "periodicNote"
708 |           ? `'${targetId}' note`
709 |           : "the active file";
710 |     let successMessage = `File content successfully ${messageAction} for ${targetName}.`; // Use let
711 |     logger.info(successMessage, context); // Log initial success message
712 | 
713 |     // Append a warning if the final state couldn't be retrieved
714 |     if (finalState === null) {
715 |       const warningMsg =
716 |         " (Warning: Could not retrieve final file stats/content after update.)";
717 |       successMessage += warningMsg;
718 |       logger.warning(
719 |         `Appending warning to response message: ${warningMsg}`,
720 |         context,
721 |       );
722 |     }
723 | 
724 |     // --- Step 6: Build and Return Response ---
725 |     // Format the file statistics (if available) using the shared utility.
726 |     const finalContentForStat = finalState?.content ?? ""; // Provide content for token counting
727 |     const formattedStatResult = finalState?.stat
728 |       ? await createFormattedStatWithTokenCount(
729 |           finalState.stat,
730 |           finalContentForStat,
731 |           context,
732 |         ) // Await the async utility
733 |       : undefined;
734 |     // Ensure stat is undefined if the utility returned null (e.g., token counting failed)
735 |     const formattedStat =
736 |       formattedStatResult === null ? undefined : formattedStatResult;
737 | 
738 |     // Construct the final response object.
739 |     const response: ObsidianUpdateNoteResponse = {
740 |       success: true,
741 |       message: successMessage,
742 |       stats: formattedStat,
743 |     };
744 | 
745 |     // Include final content if requested and available.
746 |     if (params.returnContent) {
747 |       response.finalContent = finalState?.content; // Assign content if available, otherwise undefined
748 |       logger.debug(
749 |         `Including final content in response as requested.`,
750 |         context,
751 |       );
752 |     }
753 | 
754 |     return response;
755 |   } catch (error) {
756 |     // Handle errors, ensuring they are McpError instances before re-throwing.
757 |     // Errors from obsidianService calls should already be McpErrors and logged by the service.
758 |     if (error instanceof McpError) {
759 |       // Log McpErrors specifically from this level if needed, though lower levels might have logged already
760 |       logger.error(
761 |         `McpError during file update: ${error.message}`,
762 |         error,
763 |         context,
764 |       );
765 |       throw error; // Re-throw known McpError
766 |     } else {
767 |       // Catch unexpected errors, log them, and wrap in a generic McpError.
768 |       const errorMessage = `Unexpected error updating Obsidian file/note`;
769 |       logger.error(
770 |         errorMessage,
771 |         error instanceof Error ? error : undefined,
772 |         context,
773 |       );
774 |       throw new McpError(
775 |         BaseErrorCode.INTERNAL_ERROR,
776 |         `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
777 |         context,
778 |       );
779 |     }
780 |   }
781 | };
782 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/tools/obsidianSearchReplaceTool/logic.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import path from "node:path"; // For file path fallback logic using POSIX separators
  2 | import { z } from "zod";
  3 | import {
  4 |   NoteJson,
  5 |   ObsidianRestApiService,
  6 |   VaultCacheService,
  7 | } from "../../../services/obsidianRestAPI/index.js";
  8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  9 | import {
 10 |   createFormattedStatWithTokenCount,
 11 |   logger,
 12 |   RequestContext,
 13 |   retryWithDelay,
 14 | } from "../../../utils/index.js";
 15 | 
 16 | // ====================================================================================
 17 | // Schema Definitions for Input Validation
 18 | // ====================================================================================
 19 | 
 20 | /** Defines the possible types of targets for the search/replace operation. */
 21 | const TargetTypeSchema = z
 22 |   .enum(["filePath", "activeFile", "periodicNote"])
 23 |   .describe(
 24 |     "Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.",
 25 |   );
 26 | 
 27 | /** Defines the valid periods for periodic notes. */
 28 | const PeriodicNotePeriodSchema = z
 29 |   .enum(["daily", "weekly", "monthly", "quarterly", "yearly"])
 30 |   .describe("Valid periods for 'periodicNote' target type.");
 31 | 
 32 | /**
 33 |  * Defines the structure for a single search and replace operation block.
 34 |  */
 35 | const ReplacementBlockSchema = z.object({
 36 |   /** The exact string or regex pattern to search for within the note content. Cannot be empty. */
 37 |   search: z
 38 |     .string()
 39 |     .min(1, "Search pattern cannot be empty.")
 40 |     .describe("The exact string or regex pattern to search for."),
 41 |   /** The string to replace each match with. An empty string effectively deletes the matched text. */
 42 |   replace: z.string().describe("The string to replace matches with."),
 43 | });
 44 | 
 45 | /**
 46 |  * Base Zod schema object containing fields common to the search/replace tool input.
 47 |  * This is used as the foundation for both the registration shape and the refined internal schema.
 48 |  */
 49 | const BaseObsidianSearchReplaceInputSchema = z.object({
 50 |   /** Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'. */
 51 |   targetType: TargetTypeSchema,
 52 |   /**
 53 |    * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'.
 54 |    * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'.
 55 |    * Not used if targetType is 'activeFile'. The tool attempts a case-insensitive fallback if the exact filePath is not found.
 56 |    */
 57 |   targetIdentifier: z
 58 |     .string()
 59 |     .optional()
 60 |     .describe(
 61 |       "Required if targetType is 'filePath' (vault-relative path) or 'periodicNote' (period string: 'daily', etc.). Tries case-insensitive fallback for filePath.",
 62 |     ),
 63 |   /** An array of one or more search/replace operations to perform sequentially on the note content. */
 64 |   replacements: z
 65 |     .array(ReplacementBlockSchema)
 66 |     .min(1, "Replacements array cannot be empty.")
 67 |     .describe("An array of search/replace operations to perform sequentially."),
 68 |   /** If true, treats the 'search' field in each replacement block as a JavaScript regular expression pattern. Defaults to false (exact string matching). */
 69 |   useRegex: z
 70 |     .boolean()
 71 |     .optional()
 72 |     .default(false)
 73 |     .describe(
 74 |       "If true, treat the 'search' field in replacements as JavaScript regex patterns. Defaults to false (exact string matching).",
 75 |     ),
 76 |   /** If true (default), replaces all occurrences matching each search pattern within the note. If false, replaces only the first occurrence of each pattern. */
 77 |   replaceAll: z
 78 |     .boolean()
 79 |     .optional()
 80 |     .default(true)
 81 |     .describe(
 82 |       "If true (default), replace all occurrences for each search pattern. If false, replace only the first occurrence.",
 83 |     ),
 84 |   /** If true (default), the search operation is case-sensitive. If false, it's case-insensitive. Applies to both string and regex searches. */
 85 |   caseSensitive: z
 86 |     .boolean()
 87 |     .optional()
 88 |     .default(true)
 89 |     .describe(
 90 |       "If true (default), the search is case-sensitive. If false, it's case-insensitive. Applies to both string and regex search.",
 91 |     ),
 92 |   /** 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. */
 93 |   flexibleWhitespace: z
 94 |     .boolean()
 95 |     .optional()
 96 |     .default(false)
 97 |     .describe(
 98 |       "If true (and useRegex=false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\\s+). Defaults to false.",
 99 |     ),
100 |   /** 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. */
101 |   wholeWord: z
102 |     .boolean()
103 |     .optional()
104 |     .default(false)
105 |     .describe(
106 |       "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.",
107 |     ),
108 |   /** If true, includes the final content of the modified file in the response. Defaults to false. */
109 |   returnContent: z
110 |     .boolean()
111 |     .optional()
112 |     .default(false)
113 |     .describe(
114 |       "If true, returns the final content of the file in the response. Defaults to false.",
115 |     ),
116 | });
117 | 
118 | // ====================================================================================
119 | // Refined Schema for Internal Logic and Strict Validation
120 | // ====================================================================================
121 | 
122 | /**
123 |  * Refined Zod schema used internally within the tool's logic for strict validation.
124 |  * It builds upon `BaseObsidianSearchReplaceInputSchema` and adds cross-field validation rules:
125 |  * 1. Ensures `targetIdentifier` is provided and valid when required by `targetType`.
126 |  * 2. Ensures `flexibleWhitespace` is not used concurrently with `useRegex`.
127 |  */
128 | export const ObsidianSearchReplaceInputSchema =
129 |   BaseObsidianSearchReplaceInputSchema.refine(
130 |     (data) => {
131 |       // Rule 1: Validate targetIdentifier based on targetType
132 |       if (
133 |         (data.targetType === "filePath" ||
134 |           data.targetType === "periodicNote") &&
135 |         !data.targetIdentifier
136 |       ) {
137 |         return false; // Missing targetIdentifier
138 |       }
139 |       if (
140 |         data.targetType === "periodicNote" &&
141 |         data.targetIdentifier &&
142 |         !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success
143 |       ) {
144 |         return false; // Invalid period
145 |       }
146 |       // Rule 2: flexibleWhitespace cannot be true if useRegex is true
147 |       if (data.flexibleWhitespace && data.useRegex) {
148 |         return false; // Conflicting options
149 |       }
150 |       return true; // All checks passed
151 |     },
152 |     {
153 |       // Custom error message for refinement failures
154 |       message:
155 |         "Validation failed: targetIdentifier is required and must be valid for 'filePath' or 'periodicNote'. Also, 'flexibleWhitespace' cannot be true if 'useRegex' is true.",
156 |       // Point error reporting to potentially problematic fields
157 |       path: ["targetIdentifier", "flexibleWhitespace", "useRegex"],
158 |     },
159 |   ).describe(
160 |     "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.",
161 |   );
162 | 
163 | // ====================================================================================
164 | // Schema Shape and Type Exports for Registration and Logic
165 | // ====================================================================================
166 | 
167 | /**
168 |  * The shape of the base input schema, used by `server.tool` for registration and initial validation.
169 |  * @see BaseObsidianSearchReplaceInputSchema
170 |  */
171 | export const ObsidianSearchReplaceInputSchemaShape =
172 |   BaseObsidianSearchReplaceInputSchema.shape;
173 | 
174 | /**
175 |  * TypeScript type inferred from the base registration schema. Represents the raw input
176 |  * received by the tool handler *before* refinement.
177 |  * @see BaseObsidianSearchReplaceInputSchema
178 |  */
179 | export type ObsidianSearchReplaceRegistrationInput = z.infer<
180 |   typeof BaseObsidianSearchReplaceInputSchema
181 | >;
182 | 
183 | /**
184 |  * TypeScript type inferred from the *refined* input schema (`ObsidianSearchReplaceInputSchema`).
185 |  * This type represents the validated and structured input used within the core processing logic.
186 |  */
187 | export type ObsidianSearchReplaceInput = z.infer<
188 |   typeof ObsidianSearchReplaceInputSchema
189 | >;
190 | 
191 | // ====================================================================================
192 | // Response Type Definition
193 | // ====================================================================================
194 | 
195 | /**
196 |  * Represents the structure of file statistics after formatting, including
197 |  * human-readable timestamps and an estimated token count.
198 |  */
199 | type FormattedStat = {
200 |   /** Creation time formatted as a standard date-time string. */
201 |   createdTime: string;
202 |   /** Last modified time formatted as a standard date-time string. */
203 |   modifiedTime: string;
204 |   /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */
205 |   tokenCountEstimate: number;
206 | };
207 | 
208 | /**
209 |  * Defines the structure of the successful response returned by the `processObsidianSearchReplace` function.
210 |  * This object is typically serialized to JSON and sent back to the client.
211 |  */
212 | export interface ObsidianSearchReplaceResponse {
213 |   /** Indicates whether the operation was successful. */
214 |   success: boolean;
215 |   /** A human-readable message describing the outcome of the operation (e.g., number of replacements). */
216 |   message: string;
217 |   /** The total number of replacements made across all search/replace blocks. */
218 |   totalReplacementsMade: number;
219 |   /** Optional file statistics (creation/modification times, token count) if the file could be read after the update. */
220 |   stats?: FormattedStat;
221 |   /** Optional final content of the file, included only if `returnContent` was true in the request. */
222 |   finalContent?: string;
223 | }
224 | 
225 | // ====================================================================================
226 | // Helper Functions
227 | // ====================================================================================
228 | 
229 | /**
230 |  * Escapes characters that have special meaning in regular expressions.
231 |  * This allows treating a literal string as a pattern in a regex.
232 |  *
233 |  * @param {string} str - The input string to escape.
234 |  * @returns {string} The string with regex special characters escaped.
235 |  */
236 | function escapeRegex(str: string): string {
237 |   // Escape characters: . * + ? ^ $ { } ( ) | [ ] \ -
238 |   return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); // $& inserts the matched character
239 | }
240 | 
241 | /**
242 |  * Attempts to retrieve the final state (content and stats) of the target note after a search/replace operation.
243 |  * Uses the appropriate Obsidian API method based on the target type and the potentially corrected file path.
244 |  * Logs a warning and returns null if fetching the final state fails, preventing failure of the entire operation.
245 |  *
246 |  * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note.
247 |  * @param {string | undefined} effectiveFilePath - The vault-relative path (potentially corrected by case-insensitive fallback). Undefined for non-filePath targets.
248 |  * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'.
249 |  * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
250 |  * @param {RequestContext} context - The request context for logging and correlation.
251 |  * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails.
252 |  */
253 | async function getFinalState(
254 |   targetType: z.infer<typeof TargetTypeSchema>,
255 |   effectiveFilePath: string | undefined,
256 |   period: z.infer<typeof PeriodicNotePeriodSchema> | undefined,
257 |   obsidianService: ObsidianRestApiService,
258 |   context: RequestContext,
259 | ): Promise<NoteJson | null> {
260 |   const operation = "getFinalStateAfterSearchReplace";
261 |   const targetDesc =
262 |     effectiveFilePath ?? (period ? `periodic ${period}` : "active file");
263 |   logger.debug(`Attempting to retrieve final state for target: ${targetDesc}`, {
264 |     ...context,
265 |     operation,
266 |   });
267 |   try {
268 |     let noteJson: NoteJson | null = null;
269 |     // Call the appropriate API method
270 |     if (targetType === "filePath" && effectiveFilePath) {
271 |       noteJson = (await obsidianService.getFileContent(
272 |         effectiveFilePath,
273 |         "json",
274 |         context,
275 |       )) as NoteJson;
276 |     } else if (targetType === "activeFile") {
277 |       noteJson = (await obsidianService.getActiveFile(
278 |         "json",
279 |         context,
280 |       )) as NoteJson;
281 |     } else if (targetType === "periodicNote" && period) {
282 |       noteJson = (await obsidianService.getPeriodicNote(
283 |         period,
284 |         "json",
285 |         context,
286 |       )) as NoteJson;
287 |     }
288 |     logger.debug(`Successfully retrieved final state for ${targetDesc}`, {
289 |       ...context,
290 |       operation,
291 |     });
292 |     return noteJson;
293 |   } catch (error) {
294 |     // Log the error but return null to avoid failing the main operation.
295 |     const errorMsg = error instanceof Error ? error.message : String(error);
296 |     logger.warning(
297 |       `Could not retrieve final state after search/replace for target: ${targetDesc}. Error: ${errorMsg}`,
298 |       { ...context, operation, error: errorMsg },
299 |     );
300 |     return null;
301 |   }
302 | }
303 | 
304 | // ====================================================================================
305 | // Core Logic Function
306 | // ====================================================================================
307 | 
308 | /**
309 |  * Processes the core logic for the 'obsidian_search_replace' tool.
310 |  * Reads the target note content, performs a sequence of search and replace operations
311 |  * based on the provided parameters (handling regex, case sensitivity, etc.),
312 |  * writes the modified content back to Obsidian, retrieves the final state,
313 |  * and constructs the response object.
314 |  *
315 |  * @param {ObsidianSearchReplaceInput} params - The validated input parameters conforming to the refined schema.
316 |  * @param {RequestContext} context - The request context for logging and correlation.
317 |  * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service.
318 |  * @returns {Promise<ObsidianSearchReplaceResponse>} A promise resolving to the structured success response.
319 |  * @throws {McpError} Throws an McpError if validation fails, reading/writing fails, or an unexpected error occurs during processing.
320 |  */
321 | export const processObsidianSearchReplace = async (
322 |   params: ObsidianSearchReplaceInput, // Use the refined, validated type
323 |   context: RequestContext,
324 |   obsidianService: ObsidianRestApiService,
325 |   vaultCacheService: VaultCacheService | undefined,
326 | ): Promise<ObsidianSearchReplaceResponse> => {
327 |   // Destructure validated parameters for easier access
328 |   const {
329 |     targetType,
330 |     targetIdentifier,
331 |     replacements,
332 |     useRegex: initialUseRegex, // Rename to avoid shadowing loop variable
333 |     replaceAll,
334 |     caseSensitive,
335 |     flexibleWhitespace, // Note: Cannot be true if initialUseRegex is true (enforced by schema)
336 |     wholeWord,
337 |     returnContent,
338 |   } = params;
339 | 
340 |   let effectiveFilePath = targetIdentifier; // Store the path used (might be updated by fallback)
341 |   let targetDescription = targetIdentifier ?? "active file"; // For logging and error messages
342 |   let targetPeriod: z.infer<typeof PeriodicNotePeriodSchema> | undefined;
343 | 
344 |   logger.debug(`Processing obsidian_search_replace request`, {
345 |     ...context,
346 |     targetType,
347 |     targetIdentifier,
348 |     initialUseRegex,
349 |     flexibleWhitespace,
350 |     wholeWord,
351 |     returnContent,
352 |   });
353 | 
354 |   // --- Step 1: Read Initial Content (with case-insensitive fallback for filePath) ---
355 |   let originalContent: string;
356 |   const readContext = { ...context, operation: "readFileContent" };
357 |   try {
358 |     if (targetType === "filePath") {
359 |       if (!targetIdentifier) {
360 |         // Should be caught by schema, but double-check
361 |         throw new McpError(
362 |           BaseErrorCode.VALIDATION_ERROR,
363 |           "targetIdentifier is required for targetType 'filePath'.",
364 |           readContext,
365 |         );
366 |       }
367 |       targetDescription = targetIdentifier; // Initial description
368 |       try {
369 |         // Attempt 1: Case-sensitive read
370 |         logger.debug(
371 |           `Attempting to read file (case-sensitive): ${targetIdentifier}`,
372 |           readContext,
373 |         );
374 |         originalContent = (await obsidianService.getFileContent(
375 |           targetIdentifier,
376 |           "markdown",
377 |           readContext,
378 |         )) as string;
379 |         effectiveFilePath = targetIdentifier; // Confirm exact path worked
380 |         logger.debug(
381 |           `Successfully read file using exact path: ${targetIdentifier}`,
382 |           readContext,
383 |         );
384 |       } catch (readError) {
385 |         // Attempt 2: Case-insensitive fallback if NOT_FOUND
386 |         if (
387 |           readError instanceof McpError &&
388 |           readError.code === BaseErrorCode.NOT_FOUND
389 |         ) {
390 |           logger.info(
391 |             `File not found with exact path: ${targetIdentifier}. Attempting case-insensitive fallback.`,
392 |             readContext,
393 |           );
394 |           const dirname = path.posix.dirname(targetIdentifier);
395 |           const filenameLower = path.posix
396 |             .basename(targetIdentifier)
397 |             .toLowerCase();
398 |           // List directory contents (use root '/' if dirname is '.')
399 |           const dirToList = dirname === "." ? "/" : dirname;
400 |           const filesInDir = await obsidianService.listFiles(
401 |             dirToList,
402 |             readContext,
403 |           );
404 |           // Filter for files matching the lowercase basename
405 |           const matches = filesInDir.filter(
406 |             (f) =>
407 |               !f.endsWith("/") &&
408 |               path.posix.basename(f).toLowerCase() === filenameLower,
409 |           );
410 | 
411 |           if (matches.length === 1) {
412 |             // Found exactly one match
413 |             const correctFilename = path.posix.basename(matches[0]);
414 |             effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path
415 |             targetDescription = effectiveFilePath; // Update description for subsequent logs/errors
416 |             logger.info(
417 |               `Found case-insensitive match: ${effectiveFilePath}. Reading content.`,
418 |               readContext,
419 |             );
420 |             originalContent = (await obsidianService.getFileContent(
421 |               effectiveFilePath,
422 |               "markdown",
423 |               readContext,
424 |             )) as string;
425 |             logger.debug(
426 |               `Successfully read file using fallback path: ${effectiveFilePath}`,
427 |               readContext,
428 |             );
429 |           } else {
430 |             // Handle ambiguous (multiple matches) or no match found
431 |             const errorMsg =
432 |               matches.length > 1
433 |                 ? `Read failed: Ambiguous case-insensitive matches found for '${targetIdentifier}' in directory '${dirToList}'. Matches: [${matches.join(", ")}]`
434 |                 : `Read failed: File not found for '${targetIdentifier}' (case-insensitive fallback also failed in directory '${dirToList}').`;
435 |             logger.error(errorMsg, { ...readContext, matches });
436 |             // Use NOT_FOUND for no match, CONFLICT for ambiguity
437 |             throw new McpError(
438 |               matches.length > 1
439 |                 ? BaseErrorCode.CONFLICT
440 |                 : BaseErrorCode.NOT_FOUND,
441 |               errorMsg,
442 |               readContext,
443 |             );
444 |           }
445 |         } else {
446 |           // Re-throw errors other than NOT_FOUND during the initial read attempt
447 |           throw readError;
448 |         }
449 |       }
450 |     } else if (targetType === "activeFile") {
451 |       logger.debug(`Reading content from active file.`, readContext);
452 |       originalContent = (await obsidianService.getActiveFile(
453 |         "markdown",
454 |         readContext,
455 |       )) as string;
456 |       targetDescription = "the active file";
457 |       effectiveFilePath = undefined; // Not applicable
458 |       logger.debug(`Successfully read active file content.`, readContext);
459 |     } else {
460 |       // periodicNote
461 |       if (!targetIdentifier) {
462 |         // Should be caught by schema
463 |         throw new McpError(
464 |           BaseErrorCode.VALIDATION_ERROR,
465 |           "targetIdentifier is required for targetType 'periodicNote'.",
466 |           readContext,
467 |         );
468 |       }
469 |       // Parse period (already validated by refined schema)
470 |       targetPeriod = PeriodicNotePeriodSchema.parse(targetIdentifier);
471 |       targetDescription = `periodic note '${targetPeriod}'`;
472 |       effectiveFilePath = undefined; // Not applicable
473 |       logger.debug(`Reading content from ${targetDescription}.`, readContext);
474 |       originalContent = (await obsidianService.getPeriodicNote(
475 |         targetPeriod,
476 |         "markdown",
477 |         readContext,
478 |       )) as string;
479 |       logger.debug(
480 |         `Successfully read ${targetDescription} content.`,
481 |         readContext,
482 |       );
483 |     }
484 |   } catch (error) {
485 |     // Catch and handle errors during the initial read phase
486 |     if (error instanceof McpError) throw error; // Re-throw known McpErrors
487 |     const errorMessage = `Unexpected error reading target ${targetDescription} before search/replace.`;
488 |     logger.error(
489 |       errorMessage,
490 |       error instanceof Error ? error : undefined,
491 |       readContext,
492 |     );
493 |     throw new McpError(
494 |       BaseErrorCode.INTERNAL_ERROR,
495 |       `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
496 |       readContext,
497 |     );
498 |   }
499 | 
500 |   // --- Step 2: Perform Sequential Replacements ---
501 |   let modifiedContent = originalContent;
502 |   let totalReplacementsMade = 0;
503 |   const replaceContext = { ...context, operation: "performReplacements" };
504 | 
505 |   logger.debug(
506 |     `Starting ${replacements.length} replacement operations.`,
507 |     replaceContext,
508 |   );
509 | 
510 |   for (let i = 0; i < replacements.length; i++) {
511 |     const rep = replacements[i];
512 |     const repContext = {
513 |       ...replaceContext,
514 |       replacementIndex: i,
515 |       searchPattern: rep.search,
516 |     };
517 |     let currentReplacementsInBlock = 0;
518 |     let finalSearchPattern: string | RegExp = rep.search; // Start with the raw search string
519 |     let useRegexForThisRep = initialUseRegex; // Use the overall setting initially
520 | 
521 |     try {
522 |       // --- 2a: Prepare the Search Pattern (Apply options) ---
523 |       const patternPrepContext = {
524 |         ...repContext,
525 |         subOperation: "prepareSearchPattern",
526 |       };
527 |       if (!initialUseRegex) {
528 |         // Handle non-regex specific options: flexibleWhitespace and wholeWord
529 |         let searchStr = rep.search; // Work with a mutable string
530 |         if (flexibleWhitespace) {
531 |           // Convert to a regex string: escape special chars, then replace whitespace sequences with \s+
532 |           searchStr = escapeRegex(searchStr).replace(/\s+/g, "\\s+");
533 |           useRegexForThisRep = true; // Now treat it as a regex pattern string
534 |           logger.debug(
535 |             `Applying flexibleWhitespace: "${rep.search}" -> /${searchStr}/`,
536 |             patternPrepContext,
537 |           );
538 |         }
539 |         if (wholeWord) {
540 |           // Add word boundaries (\b) to the pattern string
541 |           // If flexibleWhitespace was applied, searchStr is already a regex string.
542 |           // Otherwise, escape the original search string first.
543 |           const basePattern = useRegexForThisRep
544 |             ? searchStr
545 |             : escapeRegex(searchStr);
546 |           searchStr = `\\b${basePattern}\\b`;
547 |           useRegexForThisRep = true; // Definitely treat as a regex pattern string now
548 |           logger.debug(
549 |             `Applying wholeWord: "${rep.search}" -> /${searchStr}/`,
550 |             patternPrepContext,
551 |           );
552 |         }
553 |         finalSearchPattern = searchStr; // Update the pattern to use
554 |       } else if (wholeWord) {
555 |         // Initial useRegex is true, but wholeWord is also requested.
556 |         // Apply wholeWord boundaries if the user's regex doesn't obviously have them.
557 |         let searchStr = rep.search;
558 |         // Heuristic check: Does the pattern likely already account for boundaries?
559 |         // Looks for ^, $, \b at start/end, or patterns matching full lines/non-whitespace sequences.
560 |         const hasBoundary =
561 |           /(?:^|\\b)\S.*\S(?:$|\\b)|^\S$|^\S.*\S$|^$/.test(searchStr) ||
562 |           /^\^|\\b/.test(searchStr) ||
563 |           /\$|\\b$/.test(searchStr);
564 |         if (!hasBoundary) {
565 |           searchStr = `\\b${searchStr}\\b`;
566 |           // Log a warning as this might interfere with complex user regex.
567 |           logger.warning(
568 |             `Applying wholeWord=true to user-provided regex. Original: /${rep.search}/, Modified: /${searchStr}/. This might affect complex regex behavior.`,
569 |             patternPrepContext,
570 |           );
571 |           finalSearchPattern = searchStr; // Update the pattern string
572 |         } else {
573 |           logger.debug(
574 |             `wholeWord=true requested, but user regex /${searchStr}/ appears to already contain boundary anchors. Using original regex.`,
575 |             patternPrepContext,
576 |           );
577 |           finalSearchPattern = rep.search; // Keep original regex string
578 |         }
579 |       }
580 |       // If it's still not treated as regex, finalSearchPattern remains the original rep.search string.
581 | 
582 |       // --- 2b: Execute the Replacement ---
583 |       const execContext = {
584 |         ...repContext,
585 |         subOperation: "executeReplacement",
586 |         isRegex: useRegexForThisRep,
587 |       };
588 |       if (useRegexForThisRep) {
589 |         // --- Regex Replacement ---
590 |         let flags = "";
591 |         if (replaceAll) flags += "g"; // Global flag for all matches
592 |         if (!caseSensitive) flags += "i"; // Ignore case flag
593 |         const regex = new RegExp(finalSearchPattern as string, flags); // Create RegExp object
594 |         logger.debug(
595 |           `Executing regex replacement: /${finalSearchPattern}/${flags}`,
596 |           execContext,
597 |         );
598 | 
599 |         // Count matches *before* replacing to report accurately
600 |         const matches = modifiedContent.match(regex);
601 |         currentReplacementsInBlock = matches ? matches.length : 0;
602 |         // If replaceAll is false, we only perform/count one replacement, even if regex matches more
603 |         if (!replaceAll && currentReplacementsInBlock > 0) {
604 |           currentReplacementsInBlock = 1;
605 |         }
606 | 
607 |         // Perform the replacement
608 |         if (currentReplacementsInBlock > 0) {
609 |           if (replaceAll) {
610 |             modifiedContent = modifiedContent.replace(regex, rep.replace);
611 |           } else {
612 |             // Replace only the first occurrence found by the regex
613 |             modifiedContent = modifiedContent.replace(regex, rep.replace);
614 |           }
615 |         }
616 |       } else {
617 |         // --- Simple String Replacement ---
618 |         // Note: wholeWord and flexibleWhitespace would have set useRegexForThisRep = true
619 |         const searchString = finalSearchPattern as string; // It's just a string here
620 |         const comparisonString = caseSensitive
621 |           ? searchString
622 |           : searchString.toLowerCase();
623 |         let startIndex = 0;
624 |         logger.debug(
625 |           `Executing string replacement: "${searchString}" (caseSensitive: ${caseSensitive})`,
626 |           execContext,
627 |         );
628 | 
629 |         while (true) {
630 |           const contentToSearch = caseSensitive
631 |             ? modifiedContent
632 |             : modifiedContent.toLowerCase();
633 |           const index = contentToSearch.indexOf(comparisonString, startIndex);
634 | 
635 |           if (index === -1) {
636 |             break; // No more occurrences found
637 |           }
638 | 
639 |           currentReplacementsInBlock++;
640 | 
641 |           // Perform replacement using original indices and search string length
642 |           modifiedContent =
643 |             modifiedContent.substring(0, index) +
644 |             rep.replace +
645 |             modifiedContent.substring(index + searchString.length);
646 | 
647 |           if (!replaceAll) {
648 |             break; // Stop after the first replacement
649 |           }
650 | 
651 |           // Move start index past the inserted replacement to find the next match
652 |           startIndex = index + rep.replace.length;
653 | 
654 |           // Safety break for empty search string or potential infinite loops
655 |           if (searchString.length === 0) {
656 |             logger.warning(
657 |               `Search string is empty. Breaking replacement loop to prevent infinite execution.`,
658 |               execContext,
659 |             );
660 |             break;
661 |           }
662 |           // Basic check if replacement could cause infinite loop (e.g., replacing 'a' with 'ba')
663 |           if (
664 |             rep.replace.includes(searchString) &&
665 |             rep.replace.length >= searchString.length
666 |           ) {
667 |             // This is a heuristic, might not catch all cases but prevents common ones.
668 |             logger.warning(
669 |               `Replacement string "${rep.replace}" contains search string "${searchString}". Potential infinite loop detected. Breaking loop for this block.`,
670 |               execContext,
671 |             );
672 |             break;
673 |           }
674 |         }
675 |       }
676 |       totalReplacementsMade += currentReplacementsInBlock;
677 |       logger.debug(
678 |         `Block ${i}: Performed ${currentReplacementsInBlock} replacements for search: "${rep.search}"`,
679 |         repContext,
680 |       );
681 |     } catch (error) {
682 |       // Catch errors during a specific replacement block
683 |       const errorMessage = `Error during replacement block ${i} (search: "${rep.search}")`;
684 |       logger.error(
685 |         errorMessage,
686 |         error instanceof Error ? error : undefined,
687 |         repContext,
688 |       );
689 |       // Fail fast: Stop processing further replacements if one block fails.
690 |       throw new McpError(
691 |         BaseErrorCode.INTERNAL_ERROR,
692 |         `${errorMessage}: ${error instanceof Error ? error.message : "Unknown error"}`,
693 |         repContext,
694 |       );
695 |     }
696 |   } // End of replacements loop
697 | 
698 |   logger.debug(
699 |     `Finished all replacement operations. Total replacements made: ${totalReplacementsMade}`,
700 |     replaceContext,
701 |   );
702 | 
703 |   // --- Step 3: Write Modified Content Back to Obsidian ---
704 |   let finalState: NoteJson | null = null;
705 |   const POST_UPDATE_DELAY_MS = 500; // Delay before trying to read the file back
706 | 
707 |   // Only write back if the content actually changed to avoid unnecessary file operations.
708 |   if (modifiedContent !== originalContent) {
709 |     const writeContext = { ...context, operation: "writeFileContent" };
710 |     try {
711 |       logger.debug(
712 |         `Content changed. Writing modified content back to ${targetDescription}`,
713 |         writeContext,
714 |       );
715 |       // Use the effectiveFilePath determined during the read phase for filePath targets
716 |       if (targetType === "filePath") {
717 |         await obsidianService.updateFileContent(
718 |           effectiveFilePath!,
719 |           modifiedContent,
720 |           writeContext,
721 |         );
722 |         if (vaultCacheService) {
723 |           await vaultCacheService.updateCacheForFile(
724 |             effectiveFilePath!,
725 |             writeContext,
726 |           );
727 |         }
728 |       } else if (targetType === "activeFile") {
729 |         await obsidianService.updateActiveFile(modifiedContent, writeContext);
730 |       } else {
731 |         // periodicNote
732 |         await obsidianService.updatePeriodicNote(
733 |           targetPeriod!,
734 |           modifiedContent,
735 |           writeContext,
736 |         );
737 |       }
738 |       logger.info(
739 |         `Successfully updated ${targetDescription} with ${totalReplacementsMade} replacement(s).`,
740 |         writeContext,
741 |       );
742 | 
743 |       // Attempt to get the final state *after* successfully writing.
744 |       logger.debug(
745 |         `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state after write...`,
746 |         { ...writeContext, subOperation: "postWriteDelay" },
747 |       );
748 |       await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS));
749 |       try {
750 |         finalState = await retryWithDelay(
751 |           async () =>
752 |             getFinalState(
753 |               targetType,
754 |               effectiveFilePath,
755 |               targetPeriod,
756 |               obsidianService,
757 |               context,
758 |             ),
759 |           {
760 |             operationName: "getFinalStateAfterSearchReplaceWrite",
761 |             context: {
762 |               ...context,
763 |               operation: "getFinalStateAfterSearchReplaceWriteAttempt",
764 |             },
765 |             maxRetries: 3,
766 |             delayMs: 300,
767 |             shouldRetry: (err: unknown) =>
768 |               err instanceof McpError &&
769 |               (err.code === BaseErrorCode.NOT_FOUND ||
770 |                 err.code === BaseErrorCode.SERVICE_UNAVAILABLE ||
771 |                 err.code === BaseErrorCode.TIMEOUT),
772 |             onRetry: (attempt, err) =>
773 |               logger.warning(
774 |                 `getFinalStateAfterSearchReplaceWrite (attempt ${attempt}) failed. Error: ${(err as Error).message}. Retrying...`,
775 |                 writeContext,
776 |               ),
777 |           },
778 |         );
779 |       } catch (retryError) {
780 |         finalState = null;
781 |         logger.error(
782 |           `Failed to retrieve final state for ${targetDescription} after write, even after retries. Error: ${(retryError as Error).message}`,
783 |           retryError instanceof Error ? retryError : undefined,
784 |           writeContext,
785 |         );
786 |       }
787 |     } catch (error) {
788 |       // Handle errors during the write phase
789 |       if (error instanceof McpError) throw error; // Re-throw known McpErrors
790 |       const errorMessage = `Unexpected error writing modified content to ${targetDescription}.`;
791 |       logger.error(
792 |         errorMessage,
793 |         error instanceof Error ? error : undefined,
794 |         writeContext,
795 |       );
796 |       throw new McpError(
797 |         BaseErrorCode.INTERNAL_ERROR,
798 |         `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
799 |         writeContext,
800 |       );
801 |     }
802 |   } else {
803 |     // Content did not change, no need to write.
804 |     logger.info(
805 |       `No changes detected in ${targetDescription} after search/replace operations. Skipping write.`,
806 |       context,
807 |     );
808 |     // Still attempt to get the state, as the user might want stats even if content is unchanged.
809 |     logger.debug(
810 |       `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state (no change)...`,
811 |       { ...context, subOperation: "postNoChangeDelay" },
812 |     );
813 |     await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS));
814 |     try {
815 |       finalState = await retryWithDelay(
816 |         async () =>
817 |           getFinalState(
818 |             targetType,
819 |             effectiveFilePath,
820 |             targetPeriod,
821 |             obsidianService,
822 |             context,
823 |           ),
824 |         {
825 |           operationName: "getFinalStateAfterSearchReplaceNoChange",
826 |           context: {
827 |             ...context,
828 |             operation: "getFinalStateAfterSearchReplaceNoChangeAttempt",
829 |           },
830 |           maxRetries: 3,
831 |           delayMs: 300,
832 |           shouldRetry: (err: unknown) =>
833 |             err instanceof McpError &&
834 |             (err.code === BaseErrorCode.NOT_FOUND ||
835 |               err.code === BaseErrorCode.SERVICE_UNAVAILABLE ||
836 |               err.code === BaseErrorCode.TIMEOUT),
837 |           onRetry: (attempt, err) =>
838 |             logger.warning(
839 |               `getFinalStateAfterSearchReplaceNoChange (attempt ${attempt}) failed. Error: ${(err as Error).message}. Retrying...`,
840 |               context,
841 |             ),
842 |         },
843 |       );
844 |     } catch (retryError) {
845 |       finalState = null;
846 |       logger.error(
847 |         `Failed to retrieve final state for ${targetDescription} (no change), even after retries. Error: ${(retryError as Error).message}`,
848 |         retryError instanceof Error ? retryError : undefined,
849 |         context,
850 |       );
851 |     }
852 |   }
853 | 
854 |   // --- Step 4: Construct and Return the Response ---
855 |   const responseContext = { ...context, operation: "buildResponse" };
856 |   let message: string;
857 |   if (totalReplacementsMade > 0) {
858 |     message = `Search/replace completed on ${targetDescription}. Successfully made ${totalReplacementsMade} replacement(s).`;
859 |   } else if (modifiedContent !== originalContent) {
860 |     // This case should ideally not happen if totalReplacementsMade is 0, but as a safeguard:
861 |     message = `Search/replace completed on ${targetDescription}. Content was modified, but replacement count is zero. Please review.`;
862 |   } else {
863 |     message = `Search/replace completed on ${targetDescription}. No matching text was found, so no replacements were made.`;
864 |   }
865 | 
866 |   // Append a warning if the final state couldn't be retrieved
867 |   if (finalState === null) {
868 |     const warningMsg =
869 |       " (Warning: Could not retrieve final file stats/content after update.)";
870 |     message += warningMsg;
871 |     logger.warning(
872 |       `Appending warning to response message: ${warningMsg}`,
873 |       responseContext,
874 |     );
875 |   }
876 | 
877 |   // Format the file statistics using the shared utility.
878 |   // Use final state content if available, otherwise use the (potentially modified) content in memory for token count.
879 |   const finalContentForStat = finalState?.content ?? modifiedContent;
880 |   const formattedStatResult = finalState?.stat
881 |     ? await createFormattedStatWithTokenCount(
882 |         finalState.stat,
883 |         finalContentForStat,
884 |         responseContext,
885 |       ) // Await the async utility
886 |     : undefined;
887 |   // Ensure stat is undefined if the utility returned null (e.g., token counting failed)
888 |   const formattedStat =
889 |     formattedStatResult === null ? undefined : formattedStatResult;
890 | 
891 |   // Build the final response object
892 |   const response: ObsidianSearchReplaceResponse = {
893 |     success: true,
894 |     message: message,
895 |     totalReplacementsMade,
896 |     stats: formattedStat,
897 |   };
898 | 
899 |   // Include final content if requested and available.
900 |   if (returnContent) {
901 |     // Prefer content from final state read, fallback to in-memory modified content.
902 |     response.finalContent = finalState?.content ?? modifiedContent;
903 |     logger.debug(
904 |       `Including final content in response as requested.`,
905 |       responseContext,
906 |     );
907 |   }
908 | 
909 |   logger.debug(
910 |     `Search/replace process completed successfully.`,
911 |     responseContext,
912 |   );
913 |   return response;
914 | };
915 | 
```
Page 4/5FirstPrevNextLast