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 | ```