#
tokens: 46841/50000 11/89 files (page 3/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 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/utils/security/idGenerator.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Provides a generic ID generator class for creating unique,
  3 |  * prefixed identifiers and standard UUIDs. It supports custom character sets,
  4 |  * lengths, and separators for generated IDs.
  5 |  * @module src/utils/security/idGenerator
  6 |  */
  7 | 
  8 | import { randomBytes, randomUUID as cryptoRandomUUID } from "crypto";
  9 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 10 | // Logger is not directly used in this module after previous refactoring, which is fine.
 11 | // If logging were to be added (e.g., for prefix registration), RequestContext would be needed.
 12 | // import { logger, RequestContext } from '../internal/index.js';
 13 | 
 14 | /**
 15 |  * Defines the structure for configuring entity prefixes, mapping entity types (strings)
 16 |  * to their corresponding ID prefixes (strings).
 17 |  */
 18 | export interface EntityPrefixConfig {
 19 |   [key: string]: string;
 20 | }
 21 | 
 22 | /**
 23 |  * Options for customizing ID generation.
 24 |  */
 25 | export interface IdGenerationOptions {
 26 |   /** The length of the random part of the ID. Defaults to `IdGenerator.DEFAULT_LENGTH`. */
 27 |   length?: number;
 28 |   /** The separator string used between a prefix and the random part. Defaults to `IdGenerator.DEFAULT_SEPARATOR`. */
 29 |   separator?: string;
 30 |   /** The character set from which the random part of the ID is generated. Defaults to `IdGenerator.DEFAULT_CHARSET`. */
 31 |   charset?: string;
 32 | }
 33 | 
 34 | /**
 35 |  * A generic ID Generator class for creating and managing unique identifiers.
 36 |  * It can generate IDs with entity-specific prefixes or standard UUIDs.
 37 |  */
 38 | export class IdGenerator {
 39 |   /** Default character set for the random part of generated IDs (uppercase alphanumeric). */
 40 |   private static DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
 41 |   /** Default separator used between a prefix and the random part of an ID. */
 42 |   private static DEFAULT_SEPARATOR = "_";
 43 |   /** Default length for the random part of generated IDs. */
 44 |   private static DEFAULT_LENGTH = 6;
 45 | 
 46 |   private entityPrefixes: EntityPrefixConfig = {};
 47 |   private prefixToEntityType: Record<string, string> = {};
 48 | 
 49 |   /**
 50 |    * Constructs an `IdGenerator` instance.
 51 |    * @param {EntityPrefixConfig} [entityPrefixes={}] - An optional map of entity types
 52 |    *   to their desired ID prefixes (e.g., `{ project: 'PROJ', task: 'TASK' }`).
 53 |    */
 54 |   constructor(entityPrefixes: EntityPrefixConfig = {}) {
 55 |     this.setEntityPrefixes(entityPrefixes);
 56 |   }
 57 | 
 58 |   /**
 59 |    * Sets or updates the entity prefix configuration and rebuilds the internal
 60 |    * reverse lookup table (prefix to entity type).
 61 |    * @param {EntityPrefixConfig} entityPrefixes - A map of entity types to their prefixes.
 62 |    */
 63 |   public setEntityPrefixes(entityPrefixes: EntityPrefixConfig): void {
 64 |     this.entityPrefixes = { ...entityPrefixes }; // Create a copy
 65 | 
 66 |     // Rebuild reverse mapping for efficient lookup (case-insensitive for prefix matching)
 67 |     this.prefixToEntityType = Object.entries(this.entityPrefixes).reduce(
 68 |       (acc, [type, prefix]) => {
 69 |         acc[prefix.toUpperCase()] = type; // Store prefix in uppercase for consistent lookup
 70 |         // Consider if lowercase or original case mapping is also needed based on expected input.
 71 |         // For now, assuming prefixes are matched case-insensitively by uppercasing input prefix.
 72 |         return acc;
 73 |       },
 74 |       {} as Record<string, string>,
 75 |     );
 76 |   }
 77 | 
 78 |   /**
 79 |    * Retrieves a copy of the current entity prefix configuration.
 80 |    * @returns {EntityPrefixConfig} The current entity prefix configuration.
 81 |    */
 82 |   public getEntityPrefixes(): EntityPrefixConfig {
 83 |     return { ...this.entityPrefixes };
 84 |   }
 85 | 
 86 |   /**
 87 |    * Generates a cryptographically secure random string of a specified length
 88 |    * from a given character set.
 89 |    * @param {number} [length=IdGenerator.DEFAULT_LENGTH] - The desired length of the random string.
 90 |    * @param {string} [charset=IdGenerator.DEFAULT_CHARSET] - The character set to use for generation.
 91 |    * @returns {string} A random string.
 92 |    */
 93 |   public generateRandomString(
 94 |     length: number = IdGenerator.DEFAULT_LENGTH,
 95 |     charset: string = IdGenerator.DEFAULT_CHARSET,
 96 |   ): string {
 97 |     if (length <= 0) {
 98 |       return "";
 99 |     }
100 |     const bytes = randomBytes(length);
101 |     let result = "";
102 |     for (let i = 0; i < length; i++) {
103 |       result += charset[bytes[i] % charset.length];
104 |     }
105 |     return result;
106 |   }
107 | 
108 |   /**
109 |    * Generates a unique ID, optionally with a specified prefix.
110 |    * @param {string} [prefix] - An optional prefix for the ID.
111 |    * @param {IdGenerationOptions} [options={}] - Optional parameters for customizing
112 |    *   the length, separator, and charset of the random part of the ID.
113 |    * @returns {string} A unique identifier string.
114 |    */
115 |   public generate(prefix?: string, options: IdGenerationOptions = {}): string {
116 |     const {
117 |       length = IdGenerator.DEFAULT_LENGTH,
118 |       separator = IdGenerator.DEFAULT_SEPARATOR,
119 |       charset = IdGenerator.DEFAULT_CHARSET,
120 |     } = options;
121 | 
122 |     const randomPart = this.generateRandomString(length, charset);
123 | 
124 |     return prefix ? `${prefix}${separator}${randomPart}` : randomPart;
125 |   }
126 | 
127 |   /**
128 |    * Generates a unique ID for a specified entity type, using its configured prefix.
129 |    * The format is typically `PREFIX_RANDOMPART`.
130 |    * @param {string} entityType - The type of entity for which to generate an ID (must be registered
131 |    *   via `setEntityPrefixes` or constructor).
132 |    * @param {IdGenerationOptions} [options={}] - Optional parameters for customizing the ID generation.
133 |    * @returns {string} A unique identifier string for the entity (e.g., "PROJ_A6B3J0").
134 |    * @throws {McpError} If the `entityType` is not registered (i.e., no prefix is configured for it).
135 |    */
136 |   public generateForEntity(
137 |     entityType: string,
138 |     options: IdGenerationOptions = {},
139 |   ): string {
140 |     const prefix = this.entityPrefixes[entityType];
141 |     if (!prefix) {
142 |       throw new McpError(
143 |         BaseErrorCode.VALIDATION_ERROR,
144 |         `Unknown entity type: "${entityType}". No prefix configured.`,
145 |       );
146 |     }
147 |     return this.generate(prefix, options);
148 |   }
149 | 
150 |   /**
151 |    * Validates if a given ID string matches the expected format for a specified entity type,
152 |    * including its prefix, separator, and random part characteristics.
153 |    * @param {string} id - The ID string to validate.
154 |    * @param {string} entityType - The expected entity type of the ID.
155 |    * @param {IdGenerationOptions} [options={}] - Optional parameters to specify the expected
156 |    *   length and separator if they differ from defaults for this validation.
157 |    * @returns {boolean} `true` if the ID is valid for the entity type, `false` otherwise.
158 |    */
159 |   public isValid(
160 |     id: string,
161 |     entityType: string,
162 |     options: IdGenerationOptions = {},
163 |   ): boolean {
164 |     const prefix = this.entityPrefixes[entityType];
165 |     if (!prefix) {
166 |       return false; // Cannot validate if entity type or prefix is unknown
167 |     }
168 | 
169 |     const {
170 |       length = IdGenerator.DEFAULT_LENGTH,
171 |       separator = IdGenerator.DEFAULT_SEPARATOR,
172 |       // charset is not used for regex validation but for generation
173 |     } = options;
174 | 
175 |     // Regex assumes default charset (A-Z, 0-9). If charset is customizable for validation,
176 |     // the regex would need to be dynamically built or options.charset used.
177 |     // For now, it matches the default generation charset.
178 |     const pattern = new RegExp(`^${prefix}${separator}[A-Z0-9]{${length}}$`);
179 |     return pattern.test(id);
180 |   }
181 | 
182 |   /**
183 |    * Strips the prefix from a prefixed ID string.
184 |    * @param {string} id - The ID string (e.g., "PROJ_A6B3J0").
185 |    * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
186 |    * @returns {string} The part of the ID after the first separator, or the original ID if no separator is found.
187 |    */
188 |   public stripPrefix(
189 |     id: string,
190 |     separator: string = IdGenerator.DEFAULT_SEPARATOR,
191 |   ): string {
192 |     const parts = id.split(separator);
193 |     return parts.length > 1 ? parts.slice(1).join(separator) : id; // Handle cases with multiple separators in random part
194 |   }
195 | 
196 |   /**
197 |    * Determines the entity type from a prefixed ID string.
198 |    * @param {string} id - The ID string (e.g., "PROJ_A6B3J0").
199 |    * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
200 |    * @returns {string} The determined entity type.
201 |    * @throws {McpError} If the ID format is invalid or the prefix does not map to a known entity type.
202 |    */
203 |   public getEntityType(
204 |     id: string,
205 |     separator: string = IdGenerator.DEFAULT_SEPARATOR,
206 |   ): string {
207 |     const parts = id.split(separator);
208 |     if (parts.length < 2 || !parts[0]) {
209 |       // Need at least a prefix and a random part
210 |       throw new McpError(
211 |         BaseErrorCode.VALIDATION_ERROR,
212 |         `Invalid ID format: "${id}". Expected format like "PREFIX${separator}RANDOMPART".`,
213 |       );
214 |     }
215 | 
216 |     const inputPrefix = parts[0].toUpperCase(); // Match prefix case-insensitively
217 |     const entityType = this.prefixToEntityType[inputPrefix];
218 | 
219 |     if (!entityType) {
220 |       throw new McpError(
221 |         BaseErrorCode.VALIDATION_ERROR,
222 |         `Unknown entity type for prefix: "${parts[0]}" in ID "${id}".`,
223 |       );
224 |     }
225 |     return entityType;
226 |   }
227 | 
228 |   /**
229 |    * Normalizes an entity ID to ensure the prefix matches the configured case
230 |    * (if specific casing is important for the system) and the random part is uppercase.
231 |    * This implementation assumes prefixes are stored/used consistently and focuses on random part casing.
232 |    * @param {string} id - The ID to normalize.
233 |    * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
234 |    * @returns {string} The normalized ID string.
235 |    * @throws {McpError} If the entity type cannot be determined from the ID.
236 |    */
237 |   public normalize(
238 |     id: string,
239 |     separator: string = IdGenerator.DEFAULT_SEPARATOR,
240 |   ): string {
241 |     // This will throw if entity type is not found or ID format is wrong
242 |     const entityType = this.getEntityType(id, separator);
243 |     const configuredPrefix = this.entityPrefixes[entityType]; // Get the canonical prefix
244 | 
245 |     const parts = id.split(separator);
246 |     const randomPart = parts.slice(1).join(separator); // Re-join if separator was in random part
247 | 
248 |     return `${configuredPrefix}${separator}${randomPart.toUpperCase()}`;
249 |   }
250 | }
251 | 
252 | /**
253 |  * A default, shared instance of the `IdGenerator`.
254 |  * This instance can be configured with entity prefixes at application startup
255 |  * or used directly for generating unprefixed random IDs or UUIDs.
256 |  *
257 |  * Example:
258 |  * ```typescript
259 |  * import { idGenerator, generateUUID } from './idGenerator';
260 |  *
261 |  * // Configure prefixes (optional, typically at app start)
262 |  * idGenerator.setEntityPrefixes({ user: 'USR', order: 'ORD' });
263 |  *
264 |  * const userId = idGenerator.generateForEntity('user'); // e.g., USR_X7V2L9
265 |  * const simpleId = idGenerator.generate(); // e.g., K3P8A1
266 |  * const standardUuid = generateUUID(); // e.g., '123e4567-e89b-12d3-a456-426614174000'
267 |  * ```
268 |  */
269 | export const idGenerator = new IdGenerator();
270 | 
271 | /**
272 |  * Generates a standard Version 4 UUID (Universally Unique Identifier).
273 |  * Uses the `crypto.randomUUID()` method for cryptographically strong randomness.
274 |  * @returns {string} A UUID string (e.g., "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx").
275 |  */
276 | export const generateUUID = (): string => {
277 |   return cryptoRandomUUID();
278 | };
279 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Core logic for the 'obsidian_list_notes' tool.
  3 |  * This module defines the input schema, response types, and processing logic for
  4 |  * recursively listing files and directories in an Obsidian vault with filtering.
  5 |  * @module src/mcp-server/tools/obsidianListNotesTool/logic
  6 |  */
  7 | 
  8 | import path from "node:path";
  9 | import { z } from "zod";
 10 | import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
 11 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
 12 | import {
 13 |   logger,
 14 |   RequestContext,
 15 |   retryWithDelay,
 16 | } from "../../../utils/index.js";
 17 | 
 18 | // ====================================================================================
 19 | // Schema Definitions for Input Validation
 20 | // ====================================================================================
 21 | 
 22 | /**
 23 |  * Zod schema for validating the input parameters of the 'obsidian_list_notes' tool.
 24 |  */
 25 | export const ObsidianListNotesInputSchema = z
 26 |   .object({
 27 |     /**
 28 |      * The vault-relative path to the directory whose contents should be listed.
 29 |      * The path is treated as case-sensitive by the underlying Obsidian API.
 30 |      */
 31 |     dirPath: z
 32 |       .string()
 33 |       .describe(
 34 |         'The vault-relative path to the directory to list (e.g., "developer/atlas-mcp-server", "/" for root). Case-sensitive.',
 35 |       ),
 36 |     /**
 37 |      * Optional array of file extensions (including the leading dot) to filter the results.
 38 |      * Only files matching one of these extensions will be included. Directories are always included.
 39 |      */
 40 |     fileExtensionFilter: z
 41 |       .array(z.string().startsWith(".", "Extension must start with a dot '.'"))
 42 |       .optional()
 43 |       .describe(
 44 |         'Optional array of file extensions (e.g., [".md"]) to filter files. Directories are always included.',
 45 |       ),
 46 |     /**
 47 |      * Optional JavaScript-compatible regular expression pattern string to filter results by name.
 48 |      * Only files and directories whose names match the regex will be included.
 49 |      */
 50 |     nameRegexFilter: z
 51 |       .string()
 52 |       .nullable()
 53 |       .optional()
 54 |       .describe(
 55 |         "Optional regex pattern (JavaScript syntax) to filter results by name.",
 56 |       ),
 57 |     /**
 58 |      * The maximum depth of subdirectories to list recursively.
 59 |      * - A value of `0` lists only the files and directories in the specified `dirPath`.
 60 |      * - A value of `1` lists the contents of `dirPath` and the contents of its immediate subdirectories.
 61 |      * - A value of `-1` (the default) indicates infinite recursion, listing all subdirectories.
 62 |      */
 63 |     recursionDepth: z
 64 |       .number()
 65 |       .int()
 66 |       .default(-1)
 67 |       .describe(
 68 |         "Maximum recursion depth. 0 for no recursion, -1 for infinite (default).",
 69 |       ),
 70 |   })
 71 |   .describe(
 72 |     "Input parameters for listing files and subdirectories within a specified Obsidian vault directory, with optional filtering and recursion.",
 73 |   );
 74 | 
 75 | /**
 76 |  * TypeScript type inferred from the input schema (`ObsidianListNotesInputSchema`).
 77 |  */
 78 | export type ObsidianListNotesInput = z.infer<
 79 |   typeof ObsidianListNotesInputSchema
 80 | >;
 81 | 
 82 | // ====================================================================================
 83 | // Response & Internal Type Definitions
 84 | // ====================================================================================
 85 | 
 86 | /**
 87 |  * Defines the structure of a node in the file tree.
 88 |  */
 89 | interface FileTreeNode {
 90 |   name: string;
 91 |   type: "file" | "directory";
 92 |   children: FileTreeNode[];
 93 | }
 94 | 
 95 | /**
 96 |  * Defines the structure of the successful response returned by the core logic function.
 97 |  */
 98 | export interface ObsidianListNotesResponse {
 99 |   directoryPath: string;
100 |   tree: string;
101 |   totalEntries: number;
102 | }
103 | 
104 | // ====================================================================================
105 | // Helper Functions
106 | // ====================================================================================
107 | 
108 | /**
109 |  * Recursively builds a formatted tree string from a nested array of FileTreeNode objects.
110 |  *
111 |  * @param {FileTreeNode[]} nodes - The array of nodes to format.
112 |  * @param {string} [indent=""] - The indentation prefix for the current level.
113 |  * @returns {{ tree: string, count: number }} An object containing the formatted tree string and the total count of entries.
114 |  */
115 | function formatTree(
116 |   nodes: FileTreeNode[],
117 |   indent = "",
118 | ): { tree: string; count: number } {
119 |   let treeString = "";
120 |   let count = nodes.length;
121 | 
122 |   nodes.forEach((node, index) => {
123 |     const isLast = index === nodes.length - 1;
124 |     const prefix = isLast ? "└── " : "├── ";
125 |     const childIndent = isLast ? "    " : "│   ";
126 | 
127 |     treeString += `${indent}${prefix}${node.name}\n`;
128 | 
129 |     if (node.children && node.children.length > 0) {
130 |       const result = formatTree(node.children, indent + childIndent);
131 |       treeString += result.tree;
132 |       count += result.count;
133 |     }
134 |   });
135 | 
136 |   return { tree: treeString, count };
137 | }
138 | 
139 | /**
140 |  * Recursively builds a file tree by fetching directory contents from the Obsidian API.
141 |  *
142 |  * @param {string} dirPath - The path of the directory to process.
143 |  * @param {number} currentDepth - The current recursion depth.
144 |  * @param {ObsidianListNotesInput} params - The original validated input parameters, including filters and max depth.
145 |  * @param {RequestContext} context - The request context for logging.
146 |  * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
147 |  * @returns {Promise<FileTreeNode[]>} A promise that resolves to an array of file tree nodes.
148 |  */
149 | async function buildFileTree(
150 |   dirPath: string,
151 |   currentDepth: number,
152 |   params: ObsidianListNotesInput,
153 |   context: RequestContext,
154 |   obsidianService: ObsidianRestApiService,
155 | ): Promise<FileTreeNode[]> {
156 |   const { recursionDepth, fileExtensionFilter, nameRegexFilter } = params;
157 | 
158 |   // Stop recursion if max depth is reached (and it's not infinite)
159 |   if (recursionDepth !== -1 && currentDepth > recursionDepth) {
160 |     return [];
161 |   }
162 | 
163 |   let fileNames;
164 |   try {
165 |     fileNames = await obsidianService.listFiles(dirPath, context);
166 |   } catch (error) {
167 |     if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
168 |       logger.warning(
169 |         `Directory not found during recursive list: ${dirPath}. Skipping.`,
170 |         context,
171 |       );
172 |       return []; // Return empty array if a subdirectory is not found
173 |     }
174 |     throw error; // Re-throw other errors
175 |   }
176 | 
177 |   const regex =
178 |     nameRegexFilter && nameRegexFilter.trim() !== ""
179 |       ? new RegExp(nameRegexFilter)
180 |       : null;
181 | 
182 |   const treeNodes: FileTreeNode[] = [];
183 | 
184 |   for (const name of fileNames) {
185 |     const fullPath = path.posix.join(dirPath, name);
186 |     const isDirectory = name.endsWith("/");
187 |     const cleanName = isDirectory ? name.slice(0, -1) : name;
188 | 
189 |     // Apply filters
190 |     if (regex && !regex.test(cleanName)) {
191 |       continue;
192 |     }
193 |     if (!isDirectory && fileExtensionFilter && fileExtensionFilter.length > 0) {
194 |       const extension = path.posix.extname(name);
195 |       if (!fileExtensionFilter.includes(extension)) {
196 |         continue;
197 |       }
198 |     }
199 | 
200 |     const node: FileTreeNode = {
201 |       name: cleanName,
202 |       type: isDirectory ? "directory" : "file",
203 |       children: [],
204 |     };
205 | 
206 |     if (isDirectory) {
207 |       node.name += "/"; // Add trailing slash back for display
208 |       node.children = await buildFileTree(
209 |         fullPath,
210 |         currentDepth + 1,
211 |         params,
212 |         context,
213 |         obsidianService,
214 |       );
215 |     }
216 | 
217 |     treeNodes.push(node);
218 |   }
219 | 
220 |   // Sort entries: directories first, then files, alphabetically
221 |   treeNodes.sort((a, b) => {
222 |     if (a.type === "directory" && b.type === "file") return -1;
223 |     if (a.type === "file" && b.type === "directory") return 1;
224 |     return a.name.localeCompare(b.name);
225 |   });
226 | 
227 |   return treeNodes;
228 | }
229 | 
230 | // ====================================================================================
231 | // Core Logic Function
232 | // ====================================================================================
233 | 
234 | /**
235 |  * Processes the core logic for listing files and directories recursively within the Obsidian vault.
236 |  *
237 |  * @param {ObsidianListNotesInput} params - The validated input parameters.
238 |  * @param {RequestContext} context - The request context for logging and correlation.
239 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
240 |  * @returns {Promise<ObsidianListNotesResponse>} A promise resolving to the structured success response.
241 |  * @throws {McpError} Throws an McpError if the initial directory is not found or another error occurs.
242 |  */
243 | export const processObsidianListNotes = async (
244 |   params: ObsidianListNotesInput,
245 |   context: RequestContext,
246 |   obsidianService: ObsidianRestApiService,
247 | ): Promise<ObsidianListNotesResponse> => {
248 |   const { dirPath } = params;
249 |   const dirPathForLog = dirPath === "" || dirPath === "/" ? "/" : dirPath;
250 | 
251 |   logger.debug(
252 |     `Processing obsidian_list_notes request for path: ${dirPathForLog}`,
253 |     { ...context, params },
254 |   );
255 | 
256 |   try {
257 |     const effectiveDirPath = dirPath === "" ? "/" : dirPath;
258 | 
259 |     // --- Step 1: Build the file tree recursively with retry for the initial call ---
260 |     const buildTreeContext = {
261 |       ...context,
262 |       operation: "buildFileTreeWithRetry",
263 |     };
264 |     const shouldRetryNotFound = (err: unknown) =>
265 |       err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
266 | 
267 |     const fileTree = await retryWithDelay(
268 |       () =>
269 |         buildFileTree(
270 |           effectiveDirPath,
271 |           0, // Start at depth 0
272 |           params,
273 |           buildTreeContext,
274 |           obsidianService,
275 |         ),
276 |       {
277 |         operationName: "buildFileTreeWithRetry",
278 |         context: buildTreeContext,
279 |         maxRetries: 3,
280 |         delayMs: 300,
281 |         shouldRetry: shouldRetryNotFound,
282 |       },
283 |     );
284 | 
285 |     // --- Step 2: Format the tree and count entries ---
286 |     const formatContext = { ...context, operation: "formatResponse" };
287 |     if (fileTree.length === 0) {
288 |       logger.debug(
289 |         "Directory is empty or all items were filtered out.",
290 |         formatContext,
291 |       );
292 |       return {
293 |         directoryPath: dirPathForLog,
294 |         tree: "(empty or all items filtered)",
295 |         totalEntries: 0,
296 |       };
297 |     }
298 | 
299 |     const { tree, count } = formatTree(fileTree);
300 | 
301 |     // --- Step 3: Construct and return the response ---
302 |     const response: ObsidianListNotesResponse = {
303 |       directoryPath: dirPathForLog,
304 |       tree: tree.trimEnd(), // Remove trailing newline
305 |       totalEntries: count,
306 |     };
307 | 
308 |     logger.debug(
309 |       `Successfully processed list request for ${dirPathForLog}. Found ${count} entries.`,
310 |       context,
311 |     );
312 |     return response;
313 |   } catch (error) {
314 |     if (error instanceof McpError) {
315 |       // Provide a more specific message if the directory wasn't found after retries
316 |       if (error.code === BaseErrorCode.NOT_FOUND) {
317 |         const notFoundMsg = `Directory not found after retries: ${dirPathForLog}`;
318 |         logger.error(notFoundMsg, error, context);
319 |         throw new McpError(error.code, notFoundMsg, context);
320 |       }
321 |       logger.error(
322 |         `McpError during file listing for ${dirPathForLog}: ${error.message}`,
323 |         error,
324 |         context,
325 |       );
326 |       throw error;
327 |     }
328 | 
329 |     const errorMessage = `Unexpected error listing Obsidian files in ${dirPathForLog}`;
330 |     logger.error(
331 |       errorMessage,
332 |       error instanceof Error ? error : undefined,
333 |       context,
334 |     );
335 |     throw new McpError(
336 |       BaseErrorCode.INTERNAL_ERROR,
337 |       `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
338 |       context,
339 |     );
340 |   }
341 | };
342 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Provides a generic rate limiter class to manage request rates
  3 |  * based on configurable time windows and request counts. It supports custom
  4 |  * key generation, periodic cleanup of expired entries, and skipping rate
  5 |  * limiting in development environments.
  6 |  * @module src/utils/security/rateLimiter
  7 |  */
  8 | 
  9 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 10 | import { environment } from "../../config/index.js";
 11 | import {
 12 |   logger,
 13 |   RequestContext,
 14 |   requestContextService,
 15 | } from "../internal/index.js"; // Use internal index for RequestContext
 16 | 
 17 | /**
 18 |  * Configuration options for the {@link RateLimiter}.
 19 |  */
 20 | export interface RateLimitConfig {
 21 |   /** Time window in milliseconds during which requests are counted. */
 22 |   windowMs: number;
 23 |   /** Maximum number of requests allowed from a single key within the `windowMs`. */
 24 |   maxRequests: number;
 25 |   /**
 26 |    * Custom error message template when rate limit is exceeded.
 27 |    * Use `{waitTime}` as a placeholder for the remaining seconds until reset.
 28 |    * Defaults to "Rate limit exceeded. Please try again in {waitTime} seconds."
 29 |    */
 30 |   errorMessage?: string;
 31 |   /**
 32 |    * If `true`, rate limiting checks will be skipped if `process.env.NODE_ENV` is 'development'.
 33 |    * Defaults to `false`.
 34 |    */
 35 |   skipInDevelopment?: boolean;
 36 |   /**
 37 |    * An optional function to generate a unique key for rate limiting based on an identifier
 38 |    * and optional request context. If not provided, the raw identifier is used as the key.
 39 |    * @param identifier - The base identifier (e.g., IP address, user ID).
 40 |    * @param context - Optional request context.
 41 |    * @returns A string to be used as the rate limiting key.
 42 |    */
 43 |   keyGenerator?: (identifier: string, context?: RequestContext) => string;
 44 |   /**
 45 |    * Interval in milliseconds for cleaning up expired rate limit entries from memory.
 46 |    * Defaults to 5 minutes. Set to `0` or `null` to disable automatic cleanup.
 47 |    */
 48 |   cleanupInterval?: number | null;
 49 | }
 50 | 
 51 | /**
 52 |  * Represents an individual entry in the rate limiter's tracking store.
 53 |  * @internal
 54 |  */
 55 | export interface RateLimitEntry {
 56 |   /** The current count of requests within the window. */
 57 |   count: number;
 58 |   /** The timestamp (milliseconds since epoch) when the current window resets. */
 59 |   resetTime: number;
 60 | }
 61 | 
 62 | /**
 63 |  * A generic rate limiter class that can be used to control the frequency of
 64 |  * operations or requests from various sources. It stores request counts in memory.
 65 |  */
 66 | export class RateLimiter {
 67 |   private limits: Map<string, RateLimitEntry>;
 68 |   private cleanupTimer: NodeJS.Timeout | null = null;
 69 |   private currentConfig: RateLimitConfig; // Renamed from 'config' to avoid conflict with global 'config'
 70 | 
 71 |   /**
 72 |    * Default configuration values for the rate limiter.
 73 |    */
 74 |   private static DEFAULT_CONFIG: RateLimitConfig = {
 75 |     windowMs: 15 * 60 * 1000, // 15 minutes
 76 |     maxRequests: 100,
 77 |     errorMessage:
 78 |       "Rate limit exceeded. Please try again in {waitTime} seconds.",
 79 |     skipInDevelopment: false,
 80 |     cleanupInterval: 5 * 60 * 1000, // 5 minutes
 81 |   };
 82 | 
 83 |   /**
 84 |    * Creates a new `RateLimiter` instance.
 85 |    * @param {Partial<RateLimitConfig>} [initialConfig={}] - Optional initial configuration
 86 |    *   to override default settings.
 87 |    */
 88 |   constructor(initialConfig: Partial<RateLimitConfig> = {}) {
 89 |     this.currentConfig = { ...RateLimiter.DEFAULT_CONFIG, ...initialConfig };
 90 |     this.limits = new Map();
 91 |     this.startCleanupTimer();
 92 |     // Initial log message about instantiation can be done by the code that creates the singleton instance,
 93 |     // after logger itself is fully initialized.
 94 |   }
 95 | 
 96 |   /**
 97 |    * Starts the periodic cleanup timer for expired rate limit entries.
 98 |    * If a timer already exists, it's cleared and restarted.
 99 |    * @private
100 |    */
101 |   private startCleanupTimer(): void {
102 |     if (this.cleanupTimer) {
103 |       clearInterval(this.cleanupTimer);
104 |       this.cleanupTimer = null;
105 |     }
106 | 
107 |     const interval = this.currentConfig.cleanupInterval;
108 | 
109 |     if (interval && interval > 0) {
110 |       this.cleanupTimer = setInterval(() => {
111 |         this.cleanupExpiredEntries();
112 |       }, interval);
113 | 
114 |       // Allow Node.js to exit if this timer is the only thing running.
115 |       if (this.cleanupTimer.unref) {
116 |         this.cleanupTimer.unref();
117 |       }
118 |     }
119 |   }
120 | 
121 |   /**
122 |    * Removes expired entries from the rate limit store to free up memory.
123 |    * This method is called periodically by the cleanup timer.
124 |    * @private
125 |    */
126 |   private cleanupExpiredEntries(): void {
127 |     const now = Date.now();
128 |     let expiredCount = 0;
129 |     const internalContext = requestContextService.createRequestContext({
130 |       operation: "RateLimiter.cleanupExpiredEntries",
131 |     });
132 | 
133 |     for (const [key, entry] of this.limits.entries()) {
134 |       if (now >= entry.resetTime) {
135 |         this.limits.delete(key);
136 |         expiredCount++;
137 |       }
138 |     }
139 | 
140 |     if (expiredCount > 0) {
141 |       logger.debug(`Cleaned up ${expiredCount} expired rate limit entries.`, {
142 |         ...internalContext,
143 |         totalRemaining: this.limits.size,
144 |       });
145 |     }
146 |   }
147 | 
148 |   /**
149 |    * Updates the rate limiter's configuration.
150 |    * @param {Partial<RateLimitConfig>} newConfig - Partial configuration object
151 |    *   with new settings to apply.
152 |    */
153 |   public configure(newConfig: Partial<RateLimitConfig>): void {
154 |     const oldCleanupInterval = this.currentConfig.cleanupInterval;
155 |     this.currentConfig = { ...this.currentConfig, ...newConfig };
156 | 
157 |     if (
158 |       newConfig.cleanupInterval !== undefined &&
159 |       newConfig.cleanupInterval !== oldCleanupInterval
160 |     ) {
161 |       this.startCleanupTimer(); // Restart timer if interval changed
162 |     }
163 |     // Consider logging configuration changes if needed, using a RequestContext.
164 |   }
165 | 
166 |   /**
167 |    * Retrieves a copy of the current rate limiter configuration.
168 |    * @returns {RateLimitConfig} The current configuration.
169 |    */
170 |   public getConfig(): RateLimitConfig {
171 |     return { ...this.currentConfig };
172 |   }
173 | 
174 |   /**
175 |    * Resets all rate limits, clearing all tracked keys and their counts.
176 |    * @param {RequestContext} [context] - Optional context for logging the reset operation.
177 |    */
178 |   public reset(context?: RequestContext): void {
179 |     this.limits.clear();
180 |     const opContext =
181 |       context ||
182 |       requestContextService.createRequestContext({
183 |         operation: "RateLimiter.reset",
184 |       });
185 |     logger.info("Rate limiter has been reset. All limits cleared.", opContext);
186 |   }
187 | 
188 |   /**
189 |    * Checks if a request identified by a key exceeds the configured rate limit.
190 |    * If the limit is exceeded, an `McpError` is thrown.
191 |    *
192 |    * @param {string} identifier - A unique string identifying the source of the request
193 |    *   (e.g., IP address, user ID, session ID).
194 |    * @param {RequestContext} [context] - Optional request context for logging and potentially
195 |    *   for use by a custom `keyGenerator`.
196 |    * @throws {McpError} If the rate limit is exceeded for the given key.
197 |    *   The error will have `BaseErrorCode.RATE_LIMITED`.
198 |    */
199 |   public check(identifier: string, context?: RequestContext): void {
200 |     const opContext =
201 |       context ||
202 |       requestContextService.createRequestContext({
203 |         operation: "RateLimiter.check",
204 |         identifier,
205 |       });
206 | 
207 |     if (this.currentConfig.skipInDevelopment && environment === "development") {
208 |       logger.debug(
209 |         `Rate limiting skipped for key "${identifier}" in development environment.`,
210 |         opContext,
211 |       );
212 |       return;
213 |     }
214 | 
215 |     const limitKey = this.currentConfig.keyGenerator
216 |       ? this.currentConfig.keyGenerator(identifier, opContext)
217 |       : identifier;
218 | 
219 |     const now = Date.now();
220 |     const entry = this.limits.get(limitKey);
221 | 
222 |     if (!entry || now >= entry.resetTime) {
223 |       // New entry or expired window
224 |       this.limits.set(limitKey, {
225 |         count: 1,
226 |         resetTime: now + this.currentConfig.windowMs,
227 |       });
228 |       return; // First request in window, allow
229 |     }
230 | 
231 |     // Window is active, check count
232 |     if (entry.count >= this.currentConfig.maxRequests) {
233 |       const waitTimeSeconds = Math.ceil((entry.resetTime - now) / 1000);
234 |       const errorMessageTemplate =
235 |         this.currentConfig.errorMessage ||
236 |         RateLimiter.DEFAULT_CONFIG.errorMessage!;
237 |       const errorMessage = errorMessageTemplate.replace(
238 |         "{waitTime}",
239 |         waitTimeSeconds.toString(),
240 |       );
241 | 
242 |       logger.warning(`Rate limit exceeded for key "${limitKey}".`, {
243 |         ...opContext,
244 |         limitKey,
245 |         count: entry.count,
246 |         maxRequests: this.currentConfig.maxRequests,
247 |         resetTime: new Date(entry.resetTime).toISOString(),
248 |         waitTimeSeconds,
249 |       });
250 |       throw new McpError(
251 |         BaseErrorCode.RATE_LIMITED,
252 |         errorMessage,
253 |         { ...opContext, keyUsed: limitKey, waitTime: waitTimeSeconds }, // Pass opContext to McpError
254 |       );
255 |     }
256 | 
257 |     // Increment count and update entry
258 |     entry.count++;
259 |     // No need to this.limits.set(limitKey, entry) again if entry is a reference to the object in the map.
260 |   }
261 | 
262 |   /**
263 |    * Retrieves the current rate limit status for a given key.
264 |    * @param {string} key - The rate limit key (as generated by `keyGenerator` or the raw identifier).
265 |    * @returns {{ current: number; limit: number; remaining: number; resetTime: number } | null}
266 |    *   An object with current status, or `null` if the key is not currently tracked (or has expired).
267 |    *   `resetTime` is a Unix timestamp (milliseconds).
268 |    */
269 |   public getStatus(key: string): {
270 |     current: number;
271 |     limit: number;
272 |     remaining: number;
273 |     resetTime: number;
274 |   } | null {
275 |     const entry = this.limits.get(key);
276 |     if (!entry || Date.now() >= entry.resetTime) {
277 |       // Also consider expired as not found for status
278 |       return null;
279 |     }
280 |     return {
281 |       current: entry.count,
282 |       limit: this.currentConfig.maxRequests,
283 |       remaining: Math.max(0, this.currentConfig.maxRequests - entry.count),
284 |       resetTime: entry.resetTime,
285 |     };
286 |   }
287 | 
288 |   /**
289 |    * Stops the cleanup timer and clears all rate limit entries.
290 |    * This should be called if the rate limiter instance is no longer needed,
291 |    * to prevent resource leaks (though `unref` on the timer helps).
292 |    * @param {RequestContext} [context] - Optional context for logging the disposal.
293 |    */
294 |   public dispose(context?: RequestContext): void {
295 |     if (this.cleanupTimer) {
296 |       clearInterval(this.cleanupTimer);
297 |       this.cleanupTimer = null;
298 |     }
299 |     this.limits.clear();
300 |     const opContext =
301 |       context ||
302 |       requestContextService.createRequestContext({
303 |         operation: "RateLimiter.dispose",
304 |       });
305 |     logger.info(
306 |       "Rate limiter disposed, cleanup timer stopped and limits cleared.",
307 |       opContext,
308 |     );
309 |   }
310 | }
311 | 
312 | /**
313 |  * A default, shared instance of the `RateLimiter`.
314 |  * This instance is configured with default settings (e.g., 100 requests per 15 minutes).
315 |  * It can be reconfigured using `rateLimiter.configure()`.
316 |  *
317 |  * Example:
318 |  * ```typescript
319 |  * import { rateLimiter, RequestContext } from './rateLimiter';
320 |  * import { requestContextService } from '../internal';
321 |  *
322 |  * const context: RequestContext = requestContextService.createRequestContext({ operation: 'MyApiCall' });
323 |  * const userIp = '123.45.67.89';
324 |  *
325 |  * try {
326 |  *   rateLimiter.check(userIp, context);
327 |  *   // Proceed with operation
328 |  * } catch (e) {
329 |  *   if (e instanceof McpError && e.code === BaseErrorCode.RATE_LIMITED) {
330 |  *     console.error("Rate limit hit:", e.message);
331 |  *   } else {
332 |  *     // Handle other errors
333 |  *   }
334 |  * }
335 |  * ```
336 |  */
337 | export const rateLimiter = new RateLimiter({}); // Initialize with default or empty to use class defaults
338 | 
```

--------------------------------------------------------------------------------
/docs/obsidian_mcp_tools_spec.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Obsidian MCP Tool Specification
  2 | 
  3 | This document outlines the potential tools for an Obsidian MCP server based on the capabilities of the Obsidian Local REST API plugin.
  4 | 
  5 | ## Core File/Vault Operations
  6 | 
  7 | ### 1. `obsidian_read_file`
  8 | 
  9 | - **Description:** Retrieves the content of a specified file within the Obsidian vault.
 10 | - **Parameters:**
 11 |   - `filePath` (string, required): Vault-relative path to the file.
 12 |   - `format` (enum: 'markdown' | 'json', optional, default: 'markdown'): The desired format for the returned content. 'json' returns a `NoteJson` object including frontmatter and metadata.
 13 | - **Returns:** The file content as a string (markdown) or a `NoteJson` object.
 14 | 
 15 | ### 2. `obsidian_update_file`
 16 | 
 17 | - **Description:** Modifies the content of an Obsidian note (specified by path, the active file, or a periodic note) by appending, prepending, or overwriting (**whole-file** operations) OR applying granular patches relative to internal structures (**patch** operations). Can create the target if it doesn't exist.
 18 | - **Required Parameters:**
 19 |   - `targetType` (enum: 'filePath' | 'activeFile' | 'periodicNote'): Specifies the type of target note.
 20 |   - `content` (string | object): The content to use for the modification (string for whole-file, string or object for patch).
 21 |   - `modificationType` (enum: 'wholeFile' | 'patch'): Specifies whether to perform a whole-file operation or a granular patch.
 22 | - **Optional Parameters:**
 23 |   - `targetIdentifier` (string): Required if `targetType` is 'filePath' (provide vault-relative path) or 'periodicNote' (provide period like 'daily', 'weekly'). Not used for 'activeFile'.
 24 | - **Parameters for `modificationType: 'wholeFile'`:**
 25 |   - `wholeFileMode` (enum: 'append' | 'prepend' | 'overwrite', required): The specific whole-file operation.
 26 |   - `createIfNeeded` (boolean, optional, default: true): If true, creates the target file/note if it doesn't exist before applying the modification.
 27 |   - `overwriteIfExists` (boolean, optional, default: false): Only relevant for `wholeFileMode: 'overwrite'`. If true, allows overwriting an existing file. If false (default) and the file exists when `mode` is 'overwrite', the operation will fail.
 28 | - **Parameters for `modificationType: 'patch'`:**
 29 |   - `patchOperation` (enum: 'append' | 'prepend' | 'replace', required): The type of patch operation relative to the target.
 30 |   - `patchTargetType` (enum: 'heading' | 'block' | 'frontmatter', required): The type of internal structure to target.
 31 |   - `patchTarget` (string, required): The specific heading text, block ID, or frontmatter key to target.
 32 |   - `patchTargetDelimiter` (string, optional): Delimiter for nested headings (default '::').
 33 |   - `patchTrimTargetWhitespace` (boolean, optional, default: false): Whether to trim whitespace around the patch target.
 34 |   - `patchCreateTargetIfMissing` (boolean, optional, default: false): Whether to create the target (e.g., heading, frontmatter key) if it's missing before patching.
 35 | - **Returns:** Success confirmation.
 36 | 
 37 | ### 3. `obsidian_delete_file`
 38 | 
 39 | - **Description:** Deletes a specified file from the vault.
 40 | - **Parameters:**
 41 |   - `filePath` (string, required): Vault-relative path to the file to delete.
 42 | - **Returns:** Success confirmation.
 43 | 
 44 | ### 4. `obsidian_list_files`
 45 | 
 46 | - **Description:** Lists files and directories within a specified folder in the vault.
 47 | - **Parameters:**
 48 |   - `dirPath` (string, required): Vault-relative path to the directory. Use an empty string `""` or `/` for the vault root.
 49 | - **Returns:** An array of strings, where each string is a file or directory name (directories end with `/`).
 50 | 
 51 | ## Search Operations
 52 | 
 53 | ### 5. `obsidian_global_search`
 54 | 
 55 | - **Description:** Performs text search across vault content, with server-side support for regex, wildcards, and date filtering.
 56 | - **Parameters:**
 57 |   - `query` (string, required): The text string or regex pattern to search for.
 58 |   - `contextLength` (number, optional, default: 100): The number of characters surrounding each match to include as context.
 59 |   - `modified_since` (string, optional): Filter for files modified _after_ this date/time (e.g., '2 weeks ago', '2024-01-15', 'yesterday'). Parsed by dateParser utility.
 60 |   - `modified_until` (string, optional): Filter for files modified _before_ this date/time (e.g., 'today', '2024-03-20 17:00'). Parsed by dateParser utility.
 61 | - **Returns:** An array of search results (structure TBD, likely similar to `SimpleSearchResult` but potentially filtered further based on implementation).
 62 | - **Note:** Requires custom server-side implementation for advanced filtering (regex, dates) as the underlying simple API endpoint likely doesn't support them directly. May involve listing files, reading content, and applying filters in the MCP server.
 63 | 
 64 | ### 6. `obsidian_json_search`
 65 | 
 66 | - **Description:** Performs a complex search using Dataview DQL or JsonLogic. Advanced filtering (regex, dates) depends on the capabilities of the chosen query language.
 67 | - **Parameters:**
 68 |   - `query` (string | object, required): The query string (for DQL) or JSON object (for JsonLogic).
 69 |   - `contentType` (enum: 'application/vnd.olrapi.dataview.dql+txt' | 'application/vnd.olrapi.jsonlogic+json', required): Specifies the format of the `query` parameter.
 70 | - **Returns:** An array of `ComplexSearchResult` objects.
 71 | 
 72 | ## Metadata & Properties Operations
 73 | 
 74 | ### 7. `obsidian_get_tags`
 75 | 
 76 | - **Description:** Retrieves all tags defined in the YAML frontmatter of markdown files within your Obsidian vault, along with their usage counts and associated file paths. Optionally, limit the search to a specific folder.
 77 | - **Parameters:**
 78 |   - `path` (string, optional): Folder path (relative to vault root) to restrict the tag search.
 79 | - **Returns:** An object mapping tags to their counts and associated file paths.
 80 | 
 81 | ### 8. `obsidian_get_properties`
 82 | 
 83 | - **Description:** Retrieves properties (like title, tags, status) from the YAML frontmatter of a specified Obsidian note. Returns all defined properties, including any custom fields.
 84 | - **Parameters:**
 85 |   - `filepath` (string, required): Path to the note file (relative to vault root).
 86 | - **Returns:** An object containing all frontmatter key-value pairs.
 87 | 
 88 | ### 9. `obsidian_update_properties`
 89 | 
 90 | - **Description:** Updates properties within the YAML frontmatter of a specified Obsidian note. By default, array properties (like tags) are merged; use the 'replace' option to overwrite them instead. Handles custom fields.
 91 | - **Parameters:**
 92 |   - `filepath` (string, required): Path to the note file (relative to vault root).
 93 |   - `properties` (object, required): Key-value pairs of properties to update.
 94 |   - `replace` (boolean, optional, default: false): If true, array properties will be completely replaced instead of merged.
 95 | - **Returns:** Success confirmation.
 96 | 
 97 | ## Command Operations
 98 | 
 99 | ### 10. `obsidian_execute_command`
100 | 
101 | - **Description:** Executes a registered Obsidian command using its unique ID.
102 | - **Parameters:**
103 |   - `commandId` (string, required): The ID of the command to execute (e.g., "app:go-back", "editor:toggle-bold").
104 | - **Returns:** Success confirmation.
105 | 
106 | ### 11. `obsidian_list_commands`
107 | 
108 | - **Description:** Retrieves a list of all available commands within the Obsidian application.
109 | - **Parameters:** None.
110 | - **Returns:** An array of `ObsidianCommand` objects, each containing the command's `id` and `name`.
111 | 
112 | ## UI/Navigation & Active File Operations
113 | 
114 | ### 12. `obsidian_open_file`
115 | 
116 | - **Description:** Opens a specified file in the Obsidian application interface. Creates the file if it doesn't exist.
117 | - **Parameters:**
118 |   - `filePath` (string, required): Vault-relative path to the file to open.
119 |   - `newLeaf` (boolean, optional, default: false): If true, opens the file in a new editor tab (leaf).
120 | - **Returns:** Success confirmation.
121 | 
122 | ### 13. `obsidian_get_active_file`
123 | 
124 | - **Description:** Retrieves the content of the currently active file in the Obsidian editor.
125 | - **Parameters:**
126 |   - `format` (enum: 'markdown' | 'json', optional, default: 'markdown').
127 | - **Returns:** The active file's content as a string or a `NoteJson` object.
128 | 
129 | ### 14. `obsidian_delete_active_file`
130 | 
131 | - **Description:** Deletes the currently active file in Obsidian.
132 | - **Parameters:** None.
133 | - **Returns:** Success confirmation.
134 | 
135 | ## Periodic Notes Operations
136 | 
137 | ### 15. `obsidian_get_periodic_note`
138 | 
139 | - **Description:** Retrieves the content of a periodic note (e.g., daily, weekly).
140 | - **Parameters:**
141 |   - `period` (enum: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly', required): The type of periodic note to retrieve.
142 |   - `format` (enum: 'markdown' | 'json', optional, default: 'markdown').
143 | - **Returns:** The periodic note's content as a string or a `NoteJson` object.
144 | 
145 | ### 16. `obsidian_delete_periodic_note`
146 | 
147 | - **Description:** Deletes a specified periodic note.
148 | - **Parameters:**
149 |   - `period` (enum: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly', required): The type of periodic note to delete.
150 | - **Returns:** Success confirmation.
151 | 
152 | ## Status Check
153 | 
154 | ### 17. `obsidian_check_status`
155 | 
156 | - **Description:** Checks the connection status and authentication validity of the Obsidian Local REST API plugin.
157 | - **Parameters:** None.
158 | - **Returns:** An `ApiStatusResponse` object containing authentication status, service name, and version information.
159 | 
160 | ## Phase 2
161 | 
162 | #### 1. `obsidian_manage_frontmatter`
163 | 
164 | - **Purpose**: To read, add, update, or remove specific keys from a note's YAML frontmatter without having to parse and rewrite the entire file content.
165 | - **Input Schema**:
166 |   - `filePath`: `z.string()` - Path to the target note.
167 |   - `operation`: `z.enum(['get', 'set', 'delete'])` - The action to perform.
168 |   - `key`: `z.string()` - The frontmatter key to target (e.g., "status").
169 |   - `value`: `z.any().optional()` - The value to set for the key (required for `set`).
170 | - **Output**: `{ success: true, message: "...", value: ... }` (returns the value for 'get', or the updated frontmatter).
171 | - **Why it's useful**: This is far more robust and reliable than using `search_replace` on the raw text of the frontmatter. An agent could manage a note's status, due date, or other metadata fields programmatically.
172 | 
173 | #### 2. `obsidian_manage_tags`
174 | 
175 | - **Purpose**: To add or remove tags from a note. The tool's logic would be smart enough to handle tags in both the frontmatter (`tags: [tag1, tag2]`) and inline (`#tag3`).
176 | - **Input Schema**:
177 |   - `filePath`: `z.string()` - Path to the target note.
178 |   - `operation`: `z.enum(['add', 'remove', 'list'])` - The action to perform.
179 |   - `tags`: `z.array(z.string())` - An array of tags to add or remove (without the '#').
180 | - **Output**: `{ success: true, message: "...", currentTags: ["tag1", "tag2", "tag3"] }`
181 | - **Why it's useful**: Provides a semantic way to categorize notes, which is a core Obsidian workflow. The agent could tag notes based on their content or as part of a larger task.
182 | 
183 | #### 3. `obsidian_dataview_query`
184 | 
185 | - **Purpose**: To execute a Dataview query (DQL) and return the structured results. This is the most powerful querying tool in the Obsidian ecosystem.
186 | - **Input Schema**:
187 |   - `query`: `z.string()` - The Dataview Query Language (DQL) string.
188 | - **Output**: A JSON representation of the Dataview table or list result. `{ success: true, results: [{...}, {...}] }`
189 | - **Why it's useful**: The agent could answer questions like:
190 |   - "List all unfinished tasks from my project notes." (`TASK from #project WHERE !completed`)
191 |   - "Show me all books I rated 5 stars." (`TABLE rating from #book WHERE rating = 5`)
192 |   - "Find all meeting notes from the last 7 days." (`LIST from #meeting WHERE file.cday >= date(today) - dur(7 days)`)
193 | 
194 | This tool would be incredibly potent but requires the user to have the Dataview plugin installed. It would leverage the `searchComplex` method already in your `ObsidianRestApiService`.
195 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/server.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Main entry point for the MCP (Model Context Protocol) server.
  3 |  * This file orchestrates the server's lifecycle:
  4 |  * 1. Initializes the core `McpServer` instance (from `@modelcontextprotocol/sdk`) with its identity and capabilities.
  5 |  * 2. Registers available resources and tools, making them discoverable and usable by clients.
  6 |  * 3. Selects and starts the appropriate communication transport (stdio or Streamable HTTP)
  7 |  *    based on configuration.
  8 |  * 4. Handles top-level error management during startup.
  9 |  *
 10 |  * MCP Specification References:
 11 |  * - Lifecycle: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/lifecycle.mdx
 12 |  * - Overview (Capabilities): https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/index.mdx
 13 |  * - Transports: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx
 14 |  * @module src/mcp-server/server
 15 |  */
 16 | 
 17 | import { ServerType } from "@hono/node-server";
 18 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 19 | // Import validated configuration and environment details.
 20 | import { config, environment } from "../config/index.js";
 21 | // Import core utilities: ErrorHandler, logger, requestContextService.
 22 | import { ErrorHandler, logger, requestContextService } from "../utils/index.js";
 23 | // Import the Obsidian service
 24 | import { ObsidianRestApiService } from "../services/obsidianRestAPI/index.js";
 25 | // Import the Vault Cache service
 26 | import { VaultCacheService } from "../services/obsidianRestAPI/vaultCache/index.js";
 27 | // Import registration functions for specific resources and tools.
 28 | import { registerObsidianDeleteNoteTool } from "./tools/obsidianDeleteNoteTool/index.js";
 29 | import { registerObsidianGlobalSearchTool } from "./tools/obsidianGlobalSearchTool/index.js";
 30 | import { registerObsidianListNotesTool } from "./tools/obsidianListNotesTool/index.js";
 31 | import { registerObsidianReadNoteTool } from "./tools/obsidianReadNoteTool/index.js";
 32 | import { registerObsidianSearchReplaceTool } from "./tools/obsidianSearchReplaceTool/index.js";
 33 | import { registerObsidianUpdateNoteTool } from "./tools/obsidianUpdateNoteTool/index.js";
 34 | import { registerObsidianManageFrontmatterTool } from "./tools/obsidianManageFrontmatterTool/index.js";
 35 | import { registerObsidianManageTagsTool } from "./tools/obsidianManageTagsTool/index.js";
 36 | // Import transport setup functions.
 37 | import { startHttpTransport } from "./transports/httpTransport.js";
 38 | import { connectStdioTransport } from "./transports/stdioTransport.js";
 39 | 
 40 | /**
 41 |  * Creates and configures a new instance of the `McpServer`.
 42 |  *
 43 |  * This function is central to defining the server's identity and functionality
 44 |  * as presented to connecting clients during the MCP initialization phase.
 45 |  * It uses pre-instantiated shared services like Obsidian API and Vault Cache.
 46 |  *
 47 |  * MCP Spec Relevance:
 48 |  * - Server Identity (`serverInfo`): The `name` and `version` provided here are part
 49 |  *   of the `ServerInformation` object returned in the `InitializeResult` message.
 50 |  * - Capabilities Declaration: Declares supported features (logging, dynamic resources/tools).
 51 |  * - Resource/Tool Registration: Calls registration functions, passing necessary service instances.
 52 |  *
 53 |  * Design Note: This factory is called once for 'stdio' transport and per session for 'http' transport.
 54 |  *
 55 |  * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance.
 56 |  * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance, which may be undefined if disabled.
 57 |  * @returns {Promise<McpServer>} A promise resolving with the configured `McpServer` instance.
 58 |  * @throws {Error} If any resource or tool registration fails.
 59 |  * @private
 60 |  */
 61 | async function createMcpServerInstance(
 62 |   obsidianService: ObsidianRestApiService,
 63 |   vaultCacheService: VaultCacheService | undefined,
 64 | ): Promise<McpServer> {
 65 |   const context = requestContextService.createRequestContext({
 66 |     operation: "createMcpServerInstance",
 67 |   });
 68 |   logger.info("Initializing MCP server instance with shared services", context);
 69 | 
 70 |   requestContextService.configure({
 71 |     appName: config.mcpServerName,
 72 |     appVersion: config.mcpServerVersion,
 73 |     environment,
 74 |   });
 75 | 
 76 |   logger.debug("Instantiating McpServer with capabilities", {
 77 |     ...context,
 78 |     serverInfo: {
 79 |       name: config.mcpServerName,
 80 |       version: config.mcpServerVersion,
 81 |     },
 82 |     capabilities: {
 83 |       logging: {},
 84 |       resources: { listChanged: true },
 85 |       tools: { listChanged: true },
 86 |     },
 87 |   });
 88 |   const server = new McpServer(
 89 |     { name: config.mcpServerName, version: config.mcpServerVersion },
 90 |     {
 91 |       capabilities: {
 92 |         logging: {}, // Server can receive logging/setLevel and send notifications/message
 93 |         resources: { listChanged: true }, // Server supports dynamic resource lists
 94 |         tools: { listChanged: true }, // Server supports dynamic tool lists
 95 |       },
 96 |     },
 97 |   );
 98 | 
 99 |   try {
100 |     logger.debug(
101 |       "Registering resources and tools using shared services...",
102 |       context,
103 |     );
104 |     // Register all tools, passing the vaultCacheService which may be undefined
105 |     await registerObsidianListNotesTool(server, obsidianService);
106 |     await registerObsidianReadNoteTool(server, obsidianService);
107 |     await registerObsidianDeleteNoteTool(
108 |       server,
109 |       obsidianService,
110 |       vaultCacheService,
111 |     );
112 |     if (vaultCacheService) {
113 |       await registerObsidianGlobalSearchTool(
114 |         server,
115 |         obsidianService,
116 |         vaultCacheService,
117 |       );
118 |     } else {
119 |       logger.warning(
120 |         "Skipping registration of 'obsidian_global_search' because the Vault Cache Service is disabled.",
121 |         context,
122 |       );
123 |     }
124 |     await registerObsidianSearchReplaceTool(
125 |       server,
126 |       obsidianService,
127 |       vaultCacheService,
128 |     );
129 |     await registerObsidianUpdateNoteTool(
130 |       server,
131 |       obsidianService,
132 |       vaultCacheService,
133 |     );
134 |     await registerObsidianManageFrontmatterTool(
135 |       server,
136 |       obsidianService,
137 |       vaultCacheService,
138 |     );
139 |     await registerObsidianManageTagsTool(
140 |       server,
141 |       obsidianService,
142 |       vaultCacheService,
143 |     );
144 | 
145 |     logger.info("Resources and tools registered successfully", context);
146 | 
147 |     if (vaultCacheService) {
148 |       logger.info(
149 |         "Triggering background vault cache build (if not already built/building)...",
150 |         context,
151 |       );
152 |       // Intentionally not awaiting this promise to allow server startup to proceed.
153 |       // Errors are logged within the catch block.
154 |       vaultCacheService.buildVaultCache().catch((cacheBuildError) => {
155 |         logger.error("Error occurred during background vault cache build", {
156 |           ...context, // Use the initial context for correlation
157 |           subOperation: "BackgroundVaultCacheBuild", // Add sub-operation for clarity
158 |           error:
159 |             cacheBuildError instanceof Error
160 |               ? cacheBuildError.message
161 |               : String(cacheBuildError),
162 |           stack:
163 |             cacheBuildError instanceof Error
164 |               ? cacheBuildError.stack
165 |               : undefined,
166 |         });
167 |       });
168 |     }
169 |   } catch (err) {
170 |     logger.error("Failed to register resources/tools", {
171 |       ...context,
172 |       error: err instanceof Error ? err.message : String(err),
173 |       stack: err instanceof Error ? err.stack : undefined,
174 |     });
175 |     throw err; // Re-throw to be caught by the caller (e.g., startTransport)
176 |   }
177 | 
178 |   return server;
179 | }
180 | 
181 | /**
182 |  * Selects, sets up, and starts the appropriate MCP transport layer based on configuration.
183 |  * This function acts as the bridge between the core server logic and the communication channel.
184 |  * It now accepts shared service instances to pass them down the chain.
185 |  *
186 |  * MCP Spec Relevance:
187 |  * - Transport Selection: Uses `config.mcpTransportType` ('stdio' or 'http').
188 |  * - Transport Connection: Calls dedicated functions for chosen transport.
189 |  * - Server Instance Lifecycle: Single instance for 'stdio', per-session for 'http'.
190 |  *
191 |  * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance.
192 |  * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance.
193 |  * @returns {Promise<McpServer | void>} Resolves with the `McpServer` instance for 'stdio', or `void` for 'http'.
194 |  * @throws {Error} If the configured transport type is unsupported or if transport setup fails.
195 |  * @private
196 |  */
197 | async function startTransport(
198 |   obsidianService: ObsidianRestApiService,
199 |   vaultCacheService: VaultCacheService | undefined,
200 | ): Promise<McpServer | ServerType | void> {
201 |   const transportType = config.mcpTransportType;
202 |   const context = requestContextService.createRequestContext({
203 |     operation: "startTransport",
204 |     transport: transportType,
205 |   });
206 |   logger.info(`Starting transport: ${transportType}`, context);
207 | 
208 |   if (transportType === "http") {
209 |     logger.debug(
210 |       "Delegating to startHttpTransport with a factory for McpServer instances...",
211 |       context,
212 |     );
213 |     // For HTTP, startHttpTransport manages its own lifecycle and server instances per session.
214 |     // It needs a factory function to create new McpServer instances, passing along the shared services.
215 |     const mcpServerFactory = async () =>
216 |       createMcpServerInstance(obsidianService, vaultCacheService);
217 |     const httpServerInstance = await startHttpTransport(
218 |       mcpServerFactory,
219 |       context,
220 |     );
221 |     return httpServerInstance; // Return the http.Server instance.
222 |   }
223 | 
224 |   if (transportType === "stdio") {
225 |     logger.debug(
226 |       "Creating single McpServer instance for stdio transport using shared services...",
227 |       context,
228 |     );
229 |     const server = await createMcpServerInstance(
230 |       obsidianService,
231 |       vaultCacheService,
232 |     );
233 |     logger.debug("Delegating to connectStdioTransport...", context);
234 |     await connectStdioTransport(server, context);
235 |     return server; // Return the single server instance for stdio.
236 |   }
237 | 
238 |   // Should not be reached if config validation is effective.
239 |   logger.fatal(
240 |     `Unsupported transport type configured: ${transportType}`,
241 |     context,
242 |   );
243 |   throw new Error(
244 |     `Unsupported transport type: ${transportType}. Must be 'stdio' or 'http'.`,
245 |   );
246 | }
247 | 
248 | /**
249 |  * Main application entry point. Initializes services and starts the MCP server.
250 |  * Orchestrates server startup, transport selection, and top-level error handling.
251 |  *
252 |  * MCP Spec Relevance:
253 |  * - Manages server startup, leading to a server ready for MCP messages.
254 |  * - Handles critical startup failures, ensuring appropriate process exit.
255 |  *
256 |  * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance, instantiated by the caller (e.g., index.ts).
257 |  * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance, instantiated by the caller (e.g., index.ts).
258 |  * @returns {Promise<void | McpServer>} For 'stdio', resolves with `McpServer`. For 'http', runs indefinitely.
259 |  *   Rejects on critical failure, leading to process exit.
260 |  */
261 | export async function initializeAndStartServer(
262 |   obsidianService: ObsidianRestApiService,
263 |   vaultCacheService: VaultCacheService | undefined,
264 | ): Promise<void | McpServer | ServerType> {
265 |   const context = requestContextService.createRequestContext({
266 |     operation: "initializeAndStartServer",
267 |   });
268 |   logger.info(
269 |     "MCP Server initialization sequence started (services provided).",
270 |     context,
271 |   );
272 | 
273 |   try {
274 |     // Services are now provided by the caller (e.g., index.ts)
275 |     logger.debug(
276 |       "Using provided shared services (ObsidianRestApiService, VaultCacheService).",
277 |       context,
278 |     );
279 | 
280 |     // Initiate the transport setup based on configuration, passing shared services.
281 |     const result = await startTransport(obsidianService, vaultCacheService);
282 |     logger.info(
283 |       "MCP Server initialization sequence completed successfully.",
284 |       context,
285 |     );
286 |     return result;
287 |   } catch (err) {
288 |     logger.fatal("Critical error during MCP server initialization.", {
289 |       ...context,
290 |       error: err instanceof Error ? err.message : String(err),
291 |       stack: err instanceof Error ? err.stack : undefined,
292 |     });
293 |     // Ensure the error is handled by our centralized handler, which might log more details or perform cleanup.
294 |     ErrorHandler.handleError(err, {
295 |       operation: "initializeAndStartServer", // More specific operation
296 |       context: context, // Pass the existing context
297 |       critical: true, // This is a critical failure
298 |     });
299 |     logger.info(
300 |       "Exiting process due to critical initialization error.",
301 |       context,
302 |     );
303 |     process.exit(1); // Exit with a non-zero code to indicate failure.
304 |   }
305 | }
306 | 
```

--------------------------------------------------------------------------------
/src/services/obsidianRestAPI/vaultCache/service.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @module VaultCacheService
  3 |  * @description Service for building and managing an in-memory cache of Obsidian vault content.
  4 |  */
  5 | 
  6 | import path from "node:path";
  7 | import { config } from "../../../config/index.js";
  8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  9 | import {
 10 |   logger,
 11 |   RequestContext,
 12 |   requestContextService,
 13 |   retryWithDelay,
 14 | } from "../../../utils/index.js";
 15 | import { NoteJson, ObsidianRestApiService } from "../index.js";
 16 | 
 17 | interface CacheEntry {
 18 |   content: string;
 19 |   mtime: number; // Store modification time for date filtering
 20 |   // Add other stats if needed, e.g., ctime, size
 21 | }
 22 | 
 23 | /**
 24 |  * Manages an in-memory cache of the Obsidian vault's file structure and metadata.
 25 |  *
 26 |  * __Is the cache safe and secure?__
 27 |  * Yes, the cache is safe and secure for its purpose within this application. Here's why:
 28 |  * 1. __In-Memory Storage:__ The cache exists only in the server's memory. It is not written to disk or transmitted over the network, so its attack surface is limited to the server process itself.
 29 |  * 2. __Local Data Source:__ The data populating the cache comes directly from your own Obsidian vault via the local REST API. It is not fetching data from external, untrusted sources.
 30 |  *
 31 |  * __Warning: High Memory Usage__
 32 |  * This service stores the entire content of every markdown file in the vault in memory. For users with very large vaults (e.g., many gigabytes of markdown files), this can lead to significant RAM consumption. If you experience high memory usage, consider disabling the cache via the `OBSIDIAN_ENABLE_CACHE` environment variable.
 33 |  */
 34 | export class VaultCacheService {
 35 |   private vaultContentCache: Map<string, CacheEntry> = new Map();
 36 |   private isCacheReady: boolean = false;
 37 |   private isBuilding: boolean = false;
 38 |   private obsidianService: ObsidianRestApiService;
 39 |   private refreshIntervalId: NodeJS.Timeout | null = null;
 40 | 
 41 |   constructor(obsidianService: ObsidianRestApiService) {
 42 |     this.obsidianService = obsidianService;
 43 |     logger.info(
 44 |       "VaultCacheService initialized.",
 45 |       requestContextService.createRequestContext({
 46 |         operation: "VaultCacheServiceInit",
 47 |       }),
 48 |     );
 49 |   }
 50 | 
 51 |   /**
 52 |    * Starts the periodic cache refresh mechanism.
 53 |    * The interval is controlled by the `OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN` config setting.
 54 |    */
 55 |   public startPeriodicRefresh(): void {
 56 |     const refreshIntervalMs =
 57 |       config.obsidianCacheRefreshIntervalMin * 60 * 1000;
 58 |     if (this.refreshIntervalId) {
 59 |       logger.warning(
 60 |         "Periodic refresh is already running.",
 61 |         requestContextService.createRequestContext({
 62 |           operation: "startPeriodicRefresh",
 63 |         }),
 64 |       );
 65 |       return;
 66 |     }
 67 |     this.refreshIntervalId = setInterval(
 68 |       () => this.refreshCache(),
 69 |       refreshIntervalMs,
 70 |     );
 71 |     logger.info(
 72 |       `Vault cache periodic refresh scheduled every ${config.obsidianCacheRefreshIntervalMin} minutes.`,
 73 |       requestContextService.createRequestContext({
 74 |         operation: "startPeriodicRefresh",
 75 |       }),
 76 |     );
 77 |   }
 78 | 
 79 |   /**
 80 |    * Stops the periodic cache refresh mechanism.
 81 |    * Should be called during graceful shutdown.
 82 |    */
 83 |   public stopPeriodicRefresh(): void {
 84 |     const context = requestContextService.createRequestContext({
 85 |       operation: "stopPeriodicRefresh",
 86 |     });
 87 |     if (this.refreshIntervalId) {
 88 |       clearInterval(this.refreshIntervalId);
 89 |       this.refreshIntervalId = null;
 90 |       logger.info("Stopped periodic cache refresh.", context);
 91 |     } else {
 92 |       logger.info("Periodic cache refresh was not running.", context);
 93 |     }
 94 |   }
 95 | 
 96 |   /**
 97 |    * Checks if the cache has been successfully built.
 98 |    * @returns {boolean} True if the cache is ready, false otherwise.
 99 |    */
100 |   public isReady(): boolean {
101 |     return this.isCacheReady;
102 |   }
103 | 
104 |   /**
105 |    * Checks if the cache is currently being built.
106 |    * @returns {boolean} True if the cache build is in progress, false otherwise.
107 |    */
108 |   public getIsBuilding(): boolean {
109 |     return this.isBuilding;
110 |   }
111 | 
112 |   /**
113 |    * Returns the entire vault content cache.
114 |    * Use with caution for large vaults due to potential memory usage.
115 |    * @returns {ReadonlyMap<string, CacheEntry>} The cache map.
116 |    */
117 |   public getCache(): ReadonlyMap<string, CacheEntry> {
118 |     // Return a readonly view or copy if mutation is a concern
119 |     return this.vaultContentCache;
120 |   }
121 | 
122 |   /**
123 |    * Retrieves a specific entry from the cache.
124 |    * @param {string} filePath - The vault-relative path of the file.
125 |    * @returns {CacheEntry | undefined} The cache entry or undefined if not found.
126 |    */
127 |   public getEntry(filePath: string): CacheEntry | undefined {
128 |     return this.vaultContentCache.get(filePath);
129 |   }
130 | 
131 |   /**
132 |    * Immediately fetches the latest data for a single file and updates its entry in the cache.
133 |    * This is useful for ensuring cache consistency immediately after a file modification.
134 |    * @param {string} filePath - The vault-relative path of the file to update.
135 |    * @param {RequestContext} context - The request context for logging.
136 |    */
137 |   public async updateCacheForFile(
138 |     filePath: string,
139 |     context: RequestContext,
140 |   ): Promise<void> {
141 |     const opContext = { ...context, operation: "updateCacheForFile", filePath };
142 |     logger.debug(`Proactively updating cache for file: ${filePath}`, opContext);
143 |     try {
144 |       const noteJson = await retryWithDelay(
145 |         () =>
146 |           this.obsidianService.getFileContent(
147 |             filePath,
148 |             "json",
149 |             opContext,
150 |           ) as Promise<NoteJson>,
151 |         {
152 |           operationName: "proactiveCacheUpdate",
153 |           context: opContext,
154 |           maxRetries: 3,
155 |           delayMs: 300,
156 |           shouldRetry: (err: unknown) =>
157 |             err instanceof McpError &&
158 |             (err.code === BaseErrorCode.NOT_FOUND ||
159 |               err.code === BaseErrorCode.SERVICE_UNAVAILABLE),
160 |         },
161 |       );
162 | 
163 |       if (noteJson && noteJson.content && noteJson.stat) {
164 |         this.vaultContentCache.set(filePath, {
165 |           content: noteJson.content,
166 |           mtime: noteJson.stat.mtime,
167 |         });
168 |         logger.info(`Proactively updated cache for: ${filePath}`, opContext);
169 |       } else {
170 |         logger.warning(
171 |           `Proactive cache update for ${filePath} received invalid data, skipping update.`,
172 |           opContext,
173 |         );
174 |       }
175 |     } catch (error) {
176 |       // If the file was deleted, a NOT_FOUND error is expected. We should remove it from the cache.
177 |       if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
178 |         if (this.vaultContentCache.has(filePath)) {
179 |           this.vaultContentCache.delete(filePath);
180 |           logger.info(
181 |             `Proactively removed deleted file from cache: ${filePath}`,
182 |             opContext,
183 |           );
184 |         }
185 |       } else {
186 |         logger.error(
187 |           `Failed to proactively update cache for ${filePath}. Error: ${error instanceof Error ? error.message : String(error)}`,
188 |           opContext,
189 |         );
190 |       }
191 |     }
192 |   }
193 | 
194 |   /**
195 |    * Builds the in-memory cache by fetching all markdown files and their content.
196 |    * This is intended to be run once at startup. Subsequent updates are handled by `refreshCache`.
197 |    */
198 |   public async buildVaultCache(): Promise<void> {
199 |     const initialBuildContext = requestContextService.createRequestContext({
200 |       operation: "buildVaultCache.initialCheck",
201 |     });
202 |     if (this.isBuilding) {
203 |       logger.warning(
204 |         "Cache build already in progress. Skipping.",
205 |         initialBuildContext,
206 |       );
207 |       return;
208 |     }
209 |     if (this.isCacheReady) {
210 |       logger.info("Cache already built. Skipping.", initialBuildContext);
211 |       return;
212 |     }
213 | 
214 |     await this.refreshCache(true); // Perform an initial, full build
215 |   }
216 | 
217 |   /**
218 |    * Refreshes the cache by comparing remote file modification times with cached ones.
219 |    * Only fetches content for new or updated files.
220 |    * @param isInitialBuild - If true, forces a full build and sets the cache readiness flag.
221 |    */
222 |   public async refreshCache(isInitialBuild = false): Promise<void> {
223 |     const context = requestContextService.createRequestContext({
224 |       operation: "refreshCache",
225 |       isInitialBuild,
226 |     });
227 | 
228 |     if (this.isBuilding) {
229 |       logger.warning("Cache refresh already in progress. Skipping.", context);
230 |       return;
231 |     }
232 | 
233 |     this.isBuilding = true;
234 |     if (isInitialBuild) {
235 |       this.isCacheReady = false;
236 |     }
237 | 
238 |     logger.info("Starting vault cache refresh process...", context);
239 | 
240 |     try {
241 |       const startTime = Date.now();
242 |       const remoteFiles = await this.listAllMarkdownFiles("/", context);
243 |       const remoteFileSet = new Set(remoteFiles);
244 |       const cachedFileSet = new Set(this.vaultContentCache.keys());
245 | 
246 |       let filesAdded = 0;
247 |       let filesUpdated = 0;
248 |       let filesRemoved = 0;
249 | 
250 |       // 1. Remove deleted files from cache
251 |       for (const cachedFile of cachedFileSet) {
252 |         if (!remoteFileSet.has(cachedFile)) {
253 |           this.vaultContentCache.delete(cachedFile);
254 |           filesRemoved++;
255 |           logger.debug(`Removed deleted file from cache: ${cachedFile}`, {
256 |             ...context,
257 |             filePath: cachedFile,
258 |           });
259 |         }
260 |       }
261 | 
262 |       // 2. Check for new or updated files
263 |       for (const filePath of remoteFiles) {
264 |         try {
265 |           const fileMetadata = await this.obsidianService.getFileMetadata(
266 |             filePath,
267 |             context,
268 |           );
269 | 
270 |           if (!fileMetadata) {
271 |             logger.warning(
272 |               `Skipping file during cache refresh due to missing or invalid metadata: ${filePath}`,
273 |               { ...context, filePath },
274 |             );
275 |             continue;
276 |           }
277 | 
278 |           const remoteMtime = fileMetadata.mtime;
279 |           const cachedEntry = this.vaultContentCache.get(filePath);
280 | 
281 |           if (!cachedEntry || cachedEntry.mtime < remoteMtime) {
282 |             const noteJson = (await this.obsidianService.getFileContent(
283 |               filePath,
284 |               "json",
285 |               context,
286 |             )) as NoteJson;
287 |             this.vaultContentCache.set(filePath, {
288 |               content: noteJson.content,
289 |               mtime: noteJson.stat.mtime,
290 |             });
291 | 
292 |             if (!cachedEntry) {
293 |               filesAdded++;
294 |               logger.debug(`Added new file to cache: ${filePath}`, {
295 |                 ...context,
296 |                 filePath,
297 |               });
298 |             } else {
299 |               filesUpdated++;
300 |               logger.debug(`Updated modified file in cache: ${filePath}`, {
301 |                 ...context,
302 |                 filePath,
303 |               });
304 |             }
305 |           }
306 |         } catch (error) {
307 |           logger.error(
308 |             `Failed to process file during cache refresh: ${filePath}. Skipping. Error: ${error instanceof Error ? error.message : String(error)}`,
309 |             { ...context, filePath },
310 |           );
311 |         }
312 |       }
313 | 
314 |       const duration = (Date.now() - startTime) / 1000;
315 |       if (isInitialBuild) {
316 |         this.isCacheReady = true;
317 |         logger.info(
318 |           `Initial vault cache build completed in ${duration.toFixed(2)}s. Cached ${this.vaultContentCache.size} files.`,
319 |           context,
320 |         );
321 |       } else {
322 |         logger.info(
323 |           `Vault cache refresh completed in ${duration.toFixed(2)}s. Added: ${filesAdded}, Updated: ${filesUpdated}, Removed: ${filesRemoved}. Total cached: ${this.vaultContentCache.size}.`,
324 |           context,
325 |         );
326 |       }
327 |     } catch (error) {
328 |       logger.error(
329 |         `Critical error during vault cache refresh. Cache may be incomplete. Error: ${error instanceof Error ? error.message : String(error)}`,
330 |         context,
331 |       );
332 |       if (isInitialBuild) {
333 |         this.isCacheReady = false;
334 |       }
335 |     } finally {
336 |       this.isBuilding = false;
337 |     }
338 |   }
339 | 
340 |   /**
341 |    * Helper to recursively list all markdown files. Similar to the one in search logic.
342 |    * @param dirPath - Starting directory path.
343 |    * @param context - Request context.
344 |    * @param visitedDirs - Set to track visited directories.
345 |    * @returns Array of file paths.
346 |    */
347 |   private async listAllMarkdownFiles(
348 |     dirPath: string,
349 |     context: RequestContext,
350 |     visitedDirs: Set<string> = new Set(),
351 |   ): Promise<string[]> {
352 |     const operation = "listAllMarkdownFiles";
353 |     const opContext = { ...context, operation, dirPath };
354 |     const normalizedPath = path.posix.normalize(dirPath === "" ? "/" : dirPath);
355 | 
356 |     if (visitedDirs.has(normalizedPath)) {
357 |       logger.warning(
358 |         `Cycle detected or directory already visited during cache build: ${normalizedPath}. Skipping.`,
359 |         opContext,
360 |       );
361 |       return [];
362 |     }
363 |     visitedDirs.add(normalizedPath);
364 | 
365 |     let markdownFiles: string[] = [];
366 |     try {
367 |       const entries = await this.obsidianService.listFiles(
368 |         normalizedPath,
369 |         opContext,
370 |       );
371 |       for (const entry of entries) {
372 |         const fullPath = path.posix.join(normalizedPath, entry);
373 |         if (entry.endsWith("/")) {
374 |           const subDirFiles = await this.listAllMarkdownFiles(
375 |             fullPath,
376 |             opContext,
377 |             visitedDirs,
378 |           );
379 |           markdownFiles = markdownFiles.concat(subDirFiles);
380 |         } else if (entry.toLowerCase().endsWith(".md")) {
381 |           markdownFiles.push(fullPath);
382 |         }
383 |       }
384 |       return markdownFiles;
385 |     } catch (error) {
386 |       const errMsg = `Failed to list directory during cache build scan: ${normalizedPath}`;
387 |       const err = error as McpError | Error; // Type assertion
388 |       if (err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND) {
389 |         logger.warning(`${errMsg} - Directory not found, skipping.`, opContext);
390 |         return [];
391 |       }
392 |       // Log and re-throw critical listing errors
393 |       if (err instanceof Error) {
394 |         logger.error(errMsg, err, opContext);
395 |       } else {
396 |         logger.error(errMsg, opContext);
397 |       }
398 |       const errorCode =
399 |         err instanceof McpError ? err.code : BaseErrorCode.INTERNAL_ERROR;
400 |       throw new McpError(
401 |         errorCode,
402 |         `${errMsg}: ${err instanceof Error ? err.message : String(err)}`,
403 |         opContext,
404 |       );
405 |     }
406 |   }
407 | }
408 | 
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | // Imports MUST be at the top level
  4 | import { ServerType } from "@hono/node-server";
  5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  6 | import { config, environment } from "./config/index.js"; // This loads .env via dotenv.config()
  7 | import { initializeAndStartServer } from "./mcp-server/server.js";
  8 | import { requestContextService, retryWithDelay } from "./utils/index.js";
  9 | import { logger, McpLogLevel } from "./utils/internal/logger.js"; // Import logger instance early
 10 | // Import Services
 11 | import { ObsidianRestApiService } from "./services/obsidianRestAPI/index.js";
 12 | import { VaultCacheService } from "./services/obsidianRestAPI/vaultCache/index.js"; // Import VaultCacheService
 13 | 
 14 | /**
 15 |  * The main MCP server instance (only stored globally for stdio shutdown).
 16 |  * @type {McpServer | undefined}
 17 |  */
 18 | let server: McpServer | undefined;
 19 | /**
 20 |  * The main HTTP server instance (only stored globally for http shutdown).
 21 |  * @type {ServerType | undefined}
 22 |  */
 23 | let httpServerInstance: ServerType | undefined;
 24 | /**
 25 |  * Shared Obsidian REST API service instance.
 26 |  * @type {ObsidianRestApiService | undefined}
 27 |  */
 28 | let obsidianService: ObsidianRestApiService | undefined;
 29 | /**
 30 |  * Shared Vault Cache service instance.
 31 |  * @type {VaultCacheService | undefined}
 32 |  */
 33 | let vaultCacheService: VaultCacheService | undefined;
 34 | 
 35 | /**
 36 |  * Gracefully shuts down the main MCP server.
 37 |  * Handles process termination signals (SIGTERM, SIGINT) and critical errors.
 38 |  *
 39 |  * @param signal - The signal or event name that triggered the shutdown (e.g., "SIGTERM", "uncaughtException").
 40 |  */
 41 | const shutdown = async (signal: string) => {
 42 |   // Define context for the shutdown operation
 43 |   const shutdownContext = requestContextService.createRequestContext({
 44 |     operation: "Shutdown",
 45 |     signal,
 46 |   });
 47 | 
 48 |   logger.info(
 49 |     `Received ${signal}. Starting graceful shutdown...`,
 50 |     shutdownContext,
 51 |   );
 52 | 
 53 |   try {
 54 |     // Stop cache refresh timer first
 55 |     if (config.obsidianEnableCache && vaultCacheService) {
 56 |       vaultCacheService.stopPeriodicRefresh();
 57 |     }
 58 | 
 59 |     // Close the main MCP server (only relevant for stdio)
 60 |     if (server) {
 61 |       logger.info("Closing main MCP server (stdio)...", shutdownContext);
 62 |       await server.close();
 63 |       logger.info(
 64 |         "Main MCP server (stdio) closed successfully",
 65 |         shutdownContext,
 66 |       );
 67 |     }
 68 | 
 69 |     // Close the main HTTP server instance (if it exists)
 70 |     if (httpServerInstance) {
 71 |       logger.info("Closing main HTTP server...", shutdownContext);
 72 |       await new Promise<void>((resolve, reject) => {
 73 |         httpServerInstance!.close((err?: Error) => {
 74 |           if (err) {
 75 |             logger.error("Error closing HTTP server", err, shutdownContext);
 76 |             reject(err);
 77 |             return;
 78 |           }
 79 |           logger.info("Main HTTP server closed successfully", shutdownContext);
 80 |           resolve();
 81 |         });
 82 |       });
 83 |     }
 84 | 
 85 |     if (!server && !httpServerInstance) {
 86 |       logger.warning(
 87 |         "No server instance (Stdio or HTTP) found to close during shutdown.",
 88 |         shutdownContext,
 89 |       );
 90 |     }
 91 | 
 92 |     // Add any other necessary cleanup here (e.g., closing database connections if added later)
 93 | 
 94 |     logger.info("Graceful shutdown completed successfully", shutdownContext);
 95 |     process.exit(0);
 96 |   } catch (error) {
 97 |     // Handle any errors during shutdown
 98 |     logger.error(
 99 |       "Critical error during shutdown",
100 |       error instanceof Error ? error : undefined,
101 |       {
102 |         ...shutdownContext, // Spread the existing RequestContext
103 |         // error field is handled by logger.error's second argument
104 |       },
105 |     );
106 |     process.exit(1); // Exit with error code if shutdown fails
107 |   }
108 | };
109 | 
110 | /**
111 |  * Initializes and starts the main MCP server.
112 |  * Sets up request context, initializes the server instance, starts the transport,
113 |  * and registers signal handlers for graceful shutdown and error handling.
114 |  */
115 | const start = async () => {
116 |   // --- Logger Initialization (Moved here AFTER config/dotenv is loaded) ---
117 |   const validMcpLogLevels: McpLogLevel[] = [
118 |     "debug",
119 |     "info",
120 |     "notice",
121 |     "warning",
122 |     "error",
123 |     "crit",
124 |     "alert",
125 |     "emerg",
126 |   ];
127 |   // Read level from config (which read from env var or default)
128 |   const initialLogLevelConfig = config.logLevel;
129 |   // Validate the configured log level
130 |   let validatedMcpLogLevel: McpLogLevel = "info"; // Default to 'info'
131 |   if (validMcpLogLevels.includes(initialLogLevelConfig as McpLogLevel)) {
132 |     validatedMcpLogLevel = initialLogLevelConfig as McpLogLevel;
133 |   } else {
134 |     // Use console.warn here as logger isn't initialized yet
135 |     console.warn(
136 |       `Invalid MCP_LOG_LEVEL "${initialLogLevelConfig}" provided via config/env. Defaulting to "info".`,
137 |     );
138 |   }
139 |   // Initialize the logger with the validated MCP level and wait for it to complete.
140 |   await logger.initialize(validatedMcpLogLevel);
141 |   // Log initialization message using the logger itself (will go to file/console)
142 |   logger.info(
143 |     `Logger initialized by start(). MCP logging level: ${validatedMcpLogLevel}`,
144 |   );
145 |   // --- End Logger Initialization ---
146 | 
147 |   // Log that config is loaded (this was previously done earlier)
148 |   logger.debug(
149 |     "Configuration loaded successfully",
150 |     requestContextService.createRequestContext({
151 |       configLoaded: true,
152 |       configSummary: {
153 |         serverName: config.mcpServerName,
154 |         transport: config.mcpTransportType,
155 |         logLevel: config.logLevel,
156 |       },
157 |     }),
158 |   );
159 | 
160 |   // Create application-level request context using the service instance
161 |   // Use the validated transport type from the config object
162 |   const transportType = config.mcpTransportType;
163 |   const startupContext = requestContextService.createRequestContext({
164 |     operation: `ServerStartup_${transportType}`, // Include transport in operation name
165 |     appName: config.mcpServerName,
166 |     appVersion: config.mcpServerVersion,
167 |     environment: environment,
168 |   });
169 | 
170 |   logger.info(
171 |     `Starting ${config.mcpServerName} v${config.mcpServerVersion} (Transport: ${transportType})...`,
172 |     startupContext,
173 |   );
174 | 
175 |   try {
176 |     // --- Instantiate Shared Services ---
177 |     logger.debug("Instantiating shared services...", startupContext);
178 |     obsidianService = new ObsidianRestApiService(); // Instantiate Obsidian Service
179 | 
180 |     // --- Perform Initial Obsidian API Status Check ---
181 |     try {
182 |       logger.info(
183 |         "Performing initial Obsidian API status check with retries...",
184 |         startupContext,
185 |       );
186 | 
187 |       const status = await retryWithDelay(
188 |         async () => {
189 |           if (!obsidianService) {
190 |             // This case should not happen in practice, but it satisfies the type checker.
191 |             throw new Error("Obsidian service not initialized.");
192 |           }
193 |           const checkStatusContext = {
194 |             ...startupContext,
195 |             operation: "checkStatusAttempt",
196 |           };
197 |           const currentStatus =
198 |             await obsidianService.checkStatus(checkStatusContext);
199 |           if (
200 |             currentStatus?.service !== "Obsidian Local REST API" ||
201 |             !currentStatus?.authenticated
202 |           ) {
203 |             // Throw an error to trigger a retry
204 |             throw new Error(
205 |               `Obsidian API status check failed or indicates authentication issue. Status: ${JSON.stringify(
206 |                 currentStatus,
207 |               )}`,
208 |             );
209 |           }
210 |           return currentStatus;
211 |         },
212 |         {
213 |           operationName: "initialObsidianApiCheck",
214 |           context: startupContext,
215 |           maxRetries: 5, // Retry up to 5 times
216 |           delayMs: 3000, // Wait 3 seconds between retries
217 |         },
218 |       );
219 | 
220 |       logger.info("Obsidian API status check successful.", {
221 |         ...startupContext,
222 |         obsidianVersion: status.versions.obsidian,
223 |         pluginVersion: status.versions.self,
224 |       });
225 |     } catch (statusError) {
226 |       logger.error(
227 |         "Critical error during initial Obsidian API status check after multiple retries. Check OBSIDIAN_BASE_URL, OBSIDIAN_API_KEY, and plugin status.",
228 |         {
229 |           ...startupContext,
230 |           error:
231 |             statusError instanceof Error
232 |               ? statusError.message
233 |               : String(statusError),
234 |           stack: statusError instanceof Error ? statusError.stack : undefined,
235 |         },
236 |       );
237 |       // Re-throw the final error to be caught by the main startup catch block, which will exit the process.
238 |       throw statusError;
239 |     }
240 |     // --- End Status Check ---
241 | 
242 |     if (config.obsidianEnableCache) {
243 |       vaultCacheService = new VaultCacheService(obsidianService); // Instantiate Cache Service, passing Obsidian Service
244 |       logger.info(
245 |         "Vault cache is enabled and service is instantiated.",
246 |         startupContext,
247 |       );
248 |     } else {
249 |       logger.info("Vault cache is disabled by configuration.", startupContext);
250 |     }
251 |     logger.info("Shared services instantiated.", startupContext);
252 |     // --- End Service Instantiation ---
253 | 
254 |     // Initialize the server instance and start the selected transport
255 |     logger.debug(
256 |       "Initializing and starting MCP server transport",
257 |       startupContext,
258 |     );
259 | 
260 |     // Start the server transport. Services are instantiated here and passed down.
261 |     // For stdio, this returns the McpServer instance.
262 |     // For http, it returns the http.Server instance.
263 |     const serverOrHttpInstance = await initializeAndStartServer(
264 |       obsidianService,
265 |       vaultCacheService,
266 |     );
267 | 
268 |     if (
269 |       transportType === "stdio" &&
270 |       serverOrHttpInstance instanceof McpServer
271 |     ) {
272 |       server = serverOrHttpInstance; // Store McpServer for stdio
273 |       logger.debug(
274 |         "Stored McpServer instance for stdio transport.",
275 |         startupContext,
276 |       );
277 |     } else if (transportType === "http" && serverOrHttpInstance) {
278 |       // The instance is of ServerType (http.Server or https.Server)
279 |       httpServerInstance = serverOrHttpInstance as ServerType; // Store ServerType for http transport
280 |       logger.debug(
281 |         "Stored http.Server instance for http transport.",
282 |         startupContext,
283 |       );
284 |     } else if (transportType === "http") {
285 |       // This case should ideally not be reached if startHttpTransport always returns an http.Server
286 |       logger.warning(
287 |         "HTTP transport selected, but initializeAndStartServer did not return an http.Server instance.",
288 |         startupContext,
289 |       );
290 |     }
291 | 
292 |     // If initializeAndStartServer failed, it would have thrown an error,
293 |     // and execution would jump to the outer catch block.
294 | 
295 |     logger.info(
296 |       `${config.mcpServerName} is running with ${transportType} transport`,
297 |       {
298 |         ...startupContext,
299 |         startTime: new Date().toISOString(),
300 |       },
301 |     );
302 | 
303 |     // --- Trigger Background Cache Build ---
304 |     if (config.obsidianEnableCache && vaultCacheService) {
305 |       // Start building the cache, but don't wait for it to finish.
306 |       // The server will be operational while the cache builds.
307 |       // Tools needing the cache should check its readiness state.
308 |       logger.info("Triggering background vault cache build...", startupContext);
309 |       // No 'await' here - run in background
310 |       vaultCacheService
311 |         .buildVaultCache()
312 |         .then(() => {
313 |           // Once the initial build is done, start the periodic refresh
314 |           vaultCacheService?.startPeriodicRefresh();
315 |         })
316 |         .catch((cacheBuildError) => {
317 |           // Log errors during the background build process
318 |           logger.error("Error occurred during background vault cache build", {
319 |             ...startupContext, // Use startup context for correlation
320 |             operation: "BackgroundCacheBuild",
321 |             error:
322 |               cacheBuildError instanceof Error
323 |                 ? cacheBuildError.message
324 |                 : String(cacheBuildError),
325 |             stack:
326 |               cacheBuildError instanceof Error
327 |                 ? cacheBuildError.stack
328 |                 : undefined,
329 |           });
330 |         });
331 |     }
332 |     // --- End Cache Build Trigger ---
333 | 
334 |     // --- Signal and Error Handling Setup ---
335 | 
336 |     // Handle process signals for graceful shutdown
337 |     process.on("SIGTERM", () => shutdown("SIGTERM"));
338 |     process.on("SIGINT", () => shutdown("SIGINT"));
339 | 
340 |     // Handle uncaught exceptions
341 |     process.on("uncaughtException", async (error) => {
342 |       const errorContext = {
343 |         ...startupContext, // Include base context for correlation
344 |         event: "uncaughtException",
345 |         error: error instanceof Error ? error.message : String(error),
346 |         stack: error instanceof Error ? error.stack : undefined,
347 |       };
348 |       logger.error(
349 |         "Uncaught exception detected. Initiating shutdown...",
350 |         errorContext,
351 |       );
352 |       // Attempt graceful shutdown; shutdown() handles its own errors.
353 |       await shutdown("uncaughtException");
354 |       // If shutdown fails internally, it will call process.exit(1).
355 |       // If shutdown succeeds, it calls process.exit(0).
356 |       // If shutdown itself throws unexpectedly *before* exiting, this process might terminate abruptly,
357 |       // but the core shutdown logic is handled within shutdown().
358 |     });
359 | 
360 |     // Handle unhandled promise rejections
361 |     process.on("unhandledRejection", async (reason: unknown) => {
362 |       const rejectionContext = {
363 |         ...startupContext, // Include base context for correlation
364 |         event: "unhandledRejection",
365 |         reason: reason instanceof Error ? reason.message : String(reason),
366 |         stack: reason instanceof Error ? reason.stack : undefined,
367 |       };
368 |       logger.error(
369 |         "Unhandled promise rejection detected. Initiating shutdown...",
370 |         rejectionContext,
371 |       );
372 |       // Attempt graceful shutdown; shutdown() handles its own errors.
373 |       await shutdown("unhandledRejection");
374 |       // Similar logic as uncaughtException: shutdown handles its exit codes.
375 |     });
376 |   } catch (error) {
377 |     // Handle critical startup errors (already logged by ErrorHandler or caught above)
378 |     // Log the final failure context, including error details, before exiting
379 |     logger.error("Critical error during startup, exiting.", {
380 |       ...startupContext,
381 |       finalErrorContext: "Startup Failure",
382 |       error: error instanceof Error ? error.message : String(error),
383 |       stack: error instanceof Error ? error.stack : undefined,
384 |     });
385 |     process.exit(1);
386 |   }
387 | };
388 | 
389 | // --- Async IIFE to allow top-level await ---
390 | // This remains necessary because start() is async
391 | (async () => {
392 |   // Start the application
393 |   await start();
394 | })(); // End async IIFE
395 | 
```

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

```typescript
  1 | import path from "node:path"; // Using POSIX path functions for vault path manipulation
  2 | import { z } from "zod";
  3 | import {
  4 |   NoteJson,
  5 |   ObsidianRestApiService,
  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 | /**
 20 |  * Defines the allowed formats for the returned file content.
 21 |  * - 'markdown': Returns the raw Markdown content as a string.
 22 |  * - 'json': Returns a structured NoteJson object including content, frontmatter, tags, and stats.
 23 |  */
 24 | const ReadNoteFormatSchema = z
 25 |   .enum(["markdown", "json"])
 26 |   .default("markdown")
 27 |   .describe(
 28 |     "Specifies the format for the returned content ('markdown' or 'json'). Defaults to 'markdown'.",
 29 |   );
 30 | 
 31 | /**
 32 |  * Zod schema for validating the input parameters of the 'obsidian_read_note' tool.
 33 |  */
 34 | export const ObsidianReadNoteInputSchema = z
 35 |   .object({
 36 |     /**
 37 |      * The vault-relative path to the target file (e.g., "Folder/My Note.md").
 38 |      * Must include the file extension. The tool first attempts a case-sensitive match.
 39 |      * If not found, it attempts a case-insensitive fallback search within the same directory.
 40 |      */
 41 |     filePath: z
 42 |       .string()
 43 |       .min(1, "filePath cannot be empty")
 44 |       .describe(
 45 |         'The vault-relative path to the target file (e.g., "developer/github/tips.md"). Tries case-sensitive first, then case-insensitive fallback.',
 46 |       ),
 47 |     /**
 48 |      * Specifies the desired format for the returned content.
 49 |      * 'markdown' returns the raw file content as a string.
 50 |      * 'json' returns a structured NoteJson object containing content, parsed frontmatter, tags, and file metadata (stat).
 51 |      * Defaults to 'markdown'.
 52 |      */
 53 |     format: ReadNoteFormatSchema.optional() // Optional, defaults to 'markdown' via ReadNoteFormatSchema
 54 |       .describe(
 55 |         "Format for the returned content ('markdown' or 'json'). Defaults to 'markdown'.",
 56 |       ),
 57 |     /**
 58 |      * If true and the requested format is 'markdown', includes formatted file statistics
 59 |      * (creation time, modification time, token count estimate) in the response's 'stat' field.
 60 |      * Defaults to false. This flag is ignored if the format is 'json', as stats are always included within the NoteJson object itself (and also added to the top-level 'stat' field in the response).
 61 |      */
 62 |     includeStat: z
 63 |       .boolean()
 64 |       .optional()
 65 |       .default(false)
 66 |       .describe(
 67 |         "If true and format is 'markdown', includes file stats in the response. Defaults to false. Ignored if format is 'json'.",
 68 |       ),
 69 |   })
 70 |   .describe(
 71 |     "Retrieves the content and optionally metadata of a specific file within the connected Obsidian vault. Supports case-insensitive path fallback.",
 72 |   );
 73 | 
 74 | /**
 75 |  * TypeScript type inferred from the input schema (`ObsidianReadNoteInputSchema`).
 76 |  * Represents the validated input parameters used within the core processing logic.
 77 |  */
 78 | export type ObsidianReadNoteInput = z.infer<typeof ObsidianReadNoteInputSchema>;
 79 | 
 80 | // ====================================================================================
 81 | // Response Type Definition
 82 | // ====================================================================================
 83 | 
 84 | /**
 85 |  * Represents the structure of file statistics after formatting, including
 86 |  * human-readable timestamps and an estimated token count.
 87 |  */
 88 | type FormattedStat = {
 89 |   /** Creation time formatted as a standard date-time string (e.g., "05:29:00 PM | 05-03-2025"). */
 90 |   createdTime: string;
 91 |   /** Last modified time formatted as a standard date-time string (e.g., "05:29:00 PM | 05-03-2025"). */
 92 |   modifiedTime: string;
 93 |   /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */
 94 |   tokenCountEstimate: number;
 95 | };
 96 | 
 97 | /**
 98 |  * Defines the structure of the successful response returned by the `processObsidianReadNote` function.
 99 |  * This object is typically serialized to JSON and sent back to the client.
100 |  */
101 | export interface ObsidianReadNoteResponse {
102 |   /**
103 |    * The content of the file in the requested format.
104 |    * If format='markdown', this is a string.
105 |    * If format='json', this is a NoteJson object (which also contains the content string and stats).
106 |    */
107 |   content: string | NoteJson;
108 |   /**
109 |    * Optional formatted file statistics.
110 |    * Included if format='json', or if format='markdown' and includeStat=true.
111 |    */
112 |   stats?: FormattedStat; // Renamed from stat
113 | }
114 | 
115 | // ====================================================================================
116 | // Core Logic Function
117 | // ====================================================================================
118 | 
119 | /**
120 |  * Processes the core logic for reading a file from the Obsidian vault.
121 |  *
122 |  * It attempts to read the file using the provided path (case-sensitive first,
123 |  * then case-insensitive fallback). It always fetches the full NoteJson object
124 |  * internally to access file statistics. Finally, it formats the response
125 |  * according to the requested format ('markdown' or 'json') and the 'includeStat' flag.
126 |  *
127 |  * @param {ObsidianReadNoteInput} params - The validated input parameters.
128 |  * @param {RequestContext} context - The request context for logging and correlation.
129 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
130 |  * @returns {Promise<ObsidianReadNoteResponse>} A promise resolving to the structured success response
131 |  *   containing the file content and optionally formatted statistics.
132 |  * @throws {McpError} Throws an McpError if the file cannot be found (even with fallback),
133 |  *   if there's an ambiguous fallback match, or if any other API interaction fails.
134 |  */
135 | export const processObsidianReadNote = async (
136 |   params: ObsidianReadNoteInput,
137 |   context: RequestContext,
138 |   obsidianService: ObsidianRestApiService,
139 | ): Promise<ObsidianReadNoteResponse> => {
140 |   const {
141 |     filePath: originalFilePath,
142 |     format: requestedFormat,
143 |     includeStat,
144 |   } = params;
145 |   let effectiveFilePath = originalFilePath; // Track the actual path used (might change during fallback)
146 | 
147 |   logger.debug(
148 |     `Processing obsidian_read_note request for path: ${originalFilePath}`,
149 |     { ...context, format: requestedFormat, includeStat },
150 |   );
151 | 
152 |   const shouldRetryNotFound = (err: unknown) =>
153 |     err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
154 | 
155 |   try {
156 |     let noteJson: NoteJson;
157 | 
158 |     // --- Step 1: Read File Content (always fetch JSON internally) ---
159 |     const readContext = { ...context, operation: "readFileAsJson" };
160 |     try {
161 |       // Attempt 1: Read using the provided path (case-sensitive)
162 |       logger.debug(
163 |         `Attempting to read file as JSON (case-sensitive): ${originalFilePath}`,
164 |         readContext,
165 |       );
166 |       noteJson = await retryWithDelay(
167 |         () =>
168 |           obsidianService.getFileContent(
169 |             originalFilePath,
170 |             "json",
171 |             readContext,
172 |           ) as Promise<NoteJson>,
173 |         {
174 |           operationName: "readFileWithRetry",
175 |           context: readContext,
176 |           maxRetries: 3,
177 |           delayMs: 300,
178 |           shouldRetry: shouldRetryNotFound,
179 |         },
180 |       );
181 |       effectiveFilePath = originalFilePath; // Confirm exact path worked
182 |       logger.debug(
183 |         `Successfully read file as JSON using exact path: ${originalFilePath}`,
184 |         readContext,
185 |       );
186 |     } catch (error) {
187 |       // Attempt 2: Case-insensitive fallback if initial read failed with NOT_FOUND
188 |       if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
189 |         logger.info(
190 |           `File not found with exact path: ${originalFilePath}. Attempting case-insensitive fallback.`,
191 |           readContext,
192 |         );
193 |         const fallbackContext = {
194 |           ...readContext,
195 |           subOperation: "caseInsensitiveFallback",
196 |         };
197 | 
198 |         try {
199 |           // Use POSIX path functions as vault paths are typically /-separated
200 |           const dirname = path.posix.dirname(originalFilePath);
201 |           const filenameLower = path.posix
202 |             .basename(originalFilePath)
203 |             .toLowerCase();
204 |           // Handle case where the file is in the vault root (dirname is '.')
205 |           const dirToList = dirname === "." ? "/" : dirname;
206 | 
207 |           logger.debug(
208 |             `Listing directory for fallback: ${dirToList}`,
209 |             fallbackContext,
210 |           );
211 |           const filesInDir = await retryWithDelay(
212 |             () => obsidianService.listFiles(dirToList, fallbackContext),
213 |             {
214 |               operationName: "listFilesForReadFallback",
215 |               context: fallbackContext,
216 |               maxRetries: 3,
217 |               delayMs: 300,
218 |               shouldRetry: shouldRetryNotFound,
219 |             },
220 |           );
221 | 
222 |           // Filter directory listing for files matching the lowercase filename
223 |           const matches = filesInDir.filter(
224 |             (f) =>
225 |               !f.endsWith("/") && // Ensure it's a file, not a directory entry ending in /
226 |               path.posix.basename(f).toLowerCase() === filenameLower,
227 |           );
228 | 
229 |           if (matches.length === 1) {
230 |             // Found exactly one case-insensitive match
231 |             const correctFilename = path.posix.basename(matches[0]);
232 |             effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path
233 |             logger.info(
234 |               `Found case-insensitive match: ${effectiveFilePath}. Retrying read as JSON.`,
235 |               fallbackContext,
236 |             );
237 | 
238 |             // Retry reading the file content using the corrected path
239 |             noteJson = await retryWithDelay(
240 |               () =>
241 |                 obsidianService.getFileContent(
242 |                   effectiveFilePath,
243 |                   "json",
244 |                   fallbackContext,
245 |                 ) as Promise<NoteJson>,
246 |               {
247 |                 operationName: "readFileWithFallbackRetry",
248 |                 context: fallbackContext,
249 |                 maxRetries: 3,
250 |                 delayMs: 300,
251 |                 shouldRetry: shouldRetryNotFound,
252 |               },
253 |             );
254 |             logger.debug(
255 |               `Successfully read file as JSON using fallback path: ${effectiveFilePath}`,
256 |               fallbackContext,
257 |             );
258 |           } else if (matches.length > 1) {
259 |             // Ambiguous match: Multiple files match case-insensitively
260 |             logger.error(
261 |               `Case-insensitive fallback failed: Multiple matches found for ${filenameLower} in ${dirToList}.`,
262 |               { ...fallbackContext, matches },
263 |             );
264 |             throw new McpError(
265 |               BaseErrorCode.CONFLICT, // Use CONFLICT for ambiguity
266 |               `File read failed: Ambiguous case-insensitive matches for '${originalFilePath}'. Found: [${matches.join(", ")}]`,
267 |               fallbackContext,
268 |             );
269 |           } else {
270 |             // No match found even with fallback
271 |             logger.error(
272 |               `Case-insensitive fallback failed: No match found for ${filenameLower} in ${dirToList}.`,
273 |               fallbackContext,
274 |             );
275 |             throw new McpError(
276 |               BaseErrorCode.NOT_FOUND,
277 |               `File not found: '${originalFilePath}' (case-insensitive fallback also failed).`,
278 |               fallbackContext,
279 |             );
280 |           }
281 |         } catch (fallbackError) {
282 |           // Catch errors specifically from the fallback logic
283 |           if (fallbackError instanceof McpError) throw fallbackError; // Re-throw known errors
284 |           // Wrap unexpected fallback errors
285 |           const errorMessage = `Unexpected error during case-insensitive fallback for ${originalFilePath}`;
286 |           logger.error(
287 |             errorMessage,
288 |             fallbackError instanceof Error ? fallbackError : undefined,
289 |             fallbackContext,
290 |           );
291 |           throw new McpError(
292 |             BaseErrorCode.INTERNAL_ERROR,
293 |             `${errorMessage}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
294 |             fallbackContext,
295 |           );
296 |         }
297 |       } else {
298 |         // Re-throw errors from the initial read attempt that were not NOT_FOUND
299 |         throw error;
300 |       }
301 |     }
302 | 
303 |     // --- Step 2: Format the Response ---
304 |     const formatContext = {
305 |       ...context,
306 |       operation: "formatResponse",
307 |       effectiveFilePath,
308 |     };
309 |     logger.debug(
310 |       `Formatting response. Requested format: ${requestedFormat}, Include stat: ${includeStat}`,
311 |       formatContext,
312 |     );
313 | 
314 |     // Generate formatted statistics using the utility function.
315 |     // Provide the content string for token counting. Handle cases where stat might be missing.
316 |     const formattedStatResult = noteJson.stat
317 |       ? await createFormattedStatWithTokenCount(
318 |           noteJson.stat,
319 |           noteJson.content ?? "",
320 |           formatContext,
321 |         ) // Await the async utility
322 |       : undefined;
323 |     // Ensure stat is undefined if the utility returned null (e.g., token counting failed)
324 |     const formattedStat =
325 |       formattedStatResult === null ? undefined : formattedStatResult;
326 | 
327 |     // Initialize the response object
328 |     const response: ObsidianReadNoteResponse = {
329 |       content: "", // Placeholder, will be set based on format
330 |       // stat is added conditionally below
331 |     };
332 | 
333 |     // Populate response based on requested format
334 |     if (requestedFormat === "json") {
335 |       // Return the full NoteJson object. Its internal 'stat' will remain numeric.
336 |       // The formatted stats are provided in the top-level 'response.stats'.
337 |       response.content = noteJson;
338 |       response.stats = formattedStat; // Always include formatted stat at top level for JSON format
339 |       logger.debug(
340 |         `Response format set to JSON, including full NoteJson (with original numeric stat) and top-level formatted stat.`,
341 |         formatContext,
342 |       );
343 |     } else {
344 |       // 'markdown' format
345 |       response.content = noteJson.content ?? ""; // Extract the markdown content string
346 |       if (includeStat && formattedStat) {
347 |         response.stats = formattedStat; // Include formatted stats only if requested for markdown
348 |         logger.debug(
349 |           `Response format set to markdown, including formatted stat as requested.`,
350 |           formatContext,
351 |         );
352 |       } else {
353 |         logger.debug(
354 |           `Response format set to markdown, excluding stat (includeStat=${includeStat}).`,
355 |           formatContext,
356 |         );
357 |       }
358 |     }
359 | 
360 |     logger.debug(
361 |       `Successfully processed read request for ${effectiveFilePath}.`,
362 |       context,
363 |     );
364 |     return response;
365 |   } catch (error) {
366 |     // Catch any errors that propagated up (e.g., from initial read, fallback, or unexpected issues)
367 |     if (error instanceof McpError) {
368 |       // Log known McpErrors that reached this top level
369 |       logger.error(
370 |         `McpError during file read process for ${originalFilePath}: ${error.message}`,
371 |         error,
372 |         context,
373 |       );
374 |       throw error; // Re-throw McpError
375 |     } else {
376 |       // Wrap unexpected errors in a generic McpError
377 |       const errorMessage = `Unexpected error processing read request for ${originalFilePath}`;
378 |       logger.error(
379 |         errorMessage,
380 |         error instanceof Error ? error : undefined,
381 |         context,
382 |       );
383 |       throw new McpError(
384 |         BaseErrorCode.INTERNAL_ERROR,
385 |         `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
386 |         context,
387 |       );
388 |     }
389 |   }
390 | };
391 | 
```

--------------------------------------------------------------------------------
/src/utils/internal/logger.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Provides a singleton Logger class that wraps Winston for file logging
  3 |  * and supports sending MCP (Model Context Protocol) `notifications/message`.
  4 |  * It handles different log levels compliant with RFC 5424 and MCP specifications.
  5 |  * @module src/utils/internal/logger
  6 |  */
  7 | import path from "path";
  8 | import winston from "winston";
  9 | import TransportStream from "winston-transport";
 10 | import { config } from "../../config/index.js";
 11 | import { RequestContext } from "./requestContext.js";
 12 | 
 13 | /**
 14 |  * Defines the supported logging levels based on RFC 5424 Syslog severity levels,
 15 |  * as used by the Model Context Protocol (MCP).
 16 |  * Levels are: 'debug'(7), 'info'(6), 'notice'(5), 'warning'(4), 'error'(3), 'crit'(2), 'alert'(1), 'emerg'(0).
 17 |  * Lower numeric values indicate higher severity.
 18 |  */
 19 | export type McpLogLevel =
 20 |   | "debug"
 21 |   | "info"
 22 |   | "notice"
 23 |   | "warning"
 24 |   | "error"
 25 |   | "crit"
 26 |   | "alert"
 27 |   | "emerg";
 28 | 
 29 | /**
 30 |  * Numeric severity mapping for MCP log levels (lower is more severe).
 31 |  * @private
 32 |  */
 33 | const mcpLevelSeverity: Record<McpLogLevel, number> = {
 34 |   emerg: 0,
 35 |   alert: 1,
 36 |   crit: 2,
 37 |   error: 3,
 38 |   warning: 4,
 39 |   notice: 5,
 40 |   info: 6,
 41 |   debug: 7,
 42 | };
 43 | 
 44 | /**
 45 |  * Maps MCP log levels to Winston's core levels for file logging.
 46 |  * @private
 47 |  */
 48 | const mcpToWinstonLevel: Record<
 49 |   McpLogLevel,
 50 |   "debug" | "info" | "warn" | "error"
 51 | > = {
 52 |   debug: "debug",
 53 |   info: "info",
 54 |   notice: "info",
 55 |   warning: "warn",
 56 |   error: "error",
 57 |   crit: "error",
 58 |   alert: "error",
 59 |   emerg: "error",
 60 | };
 61 | 
 62 | /**
 63 |  * Interface for a more structured error object, primarily for formatting console logs.
 64 |  * @private
 65 |  */
 66 | interface ErrorWithMessageAndStack {
 67 |   message?: string;
 68 |   stack?: string;
 69 |   [key: string]: any;
 70 | }
 71 | 
 72 | /**
 73 |  * Interface for the payload of an MCP log notification.
 74 |  * This structure is used when sending log data via MCP `notifications/message`.
 75 |  */
 76 | export interface McpLogPayload {
 77 |   message: string;
 78 |   context?: RequestContext;
 79 |   error?: {
 80 |     message: string;
 81 |     stack?: string;
 82 |   };
 83 |   [key: string]: any;
 84 | }
 85 | 
 86 | /**
 87 |  * Type for the `data` parameter of the `McpNotificationSender` function.
 88 |  */
 89 | export type McpNotificationData = McpLogPayload | Record<string, unknown>;
 90 | 
 91 | /**
 92 |  * Defines the signature for a function that can send MCP log notifications.
 93 |  * This function is typically provided by the MCP server instance.
 94 |  * @param level - The severity level of the log message.
 95 |  * @param data - The payload of the log notification.
 96 |  * @param loggerName - An optional name or identifier for the logger/server.
 97 |  */
 98 | export type McpNotificationSender = (
 99 |   level: McpLogLevel,
100 |   data: McpNotificationData,
101 |   loggerName?: string,
102 | ) => void;
103 | 
104 | // The logsPath from config is already resolved and validated by src/config/index.ts
105 | const resolvedLogsDir = config.logsPath;
106 | const isLogsDirSafe = !!resolvedLogsDir; // If logsPath is set, it's considered safe by config logic.
107 | 
108 | /**
109 |  * Creates the Winston console log format.
110 |  * @returns The Winston log format for console output.
111 |  * @private
112 |  */
113 | function createWinstonConsoleFormat(): winston.Logform.Format {
114 |   return winston.format.combine(
115 |     winston.format.colorize(),
116 |     winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
117 |     winston.format.printf(({ timestamp, level, message, ...meta }) => {
118 |       let metaString = "";
119 |       const metaCopy = { ...meta };
120 |       if (metaCopy.error && typeof metaCopy.error === "object") {
121 |         const errorObj = metaCopy.error as ErrorWithMessageAndStack;
122 |         if (errorObj.message) metaString += `\n  Error: ${errorObj.message}`;
123 |         if (errorObj.stack)
124 |           metaString += `\n  Stack: ${String(errorObj.stack)
125 |             .split("\n")
126 |             .map((l: string) => `    ${l}`)
127 |             .join("\n")}`;
128 |         delete metaCopy.error;
129 |       }
130 |       if (Object.keys(metaCopy).length > 0) {
131 |         try {
132 |           const replacer = (_key: string, value: unknown) =>
133 |             typeof value === "bigint" ? value.toString() : value;
134 |           const remainingMetaJson = JSON.stringify(metaCopy, replacer, 2);
135 |           if (remainingMetaJson !== "{}")
136 |             metaString += `\n  Meta: ${remainingMetaJson}`;
137 |         } catch (stringifyError: unknown) {
138 |           const errorMessage =
139 |             stringifyError instanceof Error
140 |               ? stringifyError.message
141 |               : String(stringifyError);
142 |           metaString += `\n  Meta: [Error stringifying metadata: ${errorMessage}]`;
143 |         }
144 |       }
145 |       return `${timestamp} ${level}: ${message}${metaString}`;
146 |     }),
147 |   );
148 | }
149 | 
150 | /**
151 |  * Singleton Logger class that wraps Winston for robust logging.
152 |  * Supports file logging, conditional console logging, and MCP notifications.
153 |  */
154 | export class Logger {
155 |   private static instance: Logger;
156 |   private winstonLogger?: winston.Logger;
157 |   private initialized = false;
158 |   private mcpNotificationSender?: McpNotificationSender;
159 |   private currentMcpLevel: McpLogLevel = "info";
160 |   private currentWinstonLevel: "debug" | "info" | "warn" | "error" = "info";
161 | 
162 |   private readonly MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH = 1024;
163 |   private readonly LOG_FILE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
164 |   private readonly LOG_MAX_FILES = 5;
165 | 
166 |   /** @private */
167 |   private constructor() {}
168 | 
169 |   /**
170 |    * Initializes the Winston logger instance.
171 |    * Should be called once at application startup.
172 |    * @param level - The initial minimum MCP log level.
173 |    */
174 |   public async initialize(level: McpLogLevel = "info"): Promise<void> {
175 |     if (this.initialized) {
176 |       this.warning("Logger already initialized.", {
177 |         loggerSetup: true,
178 |         requestId: "logger-init",
179 |         timestamp: new Date().toISOString(),
180 |       });
181 |       return;
182 |     }
183 | 
184 |     // Set initialized to true at the beginning of the initialization process.
185 |     this.initialized = true;
186 | 
187 |     this.currentMcpLevel = level;
188 |     this.currentWinstonLevel = mcpToWinstonLevel[level];
189 | 
190 |     // The logs directory (config.logsPath / resolvedLogsDir) is expected to be created and validated
191 |     // by the configuration module (src/config/index.ts) before logger initialization.
192 |     // If isLogsDirSafe is true, we assume resolvedLogsDir exists and is usable.
193 |     // No redundant directory creation logic here.
194 | 
195 |     const fileFormat = winston.format.combine(
196 |       winston.format.timestamp(),
197 |       winston.format.errors({ stack: true }),
198 |       winston.format.json(),
199 |     );
200 | 
201 |     const transports: TransportStream[] = [];
202 |     const fileTransportOptions = {
203 |       format: fileFormat,
204 |       maxsize: this.LOG_FILE_MAX_SIZE,
205 |       maxFiles: this.LOG_MAX_FILES,
206 |       tailable: true,
207 |     };
208 | 
209 |     if (isLogsDirSafe) {
210 |       transports.push(
211 |         new winston.transports.File({
212 |           filename: path.join(resolvedLogsDir, "error.log"),
213 |           level: "error",
214 |           ...fileTransportOptions,
215 |         }),
216 |         new winston.transports.File({
217 |           filename: path.join(resolvedLogsDir, "warn.log"),
218 |           level: "warn",
219 |           ...fileTransportOptions,
220 |         }),
221 |         new winston.transports.File({
222 |           filename: path.join(resolvedLogsDir, "info.log"),
223 |           level: "info",
224 |           ...fileTransportOptions,
225 |         }),
226 |         new winston.transports.File({
227 |           filename: path.join(resolvedLogsDir, "debug.log"),
228 |           level: "debug",
229 |           ...fileTransportOptions,
230 |         }),
231 |         new winston.transports.File({
232 |           filename: path.join(resolvedLogsDir, "combined.log"),
233 |           ...fileTransportOptions,
234 |         }),
235 |       );
236 |     } else {
237 |       if (process.stdout.isTTY) {
238 |         console.warn(
239 |           "File logging disabled as logsPath is not configured or invalid.",
240 |         );
241 |       }
242 |     }
243 | 
244 |     this.winstonLogger = winston.createLogger({
245 |       level: this.currentWinstonLevel,
246 |       transports,
247 |       exitOnError: false,
248 |     });
249 | 
250 |     // Configure console transport after Winston logger is created
251 |     const consoleStatus = this._configureConsoleTransport();
252 | 
253 |     const initialContext: RequestContext = {
254 |       loggerSetup: true,
255 |       requestId: "logger-init-deferred",
256 |       timestamp: new Date().toISOString(),
257 |     };
258 |     // Removed logging of logsDirCreatedMessage as it's no longer set
259 |     if (consoleStatus.message) {
260 |       this.info(consoleStatus.message, initialContext);
261 |     }
262 | 
263 |     this.initialized = true; // Ensure this is set after successful setup
264 |     this.info(
265 |       `Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
266 |       {
267 |         loggerSetup: true,
268 |         requestId: "logger-post-init",
269 |         timestamp: new Date().toISOString(),
270 |         logsPathUsed: resolvedLogsDir,
271 |       },
272 |     );
273 |   }
274 | 
275 |   /**
276 |    * Sets the function used to send MCP 'notifications/message'.
277 |    * @param sender - The function to call for sending notifications, or undefined to disable.
278 |    */
279 |   public setMcpNotificationSender(
280 |     sender: McpNotificationSender | undefined,
281 |   ): void {
282 |     this.mcpNotificationSender = sender;
283 |     const status = sender ? "enabled" : "disabled";
284 |     this.info(`MCP notification sending ${status}.`, {
285 |       loggerSetup: true,
286 |       requestId: "logger-set-sender",
287 |       timestamp: new Date().toISOString(),
288 |     });
289 |   }
290 | 
291 |   /**
292 |    * Dynamically sets the minimum logging level.
293 |    * @param newLevel - The new minimum MCP log level to set.
294 |    */
295 |   public setLevel(newLevel: McpLogLevel): void {
296 |     const setLevelContext: RequestContext = {
297 |       loggerSetup: true,
298 |       requestId: "logger-set-level",
299 |       timestamp: new Date().toISOString(),
300 |     };
301 |     if (!this.ensureInitialized()) {
302 |       if (process.stdout.isTTY) {
303 |         console.error("Cannot set level: Logger not initialized.");
304 |       }
305 |       return;
306 |     }
307 |     if (!(newLevel in mcpLevelSeverity)) {
308 |       this.warning(
309 |         `Invalid MCP log level provided: ${newLevel}. Level not changed.`,
310 |         setLevelContext,
311 |       );
312 |       return;
313 |     }
314 | 
315 |     const oldLevel = this.currentMcpLevel;
316 |     this.currentMcpLevel = newLevel;
317 |     this.currentWinstonLevel = mcpToWinstonLevel[newLevel];
318 |     if (this.winstonLogger) {
319 |       // Ensure winstonLogger is defined
320 |       this.winstonLogger.level = this.currentWinstonLevel;
321 |     }
322 | 
323 |     const consoleStatus = this._configureConsoleTransport();
324 | 
325 |     if (oldLevel !== newLevel) {
326 |       this.info(
327 |         `Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${consoleStatus.enabled ? "enabled" : "disabled"}`,
328 |         setLevelContext,
329 |       );
330 |       if (
331 |         consoleStatus.message &&
332 |         consoleStatus.message !== "Console logging status unchanged."
333 |       ) {
334 |         this.info(consoleStatus.message, setLevelContext);
335 |       }
336 |     }
337 |   }
338 | 
339 |   /**
340 |    * Configures the console transport based on the current log level and TTY status.
341 |    * Adds or removes the console transport as needed.
342 |    * @returns {{ enabled: boolean, message: string | null }} Status of console logging.
343 |    * @private
344 |    */
345 |   private _configureConsoleTransport(): {
346 |     enabled: boolean;
347 |     message: string | null;
348 |   } {
349 |     if (!this.winstonLogger) {
350 |       return {
351 |         enabled: false,
352 |         message: "Cannot configure console: Winston logger not initialized.",
353 |       };
354 |     }
355 | 
356 |     const consoleTransport = this.winstonLogger.transports.find(
357 |       (t) => t instanceof winston.transports.Console,
358 |     );
359 |     const shouldHaveConsole =
360 |       this.currentMcpLevel === "debug" && process.stdout.isTTY;
361 |     let message: string | null = null;
362 | 
363 |     if (shouldHaveConsole && !consoleTransport) {
364 |       const consoleFormat = createWinstonConsoleFormat();
365 |       this.winstonLogger.add(
366 |         new winston.transports.Console({
367 |           level: "debug", // Console always logs debug if enabled
368 |           format: consoleFormat,
369 |         }),
370 |       );
371 |       message = "Console logging enabled (level: debug, stdout is TTY).";
372 |     } else if (!shouldHaveConsole && consoleTransport) {
373 |       this.winstonLogger.remove(consoleTransport);
374 |       message = "Console logging disabled (level not debug or stdout not TTY).";
375 |     } else {
376 |       message = "Console logging status unchanged.";
377 |     }
378 |     return { enabled: shouldHaveConsole, message };
379 |   }
380 | 
381 |   /**
382 |    * Gets the singleton instance of the Logger.
383 |    * @returns The singleton Logger instance.
384 |    */
385 |   public static getInstance(): Logger {
386 |     if (!Logger.instance) {
387 |       Logger.instance = new Logger();
388 |     }
389 |     return Logger.instance;
390 |   }
391 | 
392 |   /**
393 |    * Ensures the logger has been initialized.
394 |    * @returns True if initialized, false otherwise.
395 |    * @private
396 |    */
397 |   private ensureInitialized(): boolean {
398 |     if (!this.initialized || !this.winstonLogger) {
399 |       if (process.stdout.isTTY) {
400 |         console.warn("Logger not initialized; message dropped.");
401 |       }
402 |       return false;
403 |     }
404 |     return true;
405 |   }
406 | 
407 |   /**
408 |    * Centralized log processing method.
409 |    * @param level - The MCP severity level of the message.
410 |    * @param msg - The main log message.
411 |    * @param context - Optional request context for the log.
412 |    * @param error - Optional error object associated with the log.
413 |    * @private
414 |    */
415 |   private log(
416 |     level: McpLogLevel,
417 |     msg: string,
418 |     context?: RequestContext,
419 |     error?: Error,
420 |   ): void {
421 |     if (!this.ensureInitialized()) return;
422 |     if (mcpLevelSeverity[level] > mcpLevelSeverity[this.currentMcpLevel]) {
423 |       return; // Do not log if message level is less severe than currentMcpLevel
424 |     }
425 | 
426 |     const logData: Record<string, unknown> = { ...context };
427 |     const winstonLevel = mcpToWinstonLevel[level];
428 | 
429 |     if (error) {
430 |       this.winstonLogger!.log(winstonLevel, msg, { ...logData, error });
431 |     } else {
432 |       this.winstonLogger!.log(winstonLevel, msg, logData);
433 |     }
434 | 
435 |     if (this.mcpNotificationSender) {
436 |       const mcpDataPayload: McpLogPayload = { message: msg };
437 |       if (context && Object.keys(context).length > 0)
438 |         mcpDataPayload.context = context;
439 |       if (error) {
440 |         mcpDataPayload.error = { message: error.message };
441 |         // Include stack trace in debug mode for MCP notifications, truncated for brevity
442 |         if (this.currentMcpLevel === "debug" && error.stack) {
443 |           mcpDataPayload.error.stack = error.stack.substring(
444 |             0,
445 |             this.MCP_NOTIFICATION_STACK_TRACE_MAX_LENGTH,
446 |           );
447 |         }
448 |       }
449 |       try {
450 |         const serverName =
451 |           config?.mcpServerName ?? "MCP_SERVER_NAME_NOT_CONFIGURED";
452 |         this.mcpNotificationSender(level, mcpDataPayload, serverName);
453 |       } catch (sendError: unknown) {
454 |         const errorMessage =
455 |           sendError instanceof Error ? sendError.message : String(sendError);
456 |         const internalErrorContext: RequestContext = {
457 |           requestId: context?.requestId || "logger-internal-error",
458 |           timestamp: new Date().toISOString(),
459 |           originalLevel: level,
460 |           originalMessage: msg,
461 |           sendError: errorMessage,
462 |           mcpPayload: JSON.stringify(mcpDataPayload).substring(0, 500), // Log a preview
463 |         };
464 |         this.winstonLogger!.error(
465 |           "Failed to send MCP log notification",
466 |           internalErrorContext,
467 |         );
468 |       }
469 |     }
470 |   }
471 | 
472 |   /** Logs a message at the 'debug' level. */
473 |   public debug(msg: string, context?: RequestContext): void {
474 |     this.log("debug", msg, context);
475 |   }
476 | 
477 |   /** Logs a message at the 'info' level. */
478 |   public info(msg: string, context?: RequestContext): void {
479 |     this.log("info", msg, context);
480 |   }
481 | 
482 |   /** Logs a message at the 'notice' level. */
483 |   public notice(msg: string, context?: RequestContext): void {
484 |     this.log("notice", msg, context);
485 |   }
486 | 
487 |   /** Logs a message at the 'warning' level. */
488 |   public warning(msg: string, context?: RequestContext): void {
489 |     this.log("warning", msg, context);
490 |   }
491 | 
492 |   /**
493 |    * Logs a message at the 'error' level.
494 |    * @param msg - The main log message.
495 |    * @param err - Optional. Error object or RequestContext.
496 |    * @param context - Optional. RequestContext if `err` is an Error.
497 |    */
498 |   public error(
499 |     msg: string,
500 |     err?: Error | RequestContext,
501 |     context?: RequestContext,
502 |   ): void {
503 |     const errorObj = err instanceof Error ? err : undefined;
504 |     const actualContext = err instanceof Error ? context : err;
505 |     this.log("error", msg, actualContext, errorObj);
506 |   }
507 | 
508 |   /**
509 |    * Logs a message at the 'crit' (critical) level.
510 |    * @param msg - The main log message.
511 |    * @param err - Optional. Error object or RequestContext.
512 |    * @param context - Optional. RequestContext if `err` is an Error.
513 |    */
514 |   public crit(
515 |     msg: string,
516 |     err?: Error | RequestContext,
517 |     context?: RequestContext,
518 |   ): void {
519 |     const errorObj = err instanceof Error ? err : undefined;
520 |     const actualContext = err instanceof Error ? context : err;
521 |     this.log("crit", msg, actualContext, errorObj);
522 |   }
523 | 
524 |   /**
525 |    * Logs a message at the 'alert' level.
526 |    * @param msg - The main log message.
527 |    * @param err - Optional. Error object or RequestContext.
528 |    * @param context - Optional. RequestContext if `err` is an Error.
529 |    */
530 |   public alert(
531 |     msg: string,
532 |     err?: Error | RequestContext,
533 |     context?: RequestContext,
534 |   ): void {
535 |     const errorObj = err instanceof Error ? err : undefined;
536 |     const actualContext = err instanceof Error ? context : err;
537 |     this.log("alert", msg, actualContext, errorObj);
538 |   }
539 | 
540 |   /**
541 |    * Logs a message at the 'emerg' (emergency) level.
542 |    * @param msg - The main log message.
543 |    * @param err - Optional. Error object or RequestContext.
544 |    * @param context - Optional. RequestContext if `err` is an Error.
545 |    */
546 |   public emerg(
547 |     msg: string,
548 |     err?: Error | RequestContext,
549 |     context?: RequestContext,
550 |   ): void {
551 |     const errorObj = err instanceof Error ? err : undefined;
552 |     const actualContext = err instanceof Error ? context : err;
553 |     this.log("emerg", msg, actualContext, errorObj);
554 |   }
555 | 
556 |   /**
557 |    * Logs a message at the 'emerg' (emergency) level, typically for fatal errors.
558 |    * @param msg - The main log message.
559 |    * @param err - Optional. Error object or RequestContext.
560 |    * @param context - Optional. RequestContext if `err` is an Error.
561 |    */
562 |   public fatal(
563 |     msg: string,
564 |     err?: Error | RequestContext,
565 |     context?: RequestContext,
566 |   ): void {
567 |     const errorObj = err instanceof Error ? err : undefined;
568 |     const actualContext = err instanceof Error ? context : err;
569 |     this.log("emerg", msg, actualContext, errorObj);
570 |   }
571 | }
572 | 
573 | /**
574 |  * The singleton instance of the Logger.
575 |  * Use this instance for all logging operations.
576 |  */
577 | export const logger = Logger.getInstance();
578 | 
```

--------------------------------------------------------------------------------
/src/utils/internal/errorHandler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview This module provides utilities for robust error handling.
  3 |  * It defines structures for error context, options for handling errors,
  4 |  * and mappings for classifying errors. The main `ErrorHandler` class
  5 |  * offers static methods for consistent error processing, logging, and transformation.
  6 |  * @module src/utils/internal/errorHandler
  7 |  */
  8 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
  9 | import { generateUUID, sanitizeInputForLogging } from "../index.js"; // Import from main barrel file
 10 | import { logger } from "./logger.js";
 11 | import { RequestContext } from "./requestContext.js"; // Import RequestContext
 12 | 
 13 | /**
 14 |  * Defines a generic structure for providing context with errors.
 15 |  * This context can include identifiers like `requestId` or any other relevant
 16 |  * key-value pairs that aid in debugging or understanding the error's circumstances.
 17 |  */
 18 | export interface ErrorContext {
 19 |   /**
 20 |    * A unique identifier for the request or operation during which the error occurred.
 21 |    * Useful for tracing errors through logs and distributed systems.
 22 |    */
 23 |   requestId?: string;
 24 | 
 25 |   /**
 26 |    * Allows for arbitrary additional context information.
 27 |    * Keys are strings, and values can be of any type.
 28 |    */
 29 |   [key: string]: unknown;
 30 | }
 31 | 
 32 | /**
 33 |  * Configuration options for the `ErrorHandler.handleError` method.
 34 |  * These options control how an error is processed, logged, and whether it's rethrown.
 35 |  */
 36 | export interface ErrorHandlerOptions {
 37 |   /**
 38 |    * The context of the operation that caused the error.
 39 |    * This can include `requestId` and other relevant debugging information.
 40 |    */
 41 |   context?: ErrorContext;
 42 | 
 43 |   /**
 44 |    * A descriptive name of the operation being performed when the error occurred.
 45 |    * This helps in identifying the source or nature of the error in logs.
 46 |    * Example: "UserLogin", "ProcessPayment", "FetchUserProfile".
 47 |    */
 48 |   operation: string;
 49 | 
 50 |   /**
 51 |    * The input data or parameters that were being processed when the error occurred.
 52 |    * This input will be sanitized before logging to prevent sensitive data exposure.
 53 |    */
 54 |   input?: unknown;
 55 | 
 56 |   /**
 57 |    * If true, the (potentially transformed) error will be rethrown after handling.
 58 |    * Defaults to `false`.
 59 |    */
 60 |   rethrow?: boolean;
 61 | 
 62 |   /**
 63 |    * A specific `BaseErrorCode` to assign to the error, overriding any
 64 |    * automatically determined error code.
 65 |    */
 66 |   errorCode?: BaseErrorCode;
 67 | 
 68 |   /**
 69 |    * A custom function to map or transform the original error into a new `Error` instance.
 70 |    * If provided, this function is used instead of the default `McpError` creation.
 71 |    * @param error - The original error that occurred.
 72 |    * @returns The transformed error.
 73 |    */
 74 |   errorMapper?: (error: unknown) => Error;
 75 | 
 76 |   /**
 77 |    * If true, stack traces will be included in the logs.
 78 |    * Defaults to `true`.
 79 |    */
 80 |   includeStack?: boolean;
 81 | 
 82 |   /**
 83 |    * If true, indicates that the error is critical and might require immediate attention
 84 |    * or could lead to system instability. This is primarily for logging and alerting.
 85 |    * Defaults to `false`.
 86 |    */
 87 |   critical?: boolean;
 88 | }
 89 | 
 90 | /**
 91 |  * Defines a basic rule for mapping errors based on patterns.
 92 |  * Used internally by `COMMON_ERROR_PATTERNS` and as a base for `ErrorMapping`.
 93 |  */
 94 | export interface BaseErrorMapping {
 95 |   /**
 96 |    * A string or regular expression to match against the error message.
 97 |    * If a string is provided, it's typically used for substring matching (case-insensitive).
 98 |    */
 99 |   pattern: string | RegExp;
100 | 
101 |   /**
102 |    * The `BaseErrorCode` to assign if the pattern matches.
103 |    */
104 |   errorCode: BaseErrorCode;
105 | 
106 |   /**
107 |    * An optional custom message template for the mapped error.
108 |    * (Note: This property is defined but not directly used by `ErrorHandler.determineErrorCode`
109 |    * which focuses on `errorCode`. It's more relevant for custom mapping logic.)
110 |    */
111 |   messageTemplate?: string;
112 | }
113 | 
114 | /**
115 |  * Extends `BaseErrorMapping` to include a factory function for creating
116 |  * specific error instances and additional context for the mapping.
117 |  * Used by `ErrorHandler.mapError`.
118 |  * @template T The type of `Error` this mapping will produce, defaults to `Error`.
119 |  */
120 | export interface ErrorMapping<T extends Error = Error>
121 |   extends BaseErrorMapping {
122 |   /**
123 |    * A factory function that creates and returns an instance of the mapped error type `T`.
124 |    * @param error - The original error that occurred.
125 |    * @param context - Optional additional context provided in the mapping rule.
126 |    * @returns The newly created error instance.
127 |    */
128 |   factory: (error: unknown, context?: Record<string, unknown>) => T;
129 | 
130 |   /**
131 |    * Additional static context to be merged or passed to the `factory` function
132 |    * when this mapping rule is applied.
133 |    */
134 |   additionalContext?: Record<string, unknown>;
135 | }
136 | 
137 | /**
138 |  * Maps standard JavaScript error constructor names to `BaseErrorCode` values.
139 |  * @private
140 |  */
141 | const ERROR_TYPE_MAPPINGS: Readonly<Record<string, BaseErrorCode>> = {
142 |   SyntaxError: BaseErrorCode.VALIDATION_ERROR,
143 |   TypeError: BaseErrorCode.VALIDATION_ERROR,
144 |   ReferenceError: BaseErrorCode.INTERNAL_ERROR,
145 |   RangeError: BaseErrorCode.VALIDATION_ERROR,
146 |   URIError: BaseErrorCode.VALIDATION_ERROR,
147 |   EvalError: BaseErrorCode.INTERNAL_ERROR,
148 | };
149 | 
150 | /**
151 |  * Array of `BaseErrorMapping` rules to classify errors by message/name patterns.
152 |  * Order matters: more specific patterns should precede generic ones.
153 |  * @private
154 |  */
155 | const COMMON_ERROR_PATTERNS: ReadonlyArray<Readonly<BaseErrorMapping>> = [
156 |   {
157 |     pattern:
158 |       /auth|unauthorized|unauthenticated|not.*logged.*in|invalid.*token|expired.*token/i,
159 |     errorCode: BaseErrorCode.UNAUTHORIZED,
160 |   },
161 |   {
162 |     pattern: /permission|forbidden|access.*denied|not.*allowed/i,
163 |     errorCode: BaseErrorCode.FORBIDDEN,
164 |   },
165 |   {
166 |     pattern: /not found|missing|no such|doesn't exist|couldn't find/i,
167 |     errorCode: BaseErrorCode.NOT_FOUND,
168 |   },
169 |   {
170 |     pattern:
171 |       /invalid|validation|malformed|bad request|wrong format|missing required/i,
172 |     errorCode: BaseErrorCode.VALIDATION_ERROR,
173 |   },
174 |   {
175 |     pattern: /conflict|already exists|duplicate|unique constraint/i,
176 |     errorCode: BaseErrorCode.CONFLICT,
177 |   },
178 |   {
179 |     pattern: /rate limit|too many requests|throttled/i,
180 |     errorCode: BaseErrorCode.RATE_LIMITED,
181 |   },
182 |   {
183 |     pattern: /timeout|timed out|deadline exceeded/i,
184 |     errorCode: BaseErrorCode.TIMEOUT,
185 |   },
186 |   {
187 |     pattern: /service unavailable|bad gateway|gateway timeout|upstream error/i,
188 |     errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
189 |   },
190 | ];
191 | 
192 | /**
193 |  * Creates a "safe" RegExp for testing error messages.
194 |  * Ensures case-insensitivity and removes the global flag.
195 |  * @param pattern - The string or RegExp pattern.
196 |  * @returns A new RegExp instance.
197 |  * @private
198 |  */
199 | function createSafeRegex(pattern: string | RegExp): RegExp {
200 |   if (pattern instanceof RegExp) {
201 |     let flags = pattern.flags.replace("g", "");
202 |     if (!flags.includes("i")) {
203 |       flags += "i";
204 |     }
205 |     return new RegExp(pattern.source, flags);
206 |   }
207 |   return new RegExp(pattern, "i");
208 | }
209 | 
210 | /**
211 |  * Retrieves a descriptive name for an error object or value.
212 |  * @param error - The error object or value.
213 |  * @returns A string representing the error's name or type.
214 |  * @private
215 |  */
216 | function getErrorName(error: unknown): string {
217 |   if (error instanceof Error) {
218 |     return error.name || "Error";
219 |   }
220 |   if (error === null) {
221 |     return "NullValueEncountered";
222 |   }
223 |   if (error === undefined) {
224 |     return "UndefinedValueEncountered";
225 |   }
226 |   if (
227 |     typeof error === "object" &&
228 |     error !== null &&
229 |     error.constructor &&
230 |     typeof error.constructor.name === "string" &&
231 |     error.constructor.name !== "Object"
232 |   ) {
233 |     return `${error.constructor.name}Encountered`;
234 |   }
235 |   return `${typeof error}Encountered`;
236 | }
237 | 
238 | /**
239 |  * Extracts a message string from an error object or value.
240 |  * @param error - The error object or value.
241 |  * @returns The error message string.
242 |  * @private
243 |  */
244 | function getErrorMessage(error: unknown): string {
245 |   if (error instanceof Error) {
246 |     return error.message;
247 |   }
248 |   if (error === null) {
249 |     return "Null value encountered as error";
250 |   }
251 |   if (error === undefined) {
252 |     return "Undefined value encountered as error";
253 |   }
254 |   if (typeof error === "string") {
255 |     return error;
256 |   }
257 |   try {
258 |     const str = String(error);
259 |     if (str === "[object Object]" && error !== null) {
260 |       try {
261 |         return `Non-Error object encountered: ${JSON.stringify(error)}`;
262 |       } catch (stringifyError) {
263 |         return `Unstringifyable non-Error object encountered (constructor: ${error.constructor?.name || "Unknown"})`;
264 |       }
265 |     }
266 |     return str;
267 |   } catch (e) {
268 |     return `Error converting error to string: ${e instanceof Error ? e.message : "Unknown conversion error"}`;
269 |   }
270 | }
271 | 
272 | /**
273 |  * A utility class providing static methods for comprehensive error handling.
274 |  */
275 | export class ErrorHandler {
276 |   /**
277 |    * Determines an appropriate `BaseErrorCode` for a given error.
278 |    * Checks `McpError` instances, `ERROR_TYPE_MAPPINGS`, and `COMMON_ERROR_PATTERNS`.
279 |    * Defaults to `BaseErrorCode.INTERNAL_ERROR`.
280 |    * @param error - The error instance or value to classify.
281 |    * @returns The determined error code.
282 |    */
283 |   public static determineErrorCode(error: unknown): BaseErrorCode {
284 |     if (error instanceof McpError) {
285 |       return error.code;
286 |     }
287 | 
288 |     const errorName = getErrorName(error);
289 |     const errorMessage = getErrorMessage(error);
290 | 
291 |     if (errorName in ERROR_TYPE_MAPPINGS) {
292 |       return ERROR_TYPE_MAPPINGS[errorName as keyof typeof ERROR_TYPE_MAPPINGS];
293 |     }
294 | 
295 |     for (const mapping of COMMON_ERROR_PATTERNS) {
296 |       const regex = createSafeRegex(mapping.pattern);
297 |       if (regex.test(errorMessage) || regex.test(errorName)) {
298 |         return mapping.errorCode;
299 |       }
300 |     }
301 |     return BaseErrorCode.INTERNAL_ERROR;
302 |   }
303 | 
304 |   /**
305 |    * Handles an error with consistent logging and optional transformation.
306 |    * Sanitizes input, determines error code, logs details, and can rethrow.
307 |    * @param error - The error instance or value that occurred.
308 |    * @param options - Configuration for handling the error.
309 |    * @returns The handled (and potentially transformed) error instance.
310 |    */
311 |   public static handleError(
312 |     error: unknown,
313 |     options: ErrorHandlerOptions,
314 |   ): Error {
315 |     const {
316 |       context = {},
317 |       operation,
318 |       input,
319 |       rethrow = false,
320 |       errorCode: explicitErrorCode,
321 |       includeStack = true,
322 |       critical = false,
323 |       errorMapper,
324 |     } = options;
325 | 
326 |     const sanitizedInput =
327 |       input !== undefined ? sanitizeInputForLogging(input) : undefined;
328 |     const originalErrorName = getErrorName(error);
329 |     const originalErrorMessage = getErrorMessage(error);
330 |     const originalStack = error instanceof Error ? error.stack : undefined;
331 | 
332 |     let finalError: Error;
333 |     let loggedErrorCode: BaseErrorCode;
334 | 
335 |     const errorDetailsSeed =
336 |       error instanceof McpError &&
337 |       typeof error.details === "object" &&
338 |       error.details !== null
339 |         ? { ...error.details }
340 |         : {};
341 | 
342 |     const consolidatedDetails: Record<string, unknown> = {
343 |       ...errorDetailsSeed,
344 |       ...context, // Spread context here to allow its properties to be overridden by more specific error details if needed
345 |       originalErrorName,
346 |       originalMessage: originalErrorMessage,
347 |     };
348 |     if (
349 |       originalStack &&
350 |       !(error instanceof McpError && error.details?.originalStack) // Avoid duplicating if already in McpError details
351 |     ) {
352 |       consolidatedDetails.originalStack = originalStack;
353 |     }
354 | 
355 |     if (error instanceof McpError) {
356 |       loggedErrorCode = error.code;
357 |       // If an errorMapper is provided, use it. Otherwise, reconstruct McpError with consolidated details.
358 |       finalError = errorMapper
359 |         ? errorMapper(error)
360 |         : new McpError(error.code, error.message, consolidatedDetails);
361 |     } else {
362 |       loggedErrorCode =
363 |         explicitErrorCode || ErrorHandler.determineErrorCode(error);
364 |       const message = `Error in ${operation}: ${originalErrorMessage}`;
365 |       finalError = errorMapper
366 |         ? errorMapper(error)
367 |         : new McpError(loggedErrorCode, message, consolidatedDetails);
368 |     }
369 | 
370 |     // Preserve stack trace if the error was transformed but the new error doesn't have one
371 |     if (
372 |       finalError !== error && // if error was transformed
373 |       error instanceof Error && // original was an Error
374 |       finalError instanceof Error && // final is an Error
375 |       !finalError.stack && // final has no stack
376 |       error.stack // original had a stack
377 |     ) {
378 |       finalError.stack = error.stack;
379 |     }
380 | 
381 |     const logRequestId =
382 |       typeof context.requestId === "string" && context.requestId
383 |         ? context.requestId
384 |         : generateUUID(); // Generate if not provided in context
385 | 
386 |     const logTimestamp =
387 |       typeof context.timestamp === "string" && context.timestamp
388 |         ? context.timestamp
389 |         : new Date().toISOString(); // Generate if not provided
390 | 
391 |     // Prepare log payload, ensuring RequestContext properties are at the top level for logger
392 |     const logPayload: Record<string, unknown> = {
393 |       requestId: logRequestId,
394 |       timestamp: logTimestamp,
395 |       operation,
396 |       input: sanitizedInput,
397 |       critical,
398 |       errorCode: loggedErrorCode,
399 |       originalErrorType: originalErrorName, // Renamed from originalErrorName for clarity in logs
400 |       finalErrorType: getErrorName(finalError),
401 |       // Spread remaining context properties, excluding what's already explicitly set
402 |       ...Object.fromEntries(
403 |         Object.entries(context).filter(
404 |           ([key]) => key !== "requestId" && key !== "timestamp",
405 |         ),
406 |       ),
407 |     };
408 | 
409 |     // Add detailed error information
410 |     if (finalError instanceof McpError && finalError.details) {
411 |       logPayload.errorDetails = finalError.details; // Already consolidated
412 |     } else {
413 |       // For non-McpErrors or McpErrors without details, use consolidatedDetails
414 |       logPayload.errorDetails = consolidatedDetails;
415 |     }
416 | 
417 |     if (includeStack) {
418 |       const stack =
419 |         finalError instanceof Error ? finalError.stack : originalStack;
420 |       if (stack) {
421 |         logPayload.stack = stack;
422 |       }
423 |     }
424 | 
425 |     // Log using the logger, casting logPayload to RequestContext for compatibility
426 |     // The logger's `error` method expects a RequestContext as its second or third argument.
427 |     logger.error(
428 |       `Error in ${operation}: ${finalError.message || originalErrorMessage}`,
429 |       finalError, // Pass the actual error object
430 |       logPayload as RequestContext, // Pass the structured log data as context
431 |     );
432 | 
433 |     if (rethrow) {
434 |       throw finalError;
435 |     }
436 |     return finalError;
437 |   }
438 | 
439 |   /**
440 |    * Maps an error to a specific error type `T` based on `ErrorMapping` rules.
441 |    * Returns original/default error if no mapping matches.
442 |    * @template T The target error type, extending `Error`.
443 |    * @param error - The error instance or value to map.
444 |    * @param mappings - An array of mapping rules to apply.
445 |    * @param defaultFactory - Optional factory for a default error if no mapping matches.
446 |    * @returns The mapped error of type `T`, or the original/defaulted error.
447 |    */
448 |   public static mapError<T extends Error>(
449 |     error: unknown,
450 |     mappings: ReadonlyArray<ErrorMapping<T>>,
451 |     defaultFactory?: (error: unknown, context?: Record<string, unknown>) => T,
452 |   ): T | Error {
453 |     const errorMessage = getErrorMessage(error);
454 |     const errorName = getErrorName(error);
455 | 
456 |     for (const mapping of mappings) {
457 |       const regex = createSafeRegex(mapping.pattern);
458 |       if (regex.test(errorMessage) || regex.test(errorName)) {
459 |         return mapping.factory(error, mapping.additionalContext);
460 |       }
461 |     }
462 | 
463 |     if (defaultFactory) {
464 |       return defaultFactory(error);
465 |     }
466 |     // Ensure a proper Error object is returned
467 |     return error instanceof Error ? error : new Error(String(error));
468 |   }
469 | 
470 |   /**
471 |    * Formats an error into a consistent object structure for API responses or structured logging.
472 |    * @param error - The error instance or value to format.
473 |    * @returns A structured representation of the error.
474 |    */
475 |   public static formatError(error: unknown): Record<string, unknown> {
476 |     if (error instanceof McpError) {
477 |       return {
478 |         code: error.code,
479 |         message: error.message,
480 |         details:
481 |           typeof error.details === "object" && error.details !== null
482 |             ? error.details // Use existing details if they are an object
483 |             : {}, // Default to empty object if details are not suitable
484 |       };
485 |     }
486 | 
487 |     if (error instanceof Error) {
488 |       return {
489 |         code: ErrorHandler.determineErrorCode(error),
490 |         message: error.message,
491 |         details: { errorType: error.name || "Error" }, // Ensure errorType is always present
492 |       };
493 |     }
494 | 
495 |     // Handle non-Error types
496 |     return {
497 |       code: BaseErrorCode.UNKNOWN_ERROR,
498 |       message: getErrorMessage(error), // Use helper to get a string message
499 |       details: { errorType: getErrorName(error) }, // Use helper to get a type name
500 |     };
501 |   }
502 | 
503 |   /**
504 |    * Safely executes a function (sync or async) and handles errors using `ErrorHandler.handleError`.
505 |    * The error is always rethrown by default by `handleError` when `rethrow` is true.
506 |    * @template T The expected return type of the function `fn`.
507 |    * @param fn - The function to execute.
508 |    * @param options - Error handling options (excluding `rethrow`, as it's forced to true).
509 |    * @returns A promise resolving with the result of `fn` if successful.
510 |    * @throws {McpError | Error} The error processed by `ErrorHandler.handleError`.
511 |    * @example
512 |    * ```typescript
513 |    * async function fetchData(userId: string, context: RequestContext) {
514 |    *   return ErrorHandler.tryCatch(
515 |    *     async () => {
516 |    *       const response = await fetch(`/api/users/${userId}`);
517 |    *       if (!response.ok) throw new Error(`Failed to fetch user: ${response.status}`);
518 |    *       return response.json();
519 |    *     },
520 |    *     { operation: 'fetchUserData', context, input: { userId } } // rethrow is implicitly true
521 |    *   );
522 |    * }
523 |    * ```
524 |    */
525 |   public static async tryCatch<T>(
526 |     fn: () => Promise<T> | T,
527 |     options: Omit<ErrorHandlerOptions, "rethrow">, // Omit rethrow from options type
528 |   ): Promise<T> {
529 |     try {
530 |       // Await the promise if fn returns one, otherwise resolve directly.
531 |       const result = fn();
532 |       return await Promise.resolve(result);
533 |     } catch (error) {
534 |       // ErrorHandler.handleError will return the error to be thrown.
535 |       // rethrow is true by default when calling handleError this way.
536 |       throw ErrorHandler.handleError(error, { ...options, rethrow: true });
537 |     }
538 |   }
539 | }
540 | 
```

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

```typescript
  1 | import path from "node:path/posix";
  2 | import { z } from "zod";
  3 | import {
  4 |   NoteJson,
  5 |   ObsidianRestApiService,
  6 |   SimpleSearchResult,
  7 | } from "../../../services/obsidianRestAPI/index.js"; // Removed NoteStat import
  8 | import { VaultCacheService } from "../../../services/obsidianRestAPI/vaultCache/index.js";
  9 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
 10 | // Import formatTimestamp utility
 11 | import { config } from "../../../config/index.js";
 12 | import {
 13 |   dateParser,
 14 |   formatTimestamp,
 15 |   logger,
 16 |   RequestContext,
 17 |   retryWithDelay,
 18 |   sanitizeInputForLogging,
 19 | } from "../../../utils/index.js";
 20 | 
 21 | // ====================================================================================
 22 | // Schema Definitions (Updated for Pagination, Match Limit, and Path Filter)
 23 | // ====================================================================================
 24 | const ObsidianGlobalSearchInputSchema = z
 25 |   .object({
 26 |     query: z
 27 |       .string()
 28 |       .min(1)
 29 |       .describe("The search query (text or regex pattern)."),
 30 |     searchInPath: z
 31 |       .string()
 32 |       .optional()
 33 |       .describe(
 34 |         "Optional vault-relative path to recursively search within (e.g., 'Notes/Projects'). If omitted, searches the entire vault.",
 35 |       ),
 36 |     contextLength: z
 37 |       .number()
 38 |       .int()
 39 |       .positive()
 40 |       .optional()
 41 |       .default(100)
 42 |       .describe("Characters of context around matches."),
 43 |     modified_since: z
 44 |       .string()
 45 |       .optional()
 46 |       .describe(
 47 |         "Filter files modified *since* this date/time (e.g., '2 weeks ago', '2024-01-15').",
 48 |       ),
 49 |     modified_until: z
 50 |       .string()
 51 |       .optional()
 52 |       .describe(
 53 |         "Filter files modified *until* this date/time (e.g., 'today', '2024-03-20 17:00').",
 54 |       ),
 55 |     useRegex: z
 56 |       .boolean()
 57 |       .optional()
 58 |       .default(false)
 59 |       .describe("Treat 'query' as regex. Defaults to false."),
 60 |     caseSensitive: z
 61 |       .boolean()
 62 |       .optional()
 63 |       .default(false)
 64 |       .describe("Perform case-sensitive search. Defaults to false."),
 65 |     pageSize: z
 66 |       .number()
 67 |       .int()
 68 |       .positive()
 69 |       .optional()
 70 |       .default(50)
 71 |       .describe("Maximum number of result files per page. Defaults to 50."),
 72 |     page: z
 73 |       .number()
 74 |       .int()
 75 |       .positive()
 76 |       .optional()
 77 |       .default(1)
 78 |       .describe("Page number of results to return. Defaults to 1."),
 79 |     maxMatchesPerFile: z
 80 |       .number()
 81 |       .int()
 82 |       .positive()
 83 |       .optional()
 84 |       .default(5)
 85 |       .describe("Maximum number of matches to show per file. Defaults to 5."),
 86 |   })
 87 |   .describe(
 88 |     "Performs search across vault content using text or regex. Supports filtering by modification date, directory path, pagination, and limiting matches per file.",
 89 |   );
 90 | 
 91 | export const ObsidianGlobalSearchInputSchemaShape =
 92 |   ObsidianGlobalSearchInputSchema.shape;
 93 | export type ObsidianGlobalSearchInput = z.infer<
 94 |   typeof ObsidianGlobalSearchInputSchema
 95 | >;
 96 | 
 97 | // ====================================================================================
 98 | // Response Structure Definition (Updated)
 99 | // ====================================================================================
100 | 
101 | export interface MatchContext {
102 |   context: string;
103 |   matchText?: string; // Made optional
104 |   position?: number; // Made optional (Position relative to the start of the context snippet)
105 | }
106 | 
107 | // Updated GlobalSearchResult to use formatted time strings and include numeric mtime for sorting
108 | export interface GlobalSearchResult {
109 |   path: string;
110 |   filename: string;
111 |   matches: MatchContext[];
112 |   modifiedTime: string; // Formatted string
113 |   createdTime: string; // Formatted string
114 |   numericMtime: number; // Numeric mtime for robust sorting
115 | }
116 | 
117 | // Added alsoFoundInFiles
118 | export interface ObsidianGlobalSearchResponse {
119 |   success: boolean;
120 |   message: string;
121 |   results: GlobalSearchResult[];
122 |   totalFilesFound: number; // Total files matching query *before* pagination
123 |   totalMatchesFound: number; // Total matches across all found files *before* pagination
124 |   currentPage: number;
125 |   pageSize: number;
126 |   totalPages: number;
127 |   alsoFoundInFiles?: string[]; // List of filenames found but not on the current page
128 | }
129 | 
130 | // ====================================================================================
131 | // Helper Function (findMatchesInContent - for Cache Fallback)
132 | // ====================================================================================
133 | // Removed lineNumber calculation and return
134 | function findMatchesInContent(
135 |   content: string,
136 |   query: string,
137 |   useRegex: boolean,
138 |   caseSensitive: boolean,
139 |   contextLength: number,
140 |   context: RequestContext,
141 | ): MatchContext[] {
142 |   const matches: MatchContext[] = [];
143 |   let regex: RegExp;
144 |   const operation = "findMatchesInContent";
145 |   const opContext = { ...context, operation };
146 |   try {
147 |     const flags = `g${caseSensitive ? "" : "i"}`;
148 |     regex = useRegex
149 |       ? new RegExp(query, flags)
150 |       : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
151 |   } catch (e) {
152 |     const errorMsg = `[${operation}] Invalid regex pattern: ${query}`;
153 |     logger.error(errorMsg, e instanceof Error ? e : undefined, opContext);
154 |     throw new McpError(
155 |       BaseErrorCode.VALIDATION_ERROR,
156 |       `Invalid regex pattern: ${query}`,
157 |       opContext,
158 |     );
159 |   }
160 |   let match;
161 |   // Removed line number calculation logic
162 |   while ((match = regex.exec(content)) !== null) {
163 |     const matchIndex = match.index;
164 |     const matchText = match[0];
165 |     const startIndex = Math.max(0, matchIndex - contextLength);
166 |     const endIndex = Math.min(
167 |       content.length,
168 |       matchIndex + matchText.length + contextLength,
169 |     );
170 |     const contextSnippet = content.substring(startIndex, endIndex);
171 |     // Find position *within* the snippet for consistency with API fallback
172 |     const positionInSnippet = contextSnippet
173 |       .toLowerCase()
174 |       .indexOf(matchText.toLowerCase());
175 | 
176 |     matches.push({
177 |       // lineNumber removed
178 |       context: contextSnippet,
179 |       matchText: matchText, // Included for cache search
180 |       position: positionInSnippet >= 0 ? positionInSnippet : 0, // Included for cache search
181 |     });
182 |     if (matchText.length === 0) regex.lastIndex++;
183 |   }
184 |   return matches;
185 | }
186 | 
187 | // ====================================================================================
188 | // Core Logic Function (API-First with Cache Fallback)
189 | // ====================================================================================
190 | const API_SEARCH_TIMEOUT_MS = config.obsidianApiSearchTimeoutMs;
191 | 
192 | export const processObsidianGlobalSearch = async (
193 |   params: ObsidianGlobalSearchInput,
194 |   context: RequestContext,
195 |   obsidianService: ObsidianRestApiService,
196 |   vaultCacheService: VaultCacheService | undefined,
197 | ): Promise<ObsidianGlobalSearchResponse> => {
198 |   const operation = "processObsidianGlobalSearch";
199 |   const opContext = { ...context, operation };
200 |   logger.info(
201 |     `Processing obsidian_global_search request: "${params.query}" (API-first)`,
202 |     { ...opContext, params: sanitizeInputForLogging(params) },
203 |   );
204 | 
205 |   let sinceDate: Date | null = null;
206 |   let untilDate: Date | null = null;
207 |   let strategyMessage = "";
208 |   let allFilteredResults: GlobalSearchResult[] = []; // Store all results matching filters before pagination
209 |   let totalMatchesCount = 0; // Total matches across all files before limiting per file
210 | 
211 |   // Normalize searchInPath: remove leading/trailing slashes and ensure it ends with a slash if not empty
212 |   const searchPathPrefix = params.searchInPath
213 |     ? params.searchInPath.replace(/^\/+|\/+$/g, "") +
214 |       (params.searchInPath === "/" ? "" : "/")
215 |     : ""; // Empty string means search entire vault
216 | 
217 |   // 1. Parse Date Filters
218 |   const dateParseContext = { ...opContext, subOperation: "parseDates" };
219 |   try {
220 |     if (params.modified_since)
221 |       sinceDate = await dateParser.parseToDate(
222 |         params.modified_since,
223 |         dateParseContext,
224 |       );
225 |     if (params.modified_until)
226 |       untilDate = await dateParser.parseToDate(
227 |         params.modified_until,
228 |         dateParseContext,
229 |       );
230 |   } catch (error) {
231 |     const errMsg = `Invalid date format provided`;
232 |     logger.error(
233 |       errMsg,
234 |       error instanceof Error ? error : undefined,
235 |       dateParseContext,
236 |     );
237 |     throw new McpError(
238 |       BaseErrorCode.VALIDATION_ERROR,
239 |       errMsg,
240 |       dateParseContext,
241 |     );
242 |   }
243 | 
244 |   // 2. Attempt API Search with Retries and Timeout
245 |   let apiFailedOrTimedOut = false;
246 |   try {
247 |     strategyMessage = `Attempting live API search with retries (timeout: ${API_SEARCH_TIMEOUT_MS / 1000}s per attempt). `;
248 |     const apiSearchContext = {
249 |       ...opContext,
250 |       subOperation: "searchApiSimpleWithRetry",
251 |     };
252 | 
253 |     const apiResults: SimpleSearchResult[] = await retryWithDelay(
254 |       async () => {
255 |         logger.info(
256 |           `Calling obsidianService.searchSimple for query: "${params.query}"`,
257 |           apiSearchContext,
258 |         );
259 | 
260 |         const apiCallPromise = obsidianService.searchSimple(
261 |           params.query,
262 |           params.contextLength,
263 |           apiSearchContext,
264 |         );
265 | 
266 |         const timeoutPromise = new Promise<never>((_, reject) =>
267 |           setTimeout(
268 |             () =>
269 |               reject(
270 |                 new Error(
271 |                   `API search timed out after ${API_SEARCH_TIMEOUT_MS}ms`,
272 |                 ),
273 |               ),
274 |             API_SEARCH_TIMEOUT_MS,
275 |           ),
276 |         );
277 | 
278 |         return await Promise.race([apiCallPromise, timeoutPromise]);
279 |       },
280 |       {
281 |         operationName: "obsidianService.searchSimple",
282 |         context: apiSearchContext,
283 |         maxRetries: 2, // Total of 3 attempts
284 |         delayMs: 500,
285 |         shouldRetry: (err: unknown) => {
286 |           // Retry on any error during the API call phase
287 |           logger.warning(`API search attempt failed. Retrying...`, {
288 |             ...apiSearchContext,
289 |             error: err instanceof Error ? err.message : String(err),
290 |           });
291 |           return true;
292 |         },
293 |       },
294 |     );
295 | 
296 |     strategyMessage += `API search successful, returned ${apiResults.length} potential files. `;
297 |     logger.info(
298 |       `API searchSimple returned ${apiResults.length} files with potential matches.`,
299 |       apiSearchContext,
300 |     );
301 | 
302 |     // Process API results (fetch stats for date filtering and inclusion)
303 |     const fetchStatsContext = {
304 |       ...opContext,
305 |       subOperation: "fetchStatsForApiResults",
306 |     };
307 |     let processedCount = 0;
308 |     for (const apiResult of apiResults) {
309 |       const filePathFromApi = apiResult.filename; // API uses 'filename' for the full path
310 | 
311 |       // Apply path filter
312 |       if (searchPathPrefix && !filePathFromApi.startsWith(searchPathPrefix)) {
313 |         continue; // Skip if file is not in the specified path
314 |       }
315 | 
316 |       let mtime: number;
317 |       let ctime: number;
318 | 
319 |       // Fetch stats regardless of date filtering to include in results
320 |       try {
321 |         const noteJson = (await obsidianService.getFileContent(
322 |           filePathFromApi,
323 |           "json",
324 |           fetchStatsContext,
325 |         )) as NoteJson;
326 |         mtime = noteJson.stat.mtime;
327 |         ctime = noteJson.stat.ctime; // Get ctime
328 | 
329 |         // Apply date filtering if needed
330 |         if (
331 |           (sinceDate && mtime < sinceDate.getTime()) ||
332 |           (untilDate && mtime > untilDate.getTime())
333 |         ) {
334 |           continue; // Skip due to date filter
335 |         }
336 |       } catch (statError) {
337 |         logger.warning(
338 |           `Failed to fetch stats for file ${filePathFromApi}. Skipping file. Error: ${statError instanceof Error ? statError.message : String(statError)}`,
339 |           fetchStatsContext,
340 |         );
341 |         continue; // Skip if stats cannot be fetched
342 |       }
343 | 
344 |       // Transform SimpleSearchMatch[] to MatchContext[] - OMITTING matchText and position
345 |       const transformedMatches: MatchContext[] = [];
346 |       for (const apiMatch of apiResult.matches) {
347 |         transformedMatches.push({
348 |           // lineNumber removed
349 |           context: apiMatch.context, // Use the context provided by the API
350 |           // matchText and position are omitted as they cannot be reliably determined from API result
351 |         });
352 |       }
353 | 
354 |       // Apply match limit per file
355 |       const limitedMatches = transformedMatches.slice(
356 |         0,
357 |         params.maxMatchesPerFile,
358 |       );
359 | 
360 |       // Only add if we actually found matches after transformation/filtering
361 |       if (limitedMatches.length > 0) {
362 |         allFilteredResults.push({
363 |           // Add to the unfiltered list first
364 |           path: filePathFromApi,
365 |           filename: path.basename(filePathFromApi),
366 |           matches: limitedMatches, // Use limited matches
367 |           modifiedTime: formatTimestamp(mtime, fetchStatsContext), // Format mtime
368 |           createdTime: formatTimestamp(ctime, fetchStatsContext), // Format ctime
369 |           numericMtime: mtime, // Store numeric mtime
370 |         });
371 |         totalMatchesCount += transformedMatches.length; // Count *all* matches before limiting for total count
372 |         processedCount++;
373 |       }
374 |     }
375 |     strategyMessage += `Processed ${processedCount} files matching all filters (including path: '${searchPathPrefix || "entire vault"}'). `;
376 |   } catch (apiError) {
377 |     // API call failed or timed out internally
378 |     apiFailedOrTimedOut = true;
379 |     strategyMessage += `API search failed or timed out (${apiError instanceof Error ? apiError.message : String(apiError)}). `;
380 |     logger.warning(strategyMessage, {
381 |       ...opContext,
382 |       subOperation: "apiSearchFailedOrTimedOut",
383 |     });
384 |   }
385 | 
386 |   // 3. Fallback to Cache if API Failed/Timed Out
387 |   if (apiFailedOrTimedOut) {
388 |     if (vaultCacheService && vaultCacheService.isReady()) {
389 |       strategyMessage += "Falling back to in-memory cache. ";
390 |       logger.info(
391 |         "API search failed/timed out. Falling back to in-memory cache.",
392 |         opContext,
393 |       );
394 |       const cache = vaultCacheService.getCache();
395 |       const cacheSearchContext = {
396 |         ...opContext,
397 |         subOperation: "searchCacheFallback",
398 |       };
399 |       allFilteredResults = []; // Reset results for cache search
400 |       totalMatchesCount = 0;
401 |       let processedCount = 0;
402 | 
403 |       for (const [filePath, cacheEntry] of cache.entries()) {
404 |         // Apply path filter
405 |         if (searchPathPrefix && !filePath.startsWith(searchPathPrefix)) {
406 |           continue; // Skip if file is not in the specified path
407 |         }
408 | 
409 |         const mtime = cacheEntry.mtime; // Get mtime from cache
410 | 
411 |         // Apply date filtering
412 |         if (
413 |           (sinceDate && mtime < sinceDate.getTime()) ||
414 |           (untilDate && mtime > untilDate.getTime())
415 |         ) {
416 |           continue;
417 |         }
418 | 
419 |         try {
420 |           const matches = findMatchesInContent(
421 |             cacheEntry.content,
422 |             params.query,
423 |             params.useRegex!,
424 |             params.caseSensitive!,
425 |             params.contextLength!,
426 |             cacheSearchContext,
427 |           );
428 | 
429 |           // Apply match limit per file
430 |           const limitedMatches = matches.slice(0, params.maxMatchesPerFile);
431 | 
432 |           if (limitedMatches.length > 0) {
433 |             let ctime: number | null = null;
434 |             // Attempt to fetch ctime as cache likely doesn't have it
435 |             try {
436 |               const noteJson = (await obsidianService.getFileContent(
437 |                 filePath,
438 |                 "json",
439 |                 cacheSearchContext,
440 |               )) as NoteJson;
441 |               ctime = noteJson.stat.ctime;
442 |             } catch (statError) {
443 |               logger.warning(
444 |                 `Failed to fetch ctime for cached file ${filePath} during fallback. Error: ${statError instanceof Error ? statError.message : String(statError)}`,
445 |                 cacheSearchContext,
446 |               );
447 |               // Proceed without ctime if fetch fails
448 |             }
449 | 
450 |             allFilteredResults.push({
451 |               // Add to unfiltered list
452 |               path: filePath,
453 |               filename: path.basename(filePath),
454 |               modifiedTime: formatTimestamp(mtime, cacheSearchContext), // Format mtime
455 |               createdTime: formatTimestamp(ctime ?? mtime, cacheSearchContext), // Format ctime (or mtime fallback)
456 |               matches: limitedMatches, // Use limited matches
457 |               numericMtime: mtime, // Store numeric mtime from cache
458 |             });
459 |             totalMatchesCount += matches.length; // Count *all* matches before limiting
460 |             processedCount++;
461 |           }
462 |         } catch (matchError) {
463 |           logger.warning(
464 |             `Error matching content in cached file ${filePath} during fallback: ${matchError instanceof Error ? matchError.message : String(matchError)}`,
465 |             cacheSearchContext,
466 |           );
467 |         }
468 |       }
469 |       strategyMessage += `Searched ${cache.size} cached files, processed ${processedCount} matching all filters (including path: '${searchPathPrefix || "entire vault"}'). `;
470 |     } else {
471 |       // This block now handles both "cache disabled" and "cache not ready"
472 |       const reason = vaultCacheService ? "is not ready" : "is disabled";
473 |       strategyMessage += `Cache not available (${reason}), unable to fallback. `;
474 |       logger.error(
475 |         `API search failed and cache ${reason}. Cannot perform search.`,
476 |         opContext,
477 |       );
478 |       // Throw a specific error because the tool cannot function without a data source.
479 |       throw new McpError(
480 |         BaseErrorCode.SERVICE_UNAVAILABLE,
481 |         `Live API search failed and the cache is currently ${reason}. Please ensure the Obsidian REST API is running and reachable, and that the cache is enabled and has had time to build.`,
482 |         opContext,
483 |       );
484 |     }
485 |   }
486 | 
487 |   // 4. Apply Pagination and Sorting
488 |   const totalFilesFound = allFilteredResults.length;
489 |   const pageSize = params.pageSize!;
490 |   const currentPage = params.page!;
491 |   const totalPages = Math.ceil(totalFilesFound / pageSize);
492 |   const startIndex = (currentPage - 1) * pageSize;
493 |   const endIndex = startIndex + pageSize;
494 | 
495 |   // Sort results by numeric modified time (descending) *before* pagination
496 |   allFilteredResults.sort((a, b) => {
497 |     return b.numericMtime - a.numericMtime; // Descending
498 |   });
499 | 
500 |   const paginatedResults = allFilteredResults.slice(startIndex, endIndex);
501 | 
502 |   // 5. Determine alsoFoundInFiles
503 |   let alsoFoundInFiles: string[] | undefined = undefined;
504 |   if (totalPages > 1) {
505 |     const paginatedFilePaths = new Set(paginatedResults.map((r) => r.path));
506 |     alsoFoundInFiles = allFilteredResults
507 |       .filter((r) => !paginatedFilePaths.has(r.path)) // Get files not on the current page
508 |       .map((r) => r.filename); // Then get their filenames
509 |     alsoFoundInFiles = [...new Set(alsoFoundInFiles)]; // Ensure unique filenames in the final list
510 |   }
511 | 
512 |   // 6. Construct Final Response
513 |   const finalMessage = `${strategyMessage}Found ${totalMatchesCount} matches across ${totalFilesFound} files matching all criteria. Returning page ${currentPage} of ${totalPages} (${paginatedResults.length} files on this page, page size ${pageSize}, max matches per file ${params.maxMatchesPerFile}). Results sorted by modification date (newest first).`;
514 | 
515 |   const response: ObsidianGlobalSearchResponse = {
516 |     success: true, // Indicate overall tool success, even if fallback was used or results are empty
517 |     message: finalMessage,
518 |     results: paginatedResults,
519 |     totalFilesFound: totalFilesFound,
520 |     totalMatchesFound: totalMatchesCount,
521 |     currentPage: currentPage,
522 |     pageSize: pageSize,
523 |     totalPages: totalPages,
524 |     alsoFoundInFiles: alsoFoundInFiles, // Add the list here
525 |   };
526 | 
527 |   logger.info(`Global search processing completed. ${finalMessage}`, opContext);
528 |   return response;
529 | };
530 | 
```
Page 3/5FirstPrevNextLast