#
tokens: 47326/50000 21/89 files (page 2/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 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/mcp-server/tools/obsidianGlobalSearchTool/registration.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @module ObsidianGlobalSearchToolRegistration
  3 |  * @description Registers the 'obsidian_global_search' tool with the MCP server.
  4 |  * This tool allows searching the Obsidian vault using text/regex queries with optional date filters.
  5 |  */
  6 | 
  7 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  8 | import type { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
  9 | import type { VaultCacheService } from "../../../services/obsidianRestAPI/vaultCache/index.js"; // Import VaultCacheService type
 10 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
 11 | import {
 12 |   ErrorHandler,
 13 |   logger,
 14 |   RequestContext,
 15 |   requestContextService,
 16 | } from "../../../utils/index.js";
 17 | // Import types, schema shape, and the core processing logic from logic.ts
 18 | import type {
 19 |   ObsidianGlobalSearchInput,
 20 |   ObsidianGlobalSearchResponse,
 21 | } from "./logic.js"; // Ensure '.js' extension
 22 | import {
 23 |   ObsidianGlobalSearchInputSchemaShape,
 24 |   processObsidianGlobalSearch,
 25 | } from "./logic.js"; // Ensure '.js' extension
 26 | 
 27 | /**
 28 |  * Registers the 'obsidian_global_search' tool with the MCP server instance.
 29 |  *
 30 |  * @param {McpServer} server - The MCP server instance.
 31 |  * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service.
 32 |  * @param {VaultCacheService} vaultCacheService - The instance of the Vault Cache service.
 33 |  * @returns {Promise<void>} A promise that resolves when the tool is registered.
 34 |  * @throws {McpError} If registration fails critically.
 35 |  */
 36 | export async function registerObsidianGlobalSearchTool(
 37 |   server: McpServer,
 38 |   obsidianService: ObsidianRestApiService,
 39 |   vaultCacheService: VaultCacheService, // Now required
 40 | ): Promise<void> {
 41 |   const toolName = "obsidian_global_search";
 42 |   const toolDescription = `Performs search across the Obsidian vault using text or regex, primarily relying on the Obsidian REST API's simple search. Supports filtering by modification date, optionally restricting search to a specific directory path (recursively), pagination (page, pageSize), and limiting matches shown per file (maxMatchesPerFile). Returns a JSON object containing success status, a message, pagination details (currentPage, pageSize, totalPages), total file/match counts (before pagination), and an array of results. Each result includes the file path, filename, creation timestamp (ctime), modification timestamp (mtime), and an array of match context snippets (limited by maxMatchesPerFile). If there are multiple pages of results, it also includes an 'alsoFoundInFiles' array listing filenames found on other pages.`;
 43 | 
 44 |   const registrationContext: RequestContext =
 45 |     requestContextService.createRequestContext({
 46 |       operation: "RegisterObsidianGlobalSearchTool",
 47 |       toolName: toolName,
 48 |       module: "ObsidianGlobalSearchRegistration",
 49 |     });
 50 | 
 51 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 52 | 
 53 |   await ErrorHandler.tryCatch(
 54 |     async () => {
 55 |       server.tool(
 56 |         toolName,
 57 |         toolDescription,
 58 |         ObsidianGlobalSearchInputSchemaShape,
 59 |         async (
 60 |           params: ObsidianGlobalSearchInput,
 61 |           handlerInvocationContext: any,
 62 |         ): Promise<any> => {
 63 |           const handlerContext: RequestContext =
 64 |             requestContextService.createRequestContext({
 65 |               operation: "HandleObsidianGlobalSearchRequest",
 66 |               toolName: toolName,
 67 |               paramsSummary: {
 68 |                 useRegex: params.useRegex,
 69 |                 caseSensitive: params.caseSensitive,
 70 |                 pageSize: params.pageSize,
 71 |                 page: params.page,
 72 |                 maxMatchesPerFile: params.maxMatchesPerFile,
 73 |                 searchInPath: params.searchInPath,
 74 |                 hasDateFilter: !!(
 75 |                   params.modified_since || params.modified_until
 76 |                 ),
 77 |               },
 78 |             });
 79 |           logger.debug(`Handling '${toolName}' request`, handlerContext);
 80 | 
 81 |           return await ErrorHandler.tryCatch(
 82 |             async () => {
 83 |               const response: ObsidianGlobalSearchResponse =
 84 |                 await processObsidianGlobalSearch(
 85 |                   params,
 86 |                   handlerContext,
 87 |                   obsidianService,
 88 |                   vaultCacheService,
 89 |                 );
 90 |               logger.debug(
 91 |                 `'${toolName}' processed successfully`,
 92 |                 handlerContext,
 93 |               );
 94 | 
 95 |               return {
 96 |                 content: [
 97 |                   {
 98 |                     type: "text",
 99 |                     text: JSON.stringify(response, null, 2),
100 |                   },
101 |                 ],
102 |                 isError: false,
103 |               };
104 |             },
105 |             {
106 |               operation: `executing tool ${toolName}`,
107 |               context: handlerContext,
108 |               errorCode: BaseErrorCode.INTERNAL_ERROR,
109 |             },
110 |           );
111 |         },
112 |       );
113 | 
114 |       logger.info(
115 |         `Tool registered successfully: ${toolName}`,
116 |         registrationContext,
117 |       );
118 |     },
119 |     {
120 |       operation: `registering tool ${toolName}`,
121 |       context: registrationContext,
122 |       errorCode: BaseErrorCode.INTERNAL_ERROR,
123 |       errorMapper: (error: unknown) =>
124 |         new McpError(
125 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
126 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
127 |           { ...registrationContext },
128 |         ),
129 |       critical: true,
130 |     },
131 |   );
132 | }
133 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Hono middleware for OAuth 2.1 Bearer Token validation.
  3 |  * This middleware extracts a JWT from the Authorization header, validates it against
  4 |  * a remote JWKS (JSON Web Key Set), and checks its issuer and audience claims.
  5 |  * On success, it populates an AuthInfo object and stores it in an AsyncLocalStorage
  6 |  * context for use in downstream handlers.
  7 |  *
  8 |  * @module src/mcp-server/transports/auth/strategies/oauth/oauthMiddleware
  9 |  */
 10 | 
 11 | import { HttpBindings } from "@hono/node-server";
 12 | import { Context, Next } from "hono";
 13 | import { createRemoteJWKSet, jwtVerify } from "jose";
 14 | import { config } from "../../../../../config/index.js";
 15 | import { BaseErrorCode, McpError } from "../../../../../types-global/errors.js";
 16 | import { logger, requestContextService } from "../../../../../utils/index.js";
 17 | import { ErrorHandler } from "../../../../../utils/internal/errorHandler.js";
 18 | import { authContext } from "../../core/authContext.js";
 19 | import type { AuthInfo } from "../../core/authTypes.js";
 20 | 
 21 | // --- Startup Validation ---
 22 | // Ensures that necessary OAuth configuration is present when the mode is 'oauth'.
 23 | if (config.mcpAuthMode === "oauth") {
 24 |   if (!config.oauthIssuerUrl) {
 25 |     throw new Error(
 26 |       "OAUTH_ISSUER_URL must be set when MCP_AUTH_MODE is 'oauth'",
 27 |     );
 28 |   }
 29 |   if (!config.oauthAudience) {
 30 |     throw new Error("OAUTH_AUDIENCE must be set when MCP_AUTH_MODE is 'oauth'");
 31 |   }
 32 |   logger.info(
 33 |     "OAuth 2.1 mode enabled. Verifying tokens against issuer.",
 34 |     requestContextService.createRequestContext({
 35 |       issuer: config.oauthIssuerUrl,
 36 |       audience: config.oauthAudience,
 37 |     }),
 38 |   );
 39 | }
 40 | 
 41 | // --- JWKS Client Initialization ---
 42 | // The remote JWK set is fetched and cached to avoid network calls on every request.
 43 | let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
 44 | if (config.mcpAuthMode === "oauth" && config.oauthIssuerUrl) {
 45 |   try {
 46 |     const jwksUrl = new URL(
 47 |       config.oauthJwksUri ||
 48 |         `${config.oauthIssuerUrl.replace(/\/$/, "")}/.well-known/jwks.json`,
 49 |     );
 50 |     jwks = createRemoteJWKSet(jwksUrl, {
 51 |       cooldownDuration: 300000, // 5 minutes
 52 |       timeoutDuration: 5000, // 5 seconds
 53 |     });
 54 |     logger.info(
 55 |       `JWKS client initialized for URL: ${jwksUrl.href}`,
 56 |       requestContextService.createRequestContext({
 57 |         operation: "oauthMiddlewareSetup",
 58 |       }),
 59 |     );
 60 |   } catch (error) {
 61 |     logger.fatal(
 62 |       "Failed to initialize JWKS client.",
 63 |       error as Error,
 64 |       requestContextService.createRequestContext({
 65 |         operation: "oauthMiddlewareSetup",
 66 |       }),
 67 |     );
 68 |     // Prevent server from starting if JWKS setup fails in oauth mode
 69 |     process.exit(1);
 70 |   }
 71 | }
 72 | 
 73 | /**
 74 |  * Hono middleware for verifying OAuth 2.1 JWT Bearer tokens.
 75 |  * It validates the token and uses AsyncLocalStorage to pass auth info.
 76 |  * @param c - The Hono context object.
 77 |  * @param next - The function to call to proceed to the next middleware.
 78 |  */
 79 | export async function oauthMiddleware(
 80 |   c: Context<{ Bindings: HttpBindings }>,
 81 |   next: Next,
 82 | ) {
 83 |   // If OAuth is not the configured auth mode, skip this middleware.
 84 |   if (config.mcpAuthMode !== "oauth") {
 85 |     return await next();
 86 |   }
 87 | 
 88 |   const context = requestContextService.createRequestContext({
 89 |     operation: "oauthMiddleware",
 90 |     httpMethod: c.req.method,
 91 |     httpPath: c.req.path,
 92 |   });
 93 | 
 94 |   if (!jwks) {
 95 |     // This should not happen if startup validation is correct, but it's a safeguard.
 96 |     // This should not happen if startup validation is correct, but it's a safeguard.
 97 |     throw new McpError(
 98 |       BaseErrorCode.CONFIGURATION_ERROR,
 99 |       "OAuth middleware is active, but JWKS client is not initialized.",
100 |       context,
101 |     );
102 |   }
103 | 
104 |   const authHeader = c.req.header("Authorization");
105 |   if (!authHeader || !authHeader.startsWith("Bearer ")) {
106 |     throw new McpError(
107 |       BaseErrorCode.UNAUTHORIZED,
108 |       "Missing or invalid token format.",
109 |     );
110 |   }
111 | 
112 |   const token = authHeader.substring(7);
113 | 
114 |   try {
115 |     const { payload } = await jwtVerify(token, jwks, {
116 |       issuer: config.oauthIssuerUrl!,
117 |       audience: config.oauthAudience!,
118 |     });
119 | 
120 |     // The 'scope' claim is typically a space-delimited string in OAuth 2.1.
121 |     const scopes =
122 |       typeof payload.scope === "string" ? payload.scope.split(" ") : [];
123 | 
124 |     if (scopes.length === 0) {
125 |       logger.warning(
126 |         "Authentication failed: Token contains no scopes, but scopes are required.",
127 |         { ...context, jwtPayloadKeys: Object.keys(payload) },
128 |       );
129 |       throw new McpError(
130 |         BaseErrorCode.UNAUTHORIZED,
131 |         "Token must contain valid, non-empty scopes.",
132 |       );
133 |     }
134 | 
135 |     const clientId =
136 |       typeof payload.client_id === "string" ? payload.client_id : undefined;
137 | 
138 |     if (!clientId) {
139 |       logger.warning(
140 |         "Authentication failed: OAuth token 'client_id' claim is missing or not a string.",
141 |         { ...context, jwtPayloadKeys: Object.keys(payload) },
142 |       );
143 |       throw new McpError(
144 |         BaseErrorCode.UNAUTHORIZED,
145 |         "Invalid token, missing client identifier.",
146 |       );
147 |     }
148 | 
149 |     const authInfo: AuthInfo = {
150 |       token,
151 |       clientId,
152 |       scopes,
153 |       subject: typeof payload.sub === "string" ? payload.sub : undefined,
154 |     };
155 | 
156 |     // Attach to the raw request for potential legacy compatibility and
157 |     // store in AsyncLocalStorage for modern, safe access in handlers.
158 |     c.env.incoming.auth = authInfo;
159 |     await authContext.run({ authInfo }, next);
160 |   } catch (error: unknown) {
161 |     if (error instanceof Error && error.name === "JWTExpired") {
162 |       logger.warning("Authentication failed: OAuth token expired.", context);
163 |       throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token expired.");
164 |     }
165 | 
166 |     const handledError = ErrorHandler.handleError(error, {
167 |       operation: "oauthMiddleware",
168 |       context,
169 |       rethrow: false, // We will throw a new McpError below
170 |     });
171 | 
172 |     // Ensure we always throw an McpError for consistency
173 |     if (handledError instanceof McpError) {
174 |       throw handledError;
175 |     } else {
176 |       throw new McpError(
177 |         BaseErrorCode.UNAUTHORIZED,
178 |         `Unauthorized: ${handledError.message || "Invalid token"}`,
179 |         { originalError: handledError.name },
180 |       );
181 |     }
182 |   }
183 | }
184 | 
```

--------------------------------------------------------------------------------
/src/utils/parsing/jsonParser.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Provides a utility class for parsing potentially partial JSON strings,
  3 |  * with support for handling and logging optional LLM <think> blocks.
  4 |  * It wraps the 'partial-json' library.
  5 |  * @module src/utils/parsing/jsonParser
  6 |  */
  7 | 
  8 | import {
  9 |   parse as parsePartialJson,
 10 |   Allow as PartialJsonAllow,
 11 | } from "partial-json";
 12 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 13 | import {
 14 |   logger,
 15 |   RequestContext,
 16 |   requestContextService,
 17 | } from "../internal/index.js"; // Corrected import path for internal utils
 18 | 
 19 | /**
 20 |  * Enum mirroring `partial-json`'s `Allow` constants. These constants specify
 21 |  * what types of partial JSON structures are permissible during parsing.
 22 |  * They can be combined using bitwise OR (e.g., `Allow.STR | Allow.OBJ`).
 23 |  *
 24 |  * - `Allow.OBJ`: Allows partial objects (e.g., `{"key": "value",`)
 25 |  * - `Allow.ARR`: Allows partial arrays (e.g., `[1, 2,`)
 26 |  * - `Allow.STR`: Allows partial strings (e.g., `"abc`)
 27 |  * - `Allow.NUM`: Allows partial numbers (e.g., `1.2e+`)
 28 |  * - `Allow.BOOL`: Allows partial booleans (e.g., `tru`)
 29 |  * - `Allow.NULL`: Allows partial nulls (e.g., `nul`)
 30 |  * - `Allow.ALL`: Allows all types of partial JSON structures (default).
 31 |  */
 32 | export const Allow = PartialJsonAllow;
 33 | 
 34 | // Regex to find a <think> block at the start of a string,
 35 | // capturing its content and the rest of the string.
 36 | const thinkBlockRegex = /^<think>([\s\S]*?)<\/think>\s*([\s\S]*)$/;
 37 | 
 38 | /**
 39 |  * Utility class for parsing JSON strings that may be partial or incomplete.
 40 |  * It wraps the 'partial-json' library to provide a consistent parsing interface
 41 |  * and includes logic to handle and log optional `<think>...</think>` blocks
 42 |  * that might precede the JSON content (often found in LLM outputs).
 43 |  */
 44 | class JsonParser {
 45 |   /**
 46 |    * Parses a JSON string, which may be partial or prefixed with an LLM `<think>` block.
 47 |    *
 48 |    * @template T The expected type of the parsed JavaScript value. Defaults to `any`.
 49 |    * @param {string} jsonString - The JSON string to parse.
 50 |    * @param {number} [allowPartial=Allow.ALL] - A bitwise OR combination of `Allow` constants
 51 |    *   specifying which types of partial JSON structures are permissible (e.g., `Allow.OBJ | Allow.ARR`).
 52 |    *   Defaults to `Allow.ALL`, permitting any form of partial JSON.
 53 |    * @param {RequestContext} [providedContext] - Optional `RequestContext` for logging,
 54 |    *   especially for capturing `<think>` block content or parsing errors.
 55 |    * @returns {T} The parsed JavaScript value.
 56 |    * @throws {McpError} Throws an `McpError` with `BaseErrorCode.VALIDATION_ERROR` if:
 57 |    *   - The string is empty after removing a `<think>` block.
 58 |    *   - The remaining content does not appear to be a valid JSON structure (object, array, or permitted primitive).
 59 |    *   - The `partial-json` library encounters a parsing error.
 60 |    */
 61 |   parse<T = any>(
 62 |     jsonString: string,
 63 |     allowPartial: number = Allow.ALL,
 64 |     providedContext?: RequestContext,
 65 |   ): T {
 66 |     const operation = "JsonParser.parse";
 67 |     // Ensure opContext is always a valid RequestContext for internal logging
 68 |     const opContext =
 69 |       providedContext ||
 70 |       requestContextService.createRequestContext({ operation });
 71 | 
 72 |     let stringToParse = jsonString;
 73 |     let thinkContentExtracted: string | undefined;
 74 | 
 75 |     const match = jsonString.match(thinkBlockRegex);
 76 | 
 77 |     if (match) {
 78 |       thinkContentExtracted = match[1].trim();
 79 |       const restOfString = match[2];
 80 | 
 81 |       if (thinkContentExtracted) {
 82 |         logger.debug("LLM <think> block content extracted.", {
 83 |           ...opContext,
 84 |           operation,
 85 |           thinkContent: thinkContentExtracted,
 86 |         });
 87 |       } else {
 88 |         logger.debug("Empty LLM <think> block detected and removed.", {
 89 |           ...opContext,
 90 |           operation,
 91 |         });
 92 |       }
 93 |       stringToParse = restOfString; // Continue parsing with the remainder of the string
 94 |     }
 95 | 
 96 |     stringToParse = stringToParse.trim(); // Trim whitespace from the string that will be parsed
 97 | 
 98 |     if (!stringToParse) {
 99 |       const errorMsg =
100 |         "JSON string is empty after potential <think> block removal and trimming.";
101 |       logger.warning(errorMsg, {
102 |         ...opContext,
103 |         operation,
104 |         originalInput: jsonString,
105 |       });
106 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMsg, {
107 |         ...opContext,
108 |         operation,
109 |       });
110 |     }
111 | 
112 |     try {
113 |       // The pre-check for firstChar and specific primitive types has been removed.
114 |       // We now directly rely on parsePartialJson to validate the structure according
115 |       // to the 'allowPartial' flags. If parsePartialJson fails, it will throw an
116 |       // error which is caught below and wrapped in an McpError.
117 |       return parsePartialJson(stringToParse, allowPartial) as T;
118 |     } catch (error: any) {
119 |       const errorMessage = `Failed to parse JSON content: ${error.message}`;
120 |       logger.error(errorMessage, error, {
121 |         ...opContext, // Use the guaranteed valid opContext
122 |         operation,
123 |         contentAttempted: stringToParse,
124 |         thinkContentFound: thinkContentExtracted,
125 |       });
126 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMessage, {
127 |         ...opContext, // Use the guaranteed valid opContext
128 |         operation,
129 |         originalContent: stringToParse,
130 |         thinkContentProcessed: !!thinkContentExtracted,
131 |         rawError:
132 |           error instanceof Error
133 |             ? { message: error.message, stack: error.stack }
134 |             : String(error),
135 |       });
136 |     }
137 |   }
138 | }
139 | 
140 | /**
141 |  * Singleton instance of the `JsonParser`.
142 |  * Use this instance for all partial JSON parsing needs.
143 |  *
144 |  * Example:
145 |  * ```typescript
146 |  * import { jsonParser, Allow, RequestContext } from './jsonParser';
147 |  * import { requestContextService } from '../internal'; // Assuming requestContextService is exported from internal utils
148 |  * const context: RequestContext = requestContextService.createRequestContext({ operation: 'MyOperation' });
149 |  * try {
150 |  *   const data = jsonParser.parse('<think>Thinking...</think>{"key": "value", "arr": [1,', Allow.ALL, context);
151 |  *   console.log(data); // Output: { key: "value", arr: [ 1 ] }
152 |  * } catch (e) {
153 |  *   console.error("Parsing failed:", e);
154 |  * }
155 |  * ```
156 |  */
157 | export const jsonParser = new JsonParser();
158 | 
```

--------------------------------------------------------------------------------
/src/utils/metrics/tokenCounter.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ChatCompletionMessageParam } from "openai/resources/chat/completions";
  2 | import { encoding_for_model, Tiktoken, TiktokenModel } from "tiktoken";
  3 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
  4 | // Import utils from the main barrel file (ErrorHandler, logger, RequestContext from ../internal/*)
  5 | import { ErrorHandler, logger, RequestContext } from "../index.js";
  6 | 
  7 | // Define the model used specifically for token counting
  8 | const TOKENIZATION_MODEL: TiktokenModel = "gpt-4o"; // Note this is strictly for token counting, not the model used for inference
  9 | 
 10 | /**
 11 |  * Calculates the number of tokens for a given text using the 'gpt-4o' tokenizer.
 12 |  * Uses ErrorHandler for consistent error management.
 13 |  *
 14 |  * @param text - The input text to tokenize.
 15 |  * @param context - Optional request context for logging and error handling.
 16 |  * @returns The number of tokens.
 17 |  * @throws {McpError} Throws an McpError if tokenization fails.
 18 |  */
 19 | export async function countTokens(
 20 |   text: string,
 21 |   context?: RequestContext,
 22 | ): Promise<number> {
 23 |   // Wrap the synchronous operation in tryCatch which handles both sync/async
 24 |   return ErrorHandler.tryCatch(
 25 |     () => {
 26 |       let encoding: Tiktoken | null = null;
 27 |       try {
 28 |         // Always use the defined TOKENIZATION_MODEL
 29 |         encoding = encoding_for_model(TOKENIZATION_MODEL);
 30 |         const tokens = encoding.encode(text);
 31 |         return tokens.length;
 32 |       } finally {
 33 |         encoding?.free(); // Ensure the encoder is freed if it was successfully created
 34 |       }
 35 |     },
 36 |     {
 37 |       operation: "countTokens",
 38 |       context: context,
 39 |       input: { textSample: text.substring(0, 50) + "..." }, // Log sanitized input
 40 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Use INTERNAL_ERROR for external lib issues
 41 |       // rethrow is implicitly true for tryCatch
 42 |       // Removed onErrorReturn as we now rethrow
 43 |     },
 44 |   );
 45 | }
 46 | 
 47 | /**
 48 |  * Calculates the number of tokens for chat messages using the ChatCompletionMessageParam structure
 49 |  * and the 'gpt-4o' tokenizer, considering special tokens and message overhead.
 50 |  * This implementation is based on OpenAI's guidelines for gpt-4/gpt-3.5-turbo models.
 51 |  * Uses ErrorHandler for consistent error management.
 52 |  *
 53 |  * See: https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
 54 |  *
 55 |  * @param messages - An array of chat messages in the `ChatCompletionMessageParam` format.
 56 |  * @param context - Optional request context for logging and error handling.
 57 |  * @returns The estimated number of tokens.
 58 |  * @throws {McpError} Throws an McpError if tokenization fails.
 59 |  */
 60 | export async function countChatTokens(
 61 |   messages: ReadonlyArray<ChatCompletionMessageParam>, // Use the complex type
 62 |   context?: RequestContext,
 63 | ): Promise<number> {
 64 |   // Wrap the synchronous operation in tryCatch
 65 |   return ErrorHandler.tryCatch(
 66 |     () => {
 67 |       let encoding: Tiktoken | null = null;
 68 |       let num_tokens = 0;
 69 |       try {
 70 |         // Always use the defined TOKENIZATION_MODEL
 71 |         encoding = encoding_for_model(TOKENIZATION_MODEL);
 72 | 
 73 |         // Define tokens per message/name based on gpt-4o (same as gpt-4/gpt-3.5-turbo)
 74 |         const tokens_per_message = 3;
 75 |         const tokens_per_name = 1;
 76 | 
 77 |         for (const message of messages) {
 78 |           num_tokens += tokens_per_message;
 79 |           // Encode role
 80 |           num_tokens += encoding.encode(message.role).length;
 81 | 
 82 |           // Encode content - handle potential null or array content (vision)
 83 |           if (typeof message.content === "string") {
 84 |             num_tokens += encoding.encode(message.content).length;
 85 |           } else if (Array.isArray(message.content)) {
 86 |             // Handle multi-part content (e.g., text + image) - simplified: encode text parts only
 87 |             for (const part of message.content) {
 88 |               if (part.type === "text") {
 89 |                 num_tokens += encoding.encode(part.text).length;
 90 |               } else {
 91 |                 // Add placeholder token count for non-text parts (e.g., images) if needed
 92 |                 // This requires specific model knowledge (e.g., OpenAI vision model token costs)
 93 |                 logger.warning(
 94 |                   `Non-text content part found (type: ${part.type}), token count contribution ignored.`,
 95 |                   context,
 96 |                 );
 97 |                 // num_tokens += IMAGE_TOKEN_COST; // Placeholder
 98 |               }
 99 |             }
100 |           } // else: content is null, add 0 tokens
101 | 
102 |           // Encode name if present (often associated with 'tool' or 'function' roles in newer models)
103 |           if ("name" in message && message.name) {
104 |             num_tokens += tokens_per_name;
105 |             num_tokens += encoding.encode(message.name).length;
106 |           }
107 | 
108 |           // --- Handle tool calls (specific to newer models) ---
109 |           // Assistant message requesting tool calls
110 |           if (
111 |             message.role === "assistant" &&
112 |             "tool_calls" in message &&
113 |             message.tool_calls
114 |           ) {
115 |             for (const tool_call of message.tool_calls) {
116 |               // Add tokens for the function name and arguments
117 |               if (tool_call.function.name) {
118 |                 num_tokens += encoding.encode(tool_call.function.name).length;
119 |               }
120 |               if (tool_call.function.arguments) {
121 |                 // Arguments are often JSON strings
122 |                 num_tokens += encoding.encode(
123 |                   tool_call.function.arguments,
124 |                 ).length;
125 |               }
126 |             }
127 |           }
128 | 
129 |           // Tool message providing results
130 |           if (
131 |             message.role === "tool" &&
132 |             "tool_call_id" in message &&
133 |             message.tool_call_id
134 |           ) {
135 |             num_tokens += encoding.encode(message.tool_call_id).length;
136 |             // Content of the tool message (the result) is already handled by the string content check above
137 |           }
138 |         }
139 |         num_tokens += 3; // every reply is primed with <|start|>assistant<|message|>
140 |         return num_tokens;
141 |       } finally {
142 |         encoding?.free();
143 |       }
144 |     },
145 |     {
146 |       operation: "countChatTokens",
147 |       context: context,
148 |       input: { messageCount: messages.length }, // Log sanitized input
149 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Use INTERNAL_ERROR
150 |       // rethrow is implicitly true for tryCatch
151 |       // Removed onErrorReturn
152 |     },
153 |   );
154 | }
155 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Provides utilities for handling asynchronous operations,
  3 |  * such as retrying operations with delays.
  4 |  * @module src/utils/internal/asyncUtils
  5 |  */
  6 | import { McpError, BaseErrorCode } from "../../types-global/errors.js";
  7 | import { logger } from "./logger.js";
  8 | import { RequestContext } from "./requestContext.js";
  9 | 
 10 | /**
 11 |  * Configuration for the {@link retryWithDelay} function, defining how retries are handled.
 12 |  */
 13 | export interface RetryConfig<T> {
 14 |   /**
 15 |    * A descriptive name for the operation being retried. Used in logging.
 16 |    * Example: "FetchUserData", "ProcessPayment".
 17 |    */
 18 |   operationName: string;
 19 |   /**
 20 |    * The request context associated with the operation, for logging and tracing.
 21 |    */
 22 |   context: RequestContext;
 23 |   /**
 24 |    * The maximum number of retry attempts before failing.
 25 |    */
 26 |   maxRetries: number;
 27 |   /**
 28 |    * The delay in milliseconds between retry attempts.
 29 |    */
 30 |   delayMs: number;
 31 |   /**
 32 |    * An optional function to determine if a retry should be attempted based on the error.
 33 |    * If not provided, retries will be attempted for any error.
 34 |    * @param error - The error that occurred during the operation.
 35 |    * @returns `true` if a retry should be attempted, `false` otherwise.
 36 |    */
 37 |   shouldRetry?: (error: unknown) => boolean;
 38 |   /**
 39 |    * An optional function to execute before each retry attempt.
 40 |    * Useful for custom logging or cleanup actions.
 41 |    * @param attempt - The current retry attempt number.
 42 |    * @param error - The error that triggered the retry.
 43 |    */
 44 |   onRetry?: (attempt: number, error: unknown) => void;
 45 | }
 46 | 
 47 | /**
 48 |  * Executes an asynchronous operation with a configurable retry mechanism.
 49 |  * This function will attempt the operation up to `maxRetries` times, with a specified
 50 |  * `delayMs` between attempts. It allows for custom logic to decide if an error
 51 |  * warrants a retry and for actions to be taken before each retry.
 52 |  *
 53 |  * @template T The expected return type of the asynchronous operation.
 54 |  * @param {() => Promise<T>} operation - The asynchronous function to execute.
 55 |  *   This function should return a Promise resolving to type `T`.
 56 |  * @param {RetryConfig<T>} config - Configuration options for the retry behavior,
 57 |  *   including operation name, context, retry limits, delay, and custom handlers.
 58 |  * @returns {Promise<T>} A promise that resolves with the result of the operation if successful.
 59 |  * @throws {McpError} Throws an `McpError` if the operation fails after all retry attempts,
 60 |  *   or if an unexpected error occurs during the retry logic. The error will contain details
 61 |  *   about the operation name, context, and the last encountered error.
 62 |  */
 63 | export async function retryWithDelay<T>(
 64 |   operation: () => Promise<T>,
 65 |   config: RetryConfig<T>,
 66 | ): Promise<T> {
 67 |   const {
 68 |     operationName,
 69 |     context,
 70 |     maxRetries,
 71 |     delayMs,
 72 |     shouldRetry = () => true, // Default: retry on any error
 73 |     onRetry,
 74 |   } = config;
 75 | 
 76 |   let lastError: unknown;
 77 | 
 78 |   for (let attempt = 1; attempt <= maxRetries; attempt++) {
 79 |     try {
 80 |       return await operation();
 81 |     } catch (error) {
 82 |       lastError = error;
 83 |       // Ensure the context for logging includes attempt details
 84 |       const retryAttemptContext: RequestContext = {
 85 |         ...context, // Spread existing context
 86 |         operation: operationName, // Ensure operationName is part of the context for logger
 87 |         attempt,
 88 |         maxRetries,
 89 |         lastError: error instanceof Error ? error.message : String(error),
 90 |       };
 91 | 
 92 |       if (attempt < maxRetries && shouldRetry(error)) {
 93 |         if (onRetry) {
 94 |           onRetry(attempt, error); // Custom onRetry logic
 95 |         } else {
 96 |           // Default logging for retry attempt
 97 |           logger.warning(
 98 |             `Operation '${operationName}' failed on attempt ${attempt} of ${maxRetries}. Retrying in ${delayMs}ms...`,
 99 |             retryAttemptContext, // Pass the enriched context
100 |           );
101 |         }
102 |         await new Promise((resolve) => setTimeout(resolve, delayMs));
103 |       } else {
104 |         // Max retries reached or shouldRetry returned false
105 |         const finalErrorMsg = `Operation '${operationName}' failed definitively after ${attempt} attempt(s).`;
106 |         // Log the final failure with the enriched context
107 |         logger.error(
108 |           finalErrorMsg,
109 |           error instanceof Error ? error : undefined,
110 |           retryAttemptContext,
111 |         );
112 | 
113 |         if (error instanceof McpError) {
114 |           // If the last error was already an McpError, re-throw it but ensure its details are preserved/updated.
115 |           error.details = {
116 |             ...(typeof error.details === "object" && error.details !== null
117 |               ? error.details
118 |               : {}),
119 |             ...retryAttemptContext, // Add retry context to existing details
120 |             finalAttempt: true,
121 |           };
122 |           throw error;
123 |         }
124 |         // For other errors, wrap in a new McpError
125 |         throw new McpError(
126 |           BaseErrorCode.SERVICE_UNAVAILABLE, // Default to SERVICE_UNAVAILABLE, consider making this configurable or smarter
127 |           `${finalErrorMsg} Last error: ${error instanceof Error ? error.message : String(error)}`,
128 |           {
129 |             ...retryAttemptContext, // Include all retry context
130 |             originalErrorName:
131 |               error instanceof Error ? error.name : typeof error,
132 |             originalErrorStack:
133 |               error instanceof Error ? error.stack : undefined,
134 |             finalAttempt: true,
135 |           },
136 |         );
137 |       }
138 |     }
139 |   }
140 | 
141 |   // Fallback: This part should ideally not be reached if the loop logic is correct.
142 |   // If it is, it implies an issue with the loop or maxRetries logic.
143 |   const fallbackErrorContext: RequestContext = {
144 |     ...context,
145 |     operation: operationName,
146 |     maxRetries,
147 |     reason: "Fallback_Error_Path_Reached_In_Retry_Logic",
148 |   };
149 |   logger.crit(
150 |     // Log as critical because this path indicates a logic flaw
151 |     `Operation '${operationName}' failed unexpectedly after all retries (fallback path). This may indicate a logic error in retryWithDelay.`,
152 |     lastError instanceof Error ? lastError : undefined,
153 |     fallbackErrorContext,
154 |   );
155 |   throw new McpError(
156 |     BaseErrorCode.INTERNAL_ERROR, // Indicates an issue with the retry utility itself
157 |     `Operation '${operationName}' failed unexpectedly after all retries (fallback path). Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
158 |     {
159 |       ...fallbackErrorContext,
160 |       originalError:
161 |         lastError instanceof Error
162 |           ? {
163 |               message: lastError.message,
164 |               name: lastError.name,
165 |               stack: lastError.stack,
166 |             }
167 |           : String(lastError),
168 |     },
169 |   );
170 | }
171 | 
```

--------------------------------------------------------------------------------
/src/services/obsidianRestAPI/methods/vaultMethods.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @module VaultMethods
  3 |  * @description
  4 |  * Methods for interacting with vault files and directories via the Obsidian REST API.
  5 |  */
  6 | 
  7 | import { RequestContext } from "../../../utils/index.js";
  8 | import {
  9 |   NoteJson,
 10 |   FileListResponse,
 11 |   NoteStat,
 12 |   RequestFunction,
 13 | } from "../types.js";
 14 | import { encodeVaultPath } from "../../../utils/obsidian/obsidianApiUtils.js";
 15 | 
 16 | /**
 17 |  * Gets the content of a specific file in the vault.
 18 |  * @param _request - The internal request function from the service instance.
 19 |  * @param filePath - Vault-relative path to the file.
 20 |  * @param format - 'markdown' or 'json' (for NoteJson).
 21 |  * @param context - Request context.
 22 |  * @returns The file content (string) or NoteJson object.
 23 |  */
 24 | export async function getFileContent(
 25 |   _request: RequestFunction,
 26 |   filePath: string,
 27 |   format: "markdown" | "json" = "markdown",
 28 |   context: RequestContext,
 29 | ): Promise<string | NoteJson> {
 30 |   const acceptHeader =
 31 |     format === "json" ? "application/vnd.olrapi.note+json" : "text/markdown";
 32 |   const encodedPath = encodeVaultPath(filePath); // Use the new encoding function
 33 |   return _request<string | NoteJson>(
 34 |     {
 35 |       method: "GET",
 36 |       url: `/vault${encodedPath}`,
 37 |       headers: { Accept: acceptHeader },
 38 |     },
 39 |     context,
 40 |     "getFileContent",
 41 |   );
 42 | }
 43 | 
 44 | /**
 45 |  * Updates (overwrites) the content of a file or creates it if it doesn't exist.
 46 |  * @param _request - The internal request function from the service instance.
 47 |  * @param filePath - Vault-relative path to the file.
 48 |  * @param content - The new content for the file.
 49 |  * @param context - Request context.
 50 |  * @returns {Promise<void>} Resolves on success (204 No Content).
 51 |  */
 52 | export async function updateFileContent(
 53 |   _request: RequestFunction,
 54 |   filePath: string,
 55 |   content: string,
 56 |   context: RequestContext,
 57 | ): Promise<void> {
 58 |   const encodedPath = encodeVaultPath(filePath); // Use the new encoding function
 59 |   // PUT returns 204 No Content, so the expected type is void
 60 |   await _request<void>(
 61 |     {
 62 |       method: "PUT",
 63 |       url: `/vault${encodedPath}`, // Construct URL correctly
 64 |       headers: { "Content-Type": "text/markdown" },
 65 |       data: content,
 66 |     },
 67 |     context,
 68 |     "updateFileContent",
 69 |   );
 70 | }
 71 | 
 72 | /**
 73 |  * Appends content to the end of a file. Creates the file if it doesn't exist.
 74 |  * @param _request - The internal request function from the service instance.
 75 |  * @param filePath - Vault-relative path to the file.
 76 |  * @param content - The content to append.
 77 |  * @param context - Request context.
 78 |  * @returns {Promise<void>} Resolves on success (204 No Content).
 79 |  */
 80 | export async function appendFileContent(
 81 |   _request: RequestFunction,
 82 |   filePath: string,
 83 |   content: string,
 84 |   context: RequestContext,
 85 | ): Promise<void> {
 86 |   const encodedPath = encodeVaultPath(filePath); // Use the new encoding function
 87 |   await _request<void>(
 88 |     {
 89 |       method: "POST",
 90 |       url: `/vault${encodedPath}`, // Construct URL correctly
 91 |       headers: { "Content-Type": "text/markdown" },
 92 |       data: content,
 93 |     },
 94 |     context,
 95 |     "appendFileContent",
 96 |   );
 97 | }
 98 | 
 99 | /**
100 |  * Deletes a specific file in the vault.
101 |  * @param _request - The internal request function from the service instance.
102 |  * @param filePath - Vault-relative path to the file.
103 |  * @param context - Request context.
104 |  * @returns {Promise<void>} Resolves on success (204 No Content).
105 |  */
106 | export async function deleteFile(
107 |   _request: RequestFunction,
108 |   filePath: string,
109 |   context: RequestContext,
110 | ): Promise<void> {
111 |   const encodedPath = encodeVaultPath(filePath); // Use the new encoding function
112 |   await _request<void>(
113 |     {
114 |       method: "DELETE",
115 |       url: `/vault${encodedPath}`, // Construct URL correctly
116 |     },
117 |     context,
118 |     "deleteFile",
119 |   );
120 | }
121 | 
122 | /**
123 |  * Lists files within a specified directory in the vault.
124 |  * @param _request - The internal request function from the service instance.
125 |  * @param dirPath - Vault-relative path to the directory. Use empty string "" or "/" for the root.
126 |  * @param context - Request context.
127 |  * @returns A list of file and directory names.
128 |  */
129 | export async function listFiles(
130 |   _request: RequestFunction,
131 |   dirPath: string,
132 |   context: RequestContext,
133 | ): Promise<string[]> {
134 |   // Normalize path: remove leading/trailing slashes for consistency, except for root
135 |   let pathSegment = dirPath.trim();
136 | 
137 |   // Explicitly handle root path variations ('', '/') by setting pathSegment to empty.
138 |   // This ensures that the final URL constructed later will be '/vault/', which the API
139 |   // uses to list the root directory contents.
140 |   if (pathSegment === "" || pathSegment === "/") {
141 |     pathSegment = ""; // Use empty string to signify root for URL construction
142 |   } else {
143 |     // For non-root paths:
144 |     // 1. Remove any leading/trailing slashes to prevent issues like '/vault//path/' or '/vault/path//'.
145 |     // 2. URI-encode *each component* of the remaining path segment to handle special characters safely.
146 |     pathSegment = pathSegment
147 |       .replace(/^\/+|\/+$/g, "")
148 |       .split("/")
149 |       .map(encodeURIComponent)
150 |       .join("/");
151 |   }
152 | 
153 |   // Construct the final URL for the API request:
154 |   // - If pathSegment is not empty (i.e., it's a specific directory), format as '/vault/{encoded_path}/'.
155 |   // - If pathSegment IS empty (signifying the root), format as '/vault/'.
156 |   // The trailing slash is important for directory listing endpoints in this API.
157 |   const url = pathSegment ? `/vault/${pathSegment}/` : "/vault/";
158 | 
159 |   const response = await _request<FileListResponse>(
160 |     {
161 |       method: "GET",
162 |       url: url, // Use the correctly constructed URL
163 |     },
164 |     context,
165 |     "listFiles",
166 |   );
167 |   return response.files;
168 | }
169 | 
170 | /**
171 |  * Gets the metadata (stat) of a specific file using a lightweight HEAD request.
172 |  * @param _request - The internal request function from the service instance.
173 |  * @param filePath - Vault-relative path to the file.
174 |  * @param context - Request context.
175 |  * @returns The file's metadata.
176 |  */
177 | export async function getFileMetadata(
178 |   _request: RequestFunction,
179 |   filePath: string,
180 |   context: RequestContext,
181 | ): Promise<NoteStat | null> {
182 |   const encodedPath = encodeVaultPath(filePath);
183 |   try {
184 |     const response = await _request<any>(
185 |       {
186 |         method: "HEAD",
187 |         url: `/vault${encodedPath}`,
188 |       },
189 |       context,
190 |       "getFileMetadata",
191 |     );
192 | 
193 |     if (response && response.headers) {
194 |       const headers = response.headers;
195 |       return {
196 |         mtime: headers["x-obsidian-mtime"]
197 |           ? parseFloat(headers["x-obsidian-mtime"]) * 1000
198 |           : 0,
199 |         ctime: headers["x-obsidian-ctime"]
200 |           ? parseFloat(headers["x-obsidian-ctime"]) * 1000
201 |           : 0,
202 |         size: headers["content-length"]
203 |           ? parseInt(headers["content-length"], 10)
204 |           : 0,
205 |       };
206 |     }
207 |     return null;
208 |   } catch (error) {
209 |     // Errors are already logged by the _request function, so we can just return null
210 |     return null;
211 |   }
212 | }
213 | 
```

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

```typescript
  1 | import dotenv from "dotenv";
  2 | import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
  3 | import path, { dirname, join } from "path";
  4 | import { fileURLToPath } from "url";
  5 | import { z } from "zod";
  6 | 
  7 | dotenv.config();
  8 | 
  9 | // --- Determine Project Root ---
 10 | /**
 11 |  * Finds the project root directory by searching upwards for package.json.
 12 |  * @param startDir The directory to start searching from.
 13 |  * @returns The absolute path to the project root, or throws an error if not found.
 14 |  */
 15 | const findProjectRoot = (startDir: string): string => {
 16 |   let currentDir = startDir;
 17 |   while (true) {
 18 |     const packageJsonPath = join(currentDir, "package.json");
 19 |     if (existsSync(packageJsonPath)) {
 20 |       return currentDir;
 21 |     }
 22 |     const parentDir = dirname(currentDir);
 23 |     if (parentDir === currentDir) {
 24 |       // Reached the root of the filesystem without finding package.json
 25 |       throw new Error(
 26 |         `Could not find project root (package.json) starting from ${startDir}`,
 27 |       );
 28 |     }
 29 |     currentDir = parentDir;
 30 |   }
 31 | };
 32 | 
 33 | let projectRoot: string;
 34 | try {
 35 |   // For ESM, __dirname is not available directly.
 36 |   const currentModuleDir = dirname(fileURLToPath(import.meta.url));
 37 |   projectRoot = findProjectRoot(currentModuleDir);
 38 | } catch (error: any) {
 39 |   console.error(`FATAL: Error determining project root: ${error.message}`);
 40 |   projectRoot = process.cwd();
 41 |   console.warn(
 42 |     `Warning: Using process.cwd() (${projectRoot}) as fallback project root.`,
 43 |   );
 44 | }
 45 | // --- End Determine Project Root ---
 46 | 
 47 | const pkgPath = join(projectRoot, "package.json");
 48 | let pkg = { name: "obsidian-mcp-server", version: "0.0.0" };
 49 | 
 50 | try {
 51 |   pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
 52 | } catch (error) {
 53 |   if (process.stderr.isTTY) {
 54 |     console.error(
 55 |       "Warning: Could not read package.json for default config values. Using hardcoded defaults.",
 56 |       error,
 57 |     );
 58 |   }
 59 | }
 60 | 
 61 | /**
 62 |  * Zod schema for validating environment variables.
 63 |  * @private
 64 |  */
 65 | const EnvSchema = z.object({
 66 |   MCP_SERVER_NAME: z.string().optional(),
 67 |   MCP_SERVER_VERSION: z.string().optional(),
 68 |   MCP_LOG_LEVEL: z.string().default("info"),
 69 |   LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
 70 |   NODE_ENV: z.string().default("development"),
 71 |   MCP_TRANSPORT_TYPE: z.enum(["stdio", "http"]).default("stdio"),
 72 |   MCP_HTTP_PORT: z.coerce.number().int().positive().default(3010),
 73 |   MCP_HTTP_HOST: z.string().default("127.0.0.1"),
 74 |   MCP_ALLOWED_ORIGINS: z.string().optional(),
 75 |   MCP_AUTH_MODE: z.enum(["jwt", "oauth"]).optional(),
 76 |   MCP_AUTH_SECRET_KEY: z
 77 |     .string()
 78 |     .min(
 79 |       32,
 80 |       "MCP_AUTH_SECRET_KEY must be at least 32 characters long for security",
 81 |     )
 82 |     .optional(),
 83 |   OAUTH_ISSUER_URL: z.string().url().optional(),
 84 |   OAUTH_AUDIENCE: z.string().optional(),
 85 |   OAUTH_JWKS_URI: z.string().url().optional(),
 86 |   // --- Obsidian Specific Config ---
 87 |   OBSIDIAN_API_KEY: z.string().min(1, "OBSIDIAN_API_KEY cannot be empty"),
 88 |   OBSIDIAN_BASE_URL: z.string().url().default("http://127.0.0.1:27123"),
 89 |   OBSIDIAN_VERIFY_SSL: z
 90 |     .string()
 91 |     .transform((val) => val.toLowerCase() === "true")
 92 |     .default("false"),
 93 |   OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN: z.coerce
 94 |     .number()
 95 |     .int()
 96 |     .positive()
 97 |     .default(10),
 98 |   OBSIDIAN_ENABLE_CACHE: z
 99 |     .string()
100 |     .transform((val) => val.toLowerCase() === "true")
101 |     .default("true"),
102 |   OBSIDIAN_API_SEARCH_TIMEOUT_MS: z.coerce
103 |     .number()
104 |     .int()
105 |     .positive()
106 |     .default(30000),
107 | });
108 | 
109 | const parsedEnv = EnvSchema.safeParse(process.env);
110 | 
111 | if (!parsedEnv.success) {
112 |   const errorDetails = parsedEnv.error.flatten().fieldErrors;
113 |   if (process.stderr.isTTY) {
114 |     console.error("❌ Invalid environment variables:", errorDetails);
115 |   }
116 |   throw new Error(
117 |     `Invalid environment configuration. Please check your .env file or environment variables. Details: ${JSON.stringify(errorDetails)}`,
118 |   );
119 | }
120 | 
121 | const env = parsedEnv.data;
122 | 
123 | // --- Directory Ensurance Function ---
124 | const ensureDirectory = (
125 |   dirPath: string,
126 |   rootDir: string,
127 |   dirName: string,
128 | ): string | null => {
129 |   const resolvedDirPath = path.isAbsolute(dirPath)
130 |     ? dirPath
131 |     : path.resolve(rootDir, dirPath);
132 | 
133 |   if (
134 |     !resolvedDirPath.startsWith(rootDir + path.sep) &&
135 |     resolvedDirPath !== rootDir
136 |   ) {
137 |     if (process.stderr.isTTY) {
138 |       console.error(
139 |         `Error: ${dirName} path "${dirPath}" resolves to "${resolvedDirPath}", which is outside the project boundary "${rootDir}".`,
140 |       );
141 |     }
142 |     return null;
143 |   }
144 | 
145 |   if (!existsSync(resolvedDirPath)) {
146 |     try {
147 |       mkdirSync(resolvedDirPath, { recursive: true });
148 |     } catch (err: unknown) {
149 |       if (process.stderr.isTTY) {
150 |         console.error(
151 |           `Error creating ${dirName} directory at ${resolvedDirPath}: ${err instanceof Error ? err.message : String(err)}`,
152 |         );
153 |       }
154 |       return null;
155 |     }
156 |   } else {
157 |     try {
158 |       if (!statSync(resolvedDirPath).isDirectory()) {
159 |         if (process.stderr.isTTY) {
160 |           console.error(
161 |             `Error: ${dirName} path ${resolvedDirPath} exists but is not a directory.`,
162 |           );
163 |         }
164 |         return null;
165 |       }
166 |     } catch (statError: any) {
167 |       if (process.stderr.isTTY) {
168 |         console.error(
169 |           `Error accessing ${dirName} path ${resolvedDirPath}: ${statError.message}`,
170 |         );
171 |       }
172 |       return null;
173 |     }
174 |   }
175 |   return resolvedDirPath;
176 | };
177 | // --- End Directory Ensurance Function ---
178 | 
179 | const validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
180 | 
181 | if (!validatedLogsPath) {
182 |   if (process.stderr.isTTY) {
183 |     console.error(
184 |       "FATAL: Logs directory configuration is invalid or could not be created. Please check permissions and path. Exiting.",
185 |     );
186 |   }
187 |   process.exit(1);
188 | }
189 | 
190 | /**
191 |  * Main application configuration object.
192 |  */
193 | export const config = {
194 |   pkg,
195 |   mcpServerName: env.MCP_SERVER_NAME || pkg.name,
196 |   mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
197 |   logLevel: env.MCP_LOG_LEVEL,
198 |   logsPath: validatedLogsPath,
199 |   environment: env.NODE_ENV,
200 |   mcpTransportType: env.MCP_TRANSPORT_TYPE,
201 |   mcpHttpPort: env.MCP_HTTP_PORT,
202 |   mcpHttpHost: env.MCP_HTTP_HOST,
203 |   mcpAllowedOrigins: env.MCP_ALLOWED_ORIGINS?.split(",")
204 |     .map((origin) => origin.trim())
205 |     .filter(Boolean),
206 |   mcpAuthMode: env.MCP_AUTH_MODE,
207 |   mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
208 |   oauthIssuerUrl: env.OAUTH_ISSUER_URL,
209 |   oauthAudience: env.OAUTH_AUDIENCE,
210 |   oauthJwksUri: env.OAUTH_JWKS_URI,
211 |   obsidianApiKey: env.OBSIDIAN_API_KEY,
212 |   obsidianBaseUrl: env.OBSIDIAN_BASE_URL,
213 |   obsidianVerifySsl: env.OBSIDIAN_VERIFY_SSL,
214 |   obsidianCacheRefreshIntervalMin: env.OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN,
215 |   obsidianEnableCache: env.OBSIDIAN_ENABLE_CACHE,
216 |   obsidianApiSearchTimeoutMs: env.OBSIDIAN_API_SEARCH_TIMEOUT_MS,
217 | };
218 | 
219 | /**
220 |  * The configured logging level for the application.
221 |  * Exported separately for convenience (e.g., logger initialization).
222 |  * @type {string}
223 |  */
224 | export const logLevel = config.logLevel;
225 | 
226 | /**
227 |  * The configured runtime environment for the application.
228 |  * Exported separately for convenience.
229 |  * @type {string}
230 |  */
231 | export const environment = config.environment;
232 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/tools/obsidianDeleteNoteTool/registration.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  2 | import {
  3 |   ObsidianRestApiService,
  4 |   VaultCacheService,
  5 | } from "../../../services/obsidianRestAPI/index.js";
  6 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  7 | import {
  8 |   ErrorHandler,
  9 |   logger,
 10 |   RequestContext,
 11 |   requestContextService,
 12 | } from "../../../utils/index.js";
 13 | // Import necessary types, schema, and logic function from the logic file
 14 | import type {
 15 |   ObsidianDeleteNoteInput,
 16 |   ObsidianDeleteNoteResponse,
 17 | } from "./logic.js";
 18 | import {
 19 |   ObsidianDeleteNoteInputSchema,
 20 |   processObsidianDeleteNote,
 21 | } from "./logic.js";
 22 | 
 23 | /**
 24 |  * Registers the 'obsidian_delete_note' tool with the MCP server.
 25 |  *
 26 |  * This tool permanently deletes a specified file from the user's Obsidian vault.
 27 |  * It requires the vault-relative path, including the file extension. The tool
 28 |  * attempts a case-sensitive deletion first, followed by a case-insensitive
 29 |  * fallback search and delete if the initial attempt fails with a 'NOT_FOUND' error.
 30 |  *
 31 |  * The response is a JSON string containing a success status and a confirmation message.
 32 |  *
 33 |  * @param {McpServer} server - The MCP server instance to register the tool with.
 34 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 35 |  *   used to interact with the user's Obsidian vault.
 36 |  * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 37 |  * @throws {McpError} Throws an McpError if registration fails critically.
 38 |  */
 39 | export const registerObsidianDeleteNoteTool = async (
 40 |   server: McpServer,
 41 |   obsidianService: ObsidianRestApiService,
 42 |   vaultCacheService: VaultCacheService | undefined,
 43 | ): Promise<void> => {
 44 |   const toolName = "obsidian_delete_note";
 45 |   // Updated description to accurately reflect the response (no timestamp)
 46 |   const toolDescription =
 47 |     "Permanently deletes a specified file from the Obsidian vault. Tries the exact path first, then attempts a case-insensitive fallback if the file is not found. Requires the vault-relative path including the file extension. Returns a success message.";
 48 | 
 49 |   // Create a context specifically for the registration process.
 50 |   const registrationContext: RequestContext =
 51 |     requestContextService.createRequestContext({
 52 |       operation: "RegisterObsidianDeleteNoteTool",
 53 |       toolName: toolName,
 54 |       module: "ObsidianDeleteNoteRegistration", // Identify the module
 55 |     });
 56 | 
 57 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 58 | 
 59 |   // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
 60 |   await ErrorHandler.tryCatch(
 61 |     async () => {
 62 |       // Use the high-level SDK method `server.tool` for registration.
 63 |       server.tool(
 64 |         toolName,
 65 |         toolDescription,
 66 |         ObsidianDeleteNoteInputSchema.shape, // Provide the Zod schema shape for input definition.
 67 |         /**
 68 |          * The handler function executed when the 'obsidian_delete_note' tool is called by the client.
 69 |          *
 70 |          * @param {ObsidianDeleteNoteInput} params - The input parameters received from the client,
 71 |          *   validated against the ObsidianDeleteNoteInputSchema shape.
 72 |          * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
 73 |          *   containing either the successful response data (serialized JSON) or an error indication.
 74 |          */
 75 |         async (params: ObsidianDeleteNoteInput) => {
 76 |           // Type matches the inferred input schema
 77 |           // Create a specific context for this handler invocation.
 78 |           const handlerContext: RequestContext =
 79 |             requestContextService.createRequestContext({
 80 |               parentContext: registrationContext, // Link to registration context
 81 |               operation: "HandleObsidianDeleteNoteRequest",
 82 |               toolName: toolName,
 83 |               params: { filePath: params.filePath }, // Log the file path being targeted
 84 |             });
 85 |           logger.debug(`Handling '${toolName}' request`, handlerContext);
 86 | 
 87 |           // Wrap the core logic execution in a tryCatch block.
 88 |           return await ErrorHandler.tryCatch(
 89 |             async () => {
 90 |               // Delegate the actual file deletion logic to the processing function.
 91 |               // Note: Input schema and shape are identical, no separate refinement parse needed here.
 92 |               const response: ObsidianDeleteNoteResponse =
 93 |                 await processObsidianDeleteNote(
 94 |                   params,
 95 |                   handlerContext,
 96 |                   obsidianService,
 97 |                   vaultCacheService,
 98 |                 );
 99 |               logger.debug(
100 |                 `'${toolName}' processed successfully`,
101 |                 handlerContext,
102 |               );
103 | 
104 |               // Format the successful response object from the logic function into the required MCP CallToolResult structure.
105 |               // The response object (success, message) is serialized to JSON.
106 |               return {
107 |                 content: [
108 |                   {
109 |                     type: "text", // Standard content type for structured JSON data
110 |                     text: JSON.stringify(response, null, 2), // Pretty-print JSON
111 |                   },
112 |                 ],
113 |                 isError: false, // Indicate successful execution
114 |               };
115 |             },
116 |             {
117 |               // Configuration for the inner error handler (processing logic).
118 |               operation: `processing ${toolName} handler`,
119 |               context: handlerContext,
120 |               input: params, // Log the full input parameters if an error occurs.
121 |               // Custom error mapping for consistent error reporting.
122 |               errorMapper: (error: unknown) =>
123 |                 new McpError(
124 |                   error instanceof McpError
125 |                     ? error.code
126 |                     : BaseErrorCode.INTERNAL_ERROR,
127 |                   `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
128 |                   { ...handlerContext }, // Include context
129 |                 ),
130 |             },
131 |           ); // End of inner ErrorHandler.tryCatch
132 |         },
133 |       ); // End of server.tool call
134 | 
135 |       logger.info(
136 |         `Tool registered successfully: ${toolName}`,
137 |         registrationContext,
138 |       );
139 |     },
140 |     {
141 |       // Configuration for the outer error handler (registration process).
142 |       operation: `registering tool ${toolName}`,
143 |       context: registrationContext,
144 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
145 |       // Custom error mapping for registration failures.
146 |       errorMapper: (error: unknown) =>
147 |         new McpError(
148 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
149 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
150 |           { ...registrationContext }, // Include context
151 |         ),
152 |       critical: true, // Treat registration failure as critical.
153 |     },
154 |   ); // End of outer ErrorHandler.tryCatch
155 | };
156 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Registers the 'obsidian_list_notes' tool with the MCP server.
  3 |  * This file defines the tool's metadata and sets up the handler that links
  4 |  * the tool call to its core processing logic.
  5 |  * @module src/mcp-server/tools/obsidianListNotesTool/registration
  6 |  */
  7 | 
  8 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  9 | import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
 10 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
 11 | import {
 12 |   ErrorHandler,
 13 |   logger,
 14 |   RequestContext,
 15 |   requestContextService,
 16 | } from "../../../utils/index.js";
 17 | // Import necessary types, schema, and logic function from the logic file
 18 | import type {
 19 |   ObsidianListNotesInput,
 20 |   ObsidianListNotesResponse,
 21 | } from "./logic.js";
 22 | import {
 23 |   ObsidianListNotesInputSchema,
 24 |   processObsidianListNotes,
 25 | } from "./logic.js";
 26 | 
 27 | /**
 28 |  * Registers the 'obsidian_list_notes' tool with the MCP server.
 29 |  *
 30 |  * This tool lists the files and subdirectories within a specified directory
 31 |  * in the user's Obsidian vault. It supports optional filtering by file extension,
 32 |  * by a regular expression matching the entry name, and recursive listing up to a
 33 |  * specified depth.
 34 |  *
 35 |  * @param {McpServer} server - The MCP server instance to register the tool with.
 36 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 37 |  *   used to interact with the user's Obsidian vault.
 38 |  * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 39 |  * @throws {McpError} Throws an McpError if registration fails critically.
 40 |  */
 41 | export const registerObsidianListNotesTool = async (
 42 |   server: McpServer,
 43 |   obsidianService: ObsidianRestApiService, // Dependency injection for the Obsidian service
 44 | ): Promise<void> => {
 45 |   const toolName = "obsidian_list_notes";
 46 |   const toolDescription =
 47 |     "Lists files and subdirectories within a specified Obsidian vault folder. Supports optional filtering by extension or name regex, and recursive listing to a specified depth (-1 for infinite). Returns an object containing the listed directory path, a formatted tree string of its contents, and the total entry count. Use an empty string or '/' for dirPath to list the vault root.";
 48 | 
 49 |   // Create a context specifically for the registration process.
 50 |   const registrationContext: RequestContext =
 51 |     requestContextService.createRequestContext({
 52 |       operation: "RegisterObsidianListNotesTool",
 53 |       toolName: toolName,
 54 |       module: "ObsidianListNotesRegistration", // Identify the module
 55 |     });
 56 | 
 57 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 58 | 
 59 |   // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
 60 |   await ErrorHandler.tryCatch(
 61 |     async () => {
 62 |       // Use the high-level SDK method `server.tool` for registration.
 63 |       server.tool(
 64 |         toolName,
 65 |         toolDescription,
 66 |         ObsidianListNotesInputSchema.shape, // Provide the Zod schema shape for input definition.
 67 |         /**
 68 |          * The handler function executed when the 'obsidian_list_notes' tool is called by the client.
 69 |          *
 70 |          * @param {ObsidianListNotesInput} params - The input parameters received from the client,
 71 |          *   validated against the ObsidianListNotesInputSchema shape.
 72 |          * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
 73 |          *   containing either the successful response data (serialized JSON) or an error indication.
 74 |          */
 75 |         async (params: ObsidianListNotesInput) => {
 76 |           // Type matches the inferred input schema
 77 |           // Create a specific context for this handler invocation.
 78 |           const handlerContext: RequestContext =
 79 |             requestContextService.createRequestContext({
 80 |               parentContext: registrationContext, // Link to registration context
 81 |               operation: "HandleObsidianListNotesRequest",
 82 |               toolName: toolName,
 83 |               params: {
 84 |                 // Log all relevant parameters for debugging
 85 |                 dirPath: params.dirPath,
 86 |                 fileExtensionFilter: params.fileExtensionFilter,
 87 |                 nameRegexFilter: params.nameRegexFilter,
 88 |                 recursionDepth: params.recursionDepth,
 89 |               },
 90 |             });
 91 |           logger.debug(`Handling '${toolName}' request`, handlerContext);
 92 | 
 93 |           // Wrap the core logic execution in a tryCatch block.
 94 |           return await ErrorHandler.tryCatch(
 95 |             async () => {
 96 |               // Delegate the actual file listing and filtering logic to the processing function.
 97 |               const response: ObsidianListNotesResponse =
 98 |                 await processObsidianListNotes(
 99 |                   params,
100 |                   handlerContext,
101 |                   obsidianService,
102 |                 );
103 |               logger.debug(
104 |                 `'${toolName}' processed successfully`,
105 |                 handlerContext,
106 |               );
107 | 
108 |               // Format the successful response object from the logic function into the required MCP CallToolResult structure.
109 |               return {
110 |                 content: [
111 |                   {
112 |                     type: "text", // Standard content type for structured JSON data
113 |                     text: JSON.stringify(response, null, 2), // Pretty-print JSON
114 |                   },
115 |                 ],
116 |                 isError: false, // Indicate successful execution
117 |               };
118 |             },
119 |             {
120 |               // Configuration for the inner error handler (processing logic).
121 |               operation: `processing ${toolName} handler`,
122 |               context: handlerContext,
123 |               input: params, // Log the full input parameters if an error occurs.
124 |               // Custom error mapping for consistent error reporting.
125 |               errorMapper: (error: unknown) =>
126 |                 new McpError(
127 |                   error instanceof McpError
128 |                     ? error.code
129 |                     : BaseErrorCode.INTERNAL_ERROR,
130 |                   `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
131 |                   { ...handlerContext }, // Include context
132 |                 ),
133 |             },
134 |           ); // End of inner ErrorHandler.tryCatch
135 |         },
136 |       ); // End of server.tool call
137 | 
138 |       logger.info(
139 |         `Tool registered successfully: ${toolName}`,
140 |         registrationContext,
141 |       );
142 |     },
143 |     {
144 |       // Configuration for the outer error handler (registration process).
145 |       operation: `registering tool ${toolName}`,
146 |       context: registrationContext,
147 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
148 |       // Custom error mapping for registration failures.
149 |       errorMapper: (error: unknown) =>
150 |         new McpError(
151 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
152 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
153 |           { ...registrationContext }, // Include context
154 |         ),
155 |       critical: true, // Treat registration failure as critical.
156 |     },
157 |   ); // End of outer ErrorHandler.tryCatch
158 | };
159 | 
```

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

```typescript
  1 | import { z } from "zod";
  2 | import { dump } from "js-yaml";
  3 | import {
  4 |   NoteJson,
  5 |   ObsidianRestApiService,
  6 |   PatchOptions,
  7 |   VaultCacheService,
  8 | } from "../../../services/obsidianRestAPI/index.js";
  9 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
 10 | import {
 11 |   logger,
 12 |   RequestContext,
 13 |   retryWithDelay,
 14 | } from "../../../utils/index.js";
 15 | 
 16 | // ====================================================================================
 17 | // Schema Definitions
 18 | // ====================================================================================
 19 | 
 20 | const ManageFrontmatterInputSchemaBase = z.object({
 21 |   filePath: z
 22 |     .string()
 23 |     .min(1)
 24 |     .describe(
 25 |       "The vault-relative path to the target note (e.g., 'Projects/Active/My Note.md').",
 26 |     ),
 27 |   operation: z
 28 |     .enum(["get", "set", "delete"])
 29 |     .describe(
 30 |       "The operation to perform on the frontmatter: 'get' to read a key, 'set' to create or update a key, or 'delete' to remove a key.",
 31 |     ),
 32 |   key: z
 33 |     .string()
 34 |     .min(1)
 35 |     .describe(
 36 |       "The name of the frontmatter key to target, such as 'status', 'tags', or 'aliases'.",
 37 |     ),
 38 |   value: z
 39 |     .any()
 40 |     .optional()
 41 |     .describe(
 42 |       "The value to assign when using the 'set' operation. Can be a string, number, boolean, array, or a JSON object.",
 43 |     ),
 44 | });
 45 | 
 46 | export const ObsidianManageFrontmatterInputSchemaShape =
 47 |   ManageFrontmatterInputSchemaBase.shape;
 48 | 
 49 | export const ManageFrontmatterInputSchema =
 50 |   ManageFrontmatterInputSchemaBase.refine(
 51 |     (data) => {
 52 |       if (data.operation === "set" && data.value === undefined) {
 53 |         return false;
 54 |       }
 55 |       return true;
 56 |     },
 57 |     {
 58 |       message: "A 'value' is required when the 'operation' is 'set'.",
 59 |       path: ["value"],
 60 |     },
 61 |   );
 62 | 
 63 | export type ObsidianManageFrontmatterInput = z.infer<
 64 |   typeof ManageFrontmatterInputSchema
 65 | >;
 66 | 
 67 | export interface ObsidianManageFrontmatterResponse {
 68 |   success: boolean;
 69 |   message: string;
 70 |   value?: any;
 71 | }
 72 | 
 73 | // ====================================================================================
 74 | // Core Logic Function
 75 | // ====================================================================================
 76 | 
 77 | export const processObsidianManageFrontmatter = async (
 78 |   params: ObsidianManageFrontmatterInput,
 79 |   context: RequestContext,
 80 |   obsidianService: ObsidianRestApiService,
 81 |   vaultCacheService: VaultCacheService | undefined,
 82 | ): Promise<ObsidianManageFrontmatterResponse> => {
 83 |   logger.debug(`Processing obsidian_manage_frontmatter request`, {
 84 |     ...context,
 85 |     operation: params.operation,
 86 |     filePath: params.filePath,
 87 |     key: params.key,
 88 |   });
 89 | 
 90 |   const { filePath, operation, key, value } = params;
 91 | 
 92 |   const shouldRetryNotFound = (err: unknown) =>
 93 |     err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
 94 | 
 95 |   const getFileWithRetry = async (
 96 |     opContext: RequestContext,
 97 |     format: "json" | "markdown" = "json",
 98 |   ): Promise<NoteJson | string> => {
 99 |     return await retryWithDelay(
100 |       () => obsidianService.getFileContent(filePath, format, opContext),
101 |       {
102 |         operationName: `getFileContentForFrontmatter`,
103 |         context: opContext,
104 |         maxRetries: 3,
105 |         delayMs: 300,
106 |         shouldRetry: shouldRetryNotFound,
107 |       },
108 |     );
109 |   };
110 | 
111 |   switch (operation) {
112 |     case "get": {
113 |       const note = (await getFileWithRetry(context)) as NoteJson;
114 |       const frontmatter = note.frontmatter ?? {};
115 |       const retrievedValue = frontmatter[key];
116 |       return {
117 |         success: true,
118 |         message: `Successfully retrieved key '${key}' from frontmatter.`,
119 |         value: retrievedValue,
120 |       };
121 |     }
122 | 
123 |     case "set": {
124 |       const patchOptions: PatchOptions = {
125 |         operation: "replace",
126 |         targetType: "frontmatter",
127 |         target: key,
128 |         createTargetIfMissing: true,
129 |         contentType:
130 |           typeof value === "object" ? "application/json" : "text/markdown",
131 |       };
132 |       const content =
133 |         typeof value === "object" ? JSON.stringify(value) : String(value);
134 | 
135 |       await retryWithDelay(
136 |         () =>
137 |           obsidianService.patchFile(filePath, content, patchOptions, context),
138 |         {
139 |           operationName: `patchFileForFrontmatterSet`,
140 |           context,
141 |           maxRetries: 3,
142 |           delayMs: 300,
143 |           shouldRetry: shouldRetryNotFound,
144 |         },
145 |       );
146 | 
147 |       if (vaultCacheService) {
148 |         await vaultCacheService.updateCacheForFile(filePath, context);
149 |       }
150 |       return {
151 |         success: true,
152 |         message: `Successfully set key '${key}' in frontmatter.`,
153 |         value: { [key]: value },
154 |       };
155 |     }
156 | 
157 |     case "delete": {
158 |       // Note on deletion strategy: The Obsidian REST API's PATCH endpoint for frontmatter
159 |       // supports adding/updating keys but does not have a dedicated "delete key" operation.
160 |       // Therefore, deletion is handled by reading the note content, parsing the frontmatter,
161 |       // removing the key from the JavaScript object, and then overwriting the entire note
162 |       // with the updated frontmatter block. This regex-based replacement is a workaround
163 |       // for the current API limitations.
164 |       const noteJson = (await getFileWithRetry(context, "json")) as NoteJson;
165 |       const frontmatter = noteJson.frontmatter;
166 | 
167 |       if (!frontmatter || frontmatter[key] === undefined) {
168 |         return {
169 |           success: true,
170 |           message: `Key '${key}' not found in frontmatter. No action taken.`,
171 |           value: {},
172 |         };
173 |       }
174 | 
175 |       delete frontmatter[key];
176 | 
177 |       const noteContent = (await getFileWithRetry(
178 |         context,
179 |         "markdown",
180 |       )) as string;
181 | 
182 |       const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
183 |       const match = noteContent.match(frontmatterRegex);
184 | 
185 |       let newContent;
186 |       const newFrontmatterString =
187 |         Object.keys(frontmatter).length > 0 ? dump(frontmatter) : "";
188 | 
189 |       if (match) {
190 |         // Frontmatter exists, replace it
191 |         if (newFrontmatterString) {
192 |           newContent = noteContent.replace(
193 |             frontmatterRegex,
194 |             `---\n${newFrontmatterString}---\n`,
195 |           );
196 |         } else {
197 |           // If frontmatter is now empty, remove the block entirely
198 |           newContent = noteContent.replace(frontmatterRegex, "");
199 |         }
200 |       } else {
201 |         // This case should be rare given the initial check, but handle it defensively
202 |         logger.warning(
203 |           "Frontmatter key existed in JSON but block not found in markdown. No action taken.",
204 |           context,
205 |         );
206 |         return {
207 |           success: false,
208 |           message: `Could not find frontmatter block to update, though key '${key}' was detected.`,
209 |           value: {},
210 |         };
211 |       }
212 | 
213 |       await retryWithDelay(
214 |         () => obsidianService.updateFileContent(filePath, newContent, context),
215 |         {
216 |           operationName: `updateFileForFrontmatterDelete`,
217 |           context,
218 |           maxRetries: 3,
219 |           delayMs: 300,
220 |           shouldRetry: shouldRetryNotFound,
221 |         },
222 |       );
223 | 
224 |       if (vaultCacheService) {
225 |         await vaultCacheService.updateCacheForFile(filePath, context);
226 |       }
227 | 
228 |       return {
229 |         success: true,
230 |         message: `Successfully deleted key '${key}' from frontmatter.`,
231 |         value: {},
232 |       };
233 |     }
234 | 
235 |     default:
236 |       throw new McpError(
237 |         BaseErrorCode.VALIDATION_ERROR,
238 |         `Invalid operation: ${operation}`,
239 |         context,
240 |       );
241 |   }
242 | };
243 | 
```

--------------------------------------------------------------------------------
/src/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT) for Hono.
  3 |  *
  4 |  * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
  5 |  * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
  6 |  * It verifies the token's signature and expiration using the secret key defined
  7 |  * in the configuration (`config.mcpAuthSecretKey`).
  8 |  *
  9 |  * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type
 10 |  * is attached to `c.env.incoming.auth`. This direct attachment to the raw Node.js
 11 |  * request object is for compatibility with the underlying SDK transport, which is
 12 |  * not Hono-context-aware.
 13 |  * If the token is missing, invalid, or expired, it throws an `McpError`, which is
 14 |  * then handled by the centralized `httpErrorHandler`.
 15 |  *
 16 |  * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
 17 |  * @module src/mcp-server/transports/auth/strategies/jwt/jwtMiddleware
 18 |  */
 19 | 
 20 | import { HttpBindings } from "@hono/node-server";
 21 | import { Context, Next } from "hono";
 22 | import { jwtVerify } from "jose";
 23 | import { config, environment } from "../../../../../config/index.js";
 24 | import { logger, requestContextService } from "../../../../../utils/index.js";
 25 | import { BaseErrorCode, McpError } from "../../../../../types-global/errors.js";
 26 | import { authContext } from "../../core/authContext.js";
 27 | 
 28 | // Startup Validation: Validate secret key presence on module load.
 29 | if (config.mcpAuthMode === "jwt") {
 30 |   if (environment === "production" && !config.mcpAuthSecretKey) {
 31 |     logger.fatal(
 32 |       "CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment for JWT auth. Authentication cannot proceed securely.",
 33 |     );
 34 |     throw new Error(
 35 |       "MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.",
 36 |     );
 37 |   } else if (!config.mcpAuthSecretKey) {
 38 |     logger.warning(
 39 |       "MCP_AUTH_SECRET_KEY is not set. JWT auth middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.",
 40 |     );
 41 |   }
 42 | }
 43 | 
 44 | /**
 45 |  * Hono middleware for verifying JWT Bearer token authentication.
 46 |  * It attaches authentication info to `c.env.incoming.auth` for SDK compatibility with the node server.
 47 |  */
 48 | export async function mcpAuthMiddleware(
 49 |   c: Context<{ Bindings: HttpBindings }>,
 50 |   next: Next,
 51 | ) {
 52 |   const context = requestContextService.createRequestContext({
 53 |     operation: "mcpAuthMiddleware",
 54 |     method: c.req.method,
 55 |     path: c.req.path,
 56 |   });
 57 |   logger.debug(
 58 |     "Running MCP Authentication Middleware (Bearer Token Validation)...",
 59 |     context,
 60 |   );
 61 | 
 62 |   const reqWithAuth = c.env.incoming;
 63 | 
 64 |   // If JWT auth is not enabled, skip the middleware.
 65 |   if (config.mcpAuthMode !== "jwt") {
 66 |     return await next();
 67 |   }
 68 | 
 69 |   // Development Mode Bypass
 70 |   if (!config.mcpAuthSecretKey) {
 71 |     if (environment !== "production") {
 72 |       logger.warning(
 73 |         "Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).",
 74 |         context,
 75 |       );
 76 |       reqWithAuth.auth = {
 77 |         token: "dev-mode-placeholder-token",
 78 |         clientId: "dev-client-id",
 79 |         scopes: ["dev-scope"],
 80 |       };
 81 |       const authInfo = reqWithAuth.auth;
 82 |       logger.debug("Dev mode auth object created.", {
 83 |         ...context,
 84 |         authDetails: authInfo,
 85 |       });
 86 |       return await authContext.run({ authInfo }, next);
 87 |     } else {
 88 |       logger.error(
 89 |         "FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.",
 90 |         context,
 91 |       );
 92 |       throw new McpError(
 93 |         BaseErrorCode.INTERNAL_ERROR,
 94 |         "Server configuration error: Authentication key missing.",
 95 |       );
 96 |     }
 97 |   }
 98 | 
 99 |   const secretKey = new TextEncoder().encode(config.mcpAuthSecretKey);
100 |   const authHeader = c.req.header("Authorization");
101 |   if (!authHeader || !authHeader.startsWith("Bearer ")) {
102 |     logger.warning(
103 |       "Authentication failed: Missing or malformed Authorization header (Bearer scheme required).",
104 |       context,
105 |     );
106 |     throw new McpError(
107 |       BaseErrorCode.UNAUTHORIZED,
108 |       "Missing or invalid authentication token format.",
109 |     );
110 |   }
111 | 
112 |   const tokenParts = authHeader.split(" ");
113 |   if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) {
114 |     logger.warning("Authentication failed: Malformed Bearer token.", context);
115 |     throw new McpError(
116 |       BaseErrorCode.UNAUTHORIZED,
117 |       "Malformed authentication token.",
118 |     );
119 |   }
120 |   const rawToken = tokenParts[1];
121 | 
122 |   try {
123 |     const { payload: decoded } = await jwtVerify(rawToken, secretKey);
124 | 
125 |     const clientIdFromToken =
126 |       typeof decoded.cid === "string"
127 |         ? decoded.cid
128 |         : typeof decoded.client_id === "string"
129 |           ? decoded.client_id
130 |           : undefined;
131 |     if (!clientIdFromToken) {
132 |       logger.warning(
133 |         "Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.",
134 |         { ...context, jwtPayloadKeys: Object.keys(decoded) },
135 |       );
136 |       throw new McpError(
137 |         BaseErrorCode.UNAUTHORIZED,
138 |         "Invalid token, missing client identifier.",
139 |       );
140 |     }
141 | 
142 |     let scopesFromToken: string[] = [];
143 |     if (
144 |       Array.isArray(decoded.scp) &&
145 |       decoded.scp.every((s) => typeof s === "string")
146 |     ) {
147 |       scopesFromToken = decoded.scp as string[];
148 |     } else if (
149 |       typeof decoded.scope === "string" &&
150 |       decoded.scope.trim() !== ""
151 |     ) {
152 |       scopesFromToken = decoded.scope.split(" ").filter((s) => s);
153 |       if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") {
154 |         scopesFromToken = [decoded.scope.trim()];
155 |       }
156 |     }
157 | 
158 |     if (scopesFromToken.length === 0) {
159 |       logger.warning(
160 |         "Authentication failed: Token resulted in an empty scope array, and scopes are required.",
161 |         { ...context, jwtPayloadKeys: Object.keys(decoded) },
162 |       );
163 |       throw new McpError(
164 |         BaseErrorCode.UNAUTHORIZED,
165 |         "Token must contain valid, non-empty scopes.",
166 |       );
167 |     }
168 | 
169 |     reqWithAuth.auth = {
170 |       token: rawToken,
171 |       clientId: clientIdFromToken,
172 |       scopes: scopesFromToken,
173 |     };
174 | 
175 |     const subClaimForLogging =
176 |       typeof decoded.sub === "string" ? decoded.sub : undefined;
177 |     const authInfo = reqWithAuth.auth;
178 |     logger.debug("JWT verified successfully. AuthInfo attached to request.", {
179 |       ...context,
180 |       mcpSessionIdContext: subClaimForLogging,
181 |       clientId: authInfo.clientId,
182 |       scopes: authInfo.scopes,
183 |     });
184 |     await authContext.run({ authInfo }, next);
185 |   } catch (error: unknown) {
186 |     let errorMessage = "Invalid token.";
187 |     let errorCode = BaseErrorCode.UNAUTHORIZED;
188 | 
189 |     if (error instanceof Error && error.name === "JWTExpired") {
190 |       errorMessage = "Token expired.";
191 |       logger.warning("Authentication failed: Token expired.", {
192 |         ...context,
193 |         errorName: error.name,
194 |       });
195 |     } else if (error instanceof Error) {
196 |       errorMessage = `Invalid token: ${error.message}`;
197 |       logger.warning(`Authentication failed: ${errorMessage}`, {
198 |         ...context,
199 |         errorName: error.name,
200 |       });
201 |     } else {
202 |       errorMessage = "Unknown verification error.";
203 |       errorCode = BaseErrorCode.INTERNAL_ERROR;
204 |       logger.error(
205 |         "Authentication failed: Unexpected non-error exception during token verification.",
206 |         { ...context, error },
207 |       );
208 |     }
209 |     throw new McpError(errorCode, errorMessage);
210 |   }
211 | }
212 | 
```

--------------------------------------------------------------------------------
/src/utils/obsidian/obsidianStatUtils.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Utilities for formatting Obsidian stat objects,
  3 |  * including timestamps and calculating estimated token counts.
  4 |  * @module src/utils/obsidian/obsidianStatUtils
  5 |  */
  6 | 
  7 | import { format } from "date-fns";
  8 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
  9 | import { logger, RequestContext } from "../internal/index.js";
 10 | import { countTokens } from "../metrics/index.js";
 11 | 
 12 | /**
 13 |  * Default format string for timestamps, providing a human-readable date and time.
 14 |  * Example output: "08:40:00 PM | 05-02-2025"
 15 |  */
 16 | const DEFAULT_TIMESTAMP_FORMAT = "hh:mm:ss a | MM-dd-yyyy";
 17 | 
 18 | /**
 19 |  * Formats a Unix timestamp (in milliseconds since the epoch) into a human-readable string.
 20 |  *
 21 |  * @param {number | undefined | null} timestampMs - The Unix timestamp in milliseconds.
 22 |  * @param {RequestContext} context - The request context for logging and error reporting.
 23 |  * @param {string} [formatString=DEFAULT_TIMESTAMP_FORMAT] - Optional format string adhering to `date-fns` tokens.
 24 |  *   Defaults to 'hh:mm:ss a | MM-dd-yyyy'.
 25 |  * @returns {string} The formatted timestamp string.
 26 |  * @throws {McpError} If the provided `timestampMs` is invalid (e.g., undefined, null, not a finite number, or results in an invalid Date object).
 27 |  */
 28 | export function formatTimestamp(
 29 |   timestampMs: number | undefined | null,
 30 |   context: RequestContext,
 31 |   formatString: string = DEFAULT_TIMESTAMP_FORMAT,
 32 | ): string {
 33 |   const operation = "formatTimestamp";
 34 |   if (
 35 |     timestampMs === undefined ||
 36 |     timestampMs === null ||
 37 |     !Number.isFinite(timestampMs)
 38 |   ) {
 39 |     const errorMessage = `Invalid timestamp provided for formatting: ${timestampMs}`;
 40 |     logger.warning(errorMessage, { ...context, operation });
 41 |     throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMessage, {
 42 |       ...context,
 43 |       operation,
 44 |     });
 45 |   }
 46 | 
 47 |   try {
 48 |     const date = new Date(timestampMs);
 49 |     if (isNaN(date.getTime())) {
 50 |       const errorMessage = `Timestamp resulted in an invalid date: ${timestampMs}`;
 51 |       logger.warning(errorMessage, { ...context, operation });
 52 |       throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMessage, {
 53 |         ...context,
 54 |         operation,
 55 |       });
 56 |     }
 57 |     return format(date, formatString);
 58 |   } catch (error) {
 59 |     const errorMessage = `Failed to format timestamp ${timestampMs}: ${error instanceof Error ? error.message : String(error)}`;
 60 |     logger.error(errorMessage, error instanceof Error ? error : undefined, {
 61 |       ...context,
 62 |       operation,
 63 |     });
 64 |     throw new McpError(BaseErrorCode.INTERNAL_ERROR, errorMessage, {
 65 |       ...context,
 66 |       operation,
 67 |       originalError: error instanceof Error ? error.message : String(error),
 68 |     });
 69 |   }
 70 | }
 71 | 
 72 | /**
 73 |  * Represents the structure of an Obsidian API Stat object.
 74 |  */
 75 | export interface ObsidianStat {
 76 |   /** Creation time as a Unix timestamp (milliseconds). */
 77 |   ctime: number;
 78 |   /** Modification time as a Unix timestamp (milliseconds). */
 79 |   mtime: number;
 80 |   /** File size in bytes. */
 81 |   size: number;
 82 | }
 83 | 
 84 | /**
 85 |  * Represents formatted timestamp information derived from an Obsidian Stat object.
 86 |  */
 87 | export interface FormattedTimestamps {
 88 |   /** Human-readable creation time string. */
 89 |   createdTime: string;
 90 |   /** Human-readable modification time string. */
 91 |   modifiedTime: string;
 92 | }
 93 | 
 94 | /**
 95 |  * Formats the `ctime` (creation time) and `mtime` (modification time) from an
 96 |  * Obsidian API Stat object into human-readable strings.
 97 |  *
 98 |  * @param {ObsidianStat | undefined | null} stat - The Stat object from the Obsidian API.
 99 |  *   If undefined or null, placeholder strings ('N/A') are returned.
100 |  * @param {RequestContext} context - The request context for logging and error reporting.
101 |  * @returns {FormattedTimestamps} An object containing `createdTime` and `modifiedTime` strings.
102 |  */
103 | export function formatStatTimestamps(
104 |   stat: ObsidianStat | undefined | null,
105 |   context: RequestContext,
106 | ): FormattedTimestamps {
107 |   const operation = "formatStatTimestamps";
108 |   if (!stat) {
109 |     logger.debug(
110 |       "Stat object is undefined or null, returning N/A for timestamps.",
111 |       { ...context, operation },
112 |     );
113 |     return {
114 |       createdTime: "N/A",
115 |       modifiedTime: "N/A",
116 |     };
117 |   }
118 |   try {
119 |     return {
120 |       createdTime: formatTimestamp(stat.ctime, context),
121 |       modifiedTime: formatTimestamp(stat.mtime, context),
122 |     };
123 |   } catch (error) {
124 |     // Log the error from formatTimestamp if it occurs during this higher-level operation
125 |     logger.error(
126 |       `Error formatting timestamps within formatStatTimestamps for ctime: ${stat.ctime}, mtime: ${stat.mtime}`,
127 |       error instanceof Error ? error : undefined,
128 |       { ...context, operation },
129 |     );
130 |     // Return N/A as a fallback if formatting fails at this stage
131 |     return {
132 |       createdTime: "N/A",
133 |       modifiedTime: "N/A",
134 |     };
135 |   }
136 | }
137 | 
138 | /**
139 |  * Represents a fully formatted stat object, including human-readable timestamps
140 |  * and an estimated token count for the file content.
141 |  */
142 | export interface FormattedStatWithTokenCount extends FormattedTimestamps {
143 |   /** Estimated number of tokens in the file content. -1 if counting failed or content was empty. */
144 |   tokenCountEstimate: number;
145 | }
146 | 
147 | /**
148 |  * Creates a formatted stat object that includes human-readable timestamps
149 |  * (creation and modification times) and an estimated token count for the provided file content.
150 |  *
151 |  * @param {ObsidianStat | null | undefined} stat - The original Stat object from the Obsidian API.
152 |  *   If null or undefined, the function will return the input value (null or undefined).
153 |  * @param {string} content - The file content string from which to calculate the token count.
154 |  * @param {RequestContext} context - The request context for logging and error reporting.
155 |  * @returns {Promise<FormattedStatWithTokenCount | null | undefined>} A promise resolving to an object
156 |  *   containing `createdTime`, `modifiedTime`, and `tokenCountEstimate`. Returns `null` or `undefined`
157 |  *   if the input `stat` object was `null` or `undefined`, respectively.
158 |  */
159 | export async function createFormattedStatWithTokenCount(
160 |   stat: ObsidianStat | null | undefined,
161 |   content: string,
162 |   context: RequestContext,
163 | ): Promise<FormattedStatWithTokenCount | null | undefined> {
164 |   const operation = "createFormattedStatWithTokenCount";
165 |   if (stat === null || stat === undefined) {
166 |     logger.debug("Input stat is null or undefined, returning as is.", {
167 |       ...context,
168 |       operation,
169 |     });
170 |     return stat; // Return original null/undefined
171 |   }
172 | 
173 |   const formattedTimestamps = formatStatTimestamps(stat, context);
174 |   let tokenCountEstimate = -1; // Default: indicates error or empty content
175 | 
176 |   if (content && content.trim().length > 0) {
177 |     try {
178 |       tokenCountEstimate = await countTokens(content, context);
179 |     } catch (tokenError) {
180 |       logger.warning(
181 |         `Failed to count tokens for stat object. Error: ${tokenError instanceof Error ? tokenError.message : String(tokenError)}`,
182 |         {
183 |           ...context,
184 |           operation,
185 |           originalError:
186 |             tokenError instanceof Error
187 |               ? tokenError.message
188 |               : String(tokenError),
189 |         },
190 |       );
191 |       // tokenCountEstimate remains -1
192 |     }
193 |   } else {
194 |     logger.debug(
195 |       "Content is empty or whitespace-only, setting tokenCountEstimate to 0.",
196 |       { ...context, operation },
197 |     );
198 |     tokenCountEstimate = 0;
199 |   }
200 | 
201 |   return {
202 |     createdTime: formattedTimestamps.createdTime,
203 |     modifiedTime: formattedTimestamps.modifiedTime,
204 |     tokenCountEstimate: tokenCountEstimate,
205 |   };
206 | }
207 | 
```

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

```typescript
  1 | import { z } from "zod";
  2 | import { dump } from "js-yaml";
  3 | import {
  4 |   NoteJson,
  5 |   ObsidianRestApiService,
  6 |   VaultCacheService,
  7 | } from "../../../services/obsidianRestAPI/index.js";
  8 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  9 | import {
 10 |   logger,
 11 |   RequestContext,
 12 |   retryWithDelay,
 13 | } from "../../../utils/index.js";
 14 | import { sanitization } from "../../../utils/security/sanitization.js";
 15 | 
 16 | // ====================================================================================
 17 | // Schema Definitions
 18 | // ====================================================================================
 19 | 
 20 | const ManageTagsInputSchemaBase = z.object({
 21 |   filePath: z
 22 |     .string()
 23 |     .min(1)
 24 |     .describe(
 25 |       "The vault-relative path to the target note (e.g., 'Journal/2024-06-12.md').",
 26 |     ),
 27 |   operation: z
 28 |     .enum(["add", "remove", "list"])
 29 |     .describe(
 30 |       "The tag operation to perform: 'add' to include new tags, 'remove' to delete existing tags, or 'list' to view all current tags.",
 31 |     ),
 32 |   tags: z
 33 |     .array(z.string())
 34 |     .describe(
 35 |       "An array of tag names to be processed. The '#' prefix should be omitted (e.g., use 'project/active', not '#project/active').",
 36 |     ),
 37 | });
 38 | 
 39 | export const ObsidianManageTagsInputSchemaShape =
 40 |   ManageTagsInputSchemaBase.shape;
 41 | export const ManageTagsInputSchema = ManageTagsInputSchemaBase;
 42 | 
 43 | export type ObsidianManageTagsInput = z.infer<typeof ManageTagsInputSchema>;
 44 | 
 45 | export interface ObsidianManageTagsResponse {
 46 |   success: boolean;
 47 |   message: string;
 48 |   currentTags: string[];
 49 | }
 50 | 
 51 | // ====================================================================================
 52 | // Core Logic Function
 53 | // ====================================================================================
 54 | 
 55 | export const processObsidianManageTags = async (
 56 |   params: ObsidianManageTagsInput,
 57 |   context: RequestContext,
 58 |   obsidianService: ObsidianRestApiService,
 59 |   vaultCacheService: VaultCacheService | undefined,
 60 | ): Promise<ObsidianManageTagsResponse> => {
 61 |   logger.debug(`Processing obsidian_manage_tags request`, {
 62 |     ...context,
 63 |     ...params,
 64 |   });
 65 | 
 66 |   const { filePath, operation, tags: inputTags } = params;
 67 |   const sanitizedTags = inputTags.map((t) => sanitization.sanitizeTagName(t));
 68 | 
 69 |   const shouldRetryNotFound = (err: unknown) =>
 70 |     err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
 71 | 
 72 |   const getFileWithRetry = async (
 73 |     opContext: RequestContext,
 74 |     format: "json" | "markdown",
 75 |   ): Promise<NoteJson | string> => {
 76 |     return await retryWithDelay(
 77 |       () => obsidianService.getFileContent(filePath, format, opContext),
 78 |       {
 79 |         operationName: `getFileContentForTagManagement`,
 80 |         context: opContext,
 81 |         maxRetries: 3,
 82 |         delayMs: 300,
 83 |         shouldRetry: shouldRetryNotFound,
 84 |       },
 85 |     );
 86 |   };
 87 | 
 88 |   const initialNote = (await getFileWithRetry(context, "json")) as NoteJson;
 89 |   const currentTags = initialNote.tags;
 90 | 
 91 |   switch (operation) {
 92 |     case "list": {
 93 |       return {
 94 |         success: true,
 95 |         message: "Successfully listed all tags.",
 96 |         currentTags: currentTags,
 97 |       };
 98 |     }
 99 | 
100 |     case "add": {
101 |       const tagsToAdd = sanitizedTags.filter((t) => !currentTags.includes(t));
102 |       if (tagsToAdd.length === 0) {
103 |         return {
104 |           success: true,
105 |           message:
106 |             "No new tags to add; all provided tags already exist in the note.",
107 |           currentTags: currentTags,
108 |         };
109 |       }
110 | 
111 |       const frontmatter = initialNote.frontmatter ?? {};
112 |       const frontmatterTags: string[] = Array.isArray(frontmatter.tags)
113 |         ? frontmatter.tags
114 |         : [];
115 |       const newFrontmatterTags = [
116 |         ...new Set([...frontmatterTags, ...tagsToAdd]),
117 |       ];
118 |       frontmatter.tags = newFrontmatterTags;
119 | 
120 |       const noteContent = (await getFileWithRetry(
121 |         context,
122 |         "markdown",
123 |       )) as string;
124 |       const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
125 |       const match = noteContent.match(frontmatterRegex);
126 |       const newFrontmatterString = dump(frontmatter);
127 | 
128 |       let newContent;
129 |       if (match) {
130 |         newContent = noteContent.replace(
131 |           frontmatterRegex,
132 |           `---\n${newFrontmatterString}---\n`,
133 |         );
134 |       } else {
135 |         newContent = `---\n${newFrontmatterString}---\n\n${noteContent}`;
136 |       }
137 | 
138 |       await retryWithDelay(
139 |         () => obsidianService.updateFileContent(filePath, newContent, context),
140 |         {
141 |           operationName: `updateFileForTagAdd`,
142 |           context,
143 |           maxRetries: 3,
144 |           delayMs: 300,
145 |           shouldRetry: shouldRetryNotFound,
146 |         },
147 |       );
148 | 
149 |       if (vaultCacheService) {
150 |         await vaultCacheService.updateCacheForFile(filePath, context);
151 |       }
152 | 
153 |       const finalTags = [...new Set([...currentTags, ...tagsToAdd])];
154 |       return {
155 |         success: true,
156 |         message: `Successfully added tags: ${tagsToAdd.join(", ")}.`,
157 |         currentTags: finalTags,
158 |       };
159 |     }
160 | 
161 |     case "remove": {
162 |       const tagsToRemove = sanitizedTags.filter((t) => currentTags.includes(t));
163 |       if (tagsToRemove.length === 0) {
164 |         return {
165 |           success: true,
166 |           message:
167 |             "No tags to remove; none of the provided tags exist in the note.",
168 |           currentTags: currentTags,
169 |         };
170 |       }
171 | 
172 |       let noteContent = (await getFileWithRetry(context, "markdown")) as string;
173 |       const frontmatter = initialNote.frontmatter ?? {};
174 |       let frontmatterTags: string[] = Array.isArray(frontmatter.tags)
175 |         ? frontmatter.tags
176 |         : [];
177 |       const newFrontmatterTags = frontmatterTags.filter(
178 |         (t) => !tagsToRemove.includes(t),
179 |       );
180 |       let frontmatterModified =
181 |         newFrontmatterTags.length !== frontmatterTags.length;
182 | 
183 |       if (frontmatterModified) {
184 |         frontmatter.tags = newFrontmatterTags;
185 |         if (newFrontmatterTags.length === 0) {
186 |           delete frontmatter.tags;
187 |         }
188 |       }
189 | 
190 |       const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
191 |       const match = noteContent.match(frontmatterRegex);
192 | 
193 |       if (frontmatterModified && match) {
194 |         const newFrontmatterString =
195 |           Object.keys(frontmatter).length > 0 ? dump(frontmatter) : "";
196 |         if (newFrontmatterString) {
197 |           noteContent = noteContent.replace(
198 |             frontmatterRegex,
199 |             `---\n${newFrontmatterString}---\n`,
200 |           );
201 |         } else {
202 |           noteContent = noteContent.replace(frontmatterRegex, "");
203 |         }
204 |       }
205 | 
206 |       let inlineModified = false;
207 |       for (const tag of tagsToRemove) {
208 |         const regex = new RegExp(`(^|[^\\w-#])#${tag}\\b`, "g");
209 |         if (regex.test(noteContent)) {
210 |           noteContent = noteContent.replace(regex, "$1");
211 |           inlineModified = true;
212 |         }
213 |       }
214 | 
215 |       if (frontmatterModified || inlineModified) {
216 |         await retryWithDelay(
217 |           () =>
218 |             obsidianService.updateFileContent(filePath, noteContent, context),
219 |           {
220 |             operationName: `updateFileContentForTagRemove`,
221 |             context,
222 |             maxRetries: 3,
223 |             delayMs: 300,
224 |             shouldRetry: shouldRetryNotFound,
225 |           },
226 |         );
227 |       }
228 | 
229 |       if (vaultCacheService) {
230 |         await vaultCacheService.updateCacheForFile(filePath, context);
231 |       }
232 | 
233 |       const finalTags = currentTags.filter((t) => !tagsToRemove.includes(t));
234 |       return {
235 |         success: true,
236 |         message: `Successfully removed tags: ${tagsToRemove.join(", ")}.`,
237 |         currentTags: finalTags,
238 |       };
239 |     }
240 | 
241 |     default:
242 |       throw new McpError(
243 |         BaseErrorCode.VALIDATION_ERROR,
244 |         `Invalid operation: ${operation}`,
245 |         context,
246 |       );
247 |   }
248 | };
249 | 
```

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

```typescript
  1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  2 | import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
  3 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  4 | import {
  5 |   ErrorHandler,
  6 |   logger,
  7 |   RequestContext,
  8 |   requestContextService,
  9 | } from "../../../utils/index.js";
 10 | // Import necessary types, schema, and logic function from the logic file
 11 | import type {
 12 |   ObsidianReadNoteInput,
 13 |   ObsidianReadNoteResponse,
 14 | } from "./logic.js";
 15 | import {
 16 |   ObsidianReadNoteInputSchema,
 17 |   processObsidianReadNote,
 18 | } from "./logic.js";
 19 | 
 20 | /**
 21 |  * Registers the 'obsidian_read_note' tool with the MCP server.
 22 |  *
 23 |  * This tool retrieves the content and optionally metadata of a specified file
 24 |  * within the user's Obsidian vault. It supports specifying the output format
 25 |  * ('markdown' or 'json') and includes a case-insensitive fallback mechanism
 26 |  * if the exact file path is not found initially.
 27 |  *
 28 |  * The response is a JSON string containing the file content in the requested format
 29 |  * and optionally formatted file statistics (timestamps, token count).
 30 |  *
 31 |  * @param {McpServer} server - The MCP server instance to register the tool with.
 32 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 33 |  *   used to interact with the user's Obsidian vault.
 34 |  * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 35 |  * @throws {McpError} Throws an McpError if registration fails critically.
 36 |  */
 37 | export const registerObsidianReadNoteTool = async (
 38 |   server: McpServer,
 39 |   obsidianService: ObsidianRestApiService, // Dependency injection for the Obsidian service
 40 | ): Promise<void> => {
 41 |   const toolName = "obsidian_read_note";
 42 |   const toolDescription =
 43 |     "Retrieves the content and metadata of a specified file within the Obsidian vault. Tries the exact path first, then attempts a case-insensitive fallback. Returns an object containing the content (markdown string or full NoteJson object based on 'format'), and optionally formatted file stats ('stats' object with creationTime, modifiedTime, tokenCountEstimate). Use 'includeStat: true' with 'format: markdown' to include stats; stats are always included with 'format: json'.";
 44 | 
 45 |   // Create a context specifically for the registration process.
 46 |   const registrationContext: RequestContext =
 47 |     requestContextService.createRequestContext({
 48 |       operation: "RegisterObsidianReadNoteTool",
 49 |       toolName: toolName,
 50 |       module: "ObsidianReadNoteRegistration", // Identify the module
 51 |     });
 52 | 
 53 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 54 | 
 55 |   // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
 56 |   await ErrorHandler.tryCatch(
 57 |     async () => {
 58 |       // Use the high-level SDK method `server.tool` for registration.
 59 |       // It handles schema generation from the shape, basic validation, and routing.
 60 |       server.tool(
 61 |         toolName,
 62 |         toolDescription,
 63 |         ObsidianReadNoteInputSchema.shape, // Provide the Zod schema shape for input definition.
 64 |         /**
 65 |          * The handler function executed when the 'obsidian_read_note' tool is called by the client.
 66 |          *
 67 |          * @param {ObsidianReadNoteInput} params - The input parameters received from the client,
 68 |          *   validated against the ObsidianReadNoteInputSchema shape. Note: The handler receives the raw input;
 69 |          *   stricter validation against the full schema should happen inside if needed, though in this case,
 70 |          *   the shape and the full schema are identical.
 71 |          * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
 72 |          *   containing either the successful response data (serialized JSON) or an error indication.
 73 |          */
 74 |         async (params: ObsidianReadNoteInput) => {
 75 |           // Type matches the inferred input schema
 76 |           // Create a specific context for this handler invocation.
 77 |           const handlerContext: RequestContext =
 78 |             requestContextService.createRequestContext({
 79 |               parentContext: registrationContext, // Link to registration context
 80 |               operation: "HandleObsidianReadNoteRequest",
 81 |               toolName: toolName,
 82 |               params: {
 83 |                 // Log key parameters for debugging
 84 |                 filePath: params.filePath,
 85 |                 format: params.format,
 86 |                 includeStat: params.includeStat,
 87 |               },
 88 |             });
 89 |           logger.debug(`Handling '${toolName}' request`, handlerContext);
 90 | 
 91 |           // Wrap the core logic execution in a tryCatch block.
 92 |           return await ErrorHandler.tryCatch(
 93 |             async () => {
 94 |               // Delegate the actual file reading logic to the dedicated processing function.
 95 |               // Pass the (already shape-validated) parameters, context, and the Obsidian service.
 96 |               // The process function handles the refined validation internally if needed, but here shape = refined.
 97 |               const response: ObsidianReadNoteResponse =
 98 |                 await processObsidianReadNote(
 99 |                   params, // Pass params directly as shape matches refined schema
100 |                   handlerContext,
101 |                   obsidianService,
102 |                 );
103 |               logger.debug(
104 |                 `'${toolName}' processed successfully`,
105 |                 handlerContext,
106 |               );
107 | 
108 |               // Format the successful response object from the logic function into the required MCP CallToolResult structure.
109 |               // The entire response object (containing content and optional stat) is serialized to JSON.
110 |               return {
111 |                 content: [
112 |                   {
113 |                     type: "text", // Standard content type for structured JSON data
114 |                     text: JSON.stringify(response, null, 2), // Pretty-print JSON
115 |                   },
116 |                 ],
117 |                 isError: false, // Indicate successful execution
118 |               };
119 |             },
120 |             {
121 |               // Configuration for the inner error handler (processing logic).
122 |               operation: `processing ${toolName} handler`,
123 |               context: handlerContext,
124 |               input: params, // Log the full input parameters if an error occurs.
125 |               // Custom error mapping for consistent error reporting.
126 |               errorMapper: (error: unknown) =>
127 |                 new McpError(
128 |                   error instanceof McpError
129 |                     ? error.code
130 |                     : BaseErrorCode.INTERNAL_ERROR,
131 |                   `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
132 |                   { ...handlerContext }, // Include context
133 |                 ),
134 |             },
135 |           ); // End of inner ErrorHandler.tryCatch
136 |         },
137 |       ); // End of server.tool call
138 | 
139 |       logger.info(
140 |         `Tool registered successfully: ${toolName}`,
141 |         registrationContext,
142 |       );
143 |     },
144 |     {
145 |       // Configuration for the outer error handler (registration process).
146 |       operation: `registering tool ${toolName}`,
147 |       context: registrationContext,
148 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
149 |       // Custom error mapping for registration failures.
150 |       errorMapper: (error: unknown) =>
151 |         new McpError(
152 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
153 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
154 |           { ...registrationContext }, // Include context
155 |         ),
156 |       critical: true, // Treat registration failure as critical.
157 |     },
158 |   ); // End of outer ErrorHandler.tryCatch
159 | };
160 | 
```

--------------------------------------------------------------------------------
/scripts/tree.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | /**
  4 |  * @fileoverview Generates a visual tree representation of the project's directory structure.
  5 |  * @module scripts/tree
  6 |  *   Respects .gitignore patterns and common exclusions (e.g., node_modules).
  7 |  *   Saves the tree to a markdown file (default: docs/tree.md).
  8 |  *   Supports custom output path and depth limitation.
  9 |  *   Ensures all file operations are within the project root for security.
 10 |  *
 11 |  * @example
 12 |  * // Generate tree with default settings:
 13 |  * // npm run tree
 14 |  *
 15 |  * @example
 16 |  * // Specify custom output path and depth:
 17 |  * // ts-node --esm scripts/tree.ts ./documentation/structure.md --depth=3
 18 |  */
 19 | 
 20 | import fs from "fs/promises";
 21 | import path from "path";
 22 | import ignore from "ignore"; // Import the 'ignore' library
 23 | 
 24 | // Get the type of the instance returned by ignore()
 25 | type Ignore = ReturnType<typeof ignore>;
 26 | 
 27 | const projectRoot = process.cwd();
 28 | let outputPathArg = "docs/tree.md"; // Default output path
 29 | let maxDepthArg = Infinity;
 30 | 
 31 | const args = process.argv.slice(2);
 32 | if (args.includes("--help")) {
 33 |   console.log(`
 34 | Generate Tree - Project directory structure visualization tool
 35 | 
 36 | Usage:
 37 |   ts-node --esm scripts/tree.ts [output-path] [--depth=<number>] [--help]
 38 | 
 39 | Options:
 40 |   output-path      Custom file path for the tree output (relative to project root, default: docs/tree.md)
 41 |   --depth=<number> Maximum directory depth to display (default: unlimited)
 42 |   --help           Show this help message
 43 | `);
 44 |   process.exit(0);
 45 | }
 46 | 
 47 | args.forEach((arg) => {
 48 |   if (arg.startsWith("--depth=")) {
 49 |     const depthValue = parseInt(arg.split("=")[1], 10);
 50 |     if (!isNaN(depthValue) && depthValue >= 0) {
 51 |       maxDepthArg = depthValue;
 52 |     } else {
 53 |       console.warn(`Invalid depth value: "${arg}". Using unlimited depth.`);
 54 |     }
 55 |   } else if (!arg.startsWith("--")) {
 56 |     outputPathArg = arg;
 57 |   }
 58 | });
 59 | 
 60 | const DEFAULT_IGNORE_PATTERNS: string[] = [
 61 |   ".git",
 62 |   "node_modules",
 63 |   ".DS_Store",
 64 |   "dist",
 65 |   "build",
 66 |   "logs",
 67 | ];
 68 | 
 69 | /**
 70 |  * Loads and parses patterns from the .gitignore file at the project root,
 71 |  * and combines them with default ignore patterns.
 72 |  * @returns A promise resolving to an Ignore instance from the 'ignore' library.
 73 |  */
 74 | async function loadIgnoreHandler(): Promise<Ignore> {
 75 |   const ig = ignore();
 76 |   ig.add(DEFAULT_IGNORE_PATTERNS); // Add default patterns first
 77 | 
 78 |   const gitignorePath = path.join(projectRoot, ".gitignore");
 79 |   try {
 80 |     // Security: Ensure we read only from within the project root
 81 |     if (!path.resolve(gitignorePath).startsWith(projectRoot + path.sep)) {
 82 |       console.warn(
 83 |         "Warning: Attempted to read .gitignore outside project root. Using default ignore patterns only.",
 84 |       );
 85 |       return ig;
 86 |     }
 87 |     const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
 88 |     ig.add(gitignoreContent); // Add patterns from .gitignore file
 89 |   } catch (error: any) {
 90 |     if (error.code === "ENOENT") {
 91 |       console.warn(
 92 |         "Info: No .gitignore file found at project root. Using default ignore patterns only.",
 93 |       );
 94 |     } else {
 95 |       console.error(`Error reading .gitignore: ${error.message}`);
 96 |     }
 97 |   }
 98 |   return ig;
 99 | }
100 | 
101 | /**
102 |  * Checks if a given path should be ignored.
103 |  * @param entryPath - The absolute path to the file or directory entry.
104 |  * @param ig - An Ignore instance from the 'ignore' library.
105 |  * @returns True if the path should be ignored, false otherwise.
106 |  */
107 | function isIgnored(entryPath: string, ig: Ignore): boolean {
108 |   const relativePath = path.relative(projectRoot, entryPath);
109 |   // The 'ignore' library expects POSIX-style paths (with /) even on Windows
110 |   const posixRelativePath = relativePath.split(path.sep).join(path.posix.sep);
111 |   return ig.ignores(posixRelativePath);
112 | }
113 | 
114 | /**
115 |  * Recursively generates a string representation of the directory tree.
116 |  * @param dir - The absolute path of the directory to traverse.
117 |  * @param ig - An Ignore instance.
118 |  * @param prefix - String prefix for formatting the tree lines.
119 |  * @param currentDepth - Current depth of traversal.
120 |  * @returns A promise resolving to the tree string.
121 |  */
122 | async function generateTree(
123 |   dir: string,
124 |   ig: Ignore,
125 |   prefix = "",
126 |   currentDepth = 0,
127 | ): Promise<string> {
128 |   const resolvedDir = path.resolve(dir);
129 |   if (
130 |     !resolvedDir.startsWith(projectRoot + path.sep) &&
131 |     resolvedDir !== projectRoot
132 |   ) {
133 |     console.warn(
134 |       `Security: Skipping directory outside project root: ${resolvedDir}`,
135 |     );
136 |     return "";
137 |   }
138 | 
139 |   if (currentDepth > maxDepthArg) {
140 |     return "";
141 |   }
142 | 
143 |   let entries;
144 |   try {
145 |     entries = await fs.readdir(resolvedDir, { withFileTypes: true });
146 |   } catch (error: any) {
147 |     console.error(`Error reading directory ${resolvedDir}: ${error.message}`);
148 |     return "";
149 |   }
150 | 
151 |   let output = "";
152 |   const filteredEntries = entries
153 |     .filter((entry) => !isIgnored(path.join(resolvedDir, entry.name), ig))
154 |     .sort((a, b) => {
155 |       if (a.isDirectory() && !b.isDirectory()) return -1;
156 |       if (!a.isDirectory() && b.isDirectory()) return 1;
157 |       return a.name.localeCompare(b.name);
158 |     });
159 | 
160 |   for (let i = 0; i < filteredEntries.length; i++) {
161 |     const entry = filteredEntries[i];
162 |     const isLastEntry = i === filteredEntries.length - 1;
163 |     const connector = isLastEntry ? "└── " : "├── ";
164 |     const newPrefix = prefix + (isLastEntry ? "    " : "│   ");
165 | 
166 |     output += prefix + connector + entry.name + "\n";
167 | 
168 |     if (entry.isDirectory()) {
169 |       output += await generateTree(
170 |         path.join(resolvedDir, entry.name),
171 |         ig,
172 |         newPrefix,
173 |         currentDepth + 1,
174 |       );
175 |     }
176 |   }
177 |   return output;
178 | }
179 | 
180 | /**
181 |  * Main function to orchestrate loading ignore patterns, generating the tree,
182 |  * and writing it to the specified output file.
183 |  */
184 | const writeTreeToFile = async (): Promise<void> => {
185 |   try {
186 |     const projectName = path.basename(projectRoot);
187 |     const ignoreHandler = await loadIgnoreHandler(); // Get the Ignore instance
188 |     const resolvedOutputFile = path.resolve(projectRoot, outputPathArg);
189 | 
190 |     // Security Validation for Output Path
191 |     if (!resolvedOutputFile.startsWith(projectRoot + path.sep)) {
192 |       console.error(
193 |         `Error: Output path "${outputPathArg}" resolves outside the project directory: ${resolvedOutputFile}. Aborting.`,
194 |       );
195 |       process.exit(1);
196 |     }
197 |     const resolvedOutputDir = path.dirname(resolvedOutputFile);
198 |     if (
199 |       !resolvedOutputDir.startsWith(projectRoot + path.sep) &&
200 |       resolvedOutputDir !== projectRoot
201 |     ) {
202 |       console.error(
203 |         `Error: Output directory "${resolvedOutputDir}" is outside the project directory. Aborting.`,
204 |       );
205 |       process.exit(1);
206 |     }
207 | 
208 |     console.log(`Generating directory tree for project: ${projectName}`);
209 |     console.log(`Output will be saved to: ${resolvedOutputFile}`);
210 |     if (maxDepthArg !== Infinity) {
211 |       console.log(`Maximum depth set to: ${maxDepthArg}`);
212 |     }
213 | 
214 |     const treeContent = await generateTree(projectRoot, ignoreHandler, "", 0); // Pass the Ignore instance
215 | 
216 |     try {
217 |       await fs.access(resolvedOutputDir);
218 |     } catch {
219 |       console.log(`Output directory not found. Creating: ${resolvedOutputDir}`);
220 |       await fs.mkdir(resolvedOutputDir, { recursive: true });
221 |     }
222 | 
223 |     const timestamp = new Date()
224 |       .toISOString()
225 |       .replace(/T/, " ")
226 |       .replace(/\..+/, "");
227 |     const fileHeader = `# ${projectName} - Directory Structure\n\nGenerated on: ${timestamp}\n`;
228 |     const depthInfo =
229 |       maxDepthArg !== Infinity
230 |         ? `\n_Depth limited to ${maxDepthArg} levels_\n\n`
231 |         : "\n";
232 |     const treeBlock = `\`\`\`\n${projectName}\n${treeContent}\`\`\`\n`;
233 |     const fileFooter = `\n_Note: This tree excludes files and directories matched by .gitignore and default patterns._\n`;
234 |     const finalContent = fileHeader + depthInfo + treeBlock + fileFooter;
235 | 
236 |     await fs.writeFile(resolvedOutputFile, finalContent);
237 |     console.log(
238 |       `Successfully generated tree structure in: ${resolvedOutputFile}`,
239 |     );
240 |   } catch (error) {
241 |     console.error(
242 |       `Error generating tree: ${error instanceof Error ? error.message : String(error)}`,
243 |     );
244 |     process.exit(1);
245 |   }
246 | };
247 | 
248 | writeTreeToFile();
249 | 
```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Changelog
  2 | 
  3 | All notable changes to this project will be documented in this file.
  4 | 
  5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
  6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
  7 | 
  8 | ## [2.0.7] - 2025-06-20
  9 | 
 10 | ### Changed
 11 | 
 12 | - **Package Update**: Fixed README & incremented version to 2.0.7 to ensure the latest changes are reflected in the npm package.
 13 | 
 14 | ## [2.0.6] - 2025-06-20
 15 | 
 16 | ### Changed
 17 | 
 18 | - **Tool Renaming**: Renamed `obsidian_read_file`, `obsidian_delete_file`, and `obsidian_list_files` to `obsidian_read_note`, `obsidian_delete_note`, and `obsidian_list_notes` respectively. This change improves semantic clarity and aligns the tool names more closely with Obsidian's terminology, reducing ambiguity for AI agents.
 19 | - **Dependency Updates**: Updated all dependencies to their latest versions.
 20 | - **Documentation Improvements**: Updated `.clinerules` to reflect the new tool names and ensure all documentation is current.
 21 | 
 22 | ## [2.0.5] - 2025-06-20
 23 | 
 24 | ### Changed
 25 | 
 26 | - **Tool Renaming**: Renamed the `obsidian_update_file` tool to `obsidian_update_note` to avoid conflicts and better reflect its function. During agentic use, LLMs confused this tool with filesystem operations, leading to errors. The new name clarifies that it operates on Obsidian notes specifically.
 27 | - **HTTP Transport Refactor**: Restructured the HTTP transport layer for improved clarity and robustness. Authentication logic is now more modular, and a centralized error handler has been implemented.
 28 | - **Dependency Updates**: Updated all dependencies to their latest versions.
 29 | - **Documentation Improvements**: Enhanced the documentation around installation & MCP Client configuration. Suggested by [@bgheneti](https://github.com/bgheneti) in [PR #14](https://github.com/cyanheads/obsidian-mcp-server/pull/14). Thanks!
 30 | 
 31 | ## [2.0.4] - 2025-06-13
 32 | 
 33 | ### Added
 34 | 
 35 | - **Recursive File Listing**: The `obsidian_list_files` tool now supports recursive listing of directories with a `recursionDepth` parameter.
 36 | 
 37 | ### Changed
 38 | 
 39 | - **Documentation**:
 40 |   - Consolidated tool specifications into `obsidian_mcp_tools_spec.md`.
 41 |   - Updated `.clinerules` with a detailed logger implementation example for the agent.
 42 |   - Updated the repository's directory tree documentation.
 43 | 
 44 | ## [2.0.3] - 2025-06-12
 45 | 
 46 | ### Fixed
 47 | 
 48 | - **NPM Package Display**: Explicitly included `README.md`, `LICENSE`, and `CHANGELOG.md` in the `files` array in `package.json` to ensure they are displayed correctly on the npm package page.
 49 | 
 50 | ## [2.0.2] - 2025-06-12
 51 | 
 52 | ### Fixed
 53 | 
 54 | - **NPM Package Version**: Bad npm package. Bumping to v2.0.2 for publishing.
 55 | 
 56 | ## [2.0.1] - 2025-06-12
 57 | 
 58 | ### Added
 59 | 
 60 | - **Enhanced Documentation**:
 61 |   - Added a warning to the `VaultCacheService` documentation about its potential for high memory usage on large vaults.
 62 |   - Added a code comment in `obsidianManageFrontmatterTool` to clarify the regex-based key deletion strategy.
 63 | 
 64 | ### Changed
 65 | 
 66 | - **Improved SSL Handling**: The `OBSIDIAN_VERIFY_SSL` environment variable is now correctly parsed as a boolean, ensuring more reliable SSL verification behavior.
 67 | - **API Service Refactoring**: Simplified the `httpsAgent` handling within the `ObsidianRestApiService` to improve code clarity and remove redundant agent creation on each request.
 68 | 
 69 | ### Fixed
 70 | 
 71 | - **Path Import Correction**: Corrected a path import in the `obsidianGlobalSearchTool` to use `node:path/posix` for better cross-platform compatibility.
 72 | 
 73 | ## [2.0.0] - 2025-06-12
 74 | 
 75 | Version 2.0.0 is a complete overhaul of the Obsidian MCP Server, migrating it to my [`cyanheads/mcp-ts-template`](https://github.com/cyanheads/mcp-ts-template). This release introduces a more robust architecture, a streamlined toolset, enhanced security, and significant performance improvements. It is a breaking change from the 1.x series.
 76 | 
 77 | ### Added
 78 | 
 79 | - **New Core Architecture**: The server is now built on the [`cyanheads/mcp-ts-template`](https://github.com/cyanheads/mcp-ts-template), providing a standardized, modular, and maintainable structure.
 80 | - **Hono HTTP Transport**: The HTTP transport has been migrated from Express to Hono, offering a more lightweight and performant server.
 81 | - **Vault Cache Service**: A new in-memory `VaultCacheService` has been introduced. It caches vault content to improve performance for search operations and provides a resilient fallback if the Obsidian API is temporarily unavailable. It also refreshes periodically.
 82 | - **Advanced Authentication**:
 83 |   - Added support for **OAuth 2.1** bearer token validation alongside the existing secret key-based JWTs.
 84 |   - Introduced `authContext` using `AsyncLocalStorage` for secure, request-scoped access to authentication details.
 85 | - **New Tools**:
 86 |   - `obsidian_delete_file`: A new tool to permanently delete files from the vault.
 87 |   - `obsidian_search_replace`: A powerful new tool to perform search and replace operations with regex support.
 88 | - **Enhanced Utilities**:
 89 |   - **Request Context**: A robust request context system (`requestContextService`) for improved logging and tracing.
 90 |   - **Error Handling**: A centralized `ErrorHandler` for consistent and detailed error reporting.
 91 |   - **Async Utilities**: A `retryWithDelay` utility is now used across the application to make API calls more resilient.
 92 | - **New Development Scripts**: Added `docs:generate` (for TypeDoc) and `inspect:stdio`/`inspect:http` (for MCP Inspector) to `package.json`.
 93 | 
 94 | ### Changed
 95 | 
 96 | - **Project Structure**: The entire project has been reorganized to align with the [`cyanheads/mcp-ts-template`](https://github.com/cyanheads/mcp-ts-template), improving separation of concerns (e.g., `services`, `mcp-server`, `types-global`).
 97 | - **Tool Consolidation and Enhancement**: The toolset has been redesigned for clarity and power:
 98 |   - `obsidian_list_files` replaces `obsidian_list_files_in_vault` and `obsidian_list_files_in_dir`, offering more flexible filtering.
 99 |   - `obsidian_read_file` replaces `obsidian_get_file_contents` and now supports returning content as structured JSON.
100 |   - `obsidian_update_file` replaces `obsidian_append_content` and `obsidian_update_content` with explicit modes (`append`, `prepend`, `overwrite`).
101 |   - `obsidian_global_search` replaces `obsidian_find_in_file` with added support for path/date filtering and pagination.
102 |   - `obsidian_manage_frontmatter` replaces `obsidian_get_properties` and `obsidian_update_properties` with atomic get/set/delete operations.
103 |   - `obsidian_manage_tags` replaces `obsidian_get_tags` and now manages both frontmatter and inline tags.
104 | - **Configuration Overhaul**: Environment variables have been renamed for consistency and clarity.
105 |   - `OBSIDIAN_BASE_URL` now consolidates protocol, host, and port.
106 |   - New variables like `MCP_TRANSPORT_TYPE`, `MCP_LOG_LEVEL`, and `MCP_AUTH_SECRET_KEY` have been introduced.
107 | - **Dependency Updates**: All dependencies, including the MCP SDK, have been updated to their latest stable versions.
108 | - **Obsidian API Service**: The `ObsidianRestApiService` has been completely refactored into a modular class, providing a typed, resilient, and centralized client for all interactions with the Obsidian Local REST API.
109 | 
110 | ### Removed
111 | 
112 | - **Removed Tools**: The following tools from version 1.x have been removed and their functionality integrated into the new, more comprehensive tools:
113 |   - `obsidian_list_files_in_vault`
114 |   - `obsidian_list_files_in_dir`
115 |   - `obsidian_get_file_contents`
116 |   - `obsidian_append_content`
117 |   - `obsidian_update_content`
118 |   - `obsidian_find_in_file`
119 |   - `obsidian_complex_search` (path-based searching is now a filter in `obsidian_global_search`)
120 |   - `obsidian_get_tags`
121 |   - `obsidian_get_properties`
122 |   - `obsidian_update_properties`
123 | - **Removed Resources**: The `obsidian://tags` resource has been removed. Tag information is now available through the `obsidian_manage_tags` tool. I may add the resource back in the future if there is demand for it. Please open an issue if you would like to see it return.
124 | - **Old Configuration**: All old, non-prefixed environment variables (e.g., `VERIFY_SSL`, `REQUEST_TIMEOUT`) have been removed in favor of the new, standardized configuration schema.
125 | 
```

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

```typescript
  1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  2 | import {
  3 |   ObsidianRestApiService,
  4 |   VaultCacheService,
  5 | } from "../../../services/obsidianRestAPI/index.js";
  6 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  7 | import {
  8 |   ErrorHandler,
  9 |   logger,
 10 |   RequestContext,
 11 |   requestContextService,
 12 | } from "../../../utils/index.js";
 13 | // Import types for handler signature and response structure
 14 | import type {
 15 |   ObsidianUpdateNoteRegistrationInput,
 16 |   ObsidianUpdateNoteResponse,
 17 | } from "./logic.js";
 18 | // Import the Zod schema for validation and the core processing logic
 19 | import {
 20 |   ObsidianUpdateNoteInputSchema,
 21 |   ObsidianUpdateNoteInputSchemaShape,
 22 |   processObsidianUpdateNote,
 23 | } from "./logic.js";
 24 | 
 25 | /**
 26 |  * Registers the 'obsidian_update_note' tool with the MCP server.
 27 |  *
 28 |  * This tool allows modification of Obsidian notes (specified by file path,
 29 |  * the active file, or a periodic note) using whole-file operations:
 30 |  * 'append', 'prepend', or 'overwrite'. It includes options for creating
 31 |  * missing files/targets and controlling overwrite behavior.
 32 |  *
 33 |  * The tool returns a JSON string containing the operation status, a message,
 34 |  * a formatted timestamp of the operation, file statistics (stat), and
 35 |  * optionally the final content of the modified file.
 36 |  *
 37 |  * @param {McpServer} server - The MCP server instance to register the tool with.
 38 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 39 |  *   used to interact with the user's Obsidian vault.
 40 |  * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 41 |  * @throws {McpError} Throws an McpError if registration fails critically.
 42 |  */
 43 | export const registerObsidianUpdateNoteTool = async (
 44 |   server: McpServer,
 45 |   obsidianService: ObsidianRestApiService,
 46 |   vaultCacheService: VaultCacheService | undefined,
 47 | ): Promise<void> => {
 48 |   const toolName = "obsidian_update_note";
 49 |   const toolDescription =
 50 |     "Tool to modify Obsidian notes (specified by file path, the active file, or a periodic note) using whole-file operations: 'append', 'prepend', or 'overwrite'. Options allow creating missing files/targets and controlling overwrite behavior. Returns success status, message, a formatted timestamp string, file stats (stats), and optionally the final file content.";
 51 | 
 52 |   // Create a context for the registration process itself for better traceability.
 53 |   const registrationContext: RequestContext =
 54 |     requestContextService.createRequestContext({
 55 |       operation: "RegisterObsidianUpdateNoteTool",
 56 |       toolName: toolName,
 57 |       module: "ObsidianUpdateNoteRegistration", // Identify the module performing registration
 58 |     });
 59 | 
 60 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 61 | 
 62 |   // Wrap the registration in a tryCatch block for robust error handling during setup.
 63 |   await ErrorHandler.tryCatch(
 64 |     async () => {
 65 |       // Use the high-level SDK method for tool registration.
 66 |       // This handles schema generation, validation, and routing automatically.
 67 |       server.tool(
 68 |         toolName,
 69 |         toolDescription,
 70 |         ObsidianUpdateNoteInputSchemaShape, // Provide the Zod schema shape for input validation.
 71 |         /**
 72 |          * The handler function executed when the 'obsidian_update_note' tool is called.
 73 |          *
 74 |          * @param {ObsidianUpdateNoteRegistrationInput} params - The raw input parameters received from the client,
 75 |          *   matching the structure defined by ObsidianUpdateNoteInputSchemaShape.
 76 |          * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
 77 |          *   containing either the successful response data or an error indication.
 78 |          */
 79 |         async (params: ObsidianUpdateNoteRegistrationInput) => {
 80 |           // Create a specific context for this handler invocation.
 81 |           const handlerContext: RequestContext =
 82 |             requestContextService.createRequestContext({
 83 |               parentContext: registrationContext, // Link to the registration context
 84 |               operation: "HandleObsidianUpdateNoteRequest",
 85 |               toolName: toolName,
 86 |               params: {
 87 |                 // Log key parameters for easier debugging, content is omitted for brevity/security
 88 |                 targetType: params.targetType,
 89 |                 modificationType: params.modificationType, // Note: Will always be 'wholeFile' due to schema
 90 |                 targetIdentifier: params.targetIdentifier,
 91 |                 wholeFileMode: params.wholeFileMode,
 92 |                 createIfNeeded: params.createIfNeeded,
 93 |                 overwriteIfExists: params.overwriteIfExists,
 94 |                 returnContent: params.returnContent,
 95 |               },
 96 |             });
 97 |           logger.debug(
 98 |             `Handling '${toolName}' request (wholeFile mode)`,
 99 |             handlerContext,
100 |           );
101 | 
102 |           // Wrap the core logic execution in a tryCatch block for handling errors during processing.
103 |           return await ErrorHandler.tryCatch(
104 |             async () => {
105 |               // Explicitly parse and validate the incoming parameters using the full Zod schema.
106 |               // This ensures type safety and adherence to constraints defined in logic.ts.
107 |               // While server.tool performs initial validation based on the shape,
108 |               // this step applies any stricter rules or refinements from the full schema.
109 |               const validatedParams =
110 |                 ObsidianUpdateNoteInputSchema.parse(params);
111 | 
112 |               // Delegate the actual file update logic to the dedicated processing function.
113 |               // Pass the validated parameters, the handler context, and the Obsidian service instance.
114 |               const response: ObsidianUpdateNoteResponse =
115 |                 await processObsidianUpdateNote(
116 |                   validatedParams,
117 |                   handlerContext,
118 |                   obsidianService,
119 |                   vaultCacheService,
120 |                 );
121 |               logger.debug(
122 |                 `'${toolName}' (wholeFile mode) processed successfully`,
123 |                 handlerContext,
124 |               );
125 | 
126 |               // Format the successful response from the logic function into the MCP CallToolResult structure.
127 |               // The response object (containing status, message, timestamp, stat, etc.) is serialized to JSON.
128 |               return {
129 |                 content: [
130 |                   {
131 |                     type: "text", // Standard content type for structured data
132 |                     text: JSON.stringify(response, null, 2), // Pretty-print JSON for readability
133 |                   },
134 |                 ],
135 |                 isError: false, // Indicate successful execution
136 |               };
137 |             },
138 |             {
139 |               // Configuration for the inner error handler (processing logic).
140 |               operation: `processing ${toolName} handler`,
141 |               context: handlerContext,
142 |               input: params, // Log the full raw input parameters if an error occurs during processing.
143 |               // Custom error mapping to ensure consistent McpError format.
144 |               errorMapper: (error: unknown) =>
145 |                 new McpError(
146 |                   error instanceof McpError
147 |                     ? error.code
148 |                     : BaseErrorCode.INTERNAL_ERROR, // Use INTERNAL_ERROR as the fallback
149 |                   `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
150 |                   { ...handlerContext }, // Include context in the error details
151 |                 ),
152 |             },
153 |           ); // End of inner ErrorHandler.tryCatch
154 |         },
155 |       ); // End of server.tool call
156 | 
157 |       logger.info(
158 |         `Tool registered successfully: ${toolName}`,
159 |         registrationContext,
160 |       );
161 |     },
162 |     {
163 |       // Configuration for the outer error handler (registration process).
164 |       operation: `registering tool ${toolName}`,
165 |       context: registrationContext,
166 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure
167 |       // Custom error mapping for registration failures.
168 |       errorMapper: (error: unknown) =>
169 |         new McpError(
170 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
171 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
172 |           { ...registrationContext }, // Include context
173 |         ),
174 |       critical: true, // Registration failure is considered critical and should likely halt server startup.
175 |     },
176 |   ); // End of outer ErrorHandler.tryCatch
177 | };
178 | 
```

--------------------------------------------------------------------------------
/scripts/fetch-openapi-spec.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | /**
  4 |  * @fileoverview Fetches an OpenAPI specification (YAML/JSON) from a URL,
  5 |  * parses it, and saves it locally in both YAML and JSON formats.
  6 |  * @module scripts/fetch-openapi-spec
  7 |  *   Includes fallback logic for common OpenAPI file names (openapi.yaml, openapi.json).
  8 |  *   Ensures output paths are within the project directory for security.
  9 |  *
 10 |  * @example
 11 |  * // Fetch spec and save to docs/api/my_api.yaml and docs/api/my_api.json
 12 |  * // ts-node --esm scripts/fetch-openapi-spec.ts https://api.example.com/v1 docs/api/my_api
 13 |  *
 14 |  * @example
 15 |  * // Fetch spec from a direct file URL
 16 |  * // ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3/openapi.json docs/api/petstore_v3
 17 |  */
 18 | 
 19 | import axios, { AxiosError } from "axios";
 20 | import fs from "fs/promises";
 21 | import yaml from "js-yaml";
 22 | import path from "path";
 23 | 
 24 | const projectRoot = process.cwd();
 25 | 
 26 | const args = process.argv.slice(2);
 27 | const helpFlag = args.includes("--help");
 28 | const urlArg = args[0];
 29 | const outputBaseArg = args[1];
 30 | 
 31 | if (helpFlag || !urlArg || !outputBaseArg) {
 32 |   console.log(`
 33 | Fetch OpenAPI Specification Script
 34 | 
 35 | Usage:
 36 |   ts-node --esm scripts/fetch-openapi-spec.ts <url> <output-base-path> [--help]
 37 | 
 38 | Arguments:
 39 |   <url>                Base URL or direct URL to the OpenAPI spec (YAML/JSON).
 40 |   <output-base-path>   Base path for output files (relative to project root),
 41 |                        e.g., 'docs/api/my_api'. Will generate .yaml and .json.
 42 |   --help               Show this help message.
 43 | 
 44 | Example:
 45 |   ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3 docs/api/petstore_v3
 46 | `);
 47 |   process.exit(helpFlag ? 0 : 1);
 48 | }
 49 | 
 50 | const outputBasePathAbsolute = path.resolve(projectRoot, outputBaseArg);
 51 | const yamlOutputPath = `${outputBasePathAbsolute}.yaml`;
 52 | const jsonOutputPath = `${outputBasePathAbsolute}.json`;
 53 | const outputDirAbsolute = path.dirname(outputBasePathAbsolute);
 54 | 
 55 | // Security Check: Ensure output paths are within project root
 56 | if (
 57 |   !outputDirAbsolute.startsWith(projectRoot + path.sep) ||
 58 |   !yamlOutputPath.startsWith(projectRoot + path.sep) ||
 59 |   !jsonOutputPath.startsWith(projectRoot + path.sep)
 60 | ) {
 61 |   console.error(
 62 |     `Error: Output path "${outputBaseArg}" resolves outside the project directory. Aborting.`,
 63 |   );
 64 |   process.exit(1);
 65 | }
 66 | 
 67 | /**
 68 |  * Attempts to fetch content from a given URL.
 69 |  * @param url - The URL to fetch data from.
 70 |  * @returns A promise resolving to an object with data and content type, or null if fetch fails.
 71 |  */
 72 | async function tryFetch(
 73 |   url: string,
 74 | ): Promise<{ data: string; contentType: string | null } | null> {
 75 |   try {
 76 |     console.log(`Attempting to fetch from: ${url}`);
 77 |     const response = await axios.get(url, {
 78 |       responseType: "text",
 79 |       validateStatus: (status) => status >= 200 && status < 300,
 80 |     });
 81 |     const contentType = response.headers["content-type"] || null;
 82 |     console.log(
 83 |       `Successfully fetched (Status: ${response.status}, Content-Type: ${contentType || "N/A"})`,
 84 |     );
 85 |     return { data: response.data, contentType };
 86 |   } catch (error) {
 87 |     let status = "Unknown";
 88 |     if (axios.isAxiosError(error)) {
 89 |       const axiosError = error as AxiosError;
 90 |       status = axiosError.response
 91 |         ? String(axiosError.response.status)
 92 |         : "Network Error";
 93 |     }
 94 |     console.warn(`Failed to fetch from ${url} (Status: ${status})`);
 95 |     return null;
 96 |   }
 97 | }
 98 | 
 99 | /**
100 |  * Parses fetched data as YAML or JSON, attempting to infer from content type or by trying both.
101 |  * @param data - The raw string data fetched from the URL.
102 |  * @param contentType - The content type header from the HTTP response, if available.
103 |  * @returns The parsed OpenAPI specification as an object, or null if parsing fails.
104 |  */
105 | function parseSpec(data: string, contentType: string | null): object | null {
106 |   try {
107 |     const lowerContentType = contentType?.toLowerCase();
108 |     if (
109 |       lowerContentType?.includes("yaml") ||
110 |       lowerContentType?.includes("yml")
111 |     ) {
112 |       console.log("Parsing content as YAML based on Content-Type...");
113 |       return yaml.load(data) as object;
114 |     } else if (lowerContentType?.includes("json")) {
115 |       console.log("Parsing content as JSON based on Content-Type...");
116 |       return JSON.parse(data);
117 |     } else {
118 |       console.log(
119 |         "Content-Type is ambiguous or missing. Attempting to parse as YAML first...",
120 |       );
121 |       try {
122 |         const parsedYaml = yaml.load(data) as object;
123 |         // Basic validation: check if it's a non-null object.
124 |         if (parsedYaml && typeof parsedYaml === "object") {
125 |           console.log("Successfully parsed as YAML.");
126 |           return parsedYaml;
127 |         }
128 |       } catch (yamlError) {
129 |         console.log("YAML parsing failed. Attempting to parse as JSON...");
130 |         try {
131 |           const parsedJson = JSON.parse(data);
132 |           if (parsedJson && typeof parsedJson === "object") {
133 |             console.log("Successfully parsed as JSON.");
134 |             return parsedJson;
135 |           }
136 |         } catch (jsonError) {
137 |           console.warn(
138 |             "Could not parse content as YAML or JSON after attempting both.",
139 |           );
140 |           return null;
141 |         }
142 |       }
143 |       // If YAML parsing resulted in a non-object (e.g. string, number) but didn't throw
144 |       console.warn(
145 |         "Content parsed as YAML but was not a valid object structure. Trying JSON.",
146 |       );
147 |       try {
148 |         const parsedJson = JSON.parse(data);
149 |         if (parsedJson && typeof parsedJson === "object") {
150 |           console.log(
151 |             "Successfully parsed as JSON on second attempt for non-object YAML.",
152 |           );
153 |           return parsedJson;
154 |         }
155 |       } catch (jsonError) {
156 |         console.warn(
157 |           "Could not parse content as YAML or JSON after attempting both.",
158 |         );
159 |         return null;
160 |       }
161 |     }
162 |   } catch (parseError) {
163 |     console.error(
164 |       `Error parsing specification: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
165 |     );
166 |   }
167 |   return null;
168 | }
169 | 
170 | /**
171 |  * Main orchestrator function. Fetches the OpenAPI spec from the provided URL (with fallbacks),
172 |  * parses it, and saves it to the specified output paths in both YAML and JSON formats.
173 |  */
174 | async function fetchAndProcessSpec(): Promise<void> {
175 |   let fetchedResult: { data: string; contentType: string | null } | null = null;
176 |   const potentialUrls: string[] = [urlArg];
177 | 
178 |   if (
179 |     !urlArg.endsWith(".yaml") &&
180 |     !urlArg.endsWith(".yml") &&
181 |     !urlArg.endsWith(".json")
182 |   ) {
183 |     const urlWithoutTrailingSlash = urlArg.endsWith("/")
184 |       ? urlArg.slice(0, -1)
185 |       : urlArg;
186 |     potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.yaml`);
187 |     potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.json`);
188 |   }
189 | 
190 |   for (const url of potentialUrls) {
191 |     fetchedResult = await tryFetch(url);
192 |     if (fetchedResult) break;
193 |   }
194 | 
195 |   if (!fetchedResult) {
196 |     console.error(
197 |       `Error: Failed to fetch specification from all attempted URLs: ${potentialUrls.join(", ")}. Aborting.`,
198 |     );
199 |     process.exit(1);
200 |   }
201 | 
202 |   const openapiSpec = parseSpec(fetchedResult.data, fetchedResult.contentType);
203 | 
204 |   if (!openapiSpec || typeof openapiSpec !== "object") {
205 |     console.error(
206 |       "Error: Failed to parse specification content or content is not a valid object. Aborting.",
207 |     );
208 |     process.exit(1);
209 |   }
210 | 
211 |   try {
212 |     await fs.access(outputDirAbsolute);
213 |   } catch (error: any) {
214 |     if (error.code === "ENOENT") {
215 |       console.log(`Output directory not found. Creating: ${outputDirAbsolute}`);
216 |       await fs.mkdir(outputDirAbsolute, { recursive: true });
217 |     } else {
218 |       console.error(
219 |         `Error accessing output directory ${outputDirAbsolute}: ${error.message}. Aborting.`,
220 |       );
221 |       process.exit(1);
222 |     }
223 |   }
224 | 
225 |   try {
226 |     console.log(`Saving YAML specification to: ${yamlOutputPath}`);
227 |     await fs.writeFile(yamlOutputPath, yaml.dump(openapiSpec), "utf8");
228 |     console.log(`Successfully saved YAML specification.`);
229 |   } catch (error) {
230 |     console.error(
231 |       `Error saving YAML to ${yamlOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
232 |     );
233 |     process.exit(1);
234 |   }
235 | 
236 |   try {
237 |     console.log(`Saving JSON specification to: ${jsonOutputPath}`);
238 |     await fs.writeFile(
239 |       jsonOutputPath,
240 |       JSON.stringify(openapiSpec, null, 2),
241 |       "utf8",
242 |     );
243 |     console.log(`Successfully saved JSON specification.`);
244 |   } catch (error) {
245 |     console.error(
246 |       `Error saving JSON to ${jsonOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
247 |     );
248 |     process.exit(1);
249 |   }
250 | 
251 |   console.log("OpenAPI specification processed and saved successfully.");
252 | }
253 | 
254 | fetchAndProcessSpec();
255 | 
```

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

```typescript
  1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  2 | import {
  3 |   ObsidianRestApiService,
  4 |   VaultCacheService,
  5 | } from "../../../services/obsidianRestAPI/index.js";
  6 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  7 | import {
  8 |   ErrorHandler,
  9 |   logger,
 10 |   RequestContext,
 11 |   requestContextService,
 12 | } from "../../../utils/index.js";
 13 | // Import necessary types and schemas from the logic file
 14 | import type {
 15 |   ObsidianSearchReplaceRegistrationInput,
 16 |   ObsidianSearchReplaceResponse,
 17 | } from "./logic.js";
 18 | import {
 19 |   ObsidianSearchReplaceInputSchema,
 20 |   ObsidianSearchReplaceInputSchemaShape,
 21 |   processObsidianSearchReplace,
 22 | } from "./logic.js";
 23 | 
 24 | /**
 25 |  * Registers the 'obsidian_search_replace' tool with the MCP server.
 26 |  *
 27 |  * This tool performs one or more search-and-replace operations within a specified
 28 |  * Obsidian note (identified by file path, the active file, or a periodic note).
 29 |  * It reads the note content, applies the replacements sequentially based on the
 30 |  * provided options (regex, case sensitivity, etc.), writes the modified content
 31 |  * back to the vault, and returns the operation results.
 32 |  *
 33 |  * The response includes success status, a summary message, the total number of
 34 |  * replacements made, formatted file statistics (timestamp, token count), and
 35 |  * optionally the final content of the note.
 36 |  *
 37 |  * @param {McpServer} server - The MCP server instance to register the tool with.
 38 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 39 |  *   used to interact with the user's Obsidian vault.
 40 |  * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 41 |  * @throws {McpError} Throws an McpError if registration fails critically.
 42 |  */
 43 | export const registerObsidianSearchReplaceTool = async (
 44 |   server: McpServer,
 45 |   obsidianService: ObsidianRestApiService,
 46 |   vaultCacheService: VaultCacheService | undefined,
 47 | ): Promise<void> => {
 48 |   const toolName = "obsidian_search_replace";
 49 |   const toolDescription =
 50 |     "Performs one or more search-and-replace operations within a target Obsidian note (file path, active, or periodic). Reads the file, applies replacements sequentially in memory, and writes the modified content back, overwriting the original. Supports string/regex search, case sensitivity toggle, replacing first/all occurrences, flexible whitespace matching (non-regex), and whole word matching. Returns success status, message, replacement count, a formatted timestamp string, file stats (stats), and optionally the final file content.";
 51 | 
 52 |   // Create a context specifically for the registration process.
 53 |   const registrationContext: RequestContext =
 54 |     requestContextService.createRequestContext({
 55 |       operation: "RegisterObsidianSearchReplaceTool",
 56 |       toolName: toolName,
 57 |       module: "ObsidianSearchReplaceRegistration", // Identify the module
 58 |     });
 59 | 
 60 |   logger.info(`Attempting to register tool: ${toolName}`, registrationContext);
 61 | 
 62 |   // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
 63 |   await ErrorHandler.tryCatch(
 64 |     async () => {
 65 |       // Use the high-level SDK method `server.tool` for registration.
 66 |       // It handles schema generation from the shape, basic validation, and routing.
 67 |       server.tool(
 68 |         toolName,
 69 |         toolDescription,
 70 |         ObsidianSearchReplaceInputSchemaShape, // Provide the base Zod schema shape for input definition.
 71 |         /**
 72 |          * The handler function executed when the 'obsidian_search_replace' tool is called by the client.
 73 |          *
 74 |          * @param {ObsidianSearchReplaceRegistrationInput} params - The raw input parameters received from the client,
 75 |          *   matching the structure defined by ObsidianSearchReplaceInputSchemaShape.
 76 |          * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
 77 |          *   containing either the successful response data (serialized JSON) or an error indication.
 78 |          */
 79 |         async (params: ObsidianSearchReplaceRegistrationInput) => {
 80 |           // Create a specific context for this handler invocation, linked to the registration context.
 81 |           const handlerContext: RequestContext =
 82 |             requestContextService.createRequestContext({
 83 |               parentContext: registrationContext,
 84 |               operation: "HandleObsidianSearchReplaceRequest",
 85 |               toolName: toolName,
 86 |               params: {
 87 |                 // Log key parameters for debugging (excluding potentially large replacements array)
 88 |                 targetType: params.targetType,
 89 |                 targetIdentifier: params.targetIdentifier,
 90 |                 replacementCount: params.replacements?.length ?? 0, // Log count instead of full array
 91 |                 useRegex: params.useRegex,
 92 |                 replaceAll: params.replaceAll,
 93 |                 caseSensitive: params.caseSensitive,
 94 |                 flexibleWhitespace: params.flexibleWhitespace,
 95 |                 wholeWord: params.wholeWord,
 96 |                 returnContent: params.returnContent,
 97 |               },
 98 |             });
 99 |           logger.debug(`Handling '${toolName}' request`, handlerContext);
100 | 
101 |           // Wrap the core logic execution in a tryCatch block for handling errors during processing.
102 |           return await ErrorHandler.tryCatch(
103 |             async () => {
104 |               // **Crucial Step:** Explicitly parse and validate the raw input parameters using the
105 |               // *refined* Zod schema (`ObsidianSearchReplaceInputSchema`). This applies stricter rules
106 |               // and cross-field validations defined in logic.ts.
107 |               const validatedParams =
108 |                 ObsidianSearchReplaceInputSchema.parse(params);
109 |               logger.debug(
110 |                 `Input parameters successfully validated against refined schema.`,
111 |                 handlerContext,
112 |               );
113 | 
114 |               // Delegate the actual search/replace logic to the dedicated processing function.
115 |               // Pass the *validated* parameters, the handler context, and the Obsidian service instance.
116 |               const response: ObsidianSearchReplaceResponse =
117 |                 await processObsidianSearchReplace(
118 |                   validatedParams,
119 |                   handlerContext,
120 |                   obsidianService,
121 |                   vaultCacheService,
122 |                 );
123 |               logger.debug(
124 |                 `'${toolName}' processed successfully`,
125 |                 handlerContext,
126 |               );
127 | 
128 |               // Format the successful response object from the logic function into the required MCP CallToolResult structure.
129 |               // The entire response object (containing success, message, count, stat, etc.) is serialized to JSON.
130 |               return {
131 |                 content: [
132 |                   {
133 |                     type: "text", // Standard content type for structured JSON data
134 |                     text: JSON.stringify(response, null, 2), // Pretty-print JSON for readability
135 |                   },
136 |                 ],
137 |                 isError: false, // Indicate successful execution to the client
138 |               };
139 |             },
140 |             {
141 |               // Configuration for the inner error handler (processing logic).
142 |               operation: `processing ${toolName} handler`,
143 |               context: handlerContext,
144 |               input: params, // Log the full raw input parameters if an error occurs during processing.
145 |               // Custom error mapping to ensure consistent McpError format is returned to the client.
146 |               errorMapper: (error: unknown) =>
147 |                 new McpError(
148 |                   // Use the specific code from McpError if available, otherwise default to INTERNAL_ERROR.
149 |                   error instanceof McpError
150 |                     ? error.code
151 |                     : BaseErrorCode.INTERNAL_ERROR,
152 |                   `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
153 |                   { ...handlerContext }, // Include context in the error details
154 |                 ),
155 |             },
156 |           ); // End of inner ErrorHandler.tryCatch
157 |         },
158 |       ); // End of server.tool call
159 | 
160 |       logger.info(
161 |         `Tool registered successfully: ${toolName}`,
162 |         registrationContext,
163 |       );
164 |     },
165 |     {
166 |       // Configuration for the outer error handler (registration process).
167 |       operation: `registering tool ${toolName}`,
168 |       context: registrationContext,
169 |       errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
170 |       // Custom error mapping for registration failures.
171 |       errorMapper: (error: unknown) =>
172 |         new McpError(
173 |           error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
174 |           `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
175 |           { ...registrationContext }, // Include context
176 |         ),
177 |       critical: true, // Treat registration failure as critical, potentially halting server startup.
178 |     },
179 |   ); // End of outer ErrorHandler.tryCatch
180 | };
181 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Configures and starts the Streamable HTTP MCP transport using Hono.
  3 |  * This module integrates the `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport`
  4 |  * into a Hono web server. Its responsibilities include:
  5 |  * - Creating a Hono server instance.
  6 |  * - Applying and configuring middleware for CORS, rate limiting, and authentication (JWT/OAuth).
  7 |  * - Defining the routes (`/mcp` endpoint for POST, GET, DELETE) to handle the MCP lifecycle.
  8 |  * - Orchestrating session management by mapping session IDs to SDK transport instances.
  9 |  * - Implementing port-binding logic with automatic retry on conflicts.
 10 |  *
 11 |  * The underlying implementation of the MCP Streamable HTTP specification, including
 12 |  * Server-Sent Events (SSE) for streaming, is handled by the SDK's transport class.
 13 |  *
 14 |  * Specification Reference:
 15 |  * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
 16 |  * @module src/mcp-server/transports/httpTransport
 17 |  */
 18 | 
 19 | import { HttpBindings, serve, ServerType } from "@hono/node-server";
 20 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 21 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
 22 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
 23 | import { Context, Hono, Next } from "hono";
 24 | import { cors } from "hono/cors";
 25 | import http from "http";
 26 | import { randomUUID } from "node:crypto";
 27 | import { config } from "../../config/index.js";
 28 | import { BaseErrorCode, McpError } from "../../types-global/errors.js";
 29 | import {
 30 |   logger,
 31 |   rateLimiter,
 32 |   RequestContext,
 33 |   requestContextService,
 34 | } from "../../utils/index.js";
 35 | import {
 36 |   jwtAuthMiddleware,
 37 |   oauthMiddleware,
 38 |   type AuthInfo,
 39 | } from "./auth/index.js";
 40 | import { httpErrorHandler } from "./httpErrorHandler.js";
 41 | 
 42 | const HTTP_PORT = config.mcpHttpPort;
 43 | const HTTP_HOST = config.mcpHttpHost;
 44 | const MCP_ENDPOINT_PATH = "/mcp";
 45 | const MAX_PORT_RETRIES = 15;
 46 | 
 47 | // The transports map will store active sessions, keyed by session ID.
 48 | // NOTE: This is an in-memory session store, which is a known limitation for scalability.
 49 | // It will not work in a multi-process (clustered) or serverless environment.
 50 | // For a scalable deployment, this would need to be replaced with a distributed
 51 | // store like Redis or Memcached.
 52 | const transports: Record<string, StreamableHTTPServerTransport> = {};
 53 | 
 54 | async function isPortInUse(
 55 |   port: number,
 56 |   host: string,
 57 |   parentContext: RequestContext,
 58 | ): Promise<boolean> {
 59 |   const checkContext = requestContextService.createRequestContext({
 60 |     ...parentContext,
 61 |     operation: "isPortInUse",
 62 |     port,
 63 |     host,
 64 |   });
 65 |   return new Promise((resolve) => {
 66 |     const tempServer = http.createServer();
 67 |     tempServer
 68 |       .once("error", (err: NodeJS.ErrnoException) => {
 69 |         resolve(err.code === "EADDRINUSE");
 70 |       })
 71 |       .once("listening", () => {
 72 |         tempServer.close(() => resolve(false));
 73 |       })
 74 |       .listen(port, host);
 75 |   });
 76 | }
 77 | 
 78 | function startHttpServerWithRetry(
 79 |   app: Hono<{ Bindings: HttpBindings }>,
 80 |   initialPort: number,
 81 |   host: string,
 82 |   maxRetries: number,
 83 |   parentContext: RequestContext,
 84 | ): Promise<ServerType> {
 85 |   const startContext = requestContextService.createRequestContext({
 86 |     ...parentContext,
 87 |     operation: "startHttpServerWithRetry",
 88 |   });
 89 | 
 90 |   return new Promise(async (resolve, reject) => {
 91 |     for (let i = 0; i <= maxRetries; i++) {
 92 |       const currentPort = initialPort + i;
 93 |       const attemptContext = {
 94 |         ...startContext,
 95 |         port: currentPort,
 96 |         attempt: i + 1,
 97 |       };
 98 | 
 99 |       if (await isPortInUse(currentPort, host, attemptContext)) {
100 |         logger.warning(
101 |           `Port ${currentPort} is in use, retrying...`,
102 |           attemptContext,
103 |         );
104 |         continue;
105 |       }
106 | 
107 |       try {
108 |         const serverInstance = serve(
109 |           { fetch: app.fetch, port: currentPort, hostname: host },
110 |           (info: { address: string; port: number }) => {
111 |             const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
112 |             logger.info(`HTTP transport listening at ${serverAddress}`, {
113 |               ...attemptContext,
114 |               address: serverAddress,
115 |             });
116 |             if (process.stdout.isTTY) {
117 |               console.log(`\n🚀 MCP Server running at: ${serverAddress}\n`);
118 |             }
119 |           },
120 |         );
121 |         resolve(serverInstance);
122 |         return;
123 |       } catch (err: any) {
124 |         if (err.code !== "EADDRINUSE") {
125 |           reject(err);
126 |           return;
127 |         }
128 |       }
129 |     }
130 |     reject(new Error("Failed to bind to any port after multiple retries."));
131 |   });
132 | }
133 | 
134 | export async function startHttpTransport(
135 |   createServerInstanceFn: () => Promise<McpServer>,
136 |   parentContext: RequestContext,
137 | ): Promise<ServerType> {
138 |   const app = new Hono<{ Bindings: HttpBindings }>();
139 |   const transportContext = requestContextService.createRequestContext({
140 |     ...parentContext,
141 |     component: "HttpTransportSetup",
142 |   });
143 | 
144 |   app.use(
145 |     "*",
146 |     cors({
147 |       origin: config.mcpAllowedOrigins || [],
148 |       allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
149 |       allowHeaders: [
150 |         "Content-Type",
151 |         "Mcp-Session-Id",
152 |         "Last-Event-ID",
153 |         "Authorization",
154 |       ],
155 |       credentials: true,
156 |     }),
157 |   );
158 | 
159 |   app.use("*", async (c: Context, next: Next) => {
160 |     c.res.headers.set("X-Content-Type-Options", "nosniff");
161 |     await next();
162 |   });
163 | 
164 |   app.use(MCP_ENDPOINT_PATH, async (c: Context, next: Next) => {
165 |     // NOTE (Security): The 'x-forwarded-for' header is used for rate limiting.
166 |     // This is only secure if the server is run behind a trusted proxy that
167 |     // correctly sets or validates this header.
168 |     const clientIp =
169 |       c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown_ip";
170 |     const context = requestContextService.createRequestContext({
171 |       operation: "httpRateLimitCheck",
172 |       ipAddress: clientIp,
173 |     });
174 |     // Let the centralized error handler catch rate limit errors
175 |     rateLimiter.check(clientIp, context);
176 |     await next();
177 |   });
178 | 
179 |   if (config.mcpAuthMode === "oauth") {
180 |     app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
181 |   } else {
182 |     app.use(MCP_ENDPOINT_PATH, jwtAuthMiddleware);
183 |   }
184 | 
185 |   // Centralized Error Handling
186 |   app.onError(httpErrorHandler);
187 | 
188 |   app.post(MCP_ENDPOINT_PATH, async (c: Context) => {
189 |     const postContext = requestContextService.createRequestContext({
190 |       ...transportContext,
191 |       operation: "handlePost",
192 |     });
193 |     const body = await c.req.json();
194 |     const sessionId = c.req.header("mcp-session-id");
195 |     let transport: StreamableHTTPServerTransport | undefined = sessionId
196 |       ? transports[sessionId]
197 |       : undefined;
198 | 
199 |     if (isInitializeRequest(body)) {
200 |       // If a transport already exists for a session, it's a re-initialization.
201 |       if (transport) {
202 |         logger.warning("Re-initializing existing session.", {
203 |           ...postContext,
204 |           sessionId,
205 |         });
206 |         await transport.close(); // This will trigger the onclose handler.
207 |       }
208 | 
209 |       // Create a new transport for a new session.
210 |       const newTransport = new StreamableHTTPServerTransport({
211 |         sessionIdGenerator: () => randomUUID(),
212 |         onsessioninitialized: (newId) => {
213 |           transports[newId] = newTransport;
214 |           logger.info(`HTTP Session created: ${newId}`, {
215 |             ...postContext,
216 |             newSessionId: newId,
217 |           });
218 |         },
219 |       });
220 | 
221 |       // Set up cleanup logic for when the transport is closed.
222 |       newTransport.onclose = () => {
223 |         const closedSessionId = newTransport.sessionId;
224 |         if (closedSessionId && transports[closedSessionId]) {
225 |           delete transports[closedSessionId];
226 |           logger.info(`HTTP Session closed: ${closedSessionId}`, {
227 |             ...postContext,
228 |             closedSessionId,
229 |           });
230 |         }
231 |       };
232 | 
233 |       // Connect the new transport to a new server instance.
234 |       const server = await createServerInstanceFn();
235 |       await server.connect(newTransport);
236 |       transport = newTransport;
237 |     } else if (!transport) {
238 |       // If it's not an initialization request and no transport was found, it's an error.
239 |       throw new McpError(
240 |         BaseErrorCode.NOT_FOUND,
241 |         "Invalid or expired session ID.",
242 |       );
243 |     }
244 | 
245 |     // Pass the request to the transport to handle.
246 |     return await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
247 |   });
248 | 
249 |   // A reusable handler for GET and DELETE requests which operate on existing sessions.
250 |   const handleSessionRequest = async (
251 |     c: Context<{ Bindings: HttpBindings }>,
252 |   ) => {
253 |     const sessionId = c.req.header("mcp-session-id");
254 |     const transport = sessionId ? transports[sessionId] : undefined;
255 | 
256 |     if (!transport) {
257 |       throw new McpError(
258 |         BaseErrorCode.NOT_FOUND,
259 |         "Session not found or expired.",
260 |       );
261 |     }
262 | 
263 |     // Let the transport handle the streaming (GET) or termination (DELETE) request.
264 |     return await transport.handleRequest(c.env.incoming, c.env.outgoing);
265 |   };
266 | 
267 |   app.get(MCP_ENDPOINT_PATH, handleSessionRequest);
268 |   app.delete(MCP_ENDPOINT_PATH, handleSessionRequest);
269 | 
270 |   return startHttpServerWithRetry(
271 |     app,
272 |     HTTP_PORT,
273 |     HTTP_HOST,
274 |     MAX_PORT_RETRIES,
275 |     transportContext,
276 |   );
277 | }
278 | 
```

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

```typescript
  1 | import path from "node:path"; // node:path provides OS-specific path functions; using path.posix for vault path manipulation.
  2 | import { z } from "zod";
  3 | import {
  4 |   ObsidianRestApiService,
  5 |   VaultCacheService,
  6 | } from "../../../services/obsidianRestAPI/index.js";
  7 | import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
  8 | import {
  9 |   logger,
 10 |   RequestContext,
 11 |   retryWithDelay,
 12 | } from "../../../utils/index.js";
 13 | 
 14 | // ====================================================================================
 15 | // Schema Definitions for Input Validation
 16 | // ====================================================================================
 17 | 
 18 | /**
 19 |  * Zod schema for validating the input parameters of the 'obsidian_delete_note' tool.
 20 |  */
 21 | export const ObsidianDeleteNoteInputSchema = z
 22 |   .object({
 23 |     /**
 24 |      * The vault-relative path to the file to be permanently deleted.
 25 |      * Must include the file extension (e.g., "Old Notes/Obsolete File.md").
 26 |      * The tool first attempts a case-sensitive match. If not found, it attempts
 27 |      * a case-insensitive fallback search within the same directory.
 28 |      */
 29 |     filePath: z
 30 |       .string()
 31 |       .min(1, "filePath cannot be empty")
 32 |       .describe(
 33 |         'The vault-relative path to the file to be deleted (e.g., "archive/old-file.md"). Tries case-sensitive first, then case-insensitive fallback.',
 34 |       ),
 35 |   })
 36 |   .describe(
 37 |     "Input parameters for permanently deleting a specific file within the connected Obsidian vault. Includes a case-insensitive path fallback.",
 38 |   );
 39 | 
 40 | /**
 41 |  * TypeScript type inferred from the input schema (`ObsidianDeleteNoteInputSchema`).
 42 |  * Represents the validated input parameters used within the core processing logic.
 43 |  */
 44 | export type ObsidianDeleteNoteInput = z.infer<
 45 |   typeof ObsidianDeleteNoteInputSchema
 46 | >;
 47 | 
 48 | // ====================================================================================
 49 | // Response Type Definition
 50 | // ====================================================================================
 51 | 
 52 | /**
 53 |  * Defines the structure of the successful response returned by the `processObsidianDeleteNote` function.
 54 |  * This object is typically serialized to JSON and sent back to the client.
 55 |  */
 56 | export interface ObsidianDeleteNoteResponse {
 57 |   /** Indicates whether the deletion operation was successful. */
 58 |   success: boolean;
 59 |   /** A human-readable message confirming the deletion and specifying the path used. */
 60 |   message: string;
 61 | }
 62 | 
 63 | // ====================================================================================
 64 | // Core Logic Function
 65 | // ====================================================================================
 66 | 
 67 | /**
 68 |  * Processes the core logic for deleting a file from the Obsidian vault.
 69 |  *
 70 |  * It attempts to delete the file using the provided path (case-sensitive first).
 71 |  * If that fails with a 'NOT_FOUND' error, it attempts a case-insensitive fallback:
 72 |  * it lists the directory, finds a unique case-insensitive match for the filename,
 73 |  * and retries the deletion with the corrected path.
 74 |  *
 75 |  * @param {ObsidianDeleteNoteInput} params - The validated input parameters.
 76 |  * @param {RequestContext} context - The request context for logging and correlation.
 77 |  * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
 78 |  * @returns {Promise<ObsidianDeleteNoteResponse>} A promise resolving to the structured success response
 79 |  *   containing a confirmation message.
 80 |  * @throws {McpError} Throws an McpError if the file cannot be found (even with fallback),
 81 |  *   if there's an ambiguous fallback match, or if any other API interaction fails.
 82 |  */
 83 | export const processObsidianDeleteNote = async (
 84 |   params: ObsidianDeleteNoteInput,
 85 |   context: RequestContext,
 86 |   obsidianService: ObsidianRestApiService,
 87 |   vaultCacheService: VaultCacheService | undefined,
 88 | ): Promise<ObsidianDeleteNoteResponse> => {
 89 |   const { filePath: originalFilePath } = params;
 90 |   let effectiveFilePath = originalFilePath; // Track the path actually used for deletion
 91 | 
 92 |   logger.debug(
 93 |     `Processing obsidian_delete_note request for path: ${originalFilePath}`,
 94 |     context,
 95 |   );
 96 | 
 97 |   const shouldRetryNotFound = (err: unknown) =>
 98 |     err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
 99 | 
100 |   try {
101 |     // --- Attempt 1: Delete using the provided path (case-sensitive) ---
102 |     const deleteContext = {
103 |       ...context,
104 |       operation: "deleteFileAttempt",
105 |       caseSensitive: true,
106 |     };
107 |     logger.debug(
108 |       `Attempting to delete file (case-sensitive): ${originalFilePath}`,
109 |       deleteContext,
110 |     );
111 |     await retryWithDelay(
112 |       () => obsidianService.deleteFile(originalFilePath, deleteContext),
113 |       {
114 |         operationName: "deleteFile",
115 |         context: deleteContext,
116 |         maxRetries: 3,
117 |         delayMs: 300,
118 |         shouldRetry: shouldRetryNotFound,
119 |       },
120 |     );
121 | 
122 |     // If the above call succeeds, the file was deleted using the exact path.
123 |     logger.debug(
124 |       `Successfully deleted file using exact path: ${originalFilePath}`,
125 |       deleteContext,
126 |     );
127 |     if (vaultCacheService) {
128 |       await vaultCacheService.updateCacheForFile(
129 |         originalFilePath,
130 |         deleteContext,
131 |       );
132 |     }
133 |     return {
134 |       success: true,
135 |       message: `File '${originalFilePath}' deleted successfully.`,
136 |     };
137 |   } catch (error) {
138 |     // --- Attempt 2: Case-insensitive fallback if initial delete failed with NOT_FOUND ---
139 |     if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
140 |       logger.info(
141 |         `File not found with exact path: ${originalFilePath}. Attempting case-insensitive fallback for deletion.`,
142 |         context,
143 |       );
144 |       const fallbackContext = { ...context, operation: "deleteFileFallback" };
145 | 
146 |       try {
147 |         // Use POSIX path functions for vault path manipulation
148 |         const dirname = path.posix.dirname(originalFilePath);
149 |         const filenameLower = path.posix
150 |           .basename(originalFilePath)
151 |           .toLowerCase();
152 |         // Handle case where the file is in the vault root (dirname is '.')
153 |         const dirToList = dirname === "." ? "/" : dirname;
154 | 
155 |         logger.debug(
156 |           `Listing directory for fallback deletion: ${dirToList}`,
157 |           fallbackContext,
158 |         );
159 |         const filesInDir = await retryWithDelay(
160 |           () => obsidianService.listFiles(dirToList, fallbackContext),
161 |           {
162 |             operationName: "listFilesForDeleteFallback",
163 |             context: fallbackContext,
164 |             maxRetries: 3,
165 |             delayMs: 300,
166 |             shouldRetry: shouldRetryNotFound,
167 |           },
168 |         );
169 | 
170 |         // Filter directory listing for files matching the lowercase filename
171 |         const matches = filesInDir.filter(
172 |           (f) =>
173 |             !f.endsWith("/") && // Ensure it's a file
174 |             path.posix.basename(f).toLowerCase() === filenameLower,
175 |         );
176 | 
177 |         if (matches.length === 1) {
178 |           // Found exactly one case-insensitive match
179 |           const correctFilename = path.posix.basename(matches[0]);
180 |           effectiveFilePath = path.posix.join(dirname, correctFilename); // Update the path to use
181 |           logger.info(
182 |             `Found case-insensitive match: ${effectiveFilePath}. Retrying delete.`,
183 |             fallbackContext,
184 |           );
185 | 
186 |           // Retry deleting with the correctly cased path
187 |           const retryContext = {
188 |             ...fallbackContext,
189 |             subOperation: "retryDelete",
190 |             effectiveFilePath,
191 |           };
192 |           await retryWithDelay(
193 |             () => obsidianService.deleteFile(effectiveFilePath, retryContext),
194 |             {
195 |               operationName: "deleteFileFallback",
196 |               context: retryContext,
197 |               maxRetries: 3,
198 |               delayMs: 300,
199 |               shouldRetry: shouldRetryNotFound,
200 |             },
201 |           );
202 | 
203 |           logger.debug(
204 |             `Successfully deleted file using fallback path: ${effectiveFilePath}`,
205 |             retryContext,
206 |           );
207 |           if (vaultCacheService) {
208 |             await vaultCacheService.updateCacheForFile(
209 |               effectiveFilePath,
210 |               retryContext,
211 |             );
212 |           }
213 |           return {
214 |             success: true,
215 |             message: `File '${effectiveFilePath}' (found via case-insensitive match for '${originalFilePath}') deleted successfully.`,
216 |           };
217 |         } else if (matches.length > 1) {
218 |           // Ambiguous match: Multiple files match case-insensitively
219 |           const errorMsg = `Deletion failed: Ambiguous case-insensitive matches for '${originalFilePath}'. Found: [${matches.join(", ")}]. Cannot determine which file to delete.`;
220 |           logger.error(errorMsg, { ...fallbackContext, matches });
221 |           // Use CONFLICT code for ambiguity, as NOT_FOUND isn't quite right anymore.
222 |           throw new McpError(BaseErrorCode.CONFLICT, errorMsg, fallbackContext);
223 |         } else {
224 |           // No match found even with fallback
225 |           const errorMsg = `Deletion failed: File not found for '${originalFilePath}' (case-insensitive fallback also failed).`;
226 |           logger.error(errorMsg, fallbackContext);
227 |           // Stick with NOT_FOUND as the original error reason holds.
228 |           throw new McpError(
229 |             BaseErrorCode.NOT_FOUND,
230 |             errorMsg,
231 |             fallbackContext,
232 |           );
233 |         }
234 |       } catch (fallbackError) {
235 |         // Catch errors specifically from the fallback logic (e.g., listFiles error, retry delete error)
236 |         if (fallbackError instanceof McpError) {
237 |           // Log and re-throw known errors from fallback
238 |           logger.error(
239 |             `McpError during fallback deletion for ${originalFilePath}: ${fallbackError.message}`,
240 |             fallbackError,
241 |             fallbackContext,
242 |           );
243 |           throw fallbackError;
244 |         } else {
245 |           // Wrap unexpected fallback errors
246 |           const errorMessage = `Unexpected error during case-insensitive fallback deletion for ${originalFilePath}`;
247 |           logger.error(
248 |             errorMessage,
249 |             fallbackError instanceof Error ? fallbackError : undefined,
250 |             fallbackContext,
251 |           );
252 |           throw new McpError(
253 |             BaseErrorCode.INTERNAL_ERROR,
254 |             `${errorMessage}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
255 |             fallbackContext,
256 |           );
257 |         }
258 |       }
259 |     } else {
260 |       // Re-throw errors from the initial delete attempt that were not NOT_FOUND or McpError
261 |       if (error instanceof McpError) {
262 |         logger.error(
263 |           `McpError during initial delete attempt for ${originalFilePath}: ${error.message}`,
264 |           error,
265 |           context,
266 |         );
267 |         throw error;
268 |       } else {
269 |         const errorMessage = `Unexpected error deleting Obsidian file ${originalFilePath}`;
270 |         logger.error(
271 |           errorMessage,
272 |           error instanceof Error ? error : undefined,
273 |           context,
274 |         );
275 |         throw new McpError(
276 |           BaseErrorCode.INTERNAL_ERROR,
277 |           `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
278 |           context,
279 |         );
280 |       }
281 |     }
282 |   }
283 | };
284 | 
```
Page 2/5FirstPrevNextLast