#
tokens: 46890/50000 21/89 files (page 2/4)
lines: off (toggle) GitHub
raw markdown copy
This is page 2 of 4. Use http://codebase.md/cyanheads/obsidian-mcp-server?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/obsidianListNotesTool/registration.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * @fileoverview Registers the 'obsidian_list_notes' tool with the MCP server.
 * This file defines the tool's metadata and sets up the handler that links
 * the tool call to its core processing logic.
 * @module src/mcp-server/tools/obsidianListNotesTool/registration
 */

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  ErrorHandler,
  logger,
  RequestContext,
  requestContextService,
} from "../../../utils/index.js";
// Import necessary types, schema, and logic function from the logic file
import type {
  ObsidianListNotesInput,
  ObsidianListNotesResponse,
} from "./logic.js";
import {
  ObsidianListNotesInputSchema,
  processObsidianListNotes,
} from "./logic.js";

/**
 * Registers the 'obsidian_list_notes' tool with the MCP server.
 *
 * This tool lists the files and subdirectories within a specified directory
 * in the user's Obsidian vault. It supports optional filtering by file extension,
 * by a regular expression matching the entry name, and recursive listing up to a
 * specified depth.
 *
 * @param {McpServer} server - The MCP server instance to register the tool with.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 *   used to interact with the user's Obsidian vault.
 * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 * @throws {McpError} Throws an McpError if registration fails critically.
 */
export const registerObsidianListNotesTool = async (
  server: McpServer,
  obsidianService: ObsidianRestApiService, // Dependency injection for the Obsidian service
): Promise<void> => {
  const toolName = "obsidian_list_notes";
  const toolDescription =
    "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.";

  // Create a context specifically for the registration process.
  const registrationContext: RequestContext =
    requestContextService.createRequestContext({
      operation: "RegisterObsidianListNotesTool",
      toolName: toolName,
      module: "ObsidianListNotesRegistration", // Identify the module
    });

  logger.info(`Attempting to register tool: ${toolName}`, registrationContext);

  // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
  await ErrorHandler.tryCatch(
    async () => {
      // Use the high-level SDK method `server.tool` for registration.
      server.tool(
        toolName,
        toolDescription,
        ObsidianListNotesInputSchema.shape, // Provide the Zod schema shape for input definition.
        /**
         * The handler function executed when the 'obsidian_list_notes' tool is called by the client.
         *
         * @param {ObsidianListNotesInput} params - The input parameters received from the client,
         *   validated against the ObsidianListNotesInputSchema shape.
         * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
         *   containing either the successful response data (serialized JSON) or an error indication.
         */
        async (params: ObsidianListNotesInput) => {
          // Type matches the inferred input schema
          // Create a specific context for this handler invocation.
          const handlerContext: RequestContext =
            requestContextService.createRequestContext({
              parentContext: registrationContext, // Link to registration context
              operation: "HandleObsidianListNotesRequest",
              toolName: toolName,
              params: {
                // Log all relevant parameters for debugging
                dirPath: params.dirPath,
                fileExtensionFilter: params.fileExtensionFilter,
                nameRegexFilter: params.nameRegexFilter,
                recursionDepth: params.recursionDepth,
              },
            });
          logger.debug(`Handling '${toolName}' request`, handlerContext);

          // Wrap the core logic execution in a tryCatch block.
          return await ErrorHandler.tryCatch(
            async () => {
              // Delegate the actual file listing and filtering logic to the processing function.
              const response: ObsidianListNotesResponse =
                await processObsidianListNotes(
                  params,
                  handlerContext,
                  obsidianService,
                );
              logger.debug(
                `'${toolName}' processed successfully`,
                handlerContext,
              );

              // Format the successful response object from the logic function into the required MCP CallToolResult structure.
              return {
                content: [
                  {
                    type: "text", // Standard content type for structured JSON data
                    text: JSON.stringify(response, null, 2), // Pretty-print JSON
                  },
                ],
                isError: false, // Indicate successful execution
              };
            },
            {
              // Configuration for the inner error handler (processing logic).
              operation: `processing ${toolName} handler`,
              context: handlerContext,
              input: params, // Log the full input parameters if an error occurs.
              // Custom error mapping for consistent error reporting.
              errorMapper: (error: unknown) =>
                new McpError(
                  error instanceof McpError
                    ? error.code
                    : BaseErrorCode.INTERNAL_ERROR,
                  `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
                  { ...handlerContext }, // Include context
                ),
            },
          ); // End of inner ErrorHandler.tryCatch
        },
      ); // End of server.tool call

      logger.info(
        `Tool registered successfully: ${toolName}`,
        registrationContext,
      );
    },
    {
      // Configuration for the outer error handler (registration process).
      operation: `registering tool ${toolName}`,
      context: registrationContext,
      errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
      // Custom error mapping for registration failures.
      errorMapper: (error: unknown) =>
        new McpError(
          error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
          `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
          { ...registrationContext }, // Include context
        ),
      critical: true, // Treat registration failure as critical.
    },
  ); // End of outer ErrorHandler.tryCatch
};

```

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

```typescript
import { z } from "zod";
import { dump } from "js-yaml";
import {
  NoteJson,
  ObsidianRestApiService,
  PatchOptions,
  VaultCacheService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  logger,
  RequestContext,
  retryWithDelay,
} from "../../../utils/index.js";

// ====================================================================================
// Schema Definitions
// ====================================================================================

const ManageFrontmatterInputSchemaBase = z.object({
  filePath: z
    .string()
    .min(1)
    .describe(
      "The vault-relative path to the target note (e.g., 'Projects/Active/My Note.md').",
    ),
  operation: z
    .enum(["get", "set", "delete"])
    .describe(
      "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.",
    ),
  key: z
    .string()
    .min(1)
    .describe(
      "The name of the frontmatter key to target, such as 'status', 'tags', or 'aliases'.",
    ),
  value: z
    .any()
    .optional()
    .describe(
      "The value to assign when using the 'set' operation. Can be a string, number, boolean, array, or a JSON object.",
    ),
});

export const ObsidianManageFrontmatterInputSchemaShape =
  ManageFrontmatterInputSchemaBase.shape;

export const ManageFrontmatterInputSchema =
  ManageFrontmatterInputSchemaBase.refine(
    (data) => {
      if (data.operation === "set" && data.value === undefined) {
        return false;
      }
      return true;
    },
    {
      message: "A 'value' is required when the 'operation' is 'set'.",
      path: ["value"],
    },
  );

export type ObsidianManageFrontmatterInput = z.infer<
  typeof ManageFrontmatterInputSchema
>;

export interface ObsidianManageFrontmatterResponse {
  success: boolean;
  message: string;
  value?: any;
}

// ====================================================================================
// Core Logic Function
// ====================================================================================

export const processObsidianManageFrontmatter = async (
  params: ObsidianManageFrontmatterInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<ObsidianManageFrontmatterResponse> => {
  logger.debug(`Processing obsidian_manage_frontmatter request`, {
    ...context,
    operation: params.operation,
    filePath: params.filePath,
    key: params.key,
  });

  const { filePath, operation, key, value } = params;

  const shouldRetryNotFound = (err: unknown) =>
    err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;

  const getFileWithRetry = async (
    opContext: RequestContext,
    format: "json" | "markdown" = "json",
  ): Promise<NoteJson | string> => {
    return await retryWithDelay(
      () => obsidianService.getFileContent(filePath, format, opContext),
      {
        operationName: `getFileContentForFrontmatter`,
        context: opContext,
        maxRetries: 3,
        delayMs: 300,
        shouldRetry: shouldRetryNotFound,
      },
    );
  };

  switch (operation) {
    case "get": {
      const note = (await getFileWithRetry(context)) as NoteJson;
      const frontmatter = note.frontmatter ?? {};
      const retrievedValue = frontmatter[key];
      return {
        success: true,
        message: `Successfully retrieved key '${key}' from frontmatter.`,
        value: retrievedValue,
      };
    }

    case "set": {
      const patchOptions: PatchOptions = {
        operation: "replace",
        targetType: "frontmatter",
        target: key,
        createTargetIfMissing: true,
        contentType:
          typeof value === "object" ? "application/json" : "text/markdown",
      };
      const content =
        typeof value === "object" ? JSON.stringify(value) : String(value);

      await retryWithDelay(
        () =>
          obsidianService.patchFile(filePath, content, patchOptions, context),
        {
          operationName: `patchFileForFrontmatterSet`,
          context,
          maxRetries: 3,
          delayMs: 300,
          shouldRetry: shouldRetryNotFound,
        },
      );

      if (vaultCacheService) {
        await vaultCacheService.updateCacheForFile(filePath, context);
      }
      return {
        success: true,
        message: `Successfully set key '${key}' in frontmatter.`,
        value: { [key]: value },
      };
    }

    case "delete": {
      // Note on deletion strategy: The Obsidian REST API's PATCH endpoint for frontmatter
      // supports adding/updating keys but does not have a dedicated "delete key" operation.
      // Therefore, deletion is handled by reading the note content, parsing the frontmatter,
      // removing the key from the JavaScript object, and then overwriting the entire note
      // with the updated frontmatter block. This regex-based replacement is a workaround
      // for the current API limitations.
      const noteJson = (await getFileWithRetry(context, "json")) as NoteJson;
      const frontmatter = noteJson.frontmatter;

      if (!frontmatter || frontmatter[key] === undefined) {
        return {
          success: true,
          message: `Key '${key}' not found in frontmatter. No action taken.`,
          value: {},
        };
      }

      delete frontmatter[key];

      const noteContent = (await getFileWithRetry(
        context,
        "markdown",
      )) as string;

      const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
      const match = noteContent.match(frontmatterRegex);

      let newContent;
      const newFrontmatterString =
        Object.keys(frontmatter).length > 0 ? dump(frontmatter) : "";

      if (match) {
        // Frontmatter exists, replace it
        if (newFrontmatterString) {
          newContent = noteContent.replace(
            frontmatterRegex,
            `---\n${newFrontmatterString}---\n`,
          );
        } else {
          // If frontmatter is now empty, remove the block entirely
          newContent = noteContent.replace(frontmatterRegex, "");
        }
      } else {
        // This case should be rare given the initial check, but handle it defensively
        logger.warning(
          "Frontmatter key existed in JSON but block not found in markdown. No action taken.",
          context,
        );
        return {
          success: false,
          message: `Could not find frontmatter block to update, though key '${key}' was detected.`,
          value: {},
        };
      }

      await retryWithDelay(
        () => obsidianService.updateFileContent(filePath, newContent, context),
        {
          operationName: `updateFileForFrontmatterDelete`,
          context,
          maxRetries: 3,
          delayMs: 300,
          shouldRetry: shouldRetryNotFound,
        },
      );

      if (vaultCacheService) {
        await vaultCacheService.updateCacheForFile(filePath, context);
      }

      return {
        success: true,
        message: `Successfully deleted key '${key}' from frontmatter.`,
        value: {},
      };
    }

    default:
      throw new McpError(
        BaseErrorCode.VALIDATION_ERROR,
        `Invalid operation: ${operation}`,
        context,
      );
  }
};

```

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

```typescript
/**
 * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT) for Hono.
 *
 * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
 * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
 * It verifies the token's signature and expiration using the secret key defined
 * in the configuration (`config.mcpAuthSecretKey`).
 *
 * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type
 * is attached to `c.env.incoming.auth`. This direct attachment to the raw Node.js
 * request object is for compatibility with the underlying SDK transport, which is
 * not Hono-context-aware.
 * If the token is missing, invalid, or expired, it throws an `McpError`, which is
 * then handled by the centralized `httpErrorHandler`.
 *
 * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
 * @module src/mcp-server/transports/auth/strategies/jwt/jwtMiddleware
 */

import { HttpBindings } from "@hono/node-server";
import { Context, Next } from "hono";
import { jwtVerify } from "jose";
import { config, environment } from "../../../../../config/index.js";
import { logger, requestContextService } from "../../../../../utils/index.js";
import { BaseErrorCode, McpError } from "../../../../../types-global/errors.js";
import { authContext } from "../../core/authContext.js";

// Startup Validation: Validate secret key presence on module load.
if (config.mcpAuthMode === "jwt") {
  if (environment === "production" && !config.mcpAuthSecretKey) {
    logger.fatal(
      "CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment for JWT auth. Authentication cannot proceed securely.",
    );
    throw new Error(
      "MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.",
    );
  } else if (!config.mcpAuthSecretKey) {
    logger.warning(
      "MCP_AUTH_SECRET_KEY is not set. JWT auth middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.",
    );
  }
}

/**
 * Hono middleware for verifying JWT Bearer token authentication.
 * It attaches authentication info to `c.env.incoming.auth` for SDK compatibility with the node server.
 */
export async function mcpAuthMiddleware(
  c: Context<{ Bindings: HttpBindings }>,
  next: Next,
) {
  const context = requestContextService.createRequestContext({
    operation: "mcpAuthMiddleware",
    method: c.req.method,
    path: c.req.path,
  });
  logger.debug(
    "Running MCP Authentication Middleware (Bearer Token Validation)...",
    context,
  );

  const reqWithAuth = c.env.incoming;

  // If JWT auth is not enabled, skip the middleware.
  if (config.mcpAuthMode !== "jwt") {
    return await next();
  }

  // Development Mode Bypass
  if (!config.mcpAuthSecretKey) {
    if (environment !== "production") {
      logger.warning(
        "Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).",
        context,
      );
      reqWithAuth.auth = {
        token: "dev-mode-placeholder-token",
        clientId: "dev-client-id",
        scopes: ["dev-scope"],
      };
      const authInfo = reqWithAuth.auth;
      logger.debug("Dev mode auth object created.", {
        ...context,
        authDetails: authInfo,
      });
      return await authContext.run({ authInfo }, next);
    } else {
      logger.error(
        "FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.",
        context,
      );
      throw new McpError(
        BaseErrorCode.INTERNAL_ERROR,
        "Server configuration error: Authentication key missing.",
      );
    }
  }

  const secretKey = new TextEncoder().encode(config.mcpAuthSecretKey);
  const authHeader = c.req.header("Authorization");
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    logger.warning(
      "Authentication failed: Missing or malformed Authorization header (Bearer scheme required).",
      context,
    );
    throw new McpError(
      BaseErrorCode.UNAUTHORIZED,
      "Missing or invalid authentication token format.",
    );
  }

  const tokenParts = authHeader.split(" ");
  if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) {
    logger.warning("Authentication failed: Malformed Bearer token.", context);
    throw new McpError(
      BaseErrorCode.UNAUTHORIZED,
      "Malformed authentication token.",
    );
  }
  const rawToken = tokenParts[1];

  try {
    const { payload: decoded } = await jwtVerify(rawToken, secretKey);

    const clientIdFromToken =
      typeof decoded.cid === "string"
        ? decoded.cid
        : typeof decoded.client_id === "string"
          ? decoded.client_id
          : undefined;
    if (!clientIdFromToken) {
      logger.warning(
        "Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.",
        { ...context, jwtPayloadKeys: Object.keys(decoded) },
      );
      throw new McpError(
        BaseErrorCode.UNAUTHORIZED,
        "Invalid token, missing client identifier.",
      );
    }

    let scopesFromToken: string[] = [];
    if (
      Array.isArray(decoded.scp) &&
      decoded.scp.every((s) => typeof s === "string")
    ) {
      scopesFromToken = decoded.scp as string[];
    } else if (
      typeof decoded.scope === "string" &&
      decoded.scope.trim() !== ""
    ) {
      scopesFromToken = decoded.scope.split(" ").filter((s) => s);
      if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") {
        scopesFromToken = [decoded.scope.trim()];
      }
    }

    if (scopesFromToken.length === 0) {
      logger.warning(
        "Authentication failed: Token resulted in an empty scope array, and scopes are required.",
        { ...context, jwtPayloadKeys: Object.keys(decoded) },
      );
      throw new McpError(
        BaseErrorCode.UNAUTHORIZED,
        "Token must contain valid, non-empty scopes.",
      );
    }

    reqWithAuth.auth = {
      token: rawToken,
      clientId: clientIdFromToken,
      scopes: scopesFromToken,
    };

    const subClaimForLogging =
      typeof decoded.sub === "string" ? decoded.sub : undefined;
    const authInfo = reqWithAuth.auth;
    logger.debug("JWT verified successfully. AuthInfo attached to request.", {
      ...context,
      mcpSessionIdContext: subClaimForLogging,
      clientId: authInfo.clientId,
      scopes: authInfo.scopes,
    });
    await authContext.run({ authInfo }, next);
  } catch (error: unknown) {
    let errorMessage = "Invalid token.";
    let errorCode = BaseErrorCode.UNAUTHORIZED;

    if (error instanceof Error && error.name === "JWTExpired") {
      errorMessage = "Token expired.";
      logger.warning("Authentication failed: Token expired.", {
        ...context,
        errorName: error.name,
      });
    } else if (error instanceof Error) {
      errorMessage = `Invalid token: ${error.message}`;
      logger.warning(`Authentication failed: ${errorMessage}`, {
        ...context,
        errorName: error.name,
      });
    } else {
      errorMessage = "Unknown verification error.";
      errorCode = BaseErrorCode.INTERNAL_ERROR;
      logger.error(
        "Authentication failed: Unexpected non-error exception during token verification.",
        { ...context, error },
      );
    }
    throw new McpError(errorCode, errorMessage);
  }
}

```

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

```typescript
/**
 * @fileoverview Utilities for formatting Obsidian stat objects,
 * including timestamps and calculating estimated token counts.
 * @module src/utils/obsidian/obsidianStatUtils
 */

import { format } from "date-fns";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, RequestContext } from "../internal/index.js";
import { countTokens } from "../metrics/index.js";

/**
 * Default format string for timestamps, providing a human-readable date and time.
 * Example output: "08:40:00 PM | 05-02-2025"
 */
const DEFAULT_TIMESTAMP_FORMAT = "hh:mm:ss a | MM-dd-yyyy";

/**
 * Formats a Unix timestamp (in milliseconds since the epoch) into a human-readable string.
 *
 * @param {number | undefined | null} timestampMs - The Unix timestamp in milliseconds.
 * @param {RequestContext} context - The request context for logging and error reporting.
 * @param {string} [formatString=DEFAULT_TIMESTAMP_FORMAT] - Optional format string adhering to `date-fns` tokens.
 *   Defaults to 'hh:mm:ss a | MM-dd-yyyy'.
 * @returns {string} The formatted timestamp string.
 * @throws {McpError} If the provided `timestampMs` is invalid (e.g., undefined, null, not a finite number, or results in an invalid Date object).
 */
export function formatTimestamp(
  timestampMs: number | undefined | null,
  context: RequestContext,
  formatString: string = DEFAULT_TIMESTAMP_FORMAT,
): string {
  const operation = "formatTimestamp";
  if (
    timestampMs === undefined ||
    timestampMs === null ||
    !Number.isFinite(timestampMs)
  ) {
    const errorMessage = `Invalid timestamp provided for formatting: ${timestampMs}`;
    logger.warning(errorMessage, { ...context, operation });
    throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMessage, {
      ...context,
      operation,
    });
  }

  try {
    const date = new Date(timestampMs);
    if (isNaN(date.getTime())) {
      const errorMessage = `Timestamp resulted in an invalid date: ${timestampMs}`;
      logger.warning(errorMessage, { ...context, operation });
      throw new McpError(BaseErrorCode.VALIDATION_ERROR, errorMessage, {
        ...context,
        operation,
      });
    }
    return format(date, formatString);
  } catch (error) {
    const errorMessage = `Failed to format timestamp ${timestampMs}: ${error instanceof Error ? error.message : String(error)}`;
    logger.error(errorMessage, error instanceof Error ? error : undefined, {
      ...context,
      operation,
    });
    throw new McpError(BaseErrorCode.INTERNAL_ERROR, errorMessage, {
      ...context,
      operation,
      originalError: error instanceof Error ? error.message : String(error),
    });
  }
}

/**
 * Represents the structure of an Obsidian API Stat object.
 */
export interface ObsidianStat {
  /** Creation time as a Unix timestamp (milliseconds). */
  ctime: number;
  /** Modification time as a Unix timestamp (milliseconds). */
  mtime: number;
  /** File size in bytes. */
  size: number;
}

/**
 * Represents formatted timestamp information derived from an Obsidian Stat object.
 */
export interface FormattedTimestamps {
  /** Human-readable creation time string. */
  createdTime: string;
  /** Human-readable modification time string. */
  modifiedTime: string;
}

/**
 * Formats the `ctime` (creation time) and `mtime` (modification time) from an
 * Obsidian API Stat object into human-readable strings.
 *
 * @param {ObsidianStat | undefined | null} stat - The Stat object from the Obsidian API.
 *   If undefined or null, placeholder strings ('N/A') are returned.
 * @param {RequestContext} context - The request context for logging and error reporting.
 * @returns {FormattedTimestamps} An object containing `createdTime` and `modifiedTime` strings.
 */
export function formatStatTimestamps(
  stat: ObsidianStat | undefined | null,
  context: RequestContext,
): FormattedTimestamps {
  const operation = "formatStatTimestamps";
  if (!stat) {
    logger.debug(
      "Stat object is undefined or null, returning N/A for timestamps.",
      { ...context, operation },
    );
    return {
      createdTime: "N/A",
      modifiedTime: "N/A",
    };
  }
  try {
    return {
      createdTime: formatTimestamp(stat.ctime, context),
      modifiedTime: formatTimestamp(stat.mtime, context),
    };
  } catch (error) {
    // Log the error from formatTimestamp if it occurs during this higher-level operation
    logger.error(
      `Error formatting timestamps within formatStatTimestamps for ctime: ${stat.ctime}, mtime: ${stat.mtime}`,
      error instanceof Error ? error : undefined,
      { ...context, operation },
    );
    // Return N/A as a fallback if formatting fails at this stage
    return {
      createdTime: "N/A",
      modifiedTime: "N/A",
    };
  }
}

/**
 * Represents a fully formatted stat object, including human-readable timestamps
 * and an estimated token count for the file content.
 */
export interface FormattedStatWithTokenCount extends FormattedTimestamps {
  /** Estimated number of tokens in the file content. -1 if counting failed or content was empty. */
  tokenCountEstimate: number;
}

/**
 * Creates a formatted stat object that includes human-readable timestamps
 * (creation and modification times) and an estimated token count for the provided file content.
 *
 * @param {ObsidianStat | null | undefined} stat - The original Stat object from the Obsidian API.
 *   If null or undefined, the function will return the input value (null or undefined).
 * @param {string} content - The file content string from which to calculate the token count.
 * @param {RequestContext} context - The request context for logging and error reporting.
 * @returns {Promise<FormattedStatWithTokenCount | null | undefined>} A promise resolving to an object
 *   containing `createdTime`, `modifiedTime`, and `tokenCountEstimate`. Returns `null` or `undefined`
 *   if the input `stat` object was `null` or `undefined`, respectively.
 */
export async function createFormattedStatWithTokenCount(
  stat: ObsidianStat | null | undefined,
  content: string,
  context: RequestContext,
): Promise<FormattedStatWithTokenCount | null | undefined> {
  const operation = "createFormattedStatWithTokenCount";
  if (stat === null || stat === undefined) {
    logger.debug("Input stat is null or undefined, returning as is.", {
      ...context,
      operation,
    });
    return stat; // Return original null/undefined
  }

  const formattedTimestamps = formatStatTimestamps(stat, context);
  let tokenCountEstimate = -1; // Default: indicates error or empty content

  if (content && content.trim().length > 0) {
    try {
      tokenCountEstimate = await countTokens(content, context);
    } catch (tokenError) {
      logger.warning(
        `Failed to count tokens for stat object. Error: ${tokenError instanceof Error ? tokenError.message : String(tokenError)}`,
        {
          ...context,
          operation,
          originalError:
            tokenError instanceof Error
              ? tokenError.message
              : String(tokenError),
        },
      );
      // tokenCountEstimate remains -1
    }
  } else {
    logger.debug(
      "Content is empty or whitespace-only, setting tokenCountEstimate to 0.",
      { ...context, operation },
    );
    tokenCountEstimate = 0;
  }

  return {
    createdTime: formattedTimestamps.createdTime,
    modifiedTime: formattedTimestamps.modifiedTime,
    tokenCountEstimate: tokenCountEstimate,
  };
}

```

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

```typescript
import { z } from "zod";
import { dump } from "js-yaml";
import {
  NoteJson,
  ObsidianRestApiService,
  VaultCacheService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  logger,
  RequestContext,
  retryWithDelay,
} from "../../../utils/index.js";
import { sanitization } from "../../../utils/security/sanitization.js";

// ====================================================================================
// Schema Definitions
// ====================================================================================

const ManageTagsInputSchemaBase = z.object({
  filePath: z
    .string()
    .min(1)
    .describe(
      "The vault-relative path to the target note (e.g., 'Journal/2024-06-12.md').",
    ),
  operation: z
    .enum(["add", "remove", "list"])
    .describe(
      "The tag operation to perform: 'add' to include new tags, 'remove' to delete existing tags, or 'list' to view all current tags.",
    ),
  tags: z
    .array(z.string())
    .describe(
      "An array of tag names to be processed. The '#' prefix should be omitted (e.g., use 'project/active', not '#project/active').",
    ),
});

export const ObsidianManageTagsInputSchemaShape =
  ManageTagsInputSchemaBase.shape;
export const ManageTagsInputSchema = ManageTagsInputSchemaBase;

export type ObsidianManageTagsInput = z.infer<typeof ManageTagsInputSchema>;

export interface ObsidianManageTagsResponse {
  success: boolean;
  message: string;
  currentTags: string[];
}

// ====================================================================================
// Core Logic Function
// ====================================================================================

export const processObsidianManageTags = async (
  params: ObsidianManageTagsInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<ObsidianManageTagsResponse> => {
  logger.debug(`Processing obsidian_manage_tags request`, {
    ...context,
    ...params,
  });

  const { filePath, operation, tags: inputTags } = params;
  const sanitizedTags = inputTags.map((t) => sanitization.sanitizeTagName(t));

  const shouldRetryNotFound = (err: unknown) =>
    err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;

  const getFileWithRetry = async (
    opContext: RequestContext,
    format: "json" | "markdown",
  ): Promise<NoteJson | string> => {
    return await retryWithDelay(
      () => obsidianService.getFileContent(filePath, format, opContext),
      {
        operationName: `getFileContentForTagManagement`,
        context: opContext,
        maxRetries: 3,
        delayMs: 300,
        shouldRetry: shouldRetryNotFound,
      },
    );
  };

  const initialNote = (await getFileWithRetry(context, "json")) as NoteJson;
  const currentTags = initialNote.tags;

  switch (operation) {
    case "list": {
      return {
        success: true,
        message: "Successfully listed all tags.",
        currentTags: currentTags,
      };
    }

    case "add": {
      const tagsToAdd = sanitizedTags.filter((t) => !currentTags.includes(t));
      if (tagsToAdd.length === 0) {
        return {
          success: true,
          message:
            "No new tags to add; all provided tags already exist in the note.",
          currentTags: currentTags,
        };
      }

      const frontmatter = initialNote.frontmatter ?? {};
      const frontmatterTags: string[] = Array.isArray(frontmatter.tags)
        ? frontmatter.tags
        : [];
      const newFrontmatterTags = [
        ...new Set([...frontmatterTags, ...tagsToAdd]),
      ];
      frontmatter.tags = newFrontmatterTags;

      const noteContent = (await getFileWithRetry(
        context,
        "markdown",
      )) as string;
      const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
      const match = noteContent.match(frontmatterRegex);
      const newFrontmatterString = dump(frontmatter);

      let newContent;
      if (match) {
        newContent = noteContent.replace(
          frontmatterRegex,
          `---\n${newFrontmatterString}---\n`,
        );
      } else {
        newContent = `---\n${newFrontmatterString}---\n\n${noteContent}`;
      }

      await retryWithDelay(
        () => obsidianService.updateFileContent(filePath, newContent, context),
        {
          operationName: `updateFileForTagAdd`,
          context,
          maxRetries: 3,
          delayMs: 300,
          shouldRetry: shouldRetryNotFound,
        },
      );

      if (vaultCacheService) {
        await vaultCacheService.updateCacheForFile(filePath, context);
      }

      const finalTags = [...new Set([...currentTags, ...tagsToAdd])];
      return {
        success: true,
        message: `Successfully added tags: ${tagsToAdd.join(", ")}.`,
        currentTags: finalTags,
      };
    }

    case "remove": {
      const tagsToRemove = sanitizedTags.filter((t) => currentTags.includes(t));
      if (tagsToRemove.length === 0) {
        return {
          success: true,
          message:
            "No tags to remove; none of the provided tags exist in the note.",
          currentTags: currentTags,
        };
      }

      let noteContent = (await getFileWithRetry(context, "markdown")) as string;
      const frontmatter = initialNote.frontmatter ?? {};
      let frontmatterTags: string[] = Array.isArray(frontmatter.tags)
        ? frontmatter.tags
        : [];
      const newFrontmatterTags = frontmatterTags.filter(
        (t) => !tagsToRemove.includes(t),
      );
      let frontmatterModified =
        newFrontmatterTags.length !== frontmatterTags.length;

      if (frontmatterModified) {
        frontmatter.tags = newFrontmatterTags;
        if (newFrontmatterTags.length === 0) {
          delete frontmatter.tags;
        }
      }

      const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
      const match = noteContent.match(frontmatterRegex);

      if (frontmatterModified && match) {
        const newFrontmatterString =
          Object.keys(frontmatter).length > 0 ? dump(frontmatter) : "";
        if (newFrontmatterString) {
          noteContent = noteContent.replace(
            frontmatterRegex,
            `---\n${newFrontmatterString}---\n`,
          );
        } else {
          noteContent = noteContent.replace(frontmatterRegex, "");
        }
      }

      let inlineModified = false;
      for (const tag of tagsToRemove) {
        const regex = new RegExp(`(^|[^\\w-#])#${tag}\\b`, "g");
        if (regex.test(noteContent)) {
          noteContent = noteContent.replace(regex, "$1");
          inlineModified = true;
        }
      }

      if (frontmatterModified || inlineModified) {
        await retryWithDelay(
          () =>
            obsidianService.updateFileContent(filePath, noteContent, context),
          {
            operationName: `updateFileContentForTagRemove`,
            context,
            maxRetries: 3,
            delayMs: 300,
            shouldRetry: shouldRetryNotFound,
          },
        );
      }

      if (vaultCacheService) {
        await vaultCacheService.updateCacheForFile(filePath, context);
      }

      const finalTags = currentTags.filter((t) => !tagsToRemove.includes(t));
      return {
        success: true,
        message: `Successfully removed tags: ${tagsToRemove.join(", ")}.`,
        currentTags: finalTags,
      };
    }

    default:
      throw new McpError(
        BaseErrorCode.VALIDATION_ERROR,
        `Invalid operation: ${operation}`,
        context,
      );
  }
};

```

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

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  ErrorHandler,
  logger,
  RequestContext,
  requestContextService,
} from "../../../utils/index.js";
// Import necessary types, schema, and logic function from the logic file
import type {
  ObsidianReadNoteInput,
  ObsidianReadNoteResponse,
} from "./logic.js";
import {
  ObsidianReadNoteInputSchema,
  processObsidianReadNote,
} from "./logic.js";

/**
 * Registers the 'obsidian_read_note' tool with the MCP server.
 *
 * This tool retrieves the content and optionally metadata of a specified file
 * within the user's Obsidian vault. It supports specifying the output format
 * ('markdown' or 'json') and includes a case-insensitive fallback mechanism
 * if the exact file path is not found initially.
 *
 * The response is a JSON string containing the file content in the requested format
 * and optionally formatted file statistics (timestamps, token count).
 *
 * @param {McpServer} server - The MCP server instance to register the tool with.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 *   used to interact with the user's Obsidian vault.
 * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 * @throws {McpError} Throws an McpError if registration fails critically.
 */
export const registerObsidianReadNoteTool = async (
  server: McpServer,
  obsidianService: ObsidianRestApiService, // Dependency injection for the Obsidian service
): Promise<void> => {
  const toolName = "obsidian_read_note";
  const toolDescription =
    "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'.";

  // Create a context specifically for the registration process.
  const registrationContext: RequestContext =
    requestContextService.createRequestContext({
      operation: "RegisterObsidianReadNoteTool",
      toolName: toolName,
      module: "ObsidianReadNoteRegistration", // Identify the module
    });

  logger.info(`Attempting to register tool: ${toolName}`, registrationContext);

  // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
  await ErrorHandler.tryCatch(
    async () => {
      // Use the high-level SDK method `server.tool` for registration.
      // It handles schema generation from the shape, basic validation, and routing.
      server.tool(
        toolName,
        toolDescription,
        ObsidianReadNoteInputSchema.shape, // Provide the Zod schema shape for input definition.
        /**
         * The handler function executed when the 'obsidian_read_note' tool is called by the client.
         *
         * @param {ObsidianReadNoteInput} params - The input parameters received from the client,
         *   validated against the ObsidianReadNoteInputSchema shape. Note: The handler receives the raw input;
         *   stricter validation against the full schema should happen inside if needed, though in this case,
         *   the shape and the full schema are identical.
         * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
         *   containing either the successful response data (serialized JSON) or an error indication.
         */
        async (params: ObsidianReadNoteInput) => {
          // Type matches the inferred input schema
          // Create a specific context for this handler invocation.
          const handlerContext: RequestContext =
            requestContextService.createRequestContext({
              parentContext: registrationContext, // Link to registration context
              operation: "HandleObsidianReadNoteRequest",
              toolName: toolName,
              params: {
                // Log key parameters for debugging
                filePath: params.filePath,
                format: params.format,
                includeStat: params.includeStat,
              },
            });
          logger.debug(`Handling '${toolName}' request`, handlerContext);

          // Wrap the core logic execution in a tryCatch block.
          return await ErrorHandler.tryCatch(
            async () => {
              // Delegate the actual file reading logic to the dedicated processing function.
              // Pass the (already shape-validated) parameters, context, and the Obsidian service.
              // The process function handles the refined validation internally if needed, but here shape = refined.
              const response: ObsidianReadNoteResponse =
                await processObsidianReadNote(
                  params, // Pass params directly as shape matches refined schema
                  handlerContext,
                  obsidianService,
                );
              logger.debug(
                `'${toolName}' processed successfully`,
                handlerContext,
              );

              // Format the successful response object from the logic function into the required MCP CallToolResult structure.
              // The entire response object (containing content and optional stat) is serialized to JSON.
              return {
                content: [
                  {
                    type: "text", // Standard content type for structured JSON data
                    text: JSON.stringify(response, null, 2), // Pretty-print JSON
                  },
                ],
                isError: false, // Indicate successful execution
              };
            },
            {
              // Configuration for the inner error handler (processing logic).
              operation: `processing ${toolName} handler`,
              context: handlerContext,
              input: params, // Log the full input parameters if an error occurs.
              // Custom error mapping for consistent error reporting.
              errorMapper: (error: unknown) =>
                new McpError(
                  error instanceof McpError
                    ? error.code
                    : BaseErrorCode.INTERNAL_ERROR,
                  `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
                  { ...handlerContext }, // Include context
                ),
            },
          ); // End of inner ErrorHandler.tryCatch
        },
      ); // End of server.tool call

      logger.info(
        `Tool registered successfully: ${toolName}`,
        registrationContext,
      );
    },
    {
      // Configuration for the outer error handler (registration process).
      operation: `registering tool ${toolName}`,
      context: registrationContext,
      errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
      // Custom error mapping for registration failures.
      errorMapper: (error: unknown) =>
        new McpError(
          error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
          `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
          { ...registrationContext }, // Include context
        ),
      critical: true, // Treat registration failure as critical.
    },
  ); // End of outer ErrorHandler.tryCatch
};

```

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

```typescript
#!/usr/bin/env node

/**
 * @fileoverview Generates a visual tree representation of the project's directory structure.
 * @module scripts/tree
 *   Respects .gitignore patterns and common exclusions (e.g., node_modules).
 *   Saves the tree to a markdown file (default: docs/tree.md).
 *   Supports custom output path and depth limitation.
 *   Ensures all file operations are within the project root for security.
 *
 * @example
 * // Generate tree with default settings:
 * // npm run tree
 *
 * @example
 * // Specify custom output path and depth:
 * // ts-node --esm scripts/tree.ts ./documentation/structure.md --depth=3
 */

import fs from "fs/promises";
import path from "path";
import ignore from "ignore"; // Import the 'ignore' library

// Get the type of the instance returned by ignore()
type Ignore = ReturnType<typeof ignore>;

const projectRoot = process.cwd();
let outputPathArg = "docs/tree.md"; // Default output path
let maxDepthArg = Infinity;

const args = process.argv.slice(2);
if (args.includes("--help")) {
  console.log(`
Generate Tree - Project directory structure visualization tool

Usage:
  ts-node --esm scripts/tree.ts [output-path] [--depth=<number>] [--help]

Options:
  output-path      Custom file path for the tree output (relative to project root, default: docs/tree.md)
  --depth=<number> Maximum directory depth to display (default: unlimited)
  --help           Show this help message
`);
  process.exit(0);
}

args.forEach((arg) => {
  if (arg.startsWith("--depth=")) {
    const depthValue = parseInt(arg.split("=")[1], 10);
    if (!isNaN(depthValue) && depthValue >= 0) {
      maxDepthArg = depthValue;
    } else {
      console.warn(`Invalid depth value: "${arg}". Using unlimited depth.`);
    }
  } else if (!arg.startsWith("--")) {
    outputPathArg = arg;
  }
});

const DEFAULT_IGNORE_PATTERNS: string[] = [
  ".git",
  "node_modules",
  ".DS_Store",
  "dist",
  "build",
  "logs",
];

/**
 * Loads and parses patterns from the .gitignore file at the project root,
 * and combines them with default ignore patterns.
 * @returns A promise resolving to an Ignore instance from the 'ignore' library.
 */
async function loadIgnoreHandler(): Promise<Ignore> {
  const ig = ignore();
  ig.add(DEFAULT_IGNORE_PATTERNS); // Add default patterns first

  const gitignorePath = path.join(projectRoot, ".gitignore");
  try {
    // Security: Ensure we read only from within the project root
    if (!path.resolve(gitignorePath).startsWith(projectRoot + path.sep)) {
      console.warn(
        "Warning: Attempted to read .gitignore outside project root. Using default ignore patterns only.",
      );
      return ig;
    }
    const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
    ig.add(gitignoreContent); // Add patterns from .gitignore file
  } catch (error: any) {
    if (error.code === "ENOENT") {
      console.warn(
        "Info: No .gitignore file found at project root. Using default ignore patterns only.",
      );
    } else {
      console.error(`Error reading .gitignore: ${error.message}`);
    }
  }
  return ig;
}

/**
 * Checks if a given path should be ignored.
 * @param entryPath - The absolute path to the file or directory entry.
 * @param ig - An Ignore instance from the 'ignore' library.
 * @returns True if the path should be ignored, false otherwise.
 */
function isIgnored(entryPath: string, ig: Ignore): boolean {
  const relativePath = path.relative(projectRoot, entryPath);
  // The 'ignore' library expects POSIX-style paths (with /) even on Windows
  const posixRelativePath = relativePath.split(path.sep).join(path.posix.sep);
  return ig.ignores(posixRelativePath);
}

/**
 * Recursively generates a string representation of the directory tree.
 * @param dir - The absolute path of the directory to traverse.
 * @param ig - An Ignore instance.
 * @param prefix - String prefix for formatting the tree lines.
 * @param currentDepth - Current depth of traversal.
 * @returns A promise resolving to the tree string.
 */
async function generateTree(
  dir: string,
  ig: Ignore,
  prefix = "",
  currentDepth = 0,
): Promise<string> {
  const resolvedDir = path.resolve(dir);
  if (
    !resolvedDir.startsWith(projectRoot + path.sep) &&
    resolvedDir !== projectRoot
  ) {
    console.warn(
      `Security: Skipping directory outside project root: ${resolvedDir}`,
    );
    return "";
  }

  if (currentDepth > maxDepthArg) {
    return "";
  }

  let entries;
  try {
    entries = await fs.readdir(resolvedDir, { withFileTypes: true });
  } catch (error: any) {
    console.error(`Error reading directory ${resolvedDir}: ${error.message}`);
    return "";
  }

  let output = "";
  const filteredEntries = entries
    .filter((entry) => !isIgnored(path.join(resolvedDir, entry.name), ig))
    .sort((a, b) => {
      if (a.isDirectory() && !b.isDirectory()) return -1;
      if (!a.isDirectory() && b.isDirectory()) return 1;
      return a.name.localeCompare(b.name);
    });

  for (let i = 0; i < filteredEntries.length; i++) {
    const entry = filteredEntries[i];
    const isLastEntry = i === filteredEntries.length - 1;
    const connector = isLastEntry ? "└── " : "├── ";
    const newPrefix = prefix + (isLastEntry ? "    " : "│   ");

    output += prefix + connector + entry.name + "\n";

    if (entry.isDirectory()) {
      output += await generateTree(
        path.join(resolvedDir, entry.name),
        ig,
        newPrefix,
        currentDepth + 1,
      );
    }
  }
  return output;
}

/**
 * Main function to orchestrate loading ignore patterns, generating the tree,
 * and writing it to the specified output file.
 */
const writeTreeToFile = async (): Promise<void> => {
  try {
    const projectName = path.basename(projectRoot);
    const ignoreHandler = await loadIgnoreHandler(); // Get the Ignore instance
    const resolvedOutputFile = path.resolve(projectRoot, outputPathArg);

    // Security Validation for Output Path
    if (!resolvedOutputFile.startsWith(projectRoot + path.sep)) {
      console.error(
        `Error: Output path "${outputPathArg}" resolves outside the project directory: ${resolvedOutputFile}. Aborting.`,
      );
      process.exit(1);
    }
    const resolvedOutputDir = path.dirname(resolvedOutputFile);
    if (
      !resolvedOutputDir.startsWith(projectRoot + path.sep) &&
      resolvedOutputDir !== projectRoot
    ) {
      console.error(
        `Error: Output directory "${resolvedOutputDir}" is outside the project directory. Aborting.`,
      );
      process.exit(1);
    }

    console.log(`Generating directory tree for project: ${projectName}`);
    console.log(`Output will be saved to: ${resolvedOutputFile}`);
    if (maxDepthArg !== Infinity) {
      console.log(`Maximum depth set to: ${maxDepthArg}`);
    }

    const treeContent = await generateTree(projectRoot, ignoreHandler, "", 0); // Pass the Ignore instance

    try {
      await fs.access(resolvedOutputDir);
    } catch {
      console.log(`Output directory not found. Creating: ${resolvedOutputDir}`);
      await fs.mkdir(resolvedOutputDir, { recursive: true });
    }

    const timestamp = new Date()
      .toISOString()
      .replace(/T/, " ")
      .replace(/\..+/, "");
    const fileHeader = `# ${projectName} - Directory Structure\n\nGenerated on: ${timestamp}\n`;
    const depthInfo =
      maxDepthArg !== Infinity
        ? `\n_Depth limited to ${maxDepthArg} levels_\n\n`
        : "\n";
    const treeBlock = `\`\`\`\n${projectName}\n${treeContent}\`\`\`\n`;
    const fileFooter = `\n_Note: This tree excludes files and directories matched by .gitignore and default patterns._\n`;
    const finalContent = fileHeader + depthInfo + treeBlock + fileFooter;

    await fs.writeFile(resolvedOutputFile, finalContent);
    console.log(
      `Successfully generated tree structure in: ${resolvedOutputFile}`,
    );
  } catch (error) {
    console.error(
      `Error generating tree: ${error instanceof Error ? error.message : String(error)}`,
    );
    process.exit(1);
  }
};

writeTreeToFile();

```

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

```markdown
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.7] - 2025-06-20

### Changed

- **Package Update**: Fixed README & incremented version to 2.0.7 to ensure the latest changes are reflected in the npm package.

## [2.0.6] - 2025-06-20

### Changed

- **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.
- **Dependency Updates**: Updated all dependencies to their latest versions.
- **Documentation Improvements**: Updated `.clinerules` to reflect the new tool names and ensure all documentation is current.

## [2.0.5] - 2025-06-20

### Changed

- **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.
- **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.
- **Dependency Updates**: Updated all dependencies to their latest versions.
- **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!

## [2.0.4] - 2025-06-13

### Added

- **Recursive File Listing**: The `obsidian_list_files` tool now supports recursive listing of directories with a `recursionDepth` parameter.

### Changed

- **Documentation**:
  - Consolidated tool specifications into `obsidian_mcp_tools_spec.md`.
  - Updated `.clinerules` with a detailed logger implementation example for the agent.
  - Updated the repository's directory tree documentation.

## [2.0.3] - 2025-06-12

### Fixed

- **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.

## [2.0.2] - 2025-06-12

### Fixed

- **NPM Package Version**: Bad npm package. Bumping to v2.0.2 for publishing.

## [2.0.1] - 2025-06-12

### Added

- **Enhanced Documentation**:
  - Added a warning to the `VaultCacheService` documentation about its potential for high memory usage on large vaults.
  - Added a code comment in `obsidianManageFrontmatterTool` to clarify the regex-based key deletion strategy.

### Changed

- **Improved SSL Handling**: The `OBSIDIAN_VERIFY_SSL` environment variable is now correctly parsed as a boolean, ensuring more reliable SSL verification behavior.
- **API Service Refactoring**: Simplified the `httpsAgent` handling within the `ObsidianRestApiService` to improve code clarity and remove redundant agent creation on each request.

### Fixed

- **Path Import Correction**: Corrected a path import in the `obsidianGlobalSearchTool` to use `node:path/posix` for better cross-platform compatibility.

## [2.0.0] - 2025-06-12

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.

### Added

- **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.
- **Hono HTTP Transport**: The HTTP transport has been migrated from Express to Hono, offering a more lightweight and performant server.
- **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.
- **Advanced Authentication**:
  - Added support for **OAuth 2.1** bearer token validation alongside the existing secret key-based JWTs.
  - Introduced `authContext` using `AsyncLocalStorage` for secure, request-scoped access to authentication details.
- **New Tools**:
  - `obsidian_delete_file`: A new tool to permanently delete files from the vault.
  - `obsidian_search_replace`: A powerful new tool to perform search and replace operations with regex support.
- **Enhanced Utilities**:
  - **Request Context**: A robust request context system (`requestContextService`) for improved logging and tracing.
  - **Error Handling**: A centralized `ErrorHandler` for consistent and detailed error reporting.
  - **Async Utilities**: A `retryWithDelay` utility is now used across the application to make API calls more resilient.
- **New Development Scripts**: Added `docs:generate` (for TypeDoc) and `inspect:stdio`/`inspect:http` (for MCP Inspector) to `package.json`.

### Changed

- **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`).
- **Tool Consolidation and Enhancement**: The toolset has been redesigned for clarity and power:
  - `obsidian_list_files` replaces `obsidian_list_files_in_vault` and `obsidian_list_files_in_dir`, offering more flexible filtering.
  - `obsidian_read_file` replaces `obsidian_get_file_contents` and now supports returning content as structured JSON.
  - `obsidian_update_file` replaces `obsidian_append_content` and `obsidian_update_content` with explicit modes (`append`, `prepend`, `overwrite`).
  - `obsidian_global_search` replaces `obsidian_find_in_file` with added support for path/date filtering and pagination.
  - `obsidian_manage_frontmatter` replaces `obsidian_get_properties` and `obsidian_update_properties` with atomic get/set/delete operations.
  - `obsidian_manage_tags` replaces `obsidian_get_tags` and now manages both frontmatter and inline tags.
- **Configuration Overhaul**: Environment variables have been renamed for consistency and clarity.
  - `OBSIDIAN_BASE_URL` now consolidates protocol, host, and port.
  - New variables like `MCP_TRANSPORT_TYPE`, `MCP_LOG_LEVEL`, and `MCP_AUTH_SECRET_KEY` have been introduced.
- **Dependency Updates**: All dependencies, including the MCP SDK, have been updated to their latest stable versions.
- **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.

### Removed

- **Removed Tools**: The following tools from version 1.x have been removed and their functionality integrated into the new, more comprehensive tools:
  - `obsidian_list_files_in_vault`
  - `obsidian_list_files_in_dir`
  - `obsidian_get_file_contents`
  - `obsidian_append_content`
  - `obsidian_update_content`
  - `obsidian_find_in_file`
  - `obsidian_complex_search` (path-based searching is now a filter in `obsidian_global_search`)
  - `obsidian_get_tags`
  - `obsidian_get_properties`
  - `obsidian_update_properties`
- **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.
- **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.

```

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

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
  ObsidianRestApiService,
  VaultCacheService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  ErrorHandler,
  logger,
  RequestContext,
  requestContextService,
} from "../../../utils/index.js";
// Import types for handler signature and response structure
import type {
  ObsidianUpdateNoteRegistrationInput,
  ObsidianUpdateNoteResponse,
} from "./logic.js";
// Import the Zod schema for validation and the core processing logic
import {
  ObsidianUpdateNoteInputSchema,
  ObsidianUpdateNoteInputSchemaShape,
  processObsidianUpdateNote,
} from "./logic.js";

/**
 * Registers the 'obsidian_update_note' tool with the MCP server.
 *
 * This tool allows modification of Obsidian notes (specified by file path,
 * the active file, or a periodic note) using whole-file operations:
 * 'append', 'prepend', or 'overwrite'. It includes options for creating
 * missing files/targets and controlling overwrite behavior.
 *
 * The tool returns a JSON string containing the operation status, a message,
 * a formatted timestamp of the operation, file statistics (stat), and
 * optionally the final content of the modified file.
 *
 * @param {McpServer} server - The MCP server instance to register the tool with.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 *   used to interact with the user's Obsidian vault.
 * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 * @throws {McpError} Throws an McpError if registration fails critically.
 */
export const registerObsidianUpdateNoteTool = async (
  server: McpServer,
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<void> => {
  const toolName = "obsidian_update_note";
  const toolDescription =
    "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.";

  // Create a context for the registration process itself for better traceability.
  const registrationContext: RequestContext =
    requestContextService.createRequestContext({
      operation: "RegisterObsidianUpdateNoteTool",
      toolName: toolName,
      module: "ObsidianUpdateNoteRegistration", // Identify the module performing registration
    });

  logger.info(`Attempting to register tool: ${toolName}`, registrationContext);

  // Wrap the registration in a tryCatch block for robust error handling during setup.
  await ErrorHandler.tryCatch(
    async () => {
      // Use the high-level SDK method for tool registration.
      // This handles schema generation, validation, and routing automatically.
      server.tool(
        toolName,
        toolDescription,
        ObsidianUpdateNoteInputSchemaShape, // Provide the Zod schema shape for input validation.
        /**
         * The handler function executed when the 'obsidian_update_note' tool is called.
         *
         * @param {ObsidianUpdateNoteRegistrationInput} params - The raw input parameters received from the client,
         *   matching the structure defined by ObsidianUpdateNoteInputSchemaShape.
         * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
         *   containing either the successful response data or an error indication.
         */
        async (params: ObsidianUpdateNoteRegistrationInput) => {
          // Create a specific context for this handler invocation.
          const handlerContext: RequestContext =
            requestContextService.createRequestContext({
              parentContext: registrationContext, // Link to the registration context
              operation: "HandleObsidianUpdateNoteRequest",
              toolName: toolName,
              params: {
                // Log key parameters for easier debugging, content is omitted for brevity/security
                targetType: params.targetType,
                modificationType: params.modificationType, // Note: Will always be 'wholeFile' due to schema
                targetIdentifier: params.targetIdentifier,
                wholeFileMode: params.wholeFileMode,
                createIfNeeded: params.createIfNeeded,
                overwriteIfExists: params.overwriteIfExists,
                returnContent: params.returnContent,
              },
            });
          logger.debug(
            `Handling '${toolName}' request (wholeFile mode)`,
            handlerContext,
          );

          // Wrap the core logic execution in a tryCatch block for handling errors during processing.
          return await ErrorHandler.tryCatch(
            async () => {
              // Explicitly parse and validate the incoming parameters using the full Zod schema.
              // This ensures type safety and adherence to constraints defined in logic.ts.
              // While server.tool performs initial validation based on the shape,
              // this step applies any stricter rules or refinements from the full schema.
              const validatedParams =
                ObsidianUpdateNoteInputSchema.parse(params);

              // Delegate the actual file update logic to the dedicated processing function.
              // Pass the validated parameters, the handler context, and the Obsidian service instance.
              const response: ObsidianUpdateNoteResponse =
                await processObsidianUpdateNote(
                  validatedParams,
                  handlerContext,
                  obsidianService,
                  vaultCacheService,
                );
              logger.debug(
                `'${toolName}' (wholeFile mode) processed successfully`,
                handlerContext,
              );

              // Format the successful response from the logic function into the MCP CallToolResult structure.
              // The response object (containing status, message, timestamp, stat, etc.) is serialized to JSON.
              return {
                content: [
                  {
                    type: "text", // Standard content type for structured data
                    text: JSON.stringify(response, null, 2), // Pretty-print JSON for readability
                  },
                ],
                isError: false, // Indicate successful execution
              };
            },
            {
              // Configuration for the inner error handler (processing logic).
              operation: `processing ${toolName} handler`,
              context: handlerContext,
              input: params, // Log the full raw input parameters if an error occurs during processing.
              // Custom error mapping to ensure consistent McpError format.
              errorMapper: (error: unknown) =>
                new McpError(
                  error instanceof McpError
                    ? error.code
                    : BaseErrorCode.INTERNAL_ERROR, // Use INTERNAL_ERROR as the fallback
                  `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
                  { ...handlerContext }, // Include context in the error details
                ),
            },
          ); // End of inner ErrorHandler.tryCatch
        },
      ); // End of server.tool call

      logger.info(
        `Tool registered successfully: ${toolName}`,
        registrationContext,
      );
    },
    {
      // Configuration for the outer error handler (registration process).
      operation: `registering tool ${toolName}`,
      context: registrationContext,
      errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure
      // Custom error mapping for registration failures.
      errorMapper: (error: unknown) =>
        new McpError(
          error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
          `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
          { ...registrationContext }, // Include context
        ),
      critical: true, // Registration failure is considered critical and should likely halt server startup.
    },
  ); // End of outer ErrorHandler.tryCatch
};

```

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

```typescript
#!/usr/bin/env node

/**
 * @fileoverview Fetches an OpenAPI specification (YAML/JSON) from a URL,
 * parses it, and saves it locally in both YAML and JSON formats.
 * @module scripts/fetch-openapi-spec
 *   Includes fallback logic for common OpenAPI file names (openapi.yaml, openapi.json).
 *   Ensures output paths are within the project directory for security.
 *
 * @example
 * // Fetch spec and save to docs/api/my_api.yaml and docs/api/my_api.json
 * // ts-node --esm scripts/fetch-openapi-spec.ts https://api.example.com/v1 docs/api/my_api
 *
 * @example
 * // Fetch spec from a direct file URL
 * // ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3/openapi.json docs/api/petstore_v3
 */

import axios, { AxiosError } from "axios";
import fs from "fs/promises";
import yaml from "js-yaml";
import path from "path";

const projectRoot = process.cwd();

const args = process.argv.slice(2);
const helpFlag = args.includes("--help");
const urlArg = args[0];
const outputBaseArg = args[1];

if (helpFlag || !urlArg || !outputBaseArg) {
  console.log(`
Fetch OpenAPI Specification Script

Usage:
  ts-node --esm scripts/fetch-openapi-spec.ts <url> <output-base-path> [--help]

Arguments:
  <url>                Base URL or direct URL to the OpenAPI spec (YAML/JSON).
  <output-base-path>   Base path for output files (relative to project root),
                       e.g., 'docs/api/my_api'. Will generate .yaml and .json.
  --help               Show this help message.

Example:
  ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3 docs/api/petstore_v3
`);
  process.exit(helpFlag ? 0 : 1);
}

const outputBasePathAbsolute = path.resolve(projectRoot, outputBaseArg);
const yamlOutputPath = `${outputBasePathAbsolute}.yaml`;
const jsonOutputPath = `${outputBasePathAbsolute}.json`;
const outputDirAbsolute = path.dirname(outputBasePathAbsolute);

// Security Check: Ensure output paths are within project root
if (
  !outputDirAbsolute.startsWith(projectRoot + path.sep) ||
  !yamlOutputPath.startsWith(projectRoot + path.sep) ||
  !jsonOutputPath.startsWith(projectRoot + path.sep)
) {
  console.error(
    `Error: Output path "${outputBaseArg}" resolves outside the project directory. Aborting.`,
  );
  process.exit(1);
}

/**
 * Attempts to fetch content from a given URL.
 * @param url - The URL to fetch data from.
 * @returns A promise resolving to an object with data and content type, or null if fetch fails.
 */
async function tryFetch(
  url: string,
): Promise<{ data: string; contentType: string | null } | null> {
  try {
    console.log(`Attempting to fetch from: ${url}`);
    const response = await axios.get(url, {
      responseType: "text",
      validateStatus: (status) => status >= 200 && status < 300,
    });
    const contentType = response.headers["content-type"] || null;
    console.log(
      `Successfully fetched (Status: ${response.status}, Content-Type: ${contentType || "N/A"})`,
    );
    return { data: response.data, contentType };
  } catch (error) {
    let status = "Unknown";
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError;
      status = axiosError.response
        ? String(axiosError.response.status)
        : "Network Error";
    }
    console.warn(`Failed to fetch from ${url} (Status: ${status})`);
    return null;
  }
}

/**
 * Parses fetched data as YAML or JSON, attempting to infer from content type or by trying both.
 * @param data - The raw string data fetched from the URL.
 * @param contentType - The content type header from the HTTP response, if available.
 * @returns The parsed OpenAPI specification as an object, or null if parsing fails.
 */
function parseSpec(data: string, contentType: string | null): object | null {
  try {
    const lowerContentType = contentType?.toLowerCase();
    if (
      lowerContentType?.includes("yaml") ||
      lowerContentType?.includes("yml")
    ) {
      console.log("Parsing content as YAML based on Content-Type...");
      return yaml.load(data) as object;
    } else if (lowerContentType?.includes("json")) {
      console.log("Parsing content as JSON based on Content-Type...");
      return JSON.parse(data);
    } else {
      console.log(
        "Content-Type is ambiguous or missing. Attempting to parse as YAML first...",
      );
      try {
        const parsedYaml = yaml.load(data) as object;
        // Basic validation: check if it's a non-null object.
        if (parsedYaml && typeof parsedYaml === "object") {
          console.log("Successfully parsed as YAML.");
          return parsedYaml;
        }
      } catch (yamlError) {
        console.log("YAML parsing failed. Attempting to parse as JSON...");
        try {
          const parsedJson = JSON.parse(data);
          if (parsedJson && typeof parsedJson === "object") {
            console.log("Successfully parsed as JSON.");
            return parsedJson;
          }
        } catch (jsonError) {
          console.warn(
            "Could not parse content as YAML or JSON after attempting both.",
          );
          return null;
        }
      }
      // If YAML parsing resulted in a non-object (e.g. string, number) but didn't throw
      console.warn(
        "Content parsed as YAML but was not a valid object structure. Trying JSON.",
      );
      try {
        const parsedJson = JSON.parse(data);
        if (parsedJson && typeof parsedJson === "object") {
          console.log(
            "Successfully parsed as JSON on second attempt for non-object YAML.",
          );
          return parsedJson;
        }
      } catch (jsonError) {
        console.warn(
          "Could not parse content as YAML or JSON after attempting both.",
        );
        return null;
      }
    }
  } catch (parseError) {
    console.error(
      `Error parsing specification: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
    );
  }
  return null;
}

/**
 * Main orchestrator function. Fetches the OpenAPI spec from the provided URL (with fallbacks),
 * parses it, and saves it to the specified output paths in both YAML and JSON formats.
 */
async function fetchAndProcessSpec(): Promise<void> {
  let fetchedResult: { data: string; contentType: string | null } | null = null;
  const potentialUrls: string[] = [urlArg];

  if (
    !urlArg.endsWith(".yaml") &&
    !urlArg.endsWith(".yml") &&
    !urlArg.endsWith(".json")
  ) {
    const urlWithoutTrailingSlash = urlArg.endsWith("/")
      ? urlArg.slice(0, -1)
      : urlArg;
    potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.yaml`);
    potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.json`);
  }

  for (const url of potentialUrls) {
    fetchedResult = await tryFetch(url);
    if (fetchedResult) break;
  }

  if (!fetchedResult) {
    console.error(
      `Error: Failed to fetch specification from all attempted URLs: ${potentialUrls.join(", ")}. Aborting.`,
    );
    process.exit(1);
  }

  const openapiSpec = parseSpec(fetchedResult.data, fetchedResult.contentType);

  if (!openapiSpec || typeof openapiSpec !== "object") {
    console.error(
      "Error: Failed to parse specification content or content is not a valid object. Aborting.",
    );
    process.exit(1);
  }

  try {
    await fs.access(outputDirAbsolute);
  } catch (error: any) {
    if (error.code === "ENOENT") {
      console.log(`Output directory not found. Creating: ${outputDirAbsolute}`);
      await fs.mkdir(outputDirAbsolute, { recursive: true });
    } else {
      console.error(
        `Error accessing output directory ${outputDirAbsolute}: ${error.message}. Aborting.`,
      );
      process.exit(1);
    }
  }

  try {
    console.log(`Saving YAML specification to: ${yamlOutputPath}`);
    await fs.writeFile(yamlOutputPath, yaml.dump(openapiSpec), "utf8");
    console.log(`Successfully saved YAML specification.`);
  } catch (error) {
    console.error(
      `Error saving YAML to ${yamlOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
    );
    process.exit(1);
  }

  try {
    console.log(`Saving JSON specification to: ${jsonOutputPath}`);
    await fs.writeFile(
      jsonOutputPath,
      JSON.stringify(openapiSpec, null, 2),
      "utf8",
    );
    console.log(`Successfully saved JSON specification.`);
  } catch (error) {
    console.error(
      `Error saving JSON to ${jsonOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
    );
    process.exit(1);
  }

  console.log("OpenAPI specification processed and saved successfully.");
}

fetchAndProcessSpec();

```

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

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
  ObsidianRestApiService,
  VaultCacheService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  ErrorHandler,
  logger,
  RequestContext,
  requestContextService,
} from "../../../utils/index.js";
// Import necessary types and schemas from the logic file
import type {
  ObsidianSearchReplaceRegistrationInput,
  ObsidianSearchReplaceResponse,
} from "./logic.js";
import {
  ObsidianSearchReplaceInputSchema,
  ObsidianSearchReplaceInputSchemaShape,
  processObsidianSearchReplace,
} from "./logic.js";

/**
 * Registers the 'obsidian_search_replace' tool with the MCP server.
 *
 * This tool performs one or more search-and-replace operations within a specified
 * Obsidian note (identified by file path, the active file, or a periodic note).
 * It reads the note content, applies the replacements sequentially based on the
 * provided options (regex, case sensitivity, etc.), writes the modified content
 * back to the vault, and returns the operation results.
 *
 * The response includes success status, a summary message, the total number of
 * replacements made, formatted file statistics (timestamp, token count), and
 * optionally the final content of the note.
 *
 * @param {McpServer} server - The MCP server instance to register the tool with.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
 *   used to interact with the user's Obsidian vault.
 * @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
 * @throws {McpError} Throws an McpError if registration fails critically.
 */
export const registerObsidianSearchReplaceTool = async (
  server: McpServer,
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<void> => {
  const toolName = "obsidian_search_replace";
  const toolDescription =
    "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.";

  // Create a context specifically for the registration process.
  const registrationContext: RequestContext =
    requestContextService.createRequestContext({
      operation: "RegisterObsidianSearchReplaceTool",
      toolName: toolName,
      module: "ObsidianSearchReplaceRegistration", // Identify the module
    });

  logger.info(`Attempting to register tool: ${toolName}`, registrationContext);

  // Wrap the registration logic in a tryCatch block for robust error handling during server setup.
  await ErrorHandler.tryCatch(
    async () => {
      // Use the high-level SDK method `server.tool` for registration.
      // It handles schema generation from the shape, basic validation, and routing.
      server.tool(
        toolName,
        toolDescription,
        ObsidianSearchReplaceInputSchemaShape, // Provide the base Zod schema shape for input definition.
        /**
         * The handler function executed when the 'obsidian_search_replace' tool is called by the client.
         *
         * @param {ObsidianSearchReplaceRegistrationInput} params - The raw input parameters received from the client,
         *   matching the structure defined by ObsidianSearchReplaceInputSchemaShape.
         * @returns {Promise<CallToolResult>} A promise resolving to the structured result for the MCP client,
         *   containing either the successful response data (serialized JSON) or an error indication.
         */
        async (params: ObsidianSearchReplaceRegistrationInput) => {
          // Create a specific context for this handler invocation, linked to the registration context.
          const handlerContext: RequestContext =
            requestContextService.createRequestContext({
              parentContext: registrationContext,
              operation: "HandleObsidianSearchReplaceRequest",
              toolName: toolName,
              params: {
                // Log key parameters for debugging (excluding potentially large replacements array)
                targetType: params.targetType,
                targetIdentifier: params.targetIdentifier,
                replacementCount: params.replacements?.length ?? 0, // Log count instead of full array
                useRegex: params.useRegex,
                replaceAll: params.replaceAll,
                caseSensitive: params.caseSensitive,
                flexibleWhitespace: params.flexibleWhitespace,
                wholeWord: params.wholeWord,
                returnContent: params.returnContent,
              },
            });
          logger.debug(`Handling '${toolName}' request`, handlerContext);

          // Wrap the core logic execution in a tryCatch block for handling errors during processing.
          return await ErrorHandler.tryCatch(
            async () => {
              // **Crucial Step:** Explicitly parse and validate the raw input parameters using the
              // *refined* Zod schema (`ObsidianSearchReplaceInputSchema`). This applies stricter rules
              // and cross-field validations defined in logic.ts.
              const validatedParams =
                ObsidianSearchReplaceInputSchema.parse(params);
              logger.debug(
                `Input parameters successfully validated against refined schema.`,
                handlerContext,
              );

              // Delegate the actual search/replace logic to the dedicated processing function.
              // Pass the *validated* parameters, the handler context, and the Obsidian service instance.
              const response: ObsidianSearchReplaceResponse =
                await processObsidianSearchReplace(
                  validatedParams,
                  handlerContext,
                  obsidianService,
                  vaultCacheService,
                );
              logger.debug(
                `'${toolName}' processed successfully`,
                handlerContext,
              );

              // Format the successful response object from the logic function into the required MCP CallToolResult structure.
              // The entire response object (containing success, message, count, stat, etc.) is serialized to JSON.
              return {
                content: [
                  {
                    type: "text", // Standard content type for structured JSON data
                    text: JSON.stringify(response, null, 2), // Pretty-print JSON for readability
                  },
                ],
                isError: false, // Indicate successful execution to the client
              };
            },
            {
              // Configuration for the inner error handler (processing logic).
              operation: `processing ${toolName} handler`,
              context: handlerContext,
              input: params, // Log the full raw input parameters if an error occurs during processing.
              // Custom error mapping to ensure consistent McpError format is returned to the client.
              errorMapper: (error: unknown) =>
                new McpError(
                  // Use the specific code from McpError if available, otherwise default to INTERNAL_ERROR.
                  error instanceof McpError
                    ? error.code
                    : BaseErrorCode.INTERNAL_ERROR,
                  `Error processing ${toolName} tool: ${error instanceof Error ? error.message : "Unknown error"}`,
                  { ...handlerContext }, // Include context in the error details
                ),
            },
          ); // End of inner ErrorHandler.tryCatch
        },
      ); // End of server.tool call

      logger.info(
        `Tool registered successfully: ${toolName}`,
        registrationContext,
      );
    },
    {
      // Configuration for the outer error handler (registration process).
      operation: `registering tool ${toolName}`,
      context: registrationContext,
      errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code for registration failure.
      // Custom error mapping for registration failures.
      errorMapper: (error: unknown) =>
        new McpError(
          error instanceof McpError ? error.code : BaseErrorCode.INTERNAL_ERROR,
          `Failed to register tool '${toolName}': ${error instanceof Error ? error.message : "Unknown error"}`,
          { ...registrationContext }, // Include context
        ),
      critical: true, // Treat registration failure as critical, potentially halting server startup.
    },
  ); // End of outer ErrorHandler.tryCatch
};

```

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

```typescript
/**
 * @fileoverview Configures and starts the Streamable HTTP MCP transport using Hono.
 * This module integrates the `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport`
 * into a Hono web server. Its responsibilities include:
 * - Creating a Hono server instance.
 * - Applying and configuring middleware for CORS, rate limiting, and authentication (JWT/OAuth).
 * - Defining the routes (`/mcp` endpoint for POST, GET, DELETE) to handle the MCP lifecycle.
 * - Orchestrating session management by mapping session IDs to SDK transport instances.
 * - Implementing port-binding logic with automatic retry on conflicts.
 *
 * The underlying implementation of the MCP Streamable HTTP specification, including
 * Server-Sent Events (SSE) for streaming, is handled by the SDK's transport class.
 *
 * Specification Reference:
 * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
 * @module src/mcp-server/transports/httpTransport
 */

import { HttpBindings, serve, ServerType } from "@hono/node-server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Context, Hono, Next } from "hono";
import { cors } from "hono/cors";
import http from "http";
import { randomUUID } from "node:crypto";
import { config } from "../../config/index.js";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import {
  logger,
  rateLimiter,
  RequestContext,
  requestContextService,
} from "../../utils/index.js";
import {
  jwtAuthMiddleware,
  oauthMiddleware,
  type AuthInfo,
} from "./auth/index.js";
import { httpErrorHandler } from "./httpErrorHandler.js";

const HTTP_PORT = config.mcpHttpPort;
const HTTP_HOST = config.mcpHttpHost;
const MCP_ENDPOINT_PATH = "/mcp";
const MAX_PORT_RETRIES = 15;

// The transports map will store active sessions, keyed by session ID.
// NOTE: This is an in-memory session store, which is a known limitation for scalability.
// It will not work in a multi-process (clustered) or serverless environment.
// For a scalable deployment, this would need to be replaced with a distributed
// store like Redis or Memcached.
const transports: Record<string, StreamableHTTPServerTransport> = {};

async function isPortInUse(
  port: number,
  host: string,
  parentContext: RequestContext,
): Promise<boolean> {
  const checkContext = requestContextService.createRequestContext({
    ...parentContext,
    operation: "isPortInUse",
    port,
    host,
  });
  return new Promise((resolve) => {
    const tempServer = http.createServer();
    tempServer
      .once("error", (err: NodeJS.ErrnoException) => {
        resolve(err.code === "EADDRINUSE");
      })
      .once("listening", () => {
        tempServer.close(() => resolve(false));
      })
      .listen(port, host);
  });
}

function startHttpServerWithRetry(
  app: Hono<{ Bindings: HttpBindings }>,
  initialPort: number,
  host: string,
  maxRetries: number,
  parentContext: RequestContext,
): Promise<ServerType> {
  const startContext = requestContextService.createRequestContext({
    ...parentContext,
    operation: "startHttpServerWithRetry",
  });

  return new Promise(async (resolve, reject) => {
    for (let i = 0; i <= maxRetries; i++) {
      const currentPort = initialPort + i;
      const attemptContext = {
        ...startContext,
        port: currentPort,
        attempt: i + 1,
      };

      if (await isPortInUse(currentPort, host, attemptContext)) {
        logger.warning(
          `Port ${currentPort} is in use, retrying...`,
          attemptContext,
        );
        continue;
      }

      try {
        const serverInstance = serve(
          { fetch: app.fetch, port: currentPort, hostname: host },
          (info: { address: string; port: number }) => {
            const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
            logger.info(`HTTP transport listening at ${serverAddress}`, {
              ...attemptContext,
              address: serverAddress,
            });
            if (process.stdout.isTTY) {
              console.log(`\n🚀 MCP Server running at: ${serverAddress}\n`);
            }
          },
        );
        resolve(serverInstance);
        return;
      } catch (err: any) {
        if (err.code !== "EADDRINUSE") {
          reject(err);
          return;
        }
      }
    }
    reject(new Error("Failed to bind to any port after multiple retries."));
  });
}

export async function startHttpTransport(
  createServerInstanceFn: () => Promise<McpServer>,
  parentContext: RequestContext,
): Promise<ServerType> {
  const app = new Hono<{ Bindings: HttpBindings }>();
  const transportContext = requestContextService.createRequestContext({
    ...parentContext,
    component: "HttpTransportSetup",
  });

  app.use(
    "*",
    cors({
      origin: config.mcpAllowedOrigins || [],
      allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
      allowHeaders: [
        "Content-Type",
        "Mcp-Session-Id",
        "Last-Event-ID",
        "Authorization",
      ],
      credentials: true,
    }),
  );

  app.use("*", async (c: Context, next: Next) => {
    c.res.headers.set("X-Content-Type-Options", "nosniff");
    await next();
  });

  app.use(MCP_ENDPOINT_PATH, async (c: Context, next: Next) => {
    // NOTE (Security): The 'x-forwarded-for' header is used for rate limiting.
    // This is only secure if the server is run behind a trusted proxy that
    // correctly sets or validates this header.
    const clientIp =
      c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown_ip";
    const context = requestContextService.createRequestContext({
      operation: "httpRateLimitCheck",
      ipAddress: clientIp,
    });
    // Let the centralized error handler catch rate limit errors
    rateLimiter.check(clientIp, context);
    await next();
  });

  if (config.mcpAuthMode === "oauth") {
    app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
  } else {
    app.use(MCP_ENDPOINT_PATH, jwtAuthMiddleware);
  }

  // Centralized Error Handling
  app.onError(httpErrorHandler);

  app.post(MCP_ENDPOINT_PATH, async (c: Context) => {
    const postContext = requestContextService.createRequestContext({
      ...transportContext,
      operation: "handlePost",
    });
    const body = await c.req.json();
    const sessionId = c.req.header("mcp-session-id");
    let transport: StreamableHTTPServerTransport | undefined = sessionId
      ? transports[sessionId]
      : undefined;

    if (isInitializeRequest(body)) {
      // If a transport already exists for a session, it's a re-initialization.
      if (transport) {
        logger.warning("Re-initializing existing session.", {
          ...postContext,
          sessionId,
        });
        await transport.close(); // This will trigger the onclose handler.
      }

      // Create a new transport for a new session.
      const newTransport = new StreamableHTTPServerTransport({
        sessionIdGenerator: () => randomUUID(),
        onsessioninitialized: (newId) => {
          transports[newId] = newTransport;
          logger.info(`HTTP Session created: ${newId}`, {
            ...postContext,
            newSessionId: newId,
          });
        },
      });

      // Set up cleanup logic for when the transport is closed.
      newTransport.onclose = () => {
        const closedSessionId = newTransport.sessionId;
        if (closedSessionId && transports[closedSessionId]) {
          delete transports[closedSessionId];
          logger.info(`HTTP Session closed: ${closedSessionId}`, {
            ...postContext,
            closedSessionId,
          });
        }
      };

      // Connect the new transport to a new server instance.
      const server = await createServerInstanceFn();
      await server.connect(newTransport);
      transport = newTransport;
    } else if (!transport) {
      // If it's not an initialization request and no transport was found, it's an error.
      throw new McpError(
        BaseErrorCode.NOT_FOUND,
        "Invalid or expired session ID.",
      );
    }

    // Pass the request to the transport to handle.
    return await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
  });

  // A reusable handler for GET and DELETE requests which operate on existing sessions.
  const handleSessionRequest = async (
    c: Context<{ Bindings: HttpBindings }>,
  ) => {
    const sessionId = c.req.header("mcp-session-id");
    const transport = sessionId ? transports[sessionId] : undefined;

    if (!transport) {
      throw new McpError(
        BaseErrorCode.NOT_FOUND,
        "Session not found or expired.",
      );
    }

    // Let the transport handle the streaming (GET) or termination (DELETE) request.
    return await transport.handleRequest(c.env.incoming, c.env.outgoing);
  };

  app.get(MCP_ENDPOINT_PATH, handleSessionRequest);
  app.delete(MCP_ENDPOINT_PATH, handleSessionRequest);

  return startHttpServerWithRetry(
    app,
    HTTP_PORT,
    HTTP_HOST,
    MAX_PORT_RETRIES,
    transportContext,
  );
}

```

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

```typescript
import path from "node:path"; // node:path provides OS-specific path functions; using path.posix for vault path manipulation.
import { z } from "zod";
import {
  ObsidianRestApiService,
  VaultCacheService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  logger,
  RequestContext,
  retryWithDelay,
} from "../../../utils/index.js";

// ====================================================================================
// Schema Definitions for Input Validation
// ====================================================================================

/**
 * Zod schema for validating the input parameters of the 'obsidian_delete_note' tool.
 */
export const ObsidianDeleteNoteInputSchema = z
  .object({
    /**
     * The vault-relative path to the file to be permanently deleted.
     * Must include the file extension (e.g., "Old Notes/Obsolete File.md").
     * The tool first attempts a case-sensitive match. If not found, it attempts
     * a case-insensitive fallback search within the same directory.
     */
    filePath: z
      .string()
      .min(1, "filePath cannot be empty")
      .describe(
        'The vault-relative path to the file to be deleted (e.g., "archive/old-file.md"). Tries case-sensitive first, then case-insensitive fallback.',
      ),
  })
  .describe(
    "Input parameters for permanently deleting a specific file within the connected Obsidian vault. Includes a case-insensitive path fallback.",
  );

/**
 * TypeScript type inferred from the input schema (`ObsidianDeleteNoteInputSchema`).
 * Represents the validated input parameters used within the core processing logic.
 */
export type ObsidianDeleteNoteInput = z.infer<
  typeof ObsidianDeleteNoteInputSchema
>;

// ====================================================================================
// Response Type Definition
// ====================================================================================

/**
 * Defines the structure of the successful response returned by the `processObsidianDeleteNote` function.
 * This object is typically serialized to JSON and sent back to the client.
 */
export interface ObsidianDeleteNoteResponse {
  /** Indicates whether the deletion operation was successful. */
  success: boolean;
  /** A human-readable message confirming the deletion and specifying the path used. */
  message: string;
}

// ====================================================================================
// Core Logic Function
// ====================================================================================

/**
 * Processes the core logic for deleting a file from the Obsidian vault.
 *
 * It attempts to delete the file using the provided path (case-sensitive first).
 * If that fails with a 'NOT_FOUND' error, it attempts a case-insensitive fallback:
 * it lists the directory, finds a unique case-insensitive match for the filename,
 * and retries the deletion with the corrected path.
 *
 * @param {ObsidianDeleteNoteInput} params - The validated input parameters.
 * @param {RequestContext} context - The request context for logging and correlation.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
 * @returns {Promise<ObsidianDeleteNoteResponse>} A promise resolving to the structured success response
 *   containing a confirmation message.
 * @throws {McpError} Throws an McpError if the file cannot be found (even with fallback),
 *   if there's an ambiguous fallback match, or if any other API interaction fails.
 */
export const processObsidianDeleteNote = async (
  params: ObsidianDeleteNoteInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<ObsidianDeleteNoteResponse> => {
  const { filePath: originalFilePath } = params;
  let effectiveFilePath = originalFilePath; // Track the path actually used for deletion

  logger.debug(
    `Processing obsidian_delete_note request for path: ${originalFilePath}`,
    context,
  );

  const shouldRetryNotFound = (err: unknown) =>
    err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;

  try {
    // --- Attempt 1: Delete using the provided path (case-sensitive) ---
    const deleteContext = {
      ...context,
      operation: "deleteFileAttempt",
      caseSensitive: true,
    };
    logger.debug(
      `Attempting to delete file (case-sensitive): ${originalFilePath}`,
      deleteContext,
    );
    await retryWithDelay(
      () => obsidianService.deleteFile(originalFilePath, deleteContext),
      {
        operationName: "deleteFile",
        context: deleteContext,
        maxRetries: 3,
        delayMs: 300,
        shouldRetry: shouldRetryNotFound,
      },
    );

    // If the above call succeeds, the file was deleted using the exact path.
    logger.debug(
      `Successfully deleted file using exact path: ${originalFilePath}`,
      deleteContext,
    );
    if (vaultCacheService) {
      await vaultCacheService.updateCacheForFile(
        originalFilePath,
        deleteContext,
      );
    }
    return {
      success: true,
      message: `File '${originalFilePath}' deleted successfully.`,
    };
  } catch (error) {
    // --- Attempt 2: Case-insensitive fallback if initial delete failed with NOT_FOUND ---
    if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
      logger.info(
        `File not found with exact path: ${originalFilePath}. Attempting case-insensitive fallback for deletion.`,
        context,
      );
      const fallbackContext = { ...context, operation: "deleteFileFallback" };

      try {
        // Use POSIX path functions for vault path manipulation
        const dirname = path.posix.dirname(originalFilePath);
        const filenameLower = path.posix
          .basename(originalFilePath)
          .toLowerCase();
        // Handle case where the file is in the vault root (dirname is '.')
        const dirToList = dirname === "." ? "/" : dirname;

        logger.debug(
          `Listing directory for fallback deletion: ${dirToList}`,
          fallbackContext,
        );
        const filesInDir = await retryWithDelay(
          () => obsidianService.listFiles(dirToList, fallbackContext),
          {
            operationName: "listFilesForDeleteFallback",
            context: fallbackContext,
            maxRetries: 3,
            delayMs: 300,
            shouldRetry: shouldRetryNotFound,
          },
        );

        // Filter directory listing for files matching the lowercase filename
        const matches = filesInDir.filter(
          (f) =>
            !f.endsWith("/") && // Ensure it's a file
            path.posix.basename(f).toLowerCase() === filenameLower,
        );

        if (matches.length === 1) {
          // Found exactly one case-insensitive match
          const correctFilename = path.posix.basename(matches[0]);
          effectiveFilePath = path.posix.join(dirname, correctFilename); // Update the path to use
          logger.info(
            `Found case-insensitive match: ${effectiveFilePath}. Retrying delete.`,
            fallbackContext,
          );

          // Retry deleting with the correctly cased path
          const retryContext = {
            ...fallbackContext,
            subOperation: "retryDelete",
            effectiveFilePath,
          };
          await retryWithDelay(
            () => obsidianService.deleteFile(effectiveFilePath, retryContext),
            {
              operationName: "deleteFileFallback",
              context: retryContext,
              maxRetries: 3,
              delayMs: 300,
              shouldRetry: shouldRetryNotFound,
            },
          );

          logger.debug(
            `Successfully deleted file using fallback path: ${effectiveFilePath}`,
            retryContext,
          );
          if (vaultCacheService) {
            await vaultCacheService.updateCacheForFile(
              effectiveFilePath,
              retryContext,
            );
          }
          return {
            success: true,
            message: `File '${effectiveFilePath}' (found via case-insensitive match for '${originalFilePath}') deleted successfully.`,
          };
        } else if (matches.length > 1) {
          // Ambiguous match: Multiple files match case-insensitively
          const errorMsg = `Deletion failed: Ambiguous case-insensitive matches for '${originalFilePath}'. Found: [${matches.join(", ")}]. Cannot determine which file to delete.`;
          logger.error(errorMsg, { ...fallbackContext, matches });
          // Use CONFLICT code for ambiguity, as NOT_FOUND isn't quite right anymore.
          throw new McpError(BaseErrorCode.CONFLICT, errorMsg, fallbackContext);
        } else {
          // No match found even with fallback
          const errorMsg = `Deletion failed: File not found for '${originalFilePath}' (case-insensitive fallback also failed).`;
          logger.error(errorMsg, fallbackContext);
          // Stick with NOT_FOUND as the original error reason holds.
          throw new McpError(
            BaseErrorCode.NOT_FOUND,
            errorMsg,
            fallbackContext,
          );
        }
      } catch (fallbackError) {
        // Catch errors specifically from the fallback logic (e.g., listFiles error, retry delete error)
        if (fallbackError instanceof McpError) {
          // Log and re-throw known errors from fallback
          logger.error(
            `McpError during fallback deletion for ${originalFilePath}: ${fallbackError.message}`,
            fallbackError,
            fallbackContext,
          );
          throw fallbackError;
        } else {
          // Wrap unexpected fallback errors
          const errorMessage = `Unexpected error during case-insensitive fallback deletion for ${originalFilePath}`;
          logger.error(
            errorMessage,
            fallbackError instanceof Error ? fallbackError : undefined,
            fallbackContext,
          );
          throw new McpError(
            BaseErrorCode.INTERNAL_ERROR,
            `${errorMessage}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
            fallbackContext,
          );
        }
      }
    } else {
      // Re-throw errors from the initial delete attempt that were not NOT_FOUND or McpError
      if (error instanceof McpError) {
        logger.error(
          `McpError during initial delete attempt for ${originalFilePath}: ${error.message}`,
          error,
          context,
        );
        throw error;
      } else {
        const errorMessage = `Unexpected error deleting Obsidian file ${originalFilePath}`;
        logger.error(
          errorMessage,
          error instanceof Error ? error : undefined,
          context,
        );
        throw new McpError(
          BaseErrorCode.INTERNAL_ERROR,
          `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
          context,
        );
      }
    }
  }
};

```

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

```typescript
/**
 * @fileoverview Provides a generic ID generator class for creating unique,
 * prefixed identifiers and standard UUIDs. It supports custom character sets,
 * lengths, and separators for generated IDs.
 * @module src/utils/security/idGenerator
 */

import { randomBytes, randomUUID as cryptoRandomUUID } from "crypto";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
// Logger is not directly used in this module after previous refactoring, which is fine.
// If logging were to be added (e.g., for prefix registration), RequestContext would be needed.
// import { logger, RequestContext } from '../internal/index.js';

/**
 * Defines the structure for configuring entity prefixes, mapping entity types (strings)
 * to their corresponding ID prefixes (strings).
 */
export interface EntityPrefixConfig {
  [key: string]: string;
}

/**
 * Options for customizing ID generation.
 */
export interface IdGenerationOptions {
  /** The length of the random part of the ID. Defaults to `IdGenerator.DEFAULT_LENGTH`. */
  length?: number;
  /** The separator string used between a prefix and the random part. Defaults to `IdGenerator.DEFAULT_SEPARATOR`. */
  separator?: string;
  /** The character set from which the random part of the ID is generated. Defaults to `IdGenerator.DEFAULT_CHARSET`. */
  charset?: string;
}

/**
 * A generic ID Generator class for creating and managing unique identifiers.
 * It can generate IDs with entity-specific prefixes or standard UUIDs.
 */
export class IdGenerator {
  /** Default character set for the random part of generated IDs (uppercase alphanumeric). */
  private static DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  /** Default separator used between a prefix and the random part of an ID. */
  private static DEFAULT_SEPARATOR = "_";
  /** Default length for the random part of generated IDs. */
  private static DEFAULT_LENGTH = 6;

  private entityPrefixes: EntityPrefixConfig = {};
  private prefixToEntityType: Record<string, string> = {};

  /**
   * Constructs an `IdGenerator` instance.
   * @param {EntityPrefixConfig} [entityPrefixes={}] - An optional map of entity types
   *   to their desired ID prefixes (e.g., `{ project: 'PROJ', task: 'TASK' }`).
   */
  constructor(entityPrefixes: EntityPrefixConfig = {}) {
    this.setEntityPrefixes(entityPrefixes);
  }

  /**
   * Sets or updates the entity prefix configuration and rebuilds the internal
   * reverse lookup table (prefix to entity type).
   * @param {EntityPrefixConfig} entityPrefixes - A map of entity types to their prefixes.
   */
  public setEntityPrefixes(entityPrefixes: EntityPrefixConfig): void {
    this.entityPrefixes = { ...entityPrefixes }; // Create a copy

    // Rebuild reverse mapping for efficient lookup (case-insensitive for prefix matching)
    this.prefixToEntityType = Object.entries(this.entityPrefixes).reduce(
      (acc, [type, prefix]) => {
        acc[prefix.toUpperCase()] = type; // Store prefix in uppercase for consistent lookup
        // Consider if lowercase or original case mapping is also needed based on expected input.
        // For now, assuming prefixes are matched case-insensitively by uppercasing input prefix.
        return acc;
      },
      {} as Record<string, string>,
    );
  }

  /**
   * Retrieves a copy of the current entity prefix configuration.
   * @returns {EntityPrefixConfig} The current entity prefix configuration.
   */
  public getEntityPrefixes(): EntityPrefixConfig {
    return { ...this.entityPrefixes };
  }

  /**
   * Generates a cryptographically secure random string of a specified length
   * from a given character set.
   * @param {number} [length=IdGenerator.DEFAULT_LENGTH] - The desired length of the random string.
   * @param {string} [charset=IdGenerator.DEFAULT_CHARSET] - The character set to use for generation.
   * @returns {string} A random string.
   */
  public generateRandomString(
    length: number = IdGenerator.DEFAULT_LENGTH,
    charset: string = IdGenerator.DEFAULT_CHARSET,
  ): string {
    if (length <= 0) {
      return "";
    }
    const bytes = randomBytes(length);
    let result = "";
    for (let i = 0; i < length; i++) {
      result += charset[bytes[i] % charset.length];
    }
    return result;
  }

  /**
   * Generates a unique ID, optionally with a specified prefix.
   * @param {string} [prefix] - An optional prefix for the ID.
   * @param {IdGenerationOptions} [options={}] - Optional parameters for customizing
   *   the length, separator, and charset of the random part of the ID.
   * @returns {string} A unique identifier string.
   */
  public generate(prefix?: string, options: IdGenerationOptions = {}): string {
    const {
      length = IdGenerator.DEFAULT_LENGTH,
      separator = IdGenerator.DEFAULT_SEPARATOR,
      charset = IdGenerator.DEFAULT_CHARSET,
    } = options;

    const randomPart = this.generateRandomString(length, charset);

    return prefix ? `${prefix}${separator}${randomPart}` : randomPart;
  }

  /**
   * Generates a unique ID for a specified entity type, using its configured prefix.
   * The format is typically `PREFIX_RANDOMPART`.
   * @param {string} entityType - The type of entity for which to generate an ID (must be registered
   *   via `setEntityPrefixes` or constructor).
   * @param {IdGenerationOptions} [options={}] - Optional parameters for customizing the ID generation.
   * @returns {string} A unique identifier string for the entity (e.g., "PROJ_A6B3J0").
   * @throws {McpError} If the `entityType` is not registered (i.e., no prefix is configured for it).
   */
  public generateForEntity(
    entityType: string,
    options: IdGenerationOptions = {},
  ): string {
    const prefix = this.entityPrefixes[entityType];
    if (!prefix) {
      throw new McpError(
        BaseErrorCode.VALIDATION_ERROR,
        `Unknown entity type: "${entityType}". No prefix configured.`,
      );
    }
    return this.generate(prefix, options);
  }

  /**
   * Validates if a given ID string matches the expected format for a specified entity type,
   * including its prefix, separator, and random part characteristics.
   * @param {string} id - The ID string to validate.
   * @param {string} entityType - The expected entity type of the ID.
   * @param {IdGenerationOptions} [options={}] - Optional parameters to specify the expected
   *   length and separator if they differ from defaults for this validation.
   * @returns {boolean} `true` if the ID is valid for the entity type, `false` otherwise.
   */
  public isValid(
    id: string,
    entityType: string,
    options: IdGenerationOptions = {},
  ): boolean {
    const prefix = this.entityPrefixes[entityType];
    if (!prefix) {
      return false; // Cannot validate if entity type or prefix is unknown
    }

    const {
      length = IdGenerator.DEFAULT_LENGTH,
      separator = IdGenerator.DEFAULT_SEPARATOR,
      // charset is not used for regex validation but for generation
    } = options;

    // Regex assumes default charset (A-Z, 0-9). If charset is customizable for validation,
    // the regex would need to be dynamically built or options.charset used.
    // For now, it matches the default generation charset.
    const pattern = new RegExp(`^${prefix}${separator}[A-Z0-9]{${length}}$`);
    return pattern.test(id);
  }

  /**
   * Strips the prefix from a prefixed ID string.
   * @param {string} id - The ID string (e.g., "PROJ_A6B3J0").
   * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
   * @returns {string} The part of the ID after the first separator, or the original ID if no separator is found.
   */
  public stripPrefix(
    id: string,
    separator: string = IdGenerator.DEFAULT_SEPARATOR,
  ): string {
    const parts = id.split(separator);
    return parts.length > 1 ? parts.slice(1).join(separator) : id; // Handle cases with multiple separators in random part
  }

  /**
   * Determines the entity type from a prefixed ID string.
   * @param {string} id - The ID string (e.g., "PROJ_A6B3J0").
   * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
   * @returns {string} The determined entity type.
   * @throws {McpError} If the ID format is invalid or the prefix does not map to a known entity type.
   */
  public getEntityType(
    id: string,
    separator: string = IdGenerator.DEFAULT_SEPARATOR,
  ): string {
    const parts = id.split(separator);
    if (parts.length < 2 || !parts[0]) {
      // Need at least a prefix and a random part
      throw new McpError(
        BaseErrorCode.VALIDATION_ERROR,
        `Invalid ID format: "${id}". Expected format like "PREFIX${separator}RANDOMPART".`,
      );
    }

    const inputPrefix = parts[0].toUpperCase(); // Match prefix case-insensitively
    const entityType = this.prefixToEntityType[inputPrefix];

    if (!entityType) {
      throw new McpError(
        BaseErrorCode.VALIDATION_ERROR,
        `Unknown entity type for prefix: "${parts[0]}" in ID "${id}".`,
      );
    }
    return entityType;
  }

  /**
   * Normalizes an entity ID to ensure the prefix matches the configured case
   * (if specific casing is important for the system) and the random part is uppercase.
   * This implementation assumes prefixes are stored/used consistently and focuses on random part casing.
   * @param {string} id - The ID to normalize.
   * @param {string} [separator=IdGenerator.DEFAULT_SEPARATOR] - The separator used in the ID.
   * @returns {string} The normalized ID string.
   * @throws {McpError} If the entity type cannot be determined from the ID.
   */
  public normalize(
    id: string,
    separator: string = IdGenerator.DEFAULT_SEPARATOR,
  ): string {
    // This will throw if entity type is not found or ID format is wrong
    const entityType = this.getEntityType(id, separator);
    const configuredPrefix = this.entityPrefixes[entityType]; // Get the canonical prefix

    const parts = id.split(separator);
    const randomPart = parts.slice(1).join(separator); // Re-join if separator was in random part

    return `${configuredPrefix}${separator}${randomPart.toUpperCase()}`;
  }
}

/**
 * A default, shared instance of the `IdGenerator`.
 * This instance can be configured with entity prefixes at application startup
 * or used directly for generating unprefixed random IDs or UUIDs.
 *
 * Example:
 * ```typescript
 * import { idGenerator, generateUUID } from './idGenerator';
 *
 * // Configure prefixes (optional, typically at app start)
 * idGenerator.setEntityPrefixes({ user: 'USR', order: 'ORD' });
 *
 * const userId = idGenerator.generateForEntity('user'); // e.g., USR_X7V2L9
 * const simpleId = idGenerator.generate(); // e.g., K3P8A1
 * const standardUuid = generateUUID(); // e.g., '123e4567-e89b-12d3-a456-426614174000'
 * ```
 */
export const idGenerator = new IdGenerator();

/**
 * Generates a standard Version 4 UUID (Universally Unique Identifier).
 * Uses the `crypto.randomUUID()` method for cryptographically strong randomness.
 * @returns {string} A UUID string (e.g., "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx").
 */
export const generateUUID = (): string => {
  return cryptoRandomUUID();
};

```

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

```typescript
/**
 * @fileoverview Core logic for the 'obsidian_list_notes' tool.
 * This module defines the input schema, response types, and processing logic for
 * recursively listing files and directories in an Obsidian vault with filtering.
 * @module src/mcp-server/tools/obsidianListNotesTool/logic
 */

import path from "node:path";
import { z } from "zod";
import { ObsidianRestApiService } from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  logger,
  RequestContext,
  retryWithDelay,
} from "../../../utils/index.js";

// ====================================================================================
// Schema Definitions for Input Validation
// ====================================================================================

/**
 * Zod schema for validating the input parameters of the 'obsidian_list_notes' tool.
 */
export const ObsidianListNotesInputSchema = z
  .object({
    /**
     * The vault-relative path to the directory whose contents should be listed.
     * The path is treated as case-sensitive by the underlying Obsidian API.
     */
    dirPath: z
      .string()
      .describe(
        'The vault-relative path to the directory to list (e.g., "developer/atlas-mcp-server", "/" for root). Case-sensitive.',
      ),
    /**
     * Optional array of file extensions (including the leading dot) to filter the results.
     * Only files matching one of these extensions will be included. Directories are always included.
     */
    fileExtensionFilter: z
      .array(z.string().startsWith(".", "Extension must start with a dot '.'"))
      .optional()
      .describe(
        'Optional array of file extensions (e.g., [".md"]) to filter files. Directories are always included.',
      ),
    /**
     * Optional JavaScript-compatible regular expression pattern string to filter results by name.
     * Only files and directories whose names match the regex will be included.
     */
    nameRegexFilter: z
      .string()
      .nullable()
      .optional()
      .describe(
        "Optional regex pattern (JavaScript syntax) to filter results by name.",
      ),
    /**
     * The maximum depth of subdirectories to list recursively.
     * - A value of `0` lists only the files and directories in the specified `dirPath`.
     * - A value of `1` lists the contents of `dirPath` and the contents of its immediate subdirectories.
     * - A value of `-1` (the default) indicates infinite recursion, listing all subdirectories.
     */
    recursionDepth: z
      .number()
      .int()
      .default(-1)
      .describe(
        "Maximum recursion depth. 0 for no recursion, -1 for infinite (default).",
      ),
  })
  .describe(
    "Input parameters for listing files and subdirectories within a specified Obsidian vault directory, with optional filtering and recursion.",
  );

/**
 * TypeScript type inferred from the input schema (`ObsidianListNotesInputSchema`).
 */
export type ObsidianListNotesInput = z.infer<
  typeof ObsidianListNotesInputSchema
>;

// ====================================================================================
// Response & Internal Type Definitions
// ====================================================================================

/**
 * Defines the structure of a node in the file tree.
 */
interface FileTreeNode {
  name: string;
  type: "file" | "directory";
  children: FileTreeNode[];
}

/**
 * Defines the structure of the successful response returned by the core logic function.
 */
export interface ObsidianListNotesResponse {
  directoryPath: string;
  tree: string;
  totalEntries: number;
}

// ====================================================================================
// Helper Functions
// ====================================================================================

/**
 * Recursively builds a formatted tree string from a nested array of FileTreeNode objects.
 *
 * @param {FileTreeNode[]} nodes - The array of nodes to format.
 * @param {string} [indent=""] - The indentation prefix for the current level.
 * @returns {{ tree: string, count: number }} An object containing the formatted tree string and the total count of entries.
 */
function formatTree(
  nodes: FileTreeNode[],
  indent = "",
): { tree: string; count: number } {
  let treeString = "";
  let count = nodes.length;

  nodes.forEach((node, index) => {
    const isLast = index === nodes.length - 1;
    const prefix = isLast ? "└── " : "├── ";
    const childIndent = isLast ? "    " : "│   ";

    treeString += `${indent}${prefix}${node.name}\n`;

    if (node.children && node.children.length > 0) {
      const result = formatTree(node.children, indent + childIndent);
      treeString += result.tree;
      count += result.count;
    }
  });

  return { tree: treeString, count };
}

/**
 * Recursively builds a file tree by fetching directory contents from the Obsidian API.
 *
 * @param {string} dirPath - The path of the directory to process.
 * @param {number} currentDepth - The current recursion depth.
 * @param {ObsidianListNotesInput} params - The original validated input parameters, including filters and max depth.
 * @param {RequestContext} context - The request context for logging.
 * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
 * @returns {Promise<FileTreeNode[]>} A promise that resolves to an array of file tree nodes.
 */
async function buildFileTree(
  dirPath: string,
  currentDepth: number,
  params: ObsidianListNotesInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
): Promise<FileTreeNode[]> {
  const { recursionDepth, fileExtensionFilter, nameRegexFilter } = params;

  // Stop recursion if max depth is reached (and it's not infinite)
  if (recursionDepth !== -1 && currentDepth > recursionDepth) {
    return [];
  }

  let fileNames;
  try {
    fileNames = await obsidianService.listFiles(dirPath, context);
  } catch (error) {
    if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
      logger.warning(
        `Directory not found during recursive list: ${dirPath}. Skipping.`,
        context,
      );
      return []; // Return empty array if a subdirectory is not found
    }
    throw error; // Re-throw other errors
  }

  const regex =
    nameRegexFilter && nameRegexFilter.trim() !== ""
      ? new RegExp(nameRegexFilter)
      : null;

  const treeNodes: FileTreeNode[] = [];

  for (const name of fileNames) {
    const fullPath = path.posix.join(dirPath, name);
    const isDirectory = name.endsWith("/");
    const cleanName = isDirectory ? name.slice(0, -1) : name;

    // Apply filters
    if (regex && !regex.test(cleanName)) {
      continue;
    }
    if (!isDirectory && fileExtensionFilter && fileExtensionFilter.length > 0) {
      const extension = path.posix.extname(name);
      if (!fileExtensionFilter.includes(extension)) {
        continue;
      }
    }

    const node: FileTreeNode = {
      name: cleanName,
      type: isDirectory ? "directory" : "file",
      children: [],
    };

    if (isDirectory) {
      node.name += "/"; // Add trailing slash back for display
      node.children = await buildFileTree(
        fullPath,
        currentDepth + 1,
        params,
        context,
        obsidianService,
      );
    }

    treeNodes.push(node);
  }

  // Sort entries: directories first, then files, alphabetically
  treeNodes.sort((a, b) => {
    if (a.type === "directory" && b.type === "file") return -1;
    if (a.type === "file" && b.type === "directory") return 1;
    return a.name.localeCompare(b.name);
  });

  return treeNodes;
}

// ====================================================================================
// Core Logic Function
// ====================================================================================

/**
 * Processes the core logic for listing files and directories recursively within the Obsidian vault.
 *
 * @param {ObsidianListNotesInput} params - The validated input parameters.
 * @param {RequestContext} context - The request context for logging and correlation.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
 * @returns {Promise<ObsidianListNotesResponse>} A promise resolving to the structured success response.
 * @throws {McpError} Throws an McpError if the initial directory is not found or another error occurs.
 */
export const processObsidianListNotes = async (
  params: ObsidianListNotesInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
): Promise<ObsidianListNotesResponse> => {
  const { dirPath } = params;
  const dirPathForLog = dirPath === "" || dirPath === "/" ? "/" : dirPath;

  logger.debug(
    `Processing obsidian_list_notes request for path: ${dirPathForLog}`,
    { ...context, params },
  );

  try {
    const effectiveDirPath = dirPath === "" ? "/" : dirPath;

    // --- Step 1: Build the file tree recursively with retry for the initial call ---
    const buildTreeContext = {
      ...context,
      operation: "buildFileTreeWithRetry",
    };
    const shouldRetryNotFound = (err: unknown) =>
      err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;

    const fileTree = await retryWithDelay(
      () =>
        buildFileTree(
          effectiveDirPath,
          0, // Start at depth 0
          params,
          buildTreeContext,
          obsidianService,
        ),
      {
        operationName: "buildFileTreeWithRetry",
        context: buildTreeContext,
        maxRetries: 3,
        delayMs: 300,
        shouldRetry: shouldRetryNotFound,
      },
    );

    // --- Step 2: Format the tree and count entries ---
    const formatContext = { ...context, operation: "formatResponse" };
    if (fileTree.length === 0) {
      logger.debug(
        "Directory is empty or all items were filtered out.",
        formatContext,
      );
      return {
        directoryPath: dirPathForLog,
        tree: "(empty or all items filtered)",
        totalEntries: 0,
      };
    }

    const { tree, count } = formatTree(fileTree);

    // --- Step 3: Construct and return the response ---
    const response: ObsidianListNotesResponse = {
      directoryPath: dirPathForLog,
      tree: tree.trimEnd(), // Remove trailing newline
      totalEntries: count,
    };

    logger.debug(
      `Successfully processed list request for ${dirPathForLog}. Found ${count} entries.`,
      context,
    );
    return response;
  } catch (error) {
    if (error instanceof McpError) {
      // Provide a more specific message if the directory wasn't found after retries
      if (error.code === BaseErrorCode.NOT_FOUND) {
        const notFoundMsg = `Directory not found after retries: ${dirPathForLog}`;
        logger.error(notFoundMsg, error, context);
        throw new McpError(error.code, notFoundMsg, context);
      }
      logger.error(
        `McpError during file listing for ${dirPathForLog}: ${error.message}`,
        error,
        context,
      );
      throw error;
    }

    const errorMessage = `Unexpected error listing Obsidian files in ${dirPathForLog}`;
    logger.error(
      errorMessage,
      error instanceof Error ? error : undefined,
      context,
    );
    throw new McpError(
      BaseErrorCode.INTERNAL_ERROR,
      `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
      context,
    );
  }
};

```

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

```typescript
/**
 * @fileoverview Provides a generic rate limiter class to manage request rates
 * based on configurable time windows and request counts. It supports custom
 * key generation, periodic cleanup of expired entries, and skipping rate
 * limiting in development environments.
 * @module src/utils/security/rateLimiter
 */

import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { environment } from "../../config/index.js";
import {
  logger,
  RequestContext,
  requestContextService,
} from "../internal/index.js"; // Use internal index for RequestContext

/**
 * Configuration options for the {@link RateLimiter}.
 */
export interface RateLimitConfig {
  /** Time window in milliseconds during which requests are counted. */
  windowMs: number;
  /** Maximum number of requests allowed from a single key within the `windowMs`. */
  maxRequests: number;
  /**
   * Custom error message template when rate limit is exceeded.
   * Use `{waitTime}` as a placeholder for the remaining seconds until reset.
   * Defaults to "Rate limit exceeded. Please try again in {waitTime} seconds."
   */
  errorMessage?: string;
  /**
   * If `true`, rate limiting checks will be skipped if `process.env.NODE_ENV` is 'development'.
   * Defaults to `false`.
   */
  skipInDevelopment?: boolean;
  /**
   * An optional function to generate a unique key for rate limiting based on an identifier
   * and optional request context. If not provided, the raw identifier is used as the key.
   * @param identifier - The base identifier (e.g., IP address, user ID).
   * @param context - Optional request context.
   * @returns A string to be used as the rate limiting key.
   */
  keyGenerator?: (identifier: string, context?: RequestContext) => string;
  /**
   * Interval in milliseconds for cleaning up expired rate limit entries from memory.
   * Defaults to 5 minutes. Set to `0` or `null` to disable automatic cleanup.
   */
  cleanupInterval?: number | null;
}

/**
 * Represents an individual entry in the rate limiter's tracking store.
 * @internal
 */
export interface RateLimitEntry {
  /** The current count of requests within the window. */
  count: number;
  /** The timestamp (milliseconds since epoch) when the current window resets. */
  resetTime: number;
}

/**
 * A generic rate limiter class that can be used to control the frequency of
 * operations or requests from various sources. It stores request counts in memory.
 */
export class RateLimiter {
  private limits: Map<string, RateLimitEntry>;
  private cleanupTimer: NodeJS.Timeout | null = null;
  private currentConfig: RateLimitConfig; // Renamed from 'config' to avoid conflict with global 'config'

  /**
   * Default configuration values for the rate limiter.
   */
  private static DEFAULT_CONFIG: RateLimitConfig = {
    windowMs: 15 * 60 * 1000, // 15 minutes
    maxRequests: 100,
    errorMessage:
      "Rate limit exceeded. Please try again in {waitTime} seconds.",
    skipInDevelopment: false,
    cleanupInterval: 5 * 60 * 1000, // 5 minutes
  };

  /**
   * Creates a new `RateLimiter` instance.
   * @param {Partial<RateLimitConfig>} [initialConfig={}] - Optional initial configuration
   *   to override default settings.
   */
  constructor(initialConfig: Partial<RateLimitConfig> = {}) {
    this.currentConfig = { ...RateLimiter.DEFAULT_CONFIG, ...initialConfig };
    this.limits = new Map();
    this.startCleanupTimer();
    // Initial log message about instantiation can be done by the code that creates the singleton instance,
    // after logger itself is fully initialized.
  }

  /**
   * Starts the periodic cleanup timer for expired rate limit entries.
   * If a timer already exists, it's cleared and restarted.
   * @private
   */
  private startCleanupTimer(): void {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }

    const interval = this.currentConfig.cleanupInterval;

    if (interval && interval > 0) {
      this.cleanupTimer = setInterval(() => {
        this.cleanupExpiredEntries();
      }, interval);

      // Allow Node.js to exit if this timer is the only thing running.
      if (this.cleanupTimer.unref) {
        this.cleanupTimer.unref();
      }
    }
  }

  /**
   * Removes expired entries from the rate limit store to free up memory.
   * This method is called periodically by the cleanup timer.
   * @private
   */
  private cleanupExpiredEntries(): void {
    const now = Date.now();
    let expiredCount = 0;
    const internalContext = requestContextService.createRequestContext({
      operation: "RateLimiter.cleanupExpiredEntries",
    });

    for (const [key, entry] of this.limits.entries()) {
      if (now >= entry.resetTime) {
        this.limits.delete(key);
        expiredCount++;
      }
    }

    if (expiredCount > 0) {
      logger.debug(`Cleaned up ${expiredCount} expired rate limit entries.`, {
        ...internalContext,
        totalRemaining: this.limits.size,
      });
    }
  }

  /**
   * Updates the rate limiter's configuration.
   * @param {Partial<RateLimitConfig>} newConfig - Partial configuration object
   *   with new settings to apply.
   */
  public configure(newConfig: Partial<RateLimitConfig>): void {
    const oldCleanupInterval = this.currentConfig.cleanupInterval;
    this.currentConfig = { ...this.currentConfig, ...newConfig };

    if (
      newConfig.cleanupInterval !== undefined &&
      newConfig.cleanupInterval !== oldCleanupInterval
    ) {
      this.startCleanupTimer(); // Restart timer if interval changed
    }
    // Consider logging configuration changes if needed, using a RequestContext.
  }

  /**
   * Retrieves a copy of the current rate limiter configuration.
   * @returns {RateLimitConfig} The current configuration.
   */
  public getConfig(): RateLimitConfig {
    return { ...this.currentConfig };
  }

  /**
   * Resets all rate limits, clearing all tracked keys and their counts.
   * @param {RequestContext} [context] - Optional context for logging the reset operation.
   */
  public reset(context?: RequestContext): void {
    this.limits.clear();
    const opContext =
      context ||
      requestContextService.createRequestContext({
        operation: "RateLimiter.reset",
      });
    logger.info("Rate limiter has been reset. All limits cleared.", opContext);
  }

  /**
   * Checks if a request identified by a key exceeds the configured rate limit.
   * If the limit is exceeded, an `McpError` is thrown.
   *
   * @param {string} identifier - A unique string identifying the source of the request
   *   (e.g., IP address, user ID, session ID).
   * @param {RequestContext} [context] - Optional request context for logging and potentially
   *   for use by a custom `keyGenerator`.
   * @throws {McpError} If the rate limit is exceeded for the given key.
   *   The error will have `BaseErrorCode.RATE_LIMITED`.
   */
  public check(identifier: string, context?: RequestContext): void {
    const opContext =
      context ||
      requestContextService.createRequestContext({
        operation: "RateLimiter.check",
        identifier,
      });

    if (this.currentConfig.skipInDevelopment && environment === "development") {
      logger.debug(
        `Rate limiting skipped for key "${identifier}" in development environment.`,
        opContext,
      );
      return;
    }

    const limitKey = this.currentConfig.keyGenerator
      ? this.currentConfig.keyGenerator(identifier, opContext)
      : identifier;

    const now = Date.now();
    const entry = this.limits.get(limitKey);

    if (!entry || now >= entry.resetTime) {
      // New entry or expired window
      this.limits.set(limitKey, {
        count: 1,
        resetTime: now + this.currentConfig.windowMs,
      });
      return; // First request in window, allow
    }

    // Window is active, check count
    if (entry.count >= this.currentConfig.maxRequests) {
      const waitTimeSeconds = Math.ceil((entry.resetTime - now) / 1000);
      const errorMessageTemplate =
        this.currentConfig.errorMessage ||
        RateLimiter.DEFAULT_CONFIG.errorMessage!;
      const errorMessage = errorMessageTemplate.replace(
        "{waitTime}",
        waitTimeSeconds.toString(),
      );

      logger.warning(`Rate limit exceeded for key "${limitKey}".`, {
        ...opContext,
        limitKey,
        count: entry.count,
        maxRequests: this.currentConfig.maxRequests,
        resetTime: new Date(entry.resetTime).toISOString(),
        waitTimeSeconds,
      });
      throw new McpError(
        BaseErrorCode.RATE_LIMITED,
        errorMessage,
        { ...opContext, keyUsed: limitKey, waitTime: waitTimeSeconds }, // Pass opContext to McpError
      );
    }

    // Increment count and update entry
    entry.count++;
    // No need to this.limits.set(limitKey, entry) again if entry is a reference to the object in the map.
  }

  /**
   * Retrieves the current rate limit status for a given key.
   * @param {string} key - The rate limit key (as generated by `keyGenerator` or the raw identifier).
   * @returns {{ current: number; limit: number; remaining: number; resetTime: number } | null}
   *   An object with current status, or `null` if the key is not currently tracked (or has expired).
   *   `resetTime` is a Unix timestamp (milliseconds).
   */
  public getStatus(key: string): {
    current: number;
    limit: number;
    remaining: number;
    resetTime: number;
  } | null {
    const entry = this.limits.get(key);
    if (!entry || Date.now() >= entry.resetTime) {
      // Also consider expired as not found for status
      return null;
    }
    return {
      current: entry.count,
      limit: this.currentConfig.maxRequests,
      remaining: Math.max(0, this.currentConfig.maxRequests - entry.count),
      resetTime: entry.resetTime,
    };
  }

  /**
   * Stops the cleanup timer and clears all rate limit entries.
   * This should be called if the rate limiter instance is no longer needed,
   * to prevent resource leaks (though `unref` on the timer helps).
   * @param {RequestContext} [context] - Optional context for logging the disposal.
   */
  public dispose(context?: RequestContext): void {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }
    this.limits.clear();
    const opContext =
      context ||
      requestContextService.createRequestContext({
        operation: "RateLimiter.dispose",
      });
    logger.info(
      "Rate limiter disposed, cleanup timer stopped and limits cleared.",
      opContext,
    );
  }
}

/**
 * A default, shared instance of the `RateLimiter`.
 * This instance is configured with default settings (e.g., 100 requests per 15 minutes).
 * It can be reconfigured using `rateLimiter.configure()`.
 *
 * Example:
 * ```typescript
 * import { rateLimiter, RequestContext } from './rateLimiter';
 * import { requestContextService } from '../internal';
 *
 * const context: RequestContext = requestContextService.createRequestContext({ operation: 'MyApiCall' });
 * const userIp = '123.45.67.89';
 *
 * try {
 *   rateLimiter.check(userIp, context);
 *   // Proceed with operation
 * } catch (e) {
 *   if (e instanceof McpError && e.code === BaseErrorCode.RATE_LIMITED) {
 *     console.error("Rate limit hit:", e.message);
 *   } else {
 *     // Handle other errors
 *   }
 * }
 * ```
 */
export const rateLimiter = new RateLimiter({}); // Initialize with default or empty to use class defaults

```

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

```markdown
# Obsidian MCP Tool Specification

This document outlines the potential tools for an Obsidian MCP server based on the capabilities of the Obsidian Local REST API plugin.

## Core File/Vault Operations

### 1. `obsidian_read_file`

- **Description:** Retrieves the content of a specified file within the Obsidian vault.
- **Parameters:**
  - `filePath` (string, required): Vault-relative path to the file.
  - `format` (enum: 'markdown' | 'json', optional, default: 'markdown'): The desired format for the returned content. 'json' returns a `NoteJson` object including frontmatter and metadata.
- **Returns:** The file content as a string (markdown) or a `NoteJson` object.

### 2. `obsidian_update_file`

- **Description:** Modifies the content of an Obsidian note (specified by path, the active file, or a periodic note) by appending, prepending, or overwriting (**whole-file** operations) OR applying granular patches relative to internal structures (**patch** operations). Can create the target if it doesn't exist.
- **Required Parameters:**
  - `targetType` (enum: 'filePath' | 'activeFile' | 'periodicNote'): Specifies the type of target note.
  - `content` (string | object): The content to use for the modification (string for whole-file, string or object for patch).
  - `modificationType` (enum: 'wholeFile' | 'patch'): Specifies whether to perform a whole-file operation or a granular patch.
- **Optional Parameters:**
  - `targetIdentifier` (string): Required if `targetType` is 'filePath' (provide vault-relative path) or 'periodicNote' (provide period like 'daily', 'weekly'). Not used for 'activeFile'.
- **Parameters for `modificationType: 'wholeFile'`:**
  - `wholeFileMode` (enum: 'append' | 'prepend' | 'overwrite', required): The specific whole-file operation.
  - `createIfNeeded` (boolean, optional, default: true): If true, creates the target file/note if it doesn't exist before applying the modification.
  - `overwriteIfExists` (boolean, optional, default: false): Only relevant for `wholeFileMode: 'overwrite'`. If true, allows overwriting an existing file. If false (default) and the file exists when `mode` is 'overwrite', the operation will fail.
- **Parameters for `modificationType: 'patch'`:**
  - `patchOperation` (enum: 'append' | 'prepend' | 'replace', required): The type of patch operation relative to the target.
  - `patchTargetType` (enum: 'heading' | 'block' | 'frontmatter', required): The type of internal structure to target.
  - `patchTarget` (string, required): The specific heading text, block ID, or frontmatter key to target.
  - `patchTargetDelimiter` (string, optional): Delimiter for nested headings (default '::').
  - `patchTrimTargetWhitespace` (boolean, optional, default: false): Whether to trim whitespace around the patch target.
  - `patchCreateTargetIfMissing` (boolean, optional, default: false): Whether to create the target (e.g., heading, frontmatter key) if it's missing before patching.
- **Returns:** Success confirmation.

### 3. `obsidian_delete_file`

- **Description:** Deletes a specified file from the vault.
- **Parameters:**
  - `filePath` (string, required): Vault-relative path to the file to delete.
- **Returns:** Success confirmation.

### 4. `obsidian_list_files`

- **Description:** Lists files and directories within a specified folder in the vault.
- **Parameters:**
  - `dirPath` (string, required): Vault-relative path to the directory. Use an empty string `""` or `/` for the vault root.
- **Returns:** An array of strings, where each string is a file or directory name (directories end with `/`).

## Search Operations

### 5. `obsidian_global_search`

- **Description:** Performs text search across vault content, with server-side support for regex, wildcards, and date filtering.
- **Parameters:**
  - `query` (string, required): The text string or regex pattern to search for.
  - `contextLength` (number, optional, default: 100): The number of characters surrounding each match to include as context.
  - `modified_since` (string, optional): Filter for files modified _after_ this date/time (e.g., '2 weeks ago', '2024-01-15', 'yesterday'). Parsed by dateParser utility.
  - `modified_until` (string, optional): Filter for files modified _before_ this date/time (e.g., 'today', '2024-03-20 17:00'). Parsed by dateParser utility.
- **Returns:** An array of search results (structure TBD, likely similar to `SimpleSearchResult` but potentially filtered further based on implementation).
- **Note:** Requires custom server-side implementation for advanced filtering (regex, dates) as the underlying simple API endpoint likely doesn't support them directly. May involve listing files, reading content, and applying filters in the MCP server.

### 6. `obsidian_json_search`

- **Description:** Performs a complex search using Dataview DQL or JsonLogic. Advanced filtering (regex, dates) depends on the capabilities of the chosen query language.
- **Parameters:**
  - `query` (string | object, required): The query string (for DQL) or JSON object (for JsonLogic).
  - `contentType` (enum: 'application/vnd.olrapi.dataview.dql+txt' | 'application/vnd.olrapi.jsonlogic+json', required): Specifies the format of the `query` parameter.
- **Returns:** An array of `ComplexSearchResult` objects.

## Metadata & Properties Operations

### 7. `obsidian_get_tags`

- **Description:** Retrieves all tags defined in the YAML frontmatter of markdown files within your Obsidian vault, along with their usage counts and associated file paths. Optionally, limit the search to a specific folder.
- **Parameters:**
  - `path` (string, optional): Folder path (relative to vault root) to restrict the tag search.
- **Returns:** An object mapping tags to their counts and associated file paths.

### 8. `obsidian_get_properties`

- **Description:** Retrieves properties (like title, tags, status) from the YAML frontmatter of a specified Obsidian note. Returns all defined properties, including any custom fields.
- **Parameters:**
  - `filepath` (string, required): Path to the note file (relative to vault root).
- **Returns:** An object containing all frontmatter key-value pairs.

### 9. `obsidian_update_properties`

- **Description:** Updates properties within the YAML frontmatter of a specified Obsidian note. By default, array properties (like tags) are merged; use the 'replace' option to overwrite them instead. Handles custom fields.
- **Parameters:**
  - `filepath` (string, required): Path to the note file (relative to vault root).
  - `properties` (object, required): Key-value pairs of properties to update.
  - `replace` (boolean, optional, default: false): If true, array properties will be completely replaced instead of merged.
- **Returns:** Success confirmation.

## Command Operations

### 10. `obsidian_execute_command`

- **Description:** Executes a registered Obsidian command using its unique ID.
- **Parameters:**
  - `commandId` (string, required): The ID of the command to execute (e.g., "app:go-back", "editor:toggle-bold").
- **Returns:** Success confirmation.

### 11. `obsidian_list_commands`

- **Description:** Retrieves a list of all available commands within the Obsidian application.
- **Parameters:** None.
- **Returns:** An array of `ObsidianCommand` objects, each containing the command's `id` and `name`.

## UI/Navigation & Active File Operations

### 12. `obsidian_open_file`

- **Description:** Opens a specified file in the Obsidian application interface. Creates the file if it doesn't exist.
- **Parameters:**
  - `filePath` (string, required): Vault-relative path to the file to open.
  - `newLeaf` (boolean, optional, default: false): If true, opens the file in a new editor tab (leaf).
- **Returns:** Success confirmation.

### 13. `obsidian_get_active_file`

- **Description:** Retrieves the content of the currently active file in the Obsidian editor.
- **Parameters:**
  - `format` (enum: 'markdown' | 'json', optional, default: 'markdown').
- **Returns:** The active file's content as a string or a `NoteJson` object.

### 14. `obsidian_delete_active_file`

- **Description:** Deletes the currently active file in Obsidian.
- **Parameters:** None.
- **Returns:** Success confirmation.

## Periodic Notes Operations

### 15. `obsidian_get_periodic_note`

- **Description:** Retrieves the content of a periodic note (e.g., daily, weekly).
- **Parameters:**
  - `period` (enum: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly', required): The type of periodic note to retrieve.
  - `format` (enum: 'markdown' | 'json', optional, default: 'markdown').
- **Returns:** The periodic note's content as a string or a `NoteJson` object.

### 16. `obsidian_delete_periodic_note`

- **Description:** Deletes a specified periodic note.
- **Parameters:**
  - `period` (enum: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly', required): The type of periodic note to delete.
- **Returns:** Success confirmation.

## Status Check

### 17. `obsidian_check_status`

- **Description:** Checks the connection status and authentication validity of the Obsidian Local REST API plugin.
- **Parameters:** None.
- **Returns:** An `ApiStatusResponse` object containing authentication status, service name, and version information.

## Phase 2

#### 1. `obsidian_manage_frontmatter`

- **Purpose**: To read, add, update, or remove specific keys from a note's YAML frontmatter without having to parse and rewrite the entire file content.
- **Input Schema**:
  - `filePath`: `z.string()` - Path to the target note.
  - `operation`: `z.enum(['get', 'set', 'delete'])` - The action to perform.
  - `key`: `z.string()` - The frontmatter key to target (e.g., "status").
  - `value`: `z.any().optional()` - The value to set for the key (required for `set`).
- **Output**: `{ success: true, message: "...", value: ... }` (returns the value for 'get', or the updated frontmatter).
- **Why it's useful**: This is far more robust and reliable than using `search_replace` on the raw text of the frontmatter. An agent could manage a note's status, due date, or other metadata fields programmatically.

#### 2. `obsidian_manage_tags`

- **Purpose**: To add or remove tags from a note. The tool's logic would be smart enough to handle tags in both the frontmatter (`tags: [tag1, tag2]`) and inline (`#tag3`).
- **Input Schema**:
  - `filePath`: `z.string()` - Path to the target note.
  - `operation`: `z.enum(['add', 'remove', 'list'])` - The action to perform.
  - `tags`: `z.array(z.string())` - An array of tags to add or remove (without the '#').
- **Output**: `{ success: true, message: "...", currentTags: ["tag1", "tag2", "tag3"] }`
- **Why it's useful**: Provides a semantic way to categorize notes, which is a core Obsidian workflow. The agent could tag notes based on their content or as part of a larger task.

#### 3. `obsidian_dataview_query`

- **Purpose**: To execute a Dataview query (DQL) and return the structured results. This is the most powerful querying tool in the Obsidian ecosystem.
- **Input Schema**:
  - `query`: `z.string()` - The Dataview Query Language (DQL) string.
- **Output**: A JSON representation of the Dataview table or list result. `{ success: true, results: [{...}, {...}] }`
- **Why it's useful**: The agent could answer questions like:
  - "List all unfinished tasks from my project notes." (`TASK from #project WHERE !completed`)
  - "Show me all books I rated 5 stars." (`TABLE rating from #book WHERE rating = 5`)
  - "Find all meeting notes from the last 7 days." (`LIST from #meeting WHERE file.cday >= date(today) - dur(7 days)`)

This tool would be incredibly potent but requires the user to have the Dataview plugin installed. It would leverage the `searchComplex` method already in your `ObsidianRestApiService`.

```

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

```typescript
/**
 * @fileoverview Main entry point for the MCP (Model Context Protocol) server.
 * This file orchestrates the server's lifecycle:
 * 1. Initializes the core `McpServer` instance (from `@modelcontextprotocol/sdk`) with its identity and capabilities.
 * 2. Registers available resources and tools, making them discoverable and usable by clients.
 * 3. Selects and starts the appropriate communication transport (stdio or Streamable HTTP)
 *    based on configuration.
 * 4. Handles top-level error management during startup.
 *
 * MCP Specification References:
 * - Lifecycle: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/lifecycle.mdx
 * - Overview (Capabilities): https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/index.mdx
 * - Transports: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx
 * @module src/mcp-server/server
 */

import { ServerType } from "@hono/node-server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Import validated configuration and environment details.
import { config, environment } from "../config/index.js";
// Import core utilities: ErrorHandler, logger, requestContextService.
import { ErrorHandler, logger, requestContextService } from "../utils/index.js";
// Import the Obsidian service
import { ObsidianRestApiService } from "../services/obsidianRestAPI/index.js";
// Import the Vault Cache service
import { VaultCacheService } from "../services/obsidianRestAPI/vaultCache/index.js";
// Import registration functions for specific resources and tools.
import { registerObsidianDeleteNoteTool } from "./tools/obsidianDeleteNoteTool/index.js";
import { registerObsidianGlobalSearchTool } from "./tools/obsidianGlobalSearchTool/index.js";
import { registerObsidianListNotesTool } from "./tools/obsidianListNotesTool/index.js";
import { registerObsidianReadNoteTool } from "./tools/obsidianReadNoteTool/index.js";
import { registerObsidianSearchReplaceTool } from "./tools/obsidianSearchReplaceTool/index.js";
import { registerObsidianUpdateNoteTool } from "./tools/obsidianUpdateNoteTool/index.js";
import { registerObsidianManageFrontmatterTool } from "./tools/obsidianManageFrontmatterTool/index.js";
import { registerObsidianManageTagsTool } from "./tools/obsidianManageTagsTool/index.js";
// Import transport setup functions.
import { startHttpTransport } from "./transports/httpTransport.js";
import { connectStdioTransport } from "./transports/stdioTransport.js";

/**
 * Creates and configures a new instance of the `McpServer`.
 *
 * This function is central to defining the server's identity and functionality
 * as presented to connecting clients during the MCP initialization phase.
 * It uses pre-instantiated shared services like Obsidian API and Vault Cache.
 *
 * MCP Spec Relevance:
 * - Server Identity (`serverInfo`): The `name` and `version` provided here are part
 *   of the `ServerInformation` object returned in the `InitializeResult` message.
 * - Capabilities Declaration: Declares supported features (logging, dynamic resources/tools).
 * - Resource/Tool Registration: Calls registration functions, passing necessary service instances.
 *
 * Design Note: This factory is called once for 'stdio' transport and per session for 'http' transport.
 *
 * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance.
 * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance, which may be undefined if disabled.
 * @returns {Promise<McpServer>} A promise resolving with the configured `McpServer` instance.
 * @throws {Error} If any resource or tool registration fails.
 * @private
 */
async function createMcpServerInstance(
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<McpServer> {
  const context = requestContextService.createRequestContext({
    operation: "createMcpServerInstance",
  });
  logger.info("Initializing MCP server instance with shared services", context);

  requestContextService.configure({
    appName: config.mcpServerName,
    appVersion: config.mcpServerVersion,
    environment,
  });

  logger.debug("Instantiating McpServer with capabilities", {
    ...context,
    serverInfo: {
      name: config.mcpServerName,
      version: config.mcpServerVersion,
    },
    capabilities: {
      logging: {},
      resources: { listChanged: true },
      tools: { listChanged: true },
    },
  });
  const server = new McpServer(
    { name: config.mcpServerName, version: config.mcpServerVersion },
    {
      capabilities: {
        logging: {}, // Server can receive logging/setLevel and send notifications/message
        resources: { listChanged: true }, // Server supports dynamic resource lists
        tools: { listChanged: true }, // Server supports dynamic tool lists
      },
    },
  );

  try {
    logger.debug(
      "Registering resources and tools using shared services...",
      context,
    );
    // Register all tools, passing the vaultCacheService which may be undefined
    await registerObsidianListNotesTool(server, obsidianService);
    await registerObsidianReadNoteTool(server, obsidianService);
    await registerObsidianDeleteNoteTool(
      server,
      obsidianService,
      vaultCacheService,
    );
    if (vaultCacheService) {
      await registerObsidianGlobalSearchTool(
        server,
        obsidianService,
        vaultCacheService,
      );
    } else {
      logger.warning(
        "Skipping registration of 'obsidian_global_search' because the Vault Cache Service is disabled.",
        context,
      );
    }
    await registerObsidianSearchReplaceTool(
      server,
      obsidianService,
      vaultCacheService,
    );
    await registerObsidianUpdateNoteTool(
      server,
      obsidianService,
      vaultCacheService,
    );
    await registerObsidianManageFrontmatterTool(
      server,
      obsidianService,
      vaultCacheService,
    );
    await registerObsidianManageTagsTool(
      server,
      obsidianService,
      vaultCacheService,
    );

    logger.info("Resources and tools registered successfully", context);

    if (vaultCacheService) {
      logger.info(
        "Triggering background vault cache build (if not already built/building)...",
        context,
      );
      // Intentionally not awaiting this promise to allow server startup to proceed.
      // Errors are logged within the catch block.
      vaultCacheService.buildVaultCache().catch((cacheBuildError) => {
        logger.error("Error occurred during background vault cache build", {
          ...context, // Use the initial context for correlation
          subOperation: "BackgroundVaultCacheBuild", // Add sub-operation for clarity
          error:
            cacheBuildError instanceof Error
              ? cacheBuildError.message
              : String(cacheBuildError),
          stack:
            cacheBuildError instanceof Error
              ? cacheBuildError.stack
              : undefined,
        });
      });
    }
  } catch (err) {
    logger.error("Failed to register resources/tools", {
      ...context,
      error: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
    });
    throw err; // Re-throw to be caught by the caller (e.g., startTransport)
  }

  return server;
}

/**
 * Selects, sets up, and starts the appropriate MCP transport layer based on configuration.
 * This function acts as the bridge between the core server logic and the communication channel.
 * It now accepts shared service instances to pass them down the chain.
 *
 * MCP Spec Relevance:
 * - Transport Selection: Uses `config.mcpTransportType` ('stdio' or 'http').
 * - Transport Connection: Calls dedicated functions for chosen transport.
 * - Server Instance Lifecycle: Single instance for 'stdio', per-session for 'http'.
 *
 * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance.
 * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance.
 * @returns {Promise<McpServer | void>} Resolves with the `McpServer` instance for 'stdio', or `void` for 'http'.
 * @throws {Error} If the configured transport type is unsupported or if transport setup fails.
 * @private
 */
async function startTransport(
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<McpServer | ServerType | void> {
  const transportType = config.mcpTransportType;
  const context = requestContextService.createRequestContext({
    operation: "startTransport",
    transport: transportType,
  });
  logger.info(`Starting transport: ${transportType}`, context);

  if (transportType === "http") {
    logger.debug(
      "Delegating to startHttpTransport with a factory for McpServer instances...",
      context,
    );
    // For HTTP, startHttpTransport manages its own lifecycle and server instances per session.
    // It needs a factory function to create new McpServer instances, passing along the shared services.
    const mcpServerFactory = async () =>
      createMcpServerInstance(obsidianService, vaultCacheService);
    const httpServerInstance = await startHttpTransport(
      mcpServerFactory,
      context,
    );
    return httpServerInstance; // Return the http.Server instance.
  }

  if (transportType === "stdio") {
    logger.debug(
      "Creating single McpServer instance for stdio transport using shared services...",
      context,
    );
    const server = await createMcpServerInstance(
      obsidianService,
      vaultCacheService,
    );
    logger.debug("Delegating to connectStdioTransport...", context);
    await connectStdioTransport(server, context);
    return server; // Return the single server instance for stdio.
  }

  // Should not be reached if config validation is effective.
  logger.fatal(
    `Unsupported transport type configured: ${transportType}`,
    context,
  );
  throw new Error(
    `Unsupported transport type: ${transportType}. Must be 'stdio' or 'http'.`,
  );
}

/**
 * Main application entry point. Initializes services and starts the MCP server.
 * Orchestrates server startup, transport selection, and top-level error handling.
 *
 * MCP Spec Relevance:
 * - Manages server startup, leading to a server ready for MCP messages.
 * - Handles critical startup failures, ensuring appropriate process exit.
 *
 * @param {ObsidianRestApiService} obsidianService - The shared Obsidian REST API service instance, instantiated by the caller (e.g., index.ts).
 * @param {VaultCacheService | undefined} vaultCacheService - The shared Vault Cache service instance, instantiated by the caller (e.g., index.ts).
 * @returns {Promise<void | McpServer>} For 'stdio', resolves with `McpServer`. For 'http', runs indefinitely.
 *   Rejects on critical failure, leading to process exit.
 */
export async function initializeAndStartServer(
  obsidianService: ObsidianRestApiService,
  vaultCacheService: VaultCacheService | undefined,
): Promise<void | McpServer | ServerType> {
  const context = requestContextService.createRequestContext({
    operation: "initializeAndStartServer",
  });
  logger.info(
    "MCP Server initialization sequence started (services provided).",
    context,
  );

  try {
    // Services are now provided by the caller (e.g., index.ts)
    logger.debug(
      "Using provided shared services (ObsidianRestApiService, VaultCacheService).",
      context,
    );

    // Initiate the transport setup based on configuration, passing shared services.
    const result = await startTransport(obsidianService, vaultCacheService);
    logger.info(
      "MCP Server initialization sequence completed successfully.",
      context,
    );
    return result;
  } catch (err) {
    logger.fatal("Critical error during MCP server initialization.", {
      ...context,
      error: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
    });
    // Ensure the error is handled by our centralized handler, which might log more details or perform cleanup.
    ErrorHandler.handleError(err, {
      operation: "initializeAndStartServer", // More specific operation
      context: context, // Pass the existing context
      critical: true, // This is a critical failure
    });
    logger.info(
      "Exiting process due to critical initialization error.",
      context,
    );
    process.exit(1); // Exit with a non-zero code to indicate failure.
  }
}

```

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

```typescript
/**
 * @module VaultCacheService
 * @description Service for building and managing an in-memory cache of Obsidian vault content.
 */

import path from "node:path";
import { config } from "../../../config/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  logger,
  RequestContext,
  requestContextService,
  retryWithDelay,
} from "../../../utils/index.js";
import { NoteJson, ObsidianRestApiService } from "../index.js";

interface CacheEntry {
  content: string;
  mtime: number; // Store modification time for date filtering
  // Add other stats if needed, e.g., ctime, size
}

/**
 * Manages an in-memory cache of the Obsidian vault's file structure and metadata.
 *
 * __Is the cache safe and secure?__
 * Yes, the cache is safe and secure for its purpose within this application. Here's why:
 * 1. __In-Memory Storage:__ The cache exists only in the server's memory. It is not written to disk or transmitted over the network, so its attack surface is limited to the server process itself.
 * 2. __Local Data Source:__ The data populating the cache comes directly from your own Obsidian vault via the local REST API. It is not fetching data from external, untrusted sources.
 *
 * __Warning: High Memory Usage__
 * This service stores the entire content of every markdown file in the vault in memory. For users with very large vaults (e.g., many gigabytes of markdown files), this can lead to significant RAM consumption. If you experience high memory usage, consider disabling the cache via the `OBSIDIAN_ENABLE_CACHE` environment variable.
 */
export class VaultCacheService {
  private vaultContentCache: Map<string, CacheEntry> = new Map();
  private isCacheReady: boolean = false;
  private isBuilding: boolean = false;
  private obsidianService: ObsidianRestApiService;
  private refreshIntervalId: NodeJS.Timeout | null = null;

  constructor(obsidianService: ObsidianRestApiService) {
    this.obsidianService = obsidianService;
    logger.info(
      "VaultCacheService initialized.",
      requestContextService.createRequestContext({
        operation: "VaultCacheServiceInit",
      }),
    );
  }

  /**
   * Starts the periodic cache refresh mechanism.
   * The interval is controlled by the `OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN` config setting.
   */
  public startPeriodicRefresh(): void {
    const refreshIntervalMs =
      config.obsidianCacheRefreshIntervalMin * 60 * 1000;
    if (this.refreshIntervalId) {
      logger.warning(
        "Periodic refresh is already running.",
        requestContextService.createRequestContext({
          operation: "startPeriodicRefresh",
        }),
      );
      return;
    }
    this.refreshIntervalId = setInterval(
      () => this.refreshCache(),
      refreshIntervalMs,
    );
    logger.info(
      `Vault cache periodic refresh scheduled every ${config.obsidianCacheRefreshIntervalMin} minutes.`,
      requestContextService.createRequestContext({
        operation: "startPeriodicRefresh",
      }),
    );
  }

  /**
   * Stops the periodic cache refresh mechanism.
   * Should be called during graceful shutdown.
   */
  public stopPeriodicRefresh(): void {
    const context = requestContextService.createRequestContext({
      operation: "stopPeriodicRefresh",
    });
    if (this.refreshIntervalId) {
      clearInterval(this.refreshIntervalId);
      this.refreshIntervalId = null;
      logger.info("Stopped periodic cache refresh.", context);
    } else {
      logger.info("Periodic cache refresh was not running.", context);
    }
  }

  /**
   * Checks if the cache has been successfully built.
   * @returns {boolean} True if the cache is ready, false otherwise.
   */
  public isReady(): boolean {
    return this.isCacheReady;
  }

  /**
   * Checks if the cache is currently being built.
   * @returns {boolean} True if the cache build is in progress, false otherwise.
   */
  public getIsBuilding(): boolean {
    return this.isBuilding;
  }

  /**
   * Returns the entire vault content cache.
   * Use with caution for large vaults due to potential memory usage.
   * @returns {ReadonlyMap<string, CacheEntry>} The cache map.
   */
  public getCache(): ReadonlyMap<string, CacheEntry> {
    // Return a readonly view or copy if mutation is a concern
    return this.vaultContentCache;
  }

  /**
   * Retrieves a specific entry from the cache.
   * @param {string} filePath - The vault-relative path of the file.
   * @returns {CacheEntry | undefined} The cache entry or undefined if not found.
   */
  public getEntry(filePath: string): CacheEntry | undefined {
    return this.vaultContentCache.get(filePath);
  }

  /**
   * Immediately fetches the latest data for a single file and updates its entry in the cache.
   * This is useful for ensuring cache consistency immediately after a file modification.
   * @param {string} filePath - The vault-relative path of the file to update.
   * @param {RequestContext} context - The request context for logging.
   */
  public async updateCacheForFile(
    filePath: string,
    context: RequestContext,
  ): Promise<void> {
    const opContext = { ...context, operation: "updateCacheForFile", filePath };
    logger.debug(`Proactively updating cache for file: ${filePath}`, opContext);
    try {
      const noteJson = await retryWithDelay(
        () =>
          this.obsidianService.getFileContent(
            filePath,
            "json",
            opContext,
          ) as Promise<NoteJson>,
        {
          operationName: "proactiveCacheUpdate",
          context: opContext,
          maxRetries: 3,
          delayMs: 300,
          shouldRetry: (err: unknown) =>
            err instanceof McpError &&
            (err.code === BaseErrorCode.NOT_FOUND ||
              err.code === BaseErrorCode.SERVICE_UNAVAILABLE),
        },
      );

      if (noteJson && noteJson.content && noteJson.stat) {
        this.vaultContentCache.set(filePath, {
          content: noteJson.content,
          mtime: noteJson.stat.mtime,
        });
        logger.info(`Proactively updated cache for: ${filePath}`, opContext);
      } else {
        logger.warning(
          `Proactive cache update for ${filePath} received invalid data, skipping update.`,
          opContext,
        );
      }
    } catch (error) {
      // If the file was deleted, a NOT_FOUND error is expected. We should remove it from the cache.
      if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
        if (this.vaultContentCache.has(filePath)) {
          this.vaultContentCache.delete(filePath);
          logger.info(
            `Proactively removed deleted file from cache: ${filePath}`,
            opContext,
          );
        }
      } else {
        logger.error(
          `Failed to proactively update cache for ${filePath}. Error: ${error instanceof Error ? error.message : String(error)}`,
          opContext,
        );
      }
    }
  }

  /**
   * Builds the in-memory cache by fetching all markdown files and their content.
   * This is intended to be run once at startup. Subsequent updates are handled by `refreshCache`.
   */
  public async buildVaultCache(): Promise<void> {
    const initialBuildContext = requestContextService.createRequestContext({
      operation: "buildVaultCache.initialCheck",
    });
    if (this.isBuilding) {
      logger.warning(
        "Cache build already in progress. Skipping.",
        initialBuildContext,
      );
      return;
    }
    if (this.isCacheReady) {
      logger.info("Cache already built. Skipping.", initialBuildContext);
      return;
    }

    await this.refreshCache(true); // Perform an initial, full build
  }

  /**
   * Refreshes the cache by comparing remote file modification times with cached ones.
   * Only fetches content for new or updated files.
   * @param isInitialBuild - If true, forces a full build and sets the cache readiness flag.
   */
  public async refreshCache(isInitialBuild = false): Promise<void> {
    const context = requestContextService.createRequestContext({
      operation: "refreshCache",
      isInitialBuild,
    });

    if (this.isBuilding) {
      logger.warning("Cache refresh already in progress. Skipping.", context);
      return;
    }

    this.isBuilding = true;
    if (isInitialBuild) {
      this.isCacheReady = false;
    }

    logger.info("Starting vault cache refresh process...", context);

    try {
      const startTime = Date.now();
      const remoteFiles = await this.listAllMarkdownFiles("/", context);
      const remoteFileSet = new Set(remoteFiles);
      const cachedFileSet = new Set(this.vaultContentCache.keys());

      let filesAdded = 0;
      let filesUpdated = 0;
      let filesRemoved = 0;

      // 1. Remove deleted files from cache
      for (const cachedFile of cachedFileSet) {
        if (!remoteFileSet.has(cachedFile)) {
          this.vaultContentCache.delete(cachedFile);
          filesRemoved++;
          logger.debug(`Removed deleted file from cache: ${cachedFile}`, {
            ...context,
            filePath: cachedFile,
          });
        }
      }

      // 2. Check for new or updated files
      for (const filePath of remoteFiles) {
        try {
          const fileMetadata = await this.obsidianService.getFileMetadata(
            filePath,
            context,
          );

          if (!fileMetadata) {
            logger.warning(
              `Skipping file during cache refresh due to missing or invalid metadata: ${filePath}`,
              { ...context, filePath },
            );
            continue;
          }

          const remoteMtime = fileMetadata.mtime;
          const cachedEntry = this.vaultContentCache.get(filePath);

          if (!cachedEntry || cachedEntry.mtime < remoteMtime) {
            const noteJson = (await this.obsidianService.getFileContent(
              filePath,
              "json",
              context,
            )) as NoteJson;
            this.vaultContentCache.set(filePath, {
              content: noteJson.content,
              mtime: noteJson.stat.mtime,
            });

            if (!cachedEntry) {
              filesAdded++;
              logger.debug(`Added new file to cache: ${filePath}`, {
                ...context,
                filePath,
              });
            } else {
              filesUpdated++;
              logger.debug(`Updated modified file in cache: ${filePath}`, {
                ...context,
                filePath,
              });
            }
          }
        } catch (error) {
          logger.error(
            `Failed to process file during cache refresh: ${filePath}. Skipping. Error: ${error instanceof Error ? error.message : String(error)}`,
            { ...context, filePath },
          );
        }
      }

      const duration = (Date.now() - startTime) / 1000;
      if (isInitialBuild) {
        this.isCacheReady = true;
        logger.info(
          `Initial vault cache build completed in ${duration.toFixed(2)}s. Cached ${this.vaultContentCache.size} files.`,
          context,
        );
      } else {
        logger.info(
          `Vault cache refresh completed in ${duration.toFixed(2)}s. Added: ${filesAdded}, Updated: ${filesUpdated}, Removed: ${filesRemoved}. Total cached: ${this.vaultContentCache.size}.`,
          context,
        );
      }
    } catch (error) {
      logger.error(
        `Critical error during vault cache refresh. Cache may be incomplete. Error: ${error instanceof Error ? error.message : String(error)}`,
        context,
      );
      if (isInitialBuild) {
        this.isCacheReady = false;
      }
    } finally {
      this.isBuilding = false;
    }
  }

  /**
   * Helper to recursively list all markdown files. Similar to the one in search logic.
   * @param dirPath - Starting directory path.
   * @param context - Request context.
   * @param visitedDirs - Set to track visited directories.
   * @returns Array of file paths.
   */
  private async listAllMarkdownFiles(
    dirPath: string,
    context: RequestContext,
    visitedDirs: Set<string> = new Set(),
  ): Promise<string[]> {
    const operation = "listAllMarkdownFiles";
    const opContext = { ...context, operation, dirPath };
    const normalizedPath = path.posix.normalize(dirPath === "" ? "/" : dirPath);

    if (visitedDirs.has(normalizedPath)) {
      logger.warning(
        `Cycle detected or directory already visited during cache build: ${normalizedPath}. Skipping.`,
        opContext,
      );
      return [];
    }
    visitedDirs.add(normalizedPath);

    let markdownFiles: string[] = [];
    try {
      const entries = await this.obsidianService.listFiles(
        normalizedPath,
        opContext,
      );
      for (const entry of entries) {
        const fullPath = path.posix.join(normalizedPath, entry);
        if (entry.endsWith("/")) {
          const subDirFiles = await this.listAllMarkdownFiles(
            fullPath,
            opContext,
            visitedDirs,
          );
          markdownFiles = markdownFiles.concat(subDirFiles);
        } else if (entry.toLowerCase().endsWith(".md")) {
          markdownFiles.push(fullPath);
        }
      }
      return markdownFiles;
    } catch (error) {
      const errMsg = `Failed to list directory during cache build scan: ${normalizedPath}`;
      const err = error as McpError | Error; // Type assertion
      if (err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND) {
        logger.warning(`${errMsg} - Directory not found, skipping.`, opContext);
        return [];
      }
      // Log and re-throw critical listing errors
      if (err instanceof Error) {
        logger.error(errMsg, err, opContext);
      } else {
        logger.error(errMsg, opContext);
      }
      const errorCode =
        err instanceof McpError ? err.code : BaseErrorCode.INTERNAL_ERROR;
      throw new McpError(
        errorCode,
        `${errMsg}: ${err instanceof Error ? err.message : String(err)}`,
        opContext,
      );
    }
  }
}

```

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

```typescript
#!/usr/bin/env node

// Imports MUST be at the top level
import { ServerType } from "@hono/node-server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { config, environment } from "./config/index.js"; // This loads .env via dotenv.config()
import { initializeAndStartServer } from "./mcp-server/server.js";
import { requestContextService, retryWithDelay } from "./utils/index.js";
import { logger, McpLogLevel } from "./utils/internal/logger.js"; // Import logger instance early
// Import Services
import { ObsidianRestApiService } from "./services/obsidianRestAPI/index.js";
import { VaultCacheService } from "./services/obsidianRestAPI/vaultCache/index.js"; // Import VaultCacheService

/**
 * The main MCP server instance (only stored globally for stdio shutdown).
 * @type {McpServer | undefined}
 */
let server: McpServer | undefined;
/**
 * The main HTTP server instance (only stored globally for http shutdown).
 * @type {ServerType | undefined}
 */
let httpServerInstance: ServerType | undefined;
/**
 * Shared Obsidian REST API service instance.
 * @type {ObsidianRestApiService | undefined}
 */
let obsidianService: ObsidianRestApiService | undefined;
/**
 * Shared Vault Cache service instance.
 * @type {VaultCacheService | undefined}
 */
let vaultCacheService: VaultCacheService | undefined;

/**
 * Gracefully shuts down the main MCP server.
 * Handles process termination signals (SIGTERM, SIGINT) and critical errors.
 *
 * @param signal - The signal or event name that triggered the shutdown (e.g., "SIGTERM", "uncaughtException").
 */
const shutdown = async (signal: string) => {
  // Define context for the shutdown operation
  const shutdownContext = requestContextService.createRequestContext({
    operation: "Shutdown",
    signal,
  });

  logger.info(
    `Received ${signal}. Starting graceful shutdown...`,
    shutdownContext,
  );

  try {
    // Stop cache refresh timer first
    if (config.obsidianEnableCache && vaultCacheService) {
      vaultCacheService.stopPeriodicRefresh();
    }

    // Close the main MCP server (only relevant for stdio)
    if (server) {
      logger.info("Closing main MCP server (stdio)...", shutdownContext);
      await server.close();
      logger.info(
        "Main MCP server (stdio) closed successfully",
        shutdownContext,
      );
    }

    // Close the main HTTP server instance (if it exists)
    if (httpServerInstance) {
      logger.info("Closing main HTTP server...", shutdownContext);
      await new Promise<void>((resolve, reject) => {
        httpServerInstance!.close((err?: Error) => {
          if (err) {
            logger.error("Error closing HTTP server", err, shutdownContext);
            reject(err);
            return;
          }
          logger.info("Main HTTP server closed successfully", shutdownContext);
          resolve();
        });
      });
    }

    if (!server && !httpServerInstance) {
      logger.warning(
        "No server instance (Stdio or HTTP) found to close during shutdown.",
        shutdownContext,
      );
    }

    // Add any other necessary cleanup here (e.g., closing database connections if added later)

    logger.info("Graceful shutdown completed successfully", shutdownContext);
    process.exit(0);
  } catch (error) {
    // Handle any errors during shutdown
    logger.error(
      "Critical error during shutdown",
      error instanceof Error ? error : undefined,
      {
        ...shutdownContext, // Spread the existing RequestContext
        // error field is handled by logger.error's second argument
      },
    );
    process.exit(1); // Exit with error code if shutdown fails
  }
};

/**
 * Initializes and starts the main MCP server.
 * Sets up request context, initializes the server instance, starts the transport,
 * and registers signal handlers for graceful shutdown and error handling.
 */
const start = async () => {
  // --- Logger Initialization (Moved here AFTER config/dotenv is loaded) ---
  const validMcpLogLevels: McpLogLevel[] = [
    "debug",
    "info",
    "notice",
    "warning",
    "error",
    "crit",
    "alert",
    "emerg",
  ];
  // Read level from config (which read from env var or default)
  const initialLogLevelConfig = config.logLevel;
  // Validate the configured log level
  let validatedMcpLogLevel: McpLogLevel = "info"; // Default to 'info'
  if (validMcpLogLevels.includes(initialLogLevelConfig as McpLogLevel)) {
    validatedMcpLogLevel = initialLogLevelConfig as McpLogLevel;
  } else {
    // Use console.warn here as logger isn't initialized yet
    console.warn(
      `Invalid MCP_LOG_LEVEL "${initialLogLevelConfig}" provided via config/env. Defaulting to "info".`,
    );
  }
  // Initialize the logger with the validated MCP level and wait for it to complete.
  await logger.initialize(validatedMcpLogLevel);
  // Log initialization message using the logger itself (will go to file/console)
  logger.info(
    `Logger initialized by start(). MCP logging level: ${validatedMcpLogLevel}`,
  );
  // --- End Logger Initialization ---

  // Log that config is loaded (this was previously done earlier)
  logger.debug(
    "Configuration loaded successfully",
    requestContextService.createRequestContext({
      configLoaded: true,
      configSummary: {
        serverName: config.mcpServerName,
        transport: config.mcpTransportType,
        logLevel: config.logLevel,
      },
    }),
  );

  // Create application-level request context using the service instance
  // Use the validated transport type from the config object
  const transportType = config.mcpTransportType;
  const startupContext = requestContextService.createRequestContext({
    operation: `ServerStartup_${transportType}`, // Include transport in operation name
    appName: config.mcpServerName,
    appVersion: config.mcpServerVersion,
    environment: environment,
  });

  logger.info(
    `Starting ${config.mcpServerName} v${config.mcpServerVersion} (Transport: ${transportType})...`,
    startupContext,
  );

  try {
    // --- Instantiate Shared Services ---
    logger.debug("Instantiating shared services...", startupContext);
    obsidianService = new ObsidianRestApiService(); // Instantiate Obsidian Service

    // --- Perform Initial Obsidian API Status Check ---
    try {
      logger.info(
        "Performing initial Obsidian API status check with retries...",
        startupContext,
      );

      const status = await retryWithDelay(
        async () => {
          if (!obsidianService) {
            // This case should not happen in practice, but it satisfies the type checker.
            throw new Error("Obsidian service not initialized.");
          }
          const checkStatusContext = {
            ...startupContext,
            operation: "checkStatusAttempt",
          };
          const currentStatus =
            await obsidianService.checkStatus(checkStatusContext);
          if (
            currentStatus?.service !== "Obsidian Local REST API" ||
            !currentStatus?.authenticated
          ) {
            // Throw an error to trigger a retry
            throw new Error(
              `Obsidian API status check failed or indicates authentication issue. Status: ${JSON.stringify(
                currentStatus,
              )}`,
            );
          }
          return currentStatus;
        },
        {
          operationName: "initialObsidianApiCheck",
          context: startupContext,
          maxRetries: 5, // Retry up to 5 times
          delayMs: 3000, // Wait 3 seconds between retries
        },
      );

      logger.info("Obsidian API status check successful.", {
        ...startupContext,
        obsidianVersion: status.versions.obsidian,
        pluginVersion: status.versions.self,
      });
    } catch (statusError) {
      logger.error(
        "Critical error during initial Obsidian API status check after multiple retries. Check OBSIDIAN_BASE_URL, OBSIDIAN_API_KEY, and plugin status.",
        {
          ...startupContext,
          error:
            statusError instanceof Error
              ? statusError.message
              : String(statusError),
          stack: statusError instanceof Error ? statusError.stack : undefined,
        },
      );
      // Re-throw the final error to be caught by the main startup catch block, which will exit the process.
      throw statusError;
    }
    // --- End Status Check ---

    if (config.obsidianEnableCache) {
      vaultCacheService = new VaultCacheService(obsidianService); // Instantiate Cache Service, passing Obsidian Service
      logger.info(
        "Vault cache is enabled and service is instantiated.",
        startupContext,
      );
    } else {
      logger.info("Vault cache is disabled by configuration.", startupContext);
    }
    logger.info("Shared services instantiated.", startupContext);
    // --- End Service Instantiation ---

    // Initialize the server instance and start the selected transport
    logger.debug(
      "Initializing and starting MCP server transport",
      startupContext,
    );

    // Start the server transport. Services are instantiated here and passed down.
    // For stdio, this returns the McpServer instance.
    // For http, it returns the http.Server instance.
    const serverOrHttpInstance = await initializeAndStartServer(
      obsidianService,
      vaultCacheService,
    );

    if (
      transportType === "stdio" &&
      serverOrHttpInstance instanceof McpServer
    ) {
      server = serverOrHttpInstance; // Store McpServer for stdio
      logger.debug(
        "Stored McpServer instance for stdio transport.",
        startupContext,
      );
    } else if (transportType === "http" && serverOrHttpInstance) {
      // The instance is of ServerType (http.Server or https.Server)
      httpServerInstance = serverOrHttpInstance as ServerType; // Store ServerType for http transport
      logger.debug(
        "Stored http.Server instance for http transport.",
        startupContext,
      );
    } else if (transportType === "http") {
      // This case should ideally not be reached if startHttpTransport always returns an http.Server
      logger.warning(
        "HTTP transport selected, but initializeAndStartServer did not return an http.Server instance.",
        startupContext,
      );
    }

    // If initializeAndStartServer failed, it would have thrown an error,
    // and execution would jump to the outer catch block.

    logger.info(
      `${config.mcpServerName} is running with ${transportType} transport`,
      {
        ...startupContext,
        startTime: new Date().toISOString(),
      },
    );

    // --- Trigger Background Cache Build ---
    if (config.obsidianEnableCache && vaultCacheService) {
      // Start building the cache, but don't wait for it to finish.
      // The server will be operational while the cache builds.
      // Tools needing the cache should check its readiness state.
      logger.info("Triggering background vault cache build...", startupContext);
      // No 'await' here - run in background
      vaultCacheService
        .buildVaultCache()
        .then(() => {
          // Once the initial build is done, start the periodic refresh
          vaultCacheService?.startPeriodicRefresh();
        })
        .catch((cacheBuildError) => {
          // Log errors during the background build process
          logger.error("Error occurred during background vault cache build", {
            ...startupContext, // Use startup context for correlation
            operation: "BackgroundCacheBuild",
            error:
              cacheBuildError instanceof Error
                ? cacheBuildError.message
                : String(cacheBuildError),
            stack:
              cacheBuildError instanceof Error
                ? cacheBuildError.stack
                : undefined,
          });
        });
    }
    // --- End Cache Build Trigger ---

    // --- Signal and Error Handling Setup ---

    // Handle process signals for graceful shutdown
    process.on("SIGTERM", () => shutdown("SIGTERM"));
    process.on("SIGINT", () => shutdown("SIGINT"));

    // Handle uncaught exceptions
    process.on("uncaughtException", async (error) => {
      const errorContext = {
        ...startupContext, // Include base context for correlation
        event: "uncaughtException",
        error: error instanceof Error ? error.message : String(error),
        stack: error instanceof Error ? error.stack : undefined,
      };
      logger.error(
        "Uncaught exception detected. Initiating shutdown...",
        errorContext,
      );
      // Attempt graceful shutdown; shutdown() handles its own errors.
      await shutdown("uncaughtException");
      // If shutdown fails internally, it will call process.exit(1).
      // If shutdown succeeds, it calls process.exit(0).
      // If shutdown itself throws unexpectedly *before* exiting, this process might terminate abruptly,
      // but the core shutdown logic is handled within shutdown().
    });

    // Handle unhandled promise rejections
    process.on("unhandledRejection", async (reason: unknown) => {
      const rejectionContext = {
        ...startupContext, // Include base context for correlation
        event: "unhandledRejection",
        reason: reason instanceof Error ? reason.message : String(reason),
        stack: reason instanceof Error ? reason.stack : undefined,
      };
      logger.error(
        "Unhandled promise rejection detected. Initiating shutdown...",
        rejectionContext,
      );
      // Attempt graceful shutdown; shutdown() handles its own errors.
      await shutdown("unhandledRejection");
      // Similar logic as uncaughtException: shutdown handles its exit codes.
    });
  } catch (error) {
    // Handle critical startup errors (already logged by ErrorHandler or caught above)
    // Log the final failure context, including error details, before exiting
    logger.error("Critical error during startup, exiting.", {
      ...startupContext,
      finalErrorContext: "Startup Failure",
      error: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined,
    });
    process.exit(1);
  }
};

// --- Async IIFE to allow top-level await ---
// This remains necessary because start() is async
(async () => {
  // Start the application
  await start();
})(); // End async IIFE

```

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

```typescript
import path from "node:path"; // Using POSIX path functions for vault path manipulation
import { z } from "zod";
import {
  NoteJson,
  ObsidianRestApiService,
} from "../../../services/obsidianRestAPI/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import {
  createFormattedStatWithTokenCount,
  logger,
  RequestContext,
  retryWithDelay,
} from "../../../utils/index.js";

// ====================================================================================
// Schema Definitions for Input Validation
// ====================================================================================

/**
 * Defines the allowed formats for the returned file content.
 * - 'markdown': Returns the raw Markdown content as a string.
 * - 'json': Returns a structured NoteJson object including content, frontmatter, tags, and stats.
 */
const ReadNoteFormatSchema = z
  .enum(["markdown", "json"])
  .default("markdown")
  .describe(
    "Specifies the format for the returned content ('markdown' or 'json'). Defaults to 'markdown'.",
  );

/**
 * Zod schema for validating the input parameters of the 'obsidian_read_note' tool.
 */
export const ObsidianReadNoteInputSchema = z
  .object({
    /**
     * The vault-relative path to the target file (e.g., "Folder/My Note.md").
     * Must include the file extension. The tool first attempts a case-sensitive match.
     * If not found, it attempts a case-insensitive fallback search within the same directory.
     */
    filePath: z
      .string()
      .min(1, "filePath cannot be empty")
      .describe(
        'The vault-relative path to the target file (e.g., "developer/github/tips.md"). Tries case-sensitive first, then case-insensitive fallback.',
      ),
    /**
     * Specifies the desired format for the returned content.
     * 'markdown' returns the raw file content as a string.
     * 'json' returns a structured NoteJson object containing content, parsed frontmatter, tags, and file metadata (stat).
     * Defaults to 'markdown'.
     */
    format: ReadNoteFormatSchema.optional() // Optional, defaults to 'markdown' via ReadNoteFormatSchema
      .describe(
        "Format for the returned content ('markdown' or 'json'). Defaults to 'markdown'.",
      ),
    /**
     * If true and the requested format is 'markdown', includes formatted file statistics
     * (creation time, modification time, token count estimate) in the response's 'stat' field.
     * Defaults to false. This flag is ignored if the format is 'json', as stats are always included within the NoteJson object itself (and also added to the top-level 'stat' field in the response).
     */
    includeStat: z
      .boolean()
      .optional()
      .default(false)
      .describe(
        "If true and format is 'markdown', includes file stats in the response. Defaults to false. Ignored if format is 'json'.",
      ),
  })
  .describe(
    "Retrieves the content and optionally metadata of a specific file within the connected Obsidian vault. Supports case-insensitive path fallback.",
  );

/**
 * TypeScript type inferred from the input schema (`ObsidianReadNoteInputSchema`).
 * Represents the validated input parameters used within the core processing logic.
 */
export type ObsidianReadNoteInput = z.infer<typeof ObsidianReadNoteInputSchema>;

// ====================================================================================
// Response Type Definition
// ====================================================================================

/**
 * Represents the structure of file statistics after formatting, including
 * human-readable timestamps and an estimated token count.
 */
type FormattedStat = {
  /** Creation time formatted as a standard date-time string (e.g., "05:29:00 PM | 05-03-2025"). */
  createdTime: string;
  /** Last modified time formatted as a standard date-time string (e.g., "05:29:00 PM | 05-03-2025"). */
  modifiedTime: string;
  /** Estimated token count of the file content (using tiktoken 'gpt-4o'). */
  tokenCountEstimate: number;
};

/**
 * Defines the structure of the successful response returned by the `processObsidianReadNote` function.
 * This object is typically serialized to JSON and sent back to the client.
 */
export interface ObsidianReadNoteResponse {
  /**
   * The content of the file in the requested format.
   * If format='markdown', this is a string.
   * If format='json', this is a NoteJson object (which also contains the content string and stats).
   */
  content: string | NoteJson;
  /**
   * Optional formatted file statistics.
   * Included if format='json', or if format='markdown' and includeStat=true.
   */
  stats?: FormattedStat; // Renamed from stat
}

// ====================================================================================
// Core Logic Function
// ====================================================================================

/**
 * Processes the core logic for reading a file from the Obsidian vault.
 *
 * It attempts to read the file using the provided path (case-sensitive first,
 * then case-insensitive fallback). It always fetches the full NoteJson object
 * internally to access file statistics. Finally, it formats the response
 * according to the requested format ('markdown' or 'json') and the 'includeStat' flag.
 *
 * @param {ObsidianReadNoteInput} params - The validated input parameters.
 * @param {RequestContext} context - The request context for logging and correlation.
 * @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service.
 * @returns {Promise<ObsidianReadNoteResponse>} A promise resolving to the structured success response
 *   containing the file content and optionally formatted statistics.
 * @throws {McpError} Throws an McpError if the file cannot be found (even with fallback),
 *   if there's an ambiguous fallback match, or if any other API interaction fails.
 */
export const processObsidianReadNote = async (
  params: ObsidianReadNoteInput,
  context: RequestContext,
  obsidianService: ObsidianRestApiService,
): Promise<ObsidianReadNoteResponse> => {
  const {
    filePath: originalFilePath,
    format: requestedFormat,
    includeStat,
  } = params;
  let effectiveFilePath = originalFilePath; // Track the actual path used (might change during fallback)

  logger.debug(
    `Processing obsidian_read_note request for path: ${originalFilePath}`,
    { ...context, format: requestedFormat, includeStat },
  );

  const shouldRetryNotFound = (err: unknown) =>
    err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;

  try {
    let noteJson: NoteJson;

    // --- Step 1: Read File Content (always fetch JSON internally) ---
    const readContext = { ...context, operation: "readFileAsJson" };
    try {
      // Attempt 1: Read using the provided path (case-sensitive)
      logger.debug(
        `Attempting to read file as JSON (case-sensitive): ${originalFilePath}`,
        readContext,
      );
      noteJson = await retryWithDelay(
        () =>
          obsidianService.getFileContent(
            originalFilePath,
            "json",
            readContext,
          ) as Promise<NoteJson>,
        {
          operationName: "readFileWithRetry",
          context: readContext,
          maxRetries: 3,
          delayMs: 300,
          shouldRetry: shouldRetryNotFound,
        },
      );
      effectiveFilePath = originalFilePath; // Confirm exact path worked
      logger.debug(
        `Successfully read file as JSON using exact path: ${originalFilePath}`,
        readContext,
      );
    } catch (error) {
      // Attempt 2: Case-insensitive fallback if initial read failed with NOT_FOUND
      if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
        logger.info(
          `File not found with exact path: ${originalFilePath}. Attempting case-insensitive fallback.`,
          readContext,
        );
        const fallbackContext = {
          ...readContext,
          subOperation: "caseInsensitiveFallback",
        };

        try {
          // Use POSIX path functions as vault paths are typically /-separated
          const dirname = path.posix.dirname(originalFilePath);
          const filenameLower = path.posix
            .basename(originalFilePath)
            .toLowerCase();
          // Handle case where the file is in the vault root (dirname is '.')
          const dirToList = dirname === "." ? "/" : dirname;

          logger.debug(
            `Listing directory for fallback: ${dirToList}`,
            fallbackContext,
          );
          const filesInDir = await retryWithDelay(
            () => obsidianService.listFiles(dirToList, fallbackContext),
            {
              operationName: "listFilesForReadFallback",
              context: fallbackContext,
              maxRetries: 3,
              delayMs: 300,
              shouldRetry: shouldRetryNotFound,
            },
          );

          // Filter directory listing for files matching the lowercase filename
          const matches = filesInDir.filter(
            (f) =>
              !f.endsWith("/") && // Ensure it's a file, not a directory entry ending in /
              path.posix.basename(f).toLowerCase() === filenameLower,
          );

          if (matches.length === 1) {
            // Found exactly one case-insensitive match
            const correctFilename = path.posix.basename(matches[0]);
            effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path
            logger.info(
              `Found case-insensitive match: ${effectiveFilePath}. Retrying read as JSON.`,
              fallbackContext,
            );

            // Retry reading the file content using the corrected path
            noteJson = await retryWithDelay(
              () =>
                obsidianService.getFileContent(
                  effectiveFilePath,
                  "json",
                  fallbackContext,
                ) as Promise<NoteJson>,
              {
                operationName: "readFileWithFallbackRetry",
                context: fallbackContext,
                maxRetries: 3,
                delayMs: 300,
                shouldRetry: shouldRetryNotFound,
              },
            );
            logger.debug(
              `Successfully read file as JSON using fallback path: ${effectiveFilePath}`,
              fallbackContext,
            );
          } else if (matches.length > 1) {
            // Ambiguous match: Multiple files match case-insensitively
            logger.error(
              `Case-insensitive fallback failed: Multiple matches found for ${filenameLower} in ${dirToList}.`,
              { ...fallbackContext, matches },
            );
            throw new McpError(
              BaseErrorCode.CONFLICT, // Use CONFLICT for ambiguity
              `File read failed: Ambiguous case-insensitive matches for '${originalFilePath}'. Found: [${matches.join(", ")}]`,
              fallbackContext,
            );
          } else {
            // No match found even with fallback
            logger.error(
              `Case-insensitive fallback failed: No match found for ${filenameLower} in ${dirToList}.`,
              fallbackContext,
            );
            throw new McpError(
              BaseErrorCode.NOT_FOUND,
              `File not found: '${originalFilePath}' (case-insensitive fallback also failed).`,
              fallbackContext,
            );
          }
        } catch (fallbackError) {
          // Catch errors specifically from the fallback logic
          if (fallbackError instanceof McpError) throw fallbackError; // Re-throw known errors
          // Wrap unexpected fallback errors
          const errorMessage = `Unexpected error during case-insensitive fallback for ${originalFilePath}`;
          logger.error(
            errorMessage,
            fallbackError instanceof Error ? fallbackError : undefined,
            fallbackContext,
          );
          throw new McpError(
            BaseErrorCode.INTERNAL_ERROR,
            `${errorMessage}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
            fallbackContext,
          );
        }
      } else {
        // Re-throw errors from the initial read attempt that were not NOT_FOUND
        throw error;
      }
    }

    // --- Step 2: Format the Response ---
    const formatContext = {
      ...context,
      operation: "formatResponse",
      effectiveFilePath,
    };
    logger.debug(
      `Formatting response. Requested format: ${requestedFormat}, Include stat: ${includeStat}`,
      formatContext,
    );

    // Generate formatted statistics using the utility function.
    // Provide the content string for token counting. Handle cases where stat might be missing.
    const formattedStatResult = noteJson.stat
      ? await createFormattedStatWithTokenCount(
          noteJson.stat,
          noteJson.content ?? "",
          formatContext,
        ) // Await the async utility
      : undefined;
    // Ensure stat is undefined if the utility returned null (e.g., token counting failed)
    const formattedStat =
      formattedStatResult === null ? undefined : formattedStatResult;

    // Initialize the response object
    const response: ObsidianReadNoteResponse = {
      content: "", // Placeholder, will be set based on format
      // stat is added conditionally below
    };

    // Populate response based on requested format
    if (requestedFormat === "json") {
      // Return the full NoteJson object. Its internal 'stat' will remain numeric.
      // The formatted stats are provided in the top-level 'response.stats'.
      response.content = noteJson;
      response.stats = formattedStat; // Always include formatted stat at top level for JSON format
      logger.debug(
        `Response format set to JSON, including full NoteJson (with original numeric stat) and top-level formatted stat.`,
        formatContext,
      );
    } else {
      // 'markdown' format
      response.content = noteJson.content ?? ""; // Extract the markdown content string
      if (includeStat && formattedStat) {
        response.stats = formattedStat; // Include formatted stats only if requested for markdown
        logger.debug(
          `Response format set to markdown, including formatted stat as requested.`,
          formatContext,
        );
      } else {
        logger.debug(
          `Response format set to markdown, excluding stat (includeStat=${includeStat}).`,
          formatContext,
        );
      }
    }

    logger.debug(
      `Successfully processed read request for ${effectiveFilePath}.`,
      context,
    );
    return response;
  } catch (error) {
    // Catch any errors that propagated up (e.g., from initial read, fallback, or unexpected issues)
    if (error instanceof McpError) {
      // Log known McpErrors that reached this top level
      logger.error(
        `McpError during file read process for ${originalFilePath}: ${error.message}`,
        error,
        context,
      );
      throw error; // Re-throw McpError
    } else {
      // Wrap unexpected errors in a generic McpError
      const errorMessage = `Unexpected error processing read request for ${originalFilePath}`;
      logger.error(
        errorMessage,
        error instanceof Error ? error : undefined,
        context,
      );
      throw new McpError(
        BaseErrorCode.INTERNAL_ERROR,
        `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`,
        context,
      );
    }
  }
};

```
Page 2/4FirstPrevNextLast