UNPKG

obsidian-mcp-server

Version:

Obsidian Knowledge-Management MCP (Model Context Protocol) server that enables AI agents and development tools to interact with an Obsidian vault. It provides a comprehensive suite of tools for reading, writing, searching, and managing notes, tags, and fr

169 lines (168 loc) 10.2 kB
import path from "node:path"; // node:path provides OS-specific path functions; using path.posix for vault path manipulation. import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { logger, 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."); // ==================================================================================== // 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, context, obsidianService, vaultCacheService) => { 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) => 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); } } } };