Skip to main content
Glama

Obsidian MCP Server

Apache 2.0
338
222
  • Apple
  • Linux
logic.ts•32.3 kB
import { z } from "zod"; import { NoteJson, ObsidianRestApiService, VaultCacheService, } 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 possible types of targets for the update operation. */ const TargetTypeSchema = z .enum(["filePath", "activeFile", "periodicNote"]) .describe( "Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.", ); /** Defines the only allowed modification type for this tool implementation. */ const ModificationTypeSchema = z .literal("wholeFile") .describe( "Determines the modification strategy: must be 'wholeFile' for this tool.", ); /** Defines the specific whole-file operations supported. */ const WholeFileModeSchema = z .enum(["append", "prepend", "overwrite"]) .describe( "Specifies the whole-file operation: 'append', 'prepend', or 'overwrite'.", ); /** Defines the valid periods for periodic notes. */ const PeriodicNotePeriodSchema = z .enum(["daily", "weekly", "monthly", "quarterly", "yearly"]) .describe("Valid periods for 'periodicNote' target type."); /** * Base Zod schema containing fields common to all update operations within this tool. * Currently, only 'wholeFile' is supported, so this forms the basis for that mode. */ const BaseUpdateSchema = z.object({ /** Specifies the type of target note. */ targetType: TargetTypeSchema, /** The content to use for the modification. Must be a string for whole-file operations. */ content: z .string() .describe( "The content for the modification (must be a string for whole-file operations).", ), /** * Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'. * Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'. * Not used if targetType is 'activeFile'. */ targetIdentifier: z .string() .optional() .describe( "Identifier for 'filePath' (vault-relative path) or 'periodicNote' (period string). Not used for 'activeFile'.", ), }); /** * Zod schema specifically for the 'wholeFile' modification type, extending the base schema. * Includes mode-specific options like createIfNeeded and overwriteIfExists. */ const WholeFileUpdateSchema = BaseUpdateSchema.extend({ /** The modification type, fixed to 'wholeFile'. */ modificationType: ModificationTypeSchema, /** The specific whole-file operation ('append', 'prepend', 'overwrite'). */ wholeFileMode: WholeFileModeSchema, /** If true (default), creates the target file/note if it doesn't exist before applying the modification. If false, the operation fails if the target doesn't exist. */ createIfNeeded: z .boolean() .optional() .default(true) .describe( "If true (default), creates the target if it doesn't exist. If false, fails if target is missing.", ), /** Only relevant for 'overwrite' mode. If true, allows overwriting an existing file. If false (default) and the file exists, the 'overwrite' operation fails. */ overwriteIfExists: z .boolean() .optional() .default(false) .describe( "For 'overwrite' mode: If true, allows overwriting. If false (default) and file exists, operation fails.", ), /** If true, includes the final content of the modified file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe("If true, returns the final file content in the response."), }); // ==================================================================================== // Schema for SDK Registration (Flattened for Tool Definition) // ==================================================================================== /** * Zod schema used for registering the tool with the MCP SDK (`server.tool`). * This schema defines the expected input structure from the client's perspective. * It flattens the structure slightly by making mode-specific fields optional at this stage, * relying on the refined schema (`ObsidianUpdateFileInputSchema`) for stricter validation * within the handler logic. */ const ObsidianUpdateNoteRegistrationSchema = z .object({ /** Specifies the target note: 'filePath' (requires targetIdentifier), 'activeFile' (currently open file), or 'periodicNote' (requires targetIdentifier with period like 'daily'). */ targetType: TargetTypeSchema, /** The content for the modification. Must be a string for whole-file operations. */ content: z .string() .describe("The content for the modification (must be a string)."), /** Identifier for the target when targetType is 'filePath' (vault-relative path, e.g., 'Notes/My File.md') or 'periodicNote' (period string: 'daily', 'weekly', etc.). Not used for 'activeFile'. */ targetIdentifier: z .string() .optional() .describe( "Identifier for 'filePath' (path) or 'periodicNote' (period). Not used for 'activeFile'.", ), /** Determines the modification strategy: must be 'wholeFile'. */ modificationType: ModificationTypeSchema, // --- WholeFile Mode Parameters (Marked optional here, refined schema enforces if modificationType is 'wholeFile') --- /** For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'. */ wholeFileMode: WholeFileModeSchema.optional() // Made optional here, refined schema handles requirement .describe( "For 'wholeFile' mode: 'append', 'prepend', or 'overwrite'. Required if modificationType is 'wholeFile'.", ), /** For 'wholeFile' mode: If true (default), creates the target file/note if it doesn't exist before modifying. If false, fails if the target doesn't exist. */ createIfNeeded: z .boolean() .optional() .default(true) .describe( "For 'wholeFile' mode: If true (default), creates target if needed. If false, fails if missing.", ), /** For 'wholeFile' mode with 'overwrite': If false (default), the operation fails if the target file already exists. If true, allows overwriting the existing file. */ overwriteIfExists: z .boolean() .optional() .default(false) .describe( "For 'wholeFile'/'overwrite' mode: If false (default), fails if target exists. If true, allows overwrite.", ), /** If true, returns the final content of the file in the response. Defaults to false. */ returnContent: z .boolean() .optional() .default(false) .describe("If true, returns the final file content in the response."), }) .describe( "Tool to modify Obsidian notes (specified by file path, active file, or periodic note) using whole-file operations: 'append', 'prepend', or 'overwrite'. Options control creation and overwrite behavior.", ); /** * The shape of the registration schema, used by `server.tool` for basic validation. * @see ObsidianUpdateFileRegistrationSchema */ export const ObsidianUpdateNoteInputSchemaShape = ObsidianUpdateNoteRegistrationSchema.shape; /** * TypeScript type inferred from the registration schema. Represents the raw input * received by the tool handler *before* refinement. * @see ObsidianUpdateFileRegistrationSchema */ export type ObsidianUpdateNoteRegistrationInput = z.infer< typeof ObsidianUpdateNoteRegistrationSchema >; // ==================================================================================== // Refined Schema for Internal Logic and Strict Validation // ==================================================================================== /** * Refined Zod schema used internally within the tool's logic for strict validation. * It builds upon `WholeFileUpdateSchema` and adds cross-field validation rules using `.refine()`. * This ensures that `targetIdentifier` is provided and valid when required by `targetType`. */ export const ObsidianUpdateNoteInputSchema = WholeFileUpdateSchema.refine( (data) => { // Rule 1: If targetType is 'filePath' or 'periodicNote', targetIdentifier must be provided. if ( (data.targetType === "filePath" || data.targetType === "periodicNote") && !data.targetIdentifier ) { return false; } // Rule 2: If targetType is 'periodicNote', targetIdentifier must be a valid period string. if ( data.targetType === "periodicNote" && data.targetIdentifier && !PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success ) { return false; } // All checks passed return true; }, { // Custom error message for refinement failure. message: "targetIdentifier is required and must be a valid path for targetType 'filePath', or a valid period ('daily', 'weekly', etc.) for targetType 'periodicNote'.", path: ["targetIdentifier"], // Associate the error with the targetIdentifier field. }, ); /** * TypeScript type inferred from the *refined* input schema (`ObsidianUpdateFileInputSchema`). * This type represents the validated and structured input used within the core processing logic. */ export type ObsidianUpdateNoteInput = z.infer< typeof ObsidianUpdateNoteInputSchema >; // ==================================================================================== // 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. */ createdTime: string; /** Last modified time formatted as a standard date-time string. */ 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 `processObsidianUpdateFile` function. * This object is typically serialized to JSON and sent back to the client. */ export interface ObsidianUpdateNoteResponse { /** Indicates whether the operation was successful. */ success: boolean; /** A human-readable message describing the outcome of the operation. */ message: string; /** Optional file statistics (creation/modification times, token count) if the file could be read after the update. */ stats?: FormattedStat; // Renamed from stat /** Optional final content of the file, included only if `returnContent` was true in the request and the file could be read. */ finalContent?: string; } // ==================================================================================== // Helper Functions // ==================================================================================== /** * Attempts to retrieve the final state (content and stats) of the target note after an update operation. * Uses the appropriate Obsidian API method based on the target type. * Logs a warning and returns null if fetching the final state fails, to avoid failing the entire update operation. * * @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note. * @param {string | undefined} targetIdentifier - The identifier (path or period) if applicable. * @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'. * @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance. * @param {RequestContext} context - The request context for logging and correlation. * @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails. */ async function getFinalState( targetType: z.infer<typeof TargetTypeSchema>, targetIdentifier: string | undefined, period: z.infer<typeof PeriodicNotePeriodSchema> | undefined, obsidianService: ObsidianRestApiService, context: RequestContext, ): Promise<NoteJson | null> { const operation = "getFinalState"; logger.debug( `Attempting to retrieve final state for target: ${targetType} ${targetIdentifier ?? "(active)"}`, { ...context, operation }, ); try { let noteJson: NoteJson | null = null; // Call the appropriate API method based on target type if (targetType === "filePath" && targetIdentifier) { noteJson = (await obsidianService.getFileContent( targetIdentifier, "json", context, )) as NoteJson; } else if (targetType === "activeFile") { noteJson = (await obsidianService.getActiveFile( "json", context, )) as NoteJson; } else if (targetType === "periodicNote" && period) { noteJson = (await obsidianService.getPeriodicNote( period, "json", context, )) as NoteJson; } logger.debug(`Successfully retrieved final state`, { ...context, operation, }); return noteJson; } catch (error) { // Log the error but don't let it fail the main update operation. const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `Could not retrieve final state after update for target: ${targetType} ${targetIdentifier ?? "(active)"}. Error: ${errorMsg}`, { ...context, operation, error: errorMsg }, ); return null; // Return null to indicate failure without throwing } } // ==================================================================================== // Core Logic Function // ==================================================================================== /** * Processes the core logic for the 'obsidian_update_file' tool when using the 'wholeFile' * modification type (append, prepend, overwrite). It handles pre-checks, performs the * update via the Obsidian REST API, retrieves the final state, and constructs the response. * * @param {ObsidianUpdateFileInput} params - The validated input parameters conforming to the refined schema. * @param {RequestContext} context - The request context for logging and correlation. * @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service. * @returns {Promise<ObsidianUpdateFileResponse>} A promise resolving to the structured success response. * @throws {McpError} Throws an McpError if validation fails or the API interaction results in an error. */ export const processObsidianUpdateNote = async ( params: ObsidianUpdateNoteInput, // Use the refined, validated type context: RequestContext, obsidianService: ObsidianRestApiService, vaultCacheService: VaultCacheService | undefined, ): Promise<ObsidianUpdateNoteResponse> => { logger.debug(`Processing obsidian_update_note request (wholeFile mode)`, { ...context, targetType: params.targetType, wholeFileMode: params.wholeFileMode, }); const targetId = params.targetIdentifier; // Alias for clarity const contentString = params.content; const mode = params.wholeFileMode; let wasCreated = false; // Flag to track if the file was newly created by the operation let targetPeriod: z.infer<typeof PeriodicNotePeriodSchema> | undefined; // Parse the period if the target is a periodic note if (params.targetType === "periodicNote" && targetId) { // Use safeParse for robustness, though refined schema should guarantee validity const parseResult = PeriodicNotePeriodSchema.safeParse(targetId); if (!parseResult.success) { // This should ideally not happen due to the refined schema, but handle defensively throw new McpError( BaseErrorCode.VALIDATION_ERROR, `Invalid period provided for periodicNote: ${targetId}`, context, ); } targetPeriod = parseResult.data; } try { // --- Step 1: Pre-operation Existence Check --- // Determine if the target file/note exists before attempting modification. // This is crucial for overwrite safety checks and createIfNeeded logic. let existsBefore = false; const checkContext = { ...context, operation: "existenceCheck" }; logger.debug( `Checking existence of target: ${params.targetType} ${targetId ?? "(active)"}`, checkContext, ); try { await retryWithDelay( async () => { if (params.targetType === "filePath" && targetId) { await obsidianService.getFileContent( targetId, "json", checkContext, ); } else if (params.targetType === "activeFile") { await obsidianService.getActiveFile("json", checkContext); } else if (params.targetType === "periodicNote" && targetPeriod) { await obsidianService.getPeriodicNote( targetPeriod, "json", checkContext, ); } // If any of the above succeed without throwing, the target exists. existsBefore = true; logger.debug(`Target exists before operation.`, checkContext); }, { operationName: "existenceCheckObsidianUpdateNote", context: checkContext, maxRetries: 3, // Total attempts: 1 initial + 2 retries delayMs: 250, shouldRetry: (error: unknown) => { // Only retry if it's a NOT_FOUND error AND createIfNeeded is true. // If createIfNeeded is false, a NOT_FOUND error means we shouldn't proceed, so don't retry. const should = error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND && params.createIfNeeded; if ( error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND ) { logger.debug( `existenceCheckObsidianUpdateNote: shouldRetry=${should} for NOT_FOUND (createIfNeeded: ${params.createIfNeeded})`, checkContext, ); } return should; }, onRetry: (attempt, error) => { const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `Existence check (attempt ${attempt}) failed for target '${params.targetType} ${targetId ?? ""}'. Error: ${errorMsg}. Retrying as createIfNeeded is true...`, checkContext, ); }, }, ); } catch (error) { // This catch block is primarily for the case where retryWithDelay itself throws // (e.g., all retries exhausted for NOT_FOUND with createIfNeeded=true, or an unretryable error occurred). if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) { // If it's still NOT_FOUND after retries (or if createIfNeeded was false and it failed the first time), // then existsBefore should definitely be false. existsBefore = false; logger.debug( `Target confirmed not to exist after existence check attempts (createIfNeeded: ${params.createIfNeeded}).`, checkContext, ); } else { // For any other error type, re-throw it as it's unexpected here. logger.error( `Unexpected error after existence check retries`, error instanceof Error ? error : undefined, checkContext, ); throw error; } } // --- Step 2: Perform Safety and Configuration Checks --- const safetyCheckContext = { ...context, operation: "safetyChecks", existsBefore, }; // Check 2a: Overwrite safety if (mode === "overwrite" && existsBefore && !params.overwriteIfExists) { logger.warning( `Overwrite attempt failed: Target exists and overwriteIfExists is false.`, safetyCheckContext, ); throw new McpError( BaseErrorCode.CONFLICT, // Use CONFLICT as it clashes with existing state + config `Target ${params.targetType} '${targetId ?? "(active)"}' exists, and 'overwriteIfExists' is set to false. Cannot overwrite.`, safetyCheckContext, ); } // Check 2b: Not Found when creation is disabled if (!existsBefore && !params.createIfNeeded) { logger.warning( `Update attempt failed: Target not found and createIfNeeded is false.`, safetyCheckContext, ); throw new McpError( BaseErrorCode.NOT_FOUND, `Target ${params.targetType} '${targetId ?? "(active)"}' not found, and 'createIfNeeded' is set to false. Cannot update.`, safetyCheckContext, ); } // Determine if the operation will result in file creation wasCreated = !existsBefore && params.createIfNeeded; logger.debug( `Operation will proceed. File creation needed: ${wasCreated}`, safetyCheckContext, ); // --- Step 3: Perform the Update Operation via Obsidian API --- const updateContext = { ...context, operation: `performUpdate:${mode}`, wasCreated, }; logger.debug(`Performing update operation: ${mode}`, updateContext); // Handle 'prepend' and 'append' manually as Obsidian API might not directly support them atomically. if (mode === "prepend" || mode === "append") { let existingContent = ""; // Only read existing content if the file existed before the operation. if (existsBefore) { const readContext = { ...updateContext, subOperation: "readForModify" }; logger.debug(`Reading existing content for ${mode}`, readContext); try { if (params.targetType === "filePath" && targetId) { existingContent = (await obsidianService.getFileContent( targetId, "markdown", readContext, )) as string; } else if (params.targetType === "activeFile") { existingContent = (await obsidianService.getActiveFile( "markdown", readContext, )) as string; } else if (params.targetType === "periodicNote" && targetPeriod) { existingContent = (await obsidianService.getPeriodicNote( targetPeriod, "markdown", readContext, )) as string; } logger.debug( `Successfully read existing content. Length: ${existingContent.length}`, readContext, ); } catch (readError) { // This should ideally not happen if existsBefore is true, but handle defensively. const errorMsg = readError instanceof Error ? readError.message : String(readError); logger.error( `Error reading existing content for ${mode} despite existence check.`, readError instanceof Error ? readError : undefined, readContext, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `Failed to read existing content for ${mode} operation. Error: ${errorMsg}`, readContext, ); } } else { logger.debug( `Target did not exist before, skipping read for ${mode}.`, updateContext, ); } // Combine content based on the mode. const newContent = mode === "prepend" ? contentString + existingContent : existingContent + contentString; logger.debug( `Combined content length for ${mode}: ${newContent.length}`, updateContext, ); // Overwrite the target with the newly combined content. const writeContext = { ...updateContext, subOperation: "writeCombined" }; logger.debug(`Writing combined content back to target`, writeContext); if (params.targetType === "filePath" && targetId) { await obsidianService.updateFileContent( targetId, newContent, writeContext, ); } else if (params.targetType === "activeFile") { await obsidianService.updateActiveFile(newContent, writeContext); } else if (params.targetType === "periodicNote" && targetPeriod) { await obsidianService.updatePeriodicNote( targetPeriod, newContent, writeContext, ); } logger.debug( `Successfully wrote combined content for ${mode}`, writeContext, ); if (params.targetType === "filePath" && targetId && vaultCacheService) { await vaultCacheService.updateCacheForFile(targetId, writeContext); } } else { // Handle 'overwrite' mode directly. switch (params.targetType) { case "filePath": // targetId is guaranteed by refined schema check await obsidianService.updateFileContent( targetId!, contentString, updateContext, ); break; case "activeFile": await obsidianService.updateActiveFile(contentString, updateContext); break; case "periodicNote": // targetPeriod is guaranteed by refined schema check await obsidianService.updatePeriodicNote( targetPeriod!, contentString, updateContext, ); break; } logger.debug( `Successfully performed overwrite on target: ${params.targetType} ${targetId ?? "(active)"}`, updateContext, ); if (params.targetType === "filePath" && targetId && vaultCacheService) { await vaultCacheService.updateCacheForFile(targetId, updateContext); } } // --- Step 4: Get Final State (Stat and Optional Content) --- // Add a small delay before attempting to get the final state, to allow Obsidian API to stabilize after write. const POST_UPDATE_DELAY_MS = 250; logger.debug( `Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state...`, { ...context, operation: "postUpdateDelay" }, ); await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS)); // Attempt to retrieve the file's state *after* the modification. let finalState: NoteJson | null = null; // Initialize to null try { finalState = await retryWithDelay( async () => getFinalState( params.targetType, targetId, targetPeriod, obsidianService, context, ), { operationName: "getFinalStateAfterUpdate", context: { ...context, operation: "getFinalStateAfterUpdateAttempt" }, // Use a distinct context for retry logs maxRetries: 3, // Total attempts: 1 initial + 2 retries delayMs: 250, // Shorter delay shouldRetry: (error: unknown) => { // Retry on common transient issues or if the file might not be immediately available const should = error instanceof McpError && (error.code === BaseErrorCode.NOT_FOUND || // File might not be indexed immediately error.code === BaseErrorCode.SERVICE_UNAVAILABLE || // API temporarily busy error.code === BaseErrorCode.TIMEOUT); // API call timed out if (should) { logger.debug( `getFinalStateAfterUpdate: shouldRetry=true for error code ${(error as McpError).code}`, context, ); } return should; }, onRetry: (attempt, error) => { const errorMsg = error instanceof Error ? error.message : String(error); logger.warning( `getFinalState (attempt ${attempt}) failed. Error: ${errorMsg}. Retrying...`, { ...context, operation: "getFinalStateRetry" }, ); }, }, ); } catch (error) { // If retryWithDelay throws after all attempts, getFinalState effectively failed. // The original getFinalState already logs a warning and returns null if it encounters an error internally // and is designed not to let its failure stop the main operation. // So, if retryWithDelay throws, it means even retries didn't help. finalState = null; // Ensure finalState remains null const errorMsg = error instanceof Error ? error.message : String(error); logger.error( `Failed to retrieve final state for target '${params.targetType} ${targetId ?? ""}' even after retries. Error: ${errorMsg}`, error instanceof Error ? error : undefined, context, ); // Do not re-throw here, allow the main process to construct a response with a warning. } // --- Step 5: Construct Success Message --- // Create a user-friendly message indicating what happened. let messageAction: string; if (wasCreated) { // Use past tense for creation events messageAction = mode === "overwrite" ? "created" : `${mode}d (and created)`; } else { // Use past tense for modifications of existing files messageAction = mode === "overwrite" ? "overwritten" : `${mode}ed`; } const targetName = params.targetType === "filePath" ? `'${targetId}'` : params.targetType === "periodicNote" ? `'${targetId}' note` : "the active file"; let successMessage = `File content successfully ${messageAction} for ${targetName}.`; // Use let logger.info(successMessage, context); // Log initial success message // Append a warning if the final state couldn't be retrieved if (finalState === null) { const warningMsg = " (Warning: Could not retrieve final file stats/content after update.)"; successMessage += warningMsg; logger.warning( `Appending warning to response message: ${warningMsg}`, context, ); } // --- Step 6: Build and Return Response --- // Format the file statistics (if available) using the shared utility. const finalContentForStat = finalState?.content ?? ""; // Provide content for token counting const formattedStatResult = finalState?.stat ? await createFormattedStatWithTokenCount( finalState.stat, finalContentForStat, context, ) // 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; // Construct the final response object. const response: ObsidianUpdateNoteResponse = { success: true, message: successMessage, stats: formattedStat, }; // Include final content if requested and available. if (params.returnContent) { response.finalContent = finalState?.content; // Assign content if available, otherwise undefined logger.debug( `Including final content in response as requested.`, context, ); } return response; } catch (error) { // Handle errors, ensuring they are McpError instances before re-throwing. // Errors from obsidianService calls should already be McpErrors and logged by the service. if (error instanceof McpError) { // Log McpErrors specifically from this level if needed, though lower levels might have logged already logger.error( `McpError during file update: ${error.message}`, error, context, ); throw error; // Re-throw known McpError } else { // Catch unexpected errors, log them, and wrap in a generic McpError. const errorMessage = `Unexpected error updating Obsidian file/note`; logger.error( errorMessage, error instanceof Error ? error : undefined, context, ); throw new McpError( BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, context, ); } } };

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/obsidian-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server