Skip to main content
Glama

Obsidian MCP Server

Apache 2.0
338
222
  • Apple
  • Linux
logic.ts15.6 kB
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, ); } } };

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