Skip to main content
Glama

Obsidian MCP Server

Apache 2.0
338
222
  • Apple
  • Linux
service.ts19.8 kB
/** * @module ObsidianRestApiService * @description * This module provides the core implementation for the Obsidian REST API service. * It encapsulates the logic for making authenticated requests to the API endpoints. */ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import https from "node:https"; // Import the https module for Agent configuration import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { ErrorHandler, logger, RequestContext, requestContextService, } from "../../utils/index.js"; // Added requestContextService import * as activeFileMethods from "./methods/activeFileMethods.js"; import * as commandMethods from "./methods/commandMethods.js"; import * as openMethods from "./methods/openMethods.js"; import * as patchMethods from "./methods/patchMethods.js"; import * as periodicNoteMethods from "./methods/periodicNoteMethods.js"; import * as searchMethods from "./methods/searchMethods.js"; import * as vaultMethods from "./methods/vaultMethods.js"; import { ApiStatusResponse, // Import PatchOptions type ComplexSearchResult, NoteJson, NoteStat, ObsidianCommand, PatchOptions, Period, SimpleSearchResult, } from "./types.js"; // Import types from the new file export class ObsidianRestApiService { private axiosInstance: AxiosInstance; private apiKey: string; constructor() { this.apiKey = config.obsidianApiKey; // Get from central config if (!this.apiKey) { // Config validation should prevent this, but double-check throw new McpError( BaseErrorCode.CONFIGURATION_ERROR, "Obsidian API Key is missing in configuration.", {}, ); } const httpsAgent = new https.Agent({ rejectUnauthorized: config.obsidianVerifySsl, }); this.axiosInstance = axios.create({ baseURL: config.obsidianBaseUrl.replace(/\/$/, ""), // Remove trailing slash headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", // Default accept type }, timeout: 60000, // Increased timeout to 60 seconds (was 15000) httpsAgent, }); logger.info( `ObsidianRestApiService initialized with base URL: ${this.axiosInstance.defaults.baseURL}, Verify SSL: ${config.obsidianVerifySsl}`, requestContextService.createRequestContext({ operation: "ObsidianServiceInit", }), ); } /** * Private helper to make requests and handle common errors. * @param config - Axios request configuration. * @param context - Request context for logging. * @param operationName - Name of the operation for logging context. * @returns The response data. * @throws {McpError} If the request fails. */ private async _request<T = any>( requestConfig: AxiosRequestConfig, context: RequestContext, operationName: string, ): Promise<T> { const operationContext = { ...context, operation: `ObsidianAPI_${operationName}`, }; logger.debug( `Making Obsidian API request: ${requestConfig.method} ${requestConfig.url}`, operationContext, ); return await ErrorHandler.tryCatch( async () => { try { const response = await this.axiosInstance.request<T>(requestConfig); logger.debug( `Obsidian API request successful: ${requestConfig.method} ${requestConfig.url}`, { ...operationContext, status: response.status }, ); // For HEAD requests, we need the headers, so return the whole response. // For other requests, returning response.data is fine. if (requestConfig.method === "HEAD") { return response as T; } return response.data; } catch (error) { const axiosError = error as AxiosError; let errorCode = BaseErrorCode.INTERNAL_ERROR; let errorMessage = `Obsidian API request failed: ${axiosError.message}`; const errorDetails: Record<string, any> = { requestUrl: requestConfig.url, requestMethod: requestConfig.method, responseStatus: axiosError.response?.status, responseData: axiosError.response?.data, }; if (axiosError.response) { // Handle specific HTTP status codes switch (axiosError.response.status) { case 400: errorCode = BaseErrorCode.VALIDATION_ERROR; errorMessage = `Obsidian API Bad Request: ${JSON.stringify(axiosError.response.data)}`; break; case 401: errorCode = BaseErrorCode.UNAUTHORIZED; errorMessage = "Obsidian API Unauthorized: Invalid API Key."; break; case 403: errorCode = BaseErrorCode.FORBIDDEN; errorMessage = "Obsidian API Forbidden: Check permissions."; break; case 404: errorCode = BaseErrorCode.NOT_FOUND; errorMessage = `Obsidian API Not Found: ${requestConfig.url}`; // Log 404s at debug level, as they might be expected (e.g., checking existence) logger.debug(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); // NOTE: We throw immediately after logging debug for 404, skipping the general error log below. case 405: errorCode = BaseErrorCode.VALIDATION_ERROR; // Method not allowed often implies incorrect usage errorMessage = `Obsidian API Method Not Allowed: ${requestConfig.method} on ${requestConfig.url}`; break; case 503: errorCode = BaseErrorCode.SERVICE_UNAVAILABLE; errorMessage = "Obsidian API Service Unavailable."; break; } // General error logging for non-404 client/server errors handled above logger.error(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); } else if (axiosError.request) { // Network error (no response received) errorCode = BaseErrorCode.SERVICE_UNAVAILABLE; errorMessage = `Obsidian API Network Error: No response received from ${requestConfig.url}. This may be due to Obsidian not running, the Local REST API plugin being disabled, or a network issue.`; logger.error(errorMessage, { ...operationContext, ...errorDetails, }); throw new McpError(errorCode, errorMessage, operationContext); } else { // Other errors (e.g., setup issues) // Pass error object correctly if it's an Error instance logger.error( errorMessage, error instanceof Error ? error : undefined, { ...operationContext, ...errorDetails, originalError: String(error), }, ); throw new McpError(errorCode, errorMessage, operationContext); } } }, { operation: `ObsidianAPI_${operationName}_Wrapper`, context: context, input: requestConfig, // Log request config (sanitized by ErrorHandler) errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if wrapper itself fails }, ); } // --- API Methods --- /** * Checks the status and authentication of the Obsidian Local REST API. * @param context - The request context for logging and correlation. * @returns {Promise<ApiStatusResponse>} - The status object from the API. */ async checkStatus(context: RequestContext): Promise<ApiStatusResponse> { // Note: This is the only endpoint that doesn't strictly require auth, // but sending the key helps check if it's valid. // This one is simple enough to keep inline or could be extracted too. return this._request<ApiStatusResponse>( { method: "GET", url: "/", }, context, "checkStatus", ); } // --- Vault Methods --- /** * Gets the content of a specific file in the vault. * @param filePath - Vault-relative path to the file. * @param format - 'markdown' or 'json' (for NoteJson). * @param context - Request context. * @returns The file content (string) or NoteJson object. */ async getFileContent( filePath: string, format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return vaultMethods.getFileContent( this._request.bind(this), filePath, format, context, ); } /** * Updates (overwrites) the content of a file or creates it if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param content - The new content for the file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updateFileContent( filePath: string, content: string, context: RequestContext, ): Promise<void> { return vaultMethods.updateFileContent( this._request.bind(this), filePath, content, context, ); } /** * Appends content to the end of a file. Creates the file if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendFileContent( filePath: string, content: string, context: RequestContext, ): Promise<void> { return vaultMethods.appendFileContent( this._request.bind(this), filePath, content, context, ); } /** * Deletes a specific file in the vault. * @param filePath - Vault-relative path to the file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deleteFile(filePath: string, context: RequestContext): Promise<void> { return vaultMethods.deleteFile(this._request.bind(this), filePath, context); } /** * Lists files within a specified directory in the vault. * @param dirPath - Vault-relative path to the directory. Use empty string "" or "/" for the root. * @param context - Request context. * @returns A list of file and directory names. */ async listFiles(dirPath: string, context: RequestContext): Promise<string[]> { return vaultMethods.listFiles(this._request.bind(this), dirPath, context); } /** * Gets the metadata (stat) of a specific file using a lightweight HEAD request. * @param filePath - Vault-relative path to the file. * @param context - Request context. * @returns The file's metadata. */ async getFileMetadata( filePath: string, context: RequestContext, ): Promise<NoteStat | null> { return vaultMethods.getFileMetadata( this._request.bind(this), filePath, context, ); } // --- Search Methods --- /** * Performs a simple text search across the vault. * @param query - The text query string. * @param contextLength - Number of characters surrounding each match (default 100). * @param context - Request context. * @returns An array of search results. */ async searchSimple( query: string, contextLength: number = 100, context: RequestContext, ): Promise<SimpleSearchResult[]> { return searchMethods.searchSimple( this._request.bind(this), query, contextLength, context, ); } /** * Performs a complex search using Dataview DQL or JsonLogic. * @param query - The query string (DQL) or JSON object (JsonLogic). * @param contentType - The content type header indicating the query format. * @param context - Request context. * @returns An array of search results. */ async searchComplex( query: string | object, contentType: | "application/vnd.olrapi.dataview.dql+txt" | "application/vnd.olrapi.jsonlogic+json", context: RequestContext, ): Promise<ComplexSearchResult[]> { return searchMethods.searchComplex( this._request.bind(this), query, contentType, context, ); } // --- Command Methods --- /** * Executes a registered Obsidian command by its ID. * @param commandId - The ID of the command (e.g., "app:go-back"). * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async executeCommand( commandId: string, context: RequestContext, ): Promise<void> { return commandMethods.executeCommand( this._request.bind(this), commandId, context, ); } /** * Lists all available Obsidian commands. * @param context - Request context. * @returns A list of available commands. */ async listCommands(context: RequestContext): Promise<ObsidianCommand[]> { return commandMethods.listCommands(this._request.bind(this), context); } // --- Open Methods --- /** * Opens a specific file in Obsidian. Creates the file if it doesn't exist. * @param filePath - Vault-relative path to the file. * @param newLeaf - Whether to open the file in a new editor tab (leaf). * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK, but no body expected). */ async openFile( filePath: string, newLeaf: boolean = false, context: RequestContext, ): Promise<void> { return openMethods.openFile( this._request.bind(this), filePath, newLeaf, context, ); } // --- Active File Methods --- /** * Gets the content of the currently active file in Obsidian. * @param format - 'markdown' or 'json' (for NoteJson). * @param context - Request context. * @returns The file content (string) or NoteJson object. */ async getActiveFile( format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return activeFileMethods.getActiveFile( this._request.bind(this), format, context, ); } /** * Updates (overwrites) the content of the currently active file. * @param content - The new content. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updateActiveFile( content: string, context: RequestContext, ): Promise<void> { return activeFileMethods.updateActiveFile( this._request.bind(this), content, context, ); } /** * Appends content to the end of the currently active file. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendActiveFile( content: string, context: RequestContext, ): Promise<void> { return activeFileMethods.appendActiveFile( this._request.bind(this), content, context, ); } /** * Deletes the currently active file. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deleteActiveFile(context: RequestContext): Promise<void> { return activeFileMethods.deleteActiveFile( this._request.bind(this), context, ); } // --- Periodic Notes Methods --- // PATCH methods for periodic notes are complex and omitted for brevity /** * Gets the content of a periodic note (daily, weekly, etc.). * @param period - The period type ('daily', 'weekly', 'monthly', 'quarterly', 'yearly'). * @param format - 'markdown' or 'json'. * @param context - Request context. * @returns The note content or NoteJson. */ async getPeriodicNote( period: Period, format: "markdown" | "json" = "markdown", context: RequestContext, ): Promise<string | NoteJson> { return periodicNoteMethods.getPeriodicNote( this._request.bind(this), period, format, context, ); } /** * Updates (overwrites) the content of a periodic note. Creates if needed. * @param period - The period type. * @param content - The new content. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async updatePeriodicNote( period: Period, content: string, context: RequestContext, ): Promise<void> { return periodicNoteMethods.updatePeriodicNote( this._request.bind(this), period, content, context, ); } /** * Appends content to a periodic note. Creates if needed. * @param period - The period type. * @param content - The content to append. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async appendPeriodicNote( period: Period, content: string, context: RequestContext, ): Promise<void> { return periodicNoteMethods.appendPeriodicNote( this._request.bind(this), period, content, context, ); } /** * Deletes a periodic note. * @param period - The period type. * @param context - Request context. * @returns {Promise<void>} Resolves on success (204 No Content). */ async deletePeriodicNote( period: Period, context: RequestContext, ): Promise<void> { return periodicNoteMethods.deletePeriodicNote( this._request.bind(this), period, context, ); } // --- Patch Methods --- /** * Patches a specific file in the vault using granular controls. * @param filePath - Vault-relative path to the file. * @param content - The content to insert/replace (string or JSON for tables/frontmatter). * @param options - Patch operation details (operation, targetType, target, etc.). * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchFile( filePath: string, content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchFile( this._request.bind(this), filePath, content, options, context, ); } /** * Patches the currently active file in Obsidian using granular controls. * @param content - The content to insert/replace. * @param options - Patch operation details. * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchActiveFile( content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchActiveFile( this._request.bind(this), content, options, context, ); } /** * Patches a periodic note using granular controls. * @param period - The period type ('daily', 'weekly', etc.). * @param content - The content to insert/replace. * @param options - Patch operation details. * @param context - Request context. * @returns {Promise<void>} Resolves on success (200 OK). */ async patchPeriodicNote( period: Period, content: string | object, options: PatchOptions, context: RequestContext, ): Promise<void> { return patchMethods.patchPeriodicNote( this._request.bind(this), period, content, options, 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