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
380 lines (379 loc) • 18.3 kB
JavaScript
/**
* @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 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, 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";
export class ObsidianRestApiService {
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.
*/
async _request(requestConfig, context, operationName) {
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(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;
}
return response.data;
}
catch (error) {
const axiosError = error;
let errorCode = BaseErrorCode.INTERNAL_ERROR;
let errorMessage = `Obsidian API request failed: ${axiosError.message}`;
const errorDetails = {
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) {
// 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({
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, format = "markdown", context) {
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, content, context) {
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, content, context) {
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, context) {
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, context) {
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, context) {
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, contextLength = 100, context) {
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, contentType, context) {
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, context) {
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) {
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, newLeaf = false, context) {
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", context) {
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, context) {
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, context) {
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) {
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, format = "markdown", context) {
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, content, context) {
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, content, context) {
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, context) {
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, content, options, context) {
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, options, context) {
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, content, options, context) {
return patchMethods.patchPeriodicNote(this._request.bind(this), period, content, options, context);
}
}