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
216 lines (215 loc) • 10.4 kB
JavaScript
/**
* @fileoverview Core logic for the 'obsidian_list_notes' tool.
* This module defines the input schema, response types, and processing logic for
* recursively listing files and directories in an Obsidian vault with filtering.
* @module src/mcp-server/tools/obsidianListNotesTool/logic
*/
import path from "node:path";
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_list_notes' tool.
*/
export const ObsidianListNotesInputSchema = z
.object({
/**
* The vault-relative path to the directory whose contents should be listed.
* The path is treated as case-sensitive by the underlying Obsidian API.
*/
dirPath: z
.string()
.describe('The vault-relative path to the directory to list (e.g., "developer/atlas-mcp-server", "/" for root). Case-sensitive.'),
/**
* Optional array of file extensions (including the leading dot) to filter the results.
* Only files matching one of these extensions will be included. Directories are always included.
*/
fileExtensionFilter: z
.array(z.string().startsWith(".", "Extension must start with a dot '.'"))
.optional()
.describe('Optional array of file extensions (e.g., [".md"]) to filter files. Directories are always included.'),
/**
* Optional JavaScript-compatible regular expression pattern string to filter results by name.
* Only files and directories whose names match the regex will be included.
*/
nameRegexFilter: z
.string()
.nullable()
.optional()
.describe("Optional regex pattern (JavaScript syntax) to filter results by name."),
/**
* The maximum depth of subdirectories to list recursively.
* - A value of `0` lists only the files and directories in the specified `dirPath`.
* - A value of `1` lists the contents of `dirPath` and the contents of its immediate subdirectories.
* - A value of `-1` (the default) indicates infinite recursion, listing all subdirectories.
*/
recursionDepth: z
.number()
.int()
.default(-1)
.describe("Maximum recursion depth. 0 for no recursion, -1 for infinite (default)."),
})
.describe("Input parameters for listing files and subdirectories within a specified Obsidian vault directory, with optional filtering and recursion.");
// ====================================================================================
// Helper Functions
// ====================================================================================
/**
* Recursively builds a formatted tree string from a nested array of FileTreeNode objects.
*
* @param {FileTreeNode[]} nodes - The array of nodes to format.
* @param {string} [indent=""] - The indentation prefix for the current level.
* @returns {{ tree: string, count: number }} An object containing the formatted tree string and the total count of entries.
*/
function formatTree(nodes, indent = "") {
let treeString = "";
let count = nodes.length;
nodes.forEach((node, index) => {
const isLast = index === nodes.length - 1;
const prefix = isLast ? "└── " : "├── ";
const childIndent = isLast ? " " : "│ ";
treeString += `${indent}${prefix}${node.name}\n`;
if (node.children && node.children.length > 0) {
const result = formatTree(node.children, indent + childIndent);
treeString += result.tree;
count += result.count;
}
});
return { tree: treeString, count };
}
/**
* Recursively builds a file tree by fetching directory contents from the Obsidian API.
*
* @param {string} dirPath - The path of the directory to process.
* @param {number} currentDepth - The current recursion depth.
* @param {ObsidianListNotesInput} params - The original validated input parameters, including filters and max depth.
* @param {RequestContext} context - The request context for logging.
* @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
* @returns {Promise<FileTreeNode[]>} A promise that resolves to an array of file tree nodes.
*/
async function buildFileTree(dirPath, currentDepth, params, context, obsidianService) {
const { recursionDepth, fileExtensionFilter, nameRegexFilter } = params;
// Stop recursion if max depth is reached (and it's not infinite)
if (recursionDepth !== -1 && currentDepth > recursionDepth) {
return [];
}
let fileNames;
try {
fileNames = await obsidianService.listFiles(dirPath, context);
}
catch (error) {
if (error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND) {
logger.warning(`Directory not found during recursive list: ${dirPath}. Skipping.`, context);
return []; // Return empty array if a subdirectory is not found
}
throw error; // Re-throw other errors
}
const regex = nameRegexFilter && nameRegexFilter.trim() !== ""
? new RegExp(nameRegexFilter)
: null;
const treeNodes = [];
for (const name of fileNames) {
const fullPath = path.posix.join(dirPath, name);
const isDirectory = name.endsWith("/");
const cleanName = isDirectory ? name.slice(0, -1) : name;
// Apply filters
if (regex && !regex.test(cleanName)) {
continue;
}
if (!isDirectory && fileExtensionFilter && fileExtensionFilter.length > 0) {
const extension = path.posix.extname(name);
if (!fileExtensionFilter.includes(extension)) {
continue;
}
}
const node = {
name: cleanName,
type: isDirectory ? "directory" : "file",
children: [],
};
if (isDirectory) {
node.name += "/"; // Add trailing slash back for display
node.children = await buildFileTree(fullPath, currentDepth + 1, params, context, obsidianService);
}
treeNodes.push(node);
}
// Sort entries: directories first, then files, alphabetically
treeNodes.sort((a, b) => {
if (a.type === "directory" && b.type === "file")
return -1;
if (a.type === "file" && b.type === "directory")
return 1;
return a.name.localeCompare(b.name);
});
return treeNodes;
}
// ====================================================================================
// Core Logic Function
// ====================================================================================
/**
* Processes the core logic for listing files and directories recursively within the Obsidian vault.
*
* @param {ObsidianListNotesInput} 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<ObsidianListNotesResponse>} A promise resolving to the structured success response.
* @throws {McpError} Throws an McpError if the initial directory is not found or another error occurs.
*/
export const processObsidianListNotes = async (params, context, obsidianService) => {
const { dirPath } = params;
const dirPathForLog = dirPath === "" || dirPath === "/" ? "/" : dirPath;
logger.debug(`Processing obsidian_list_notes request for path: ${dirPathForLog}`, { ...context, params });
try {
const effectiveDirPath = dirPath === "" ? "/" : dirPath;
// --- Step 1: Build the file tree recursively with retry for the initial call ---
const buildTreeContext = {
...context,
operation: "buildFileTreeWithRetry",
};
const shouldRetryNotFound = (err) => err instanceof McpError && err.code === BaseErrorCode.NOT_FOUND;
const fileTree = await retryWithDelay(() => buildFileTree(effectiveDirPath, 0, // Start at depth 0
params, buildTreeContext, obsidianService), {
operationName: "buildFileTreeWithRetry",
context: buildTreeContext,
maxRetries: 3,
delayMs: 300,
shouldRetry: shouldRetryNotFound,
});
// --- Step 2: Format the tree and count entries ---
const formatContext = { ...context, operation: "formatResponse" };
if (fileTree.length === 0) {
logger.debug("Directory is empty or all items were filtered out.", formatContext);
return {
directoryPath: dirPathForLog,
tree: "(empty or all items filtered)",
totalEntries: 0,
};
}
const { tree, count } = formatTree(fileTree);
// --- Step 3: Construct and return the response ---
const response = {
directoryPath: dirPathForLog,
tree: tree.trimEnd(), // Remove trailing newline
totalEntries: count,
};
logger.debug(`Successfully processed list request for ${dirPathForLog}. Found ${count} entries.`, context);
return response;
}
catch (error) {
if (error instanceof McpError) {
// Provide a more specific message if the directory wasn't found after retries
if (error.code === BaseErrorCode.NOT_FOUND) {
const notFoundMsg = `Directory not found after retries: ${dirPathForLog}`;
logger.error(notFoundMsg, error, context);
throw new McpError(error.code, notFoundMsg, context);
}
logger.error(`McpError during file listing for ${dirPathForLog}: ${error.message}`, error, context);
throw error;
}
const errorMessage = `Unexpected error listing Obsidian files in ${dirPathForLog}`;
logger.error(errorMessage, error instanceof Error ? error : undefined, context);
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, context);
}
};