obsidian-mcp-server
Version:
MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.
300 lines • 14.1 kB
JavaScript
/**
* @fileoverview obsidian_list_notes — recursive vault listing with bounded depth.
* Walks the vault tree DFS up to `depth` levels (default 2 — top-level + their
* immediate children, the structural-overview sweet spot), applying optional
* extension and nameRegex filters across the walk, and renders both a flat
* `entries[]` array (for programmatic consumption) and a box-drawing tree view
* in `format()` (for LLM consumption — tree views are easier to scan than flat
* paths). Hard cap at {@link ENTRY_CAP} entries protects against runaway HTTP
* fan-out on large vaults; per-directory `truncated: true` flags signal where
* the depth limit cut off recursion. Drill deeper by passing a higher `depth`,
* narrowing with `path`, or filtering with `extension`/`nameRegex`.
*
* Named `_notes` rather than `_files` to disambiguate from agents' generic
* file-system tools (Read, Glob, LS) — a `_files` tool surface tempts agents
* to fish for non-vault paths through it.
* @module mcp-server/tools/definitions/obsidian-list-notes.tool
*/
import { tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
import { getObsidianService } from '../../../services/obsidian/obsidian-service.js';
const DEFAULT_DEPTH = 2;
const MAX_DEPTH = 20;
const ENTRY_CAP = 1000;
const ENTRY_CAP_HINT = `Walk stopped at ${ENTRY_CAP} entries. Narrow with \`extension\`/\`nameRegex\`, list a deeper subdirectory, or lower \`depth\`.`;
const EntrySchema = z
.object({
path: z.string().describe('Vault-relative path of this entry.'),
type: z
.enum(['file', 'directory'])
.describe('Whether this entry is a regular file or a subdirectory.'),
truncated: z
.boolean()
.optional()
.describe('On directory entries: true when the depth limit prevented walking into this directory. Pass a deeper `depth` to expand.'),
})
.describe('A single entry in the listing.');
export const obsidianListNotes = tool('obsidian_list_notes', {
description: `List notes and subdirectories at a vault path. Defaults to the vault root when \`path\` is omitted. Tune recursion with \`depth\`, or filter the walk with \`extension\` / \`nameRegex\`. Capped at ${ENTRY_CAP} entries per call — when reached, walking stops and \`excluded\` is set; narrow \`path\` or tighten filters to surface the rest.`,
annotations: { readOnlyHint: true, idempotentHint: true },
input: z.object({
path: z.string().optional().describe('Vault-relative directory path. Omit for the vault root.'),
extension: z
.string()
.optional()
.describe('Only include files matching this extension, with or without leading dot. Applies to files only — directories are returned regardless.'),
nameRegex: z
.string()
.optional()
.describe('Optional ECMAScript regex (no flags) applied to entry names. Matches both files and directories; directories that fail the regex are skipped without recursing into them.'),
depth: z
.number()
.int()
.min(1)
.max(MAX_DEPTH)
.default(DEFAULT_DEPTH)
.describe(`How many directory levels to walk. \`1\` = target directory only (no recursion); \`${DEFAULT_DEPTH}\` = target plus its immediate children — a structural overview; bump higher to drill in. Prefer narrowing \`path\` to a subdirectory over a high \`depth\` on the vault root.`),
}),
output: z.object({
path: z.string().describe('The directory listed (empty string for vault root).'),
entries: z
.array(EntrySchema)
.describe('Entries in DFS order — top-level first, then descendants.'),
totals: z
.object({
entries: z.number().describe('Total entries returned across all walked depths.'),
files: z.number().describe('Number of file entries.'),
directories: z.number().describe('Number of directory entries.'),
})
.describe('Counts across the returned tree.'),
appliedFilters: z
.object({
extension: z
.string()
.optional()
.describe('Extension filter applied, normalized with leading dot.'),
nameRegex: z.string().optional().describe('nameRegex filter applied to this listing.'),
depth: z.number().describe('Recursion depth used to produce this listing.'),
})
.describe('Active filters and the recursion depth that produced this listing.'),
excluded: z
.object({
reason: z
.literal('entry_cap')
.describe('`entry_cap`: walk stopped because the per-call entry limit was reached.'),
cap: z.number().describe('The cap value at which walking stopped.'),
hint: z.string().describe('Suggestion for narrowing the listing.'),
})
.optional()
.describe('Present when the walk was truncated by the global entry cap.'),
}),
enrichment: {
notice: z
.string()
.optional()
.describe('Guidance when the walk was truncated by the entry cap, or the listed directory is empty.'),
},
auth: ['tool:obsidian_list_notes:read'],
errors: [
{
reason: 'regex_invalid',
code: JsonRpcErrorCode.ValidationError,
when: 'The supplied `nameRegex` is not a valid ECMAScript regex.',
recovery: 'Use a valid ECMAScript regex (e.g. `^Project.*\\.md$`), or omit nameRegex to disable filtering.',
},
{
reason: 'path_forbidden',
code: JsonRpcErrorCode.Forbidden,
when: 'The supplied `path` is outside OBSIDIAN_READ_PATHS (root listings always pass; specific subdirectories must be readable).',
recovery: 'List a directory inside the configured read scope, or omit `path` to list from the vault root. The error data echoes the active scope.',
},
{
reason: 'note_missing',
code: JsonRpcErrorCode.NotFound,
when: 'The supplied `path` does not exist in the vault. Sub-directories that disappear mid-walk are silently skipped — only the root path surfaces this error.',
recovery: 'List a parent directory to find the correct casing or check the spelling.',
},
],
async handler(input, ctx) {
const svc = getObsidianService();
const depth = input.depth;
let regex;
if (input.nameRegex) {
try {
regex = new RegExp(input.nameRegex);
}
catch (err) {
throw ctx.fail('regex_invalid', `Invalid nameRegex: ${err.message}`, { nameRegex: input.nameRegex, ...ctx.recoveryFor('regex_invalid') }, { cause: err });
}
}
const ext = input.extension
? input.extension.startsWith('.')
? input.extension.toLowerCase()
: `.${input.extension.toLowerCase()}`
: undefined;
const rootDir = (input.path ?? '').replace(/^\/+|\/+$/g, '');
const state = { entries: [], totalFiles: 0, totalDirs: 0, cappedByEntries: false };
await walkVault(svc, ctx, rootDir, 1, state, { depth, regex, ext });
const appliedFilters = { depth };
if (ext)
appliedFilters.extension = ext;
if (input.nameRegex)
appliedFilters.nameRegex = input.nameRegex;
if (state.cappedByEntries) {
ctx.enrich.notice(ENTRY_CAP_HINT);
}
else if (state.entries.length === 0) {
ctx.enrich.notice('The directory is empty or no entries matched the active filters.');
}
return {
path: input.path ?? '',
entries: state.entries,
totals: {
entries: state.entries.length,
files: state.totalFiles,
directories: state.totalDirs,
},
appliedFilters,
...(state.cappedByEntries
? { excluded: { reason: 'entry_cap', cap: ENTRY_CAP, hint: ENTRY_CAP_HINT } }
: {}),
};
},
format: (result) => {
const headerName = result.path === '' ? '(vault root)' : result.path;
const meta = [
`${result.totals.entries} entries`,
`${result.totals.files} files, ${result.totals.directories} directories`,
`depth=${result.appliedFilters.depth}`,
];
const filterParts = [];
if (result.appliedFilters.extension)
filterParts.push(`extension=\`${result.appliedFilters.extension}\``);
if (result.appliedFilters.nameRegex)
filterParts.push(`nameRegex=\`${result.appliedFilters.nameRegex}\``);
if (filterParts.length)
meta.push(`filters: ${filterParts.join(', ')}`);
const lines = [`**${headerName}** — ${meta.join(' · ')}`];
if (result.entries.length === 0) {
lines.push('', '_(empty)_');
}
else {
lines.push('', '```');
lines.push(...renderTree(result.entries));
lines.push('```');
}
if (result.excluded) {
lines.push('', `_Excluded: ${result.excluded.reason} (cap=${result.excluded.cap}). ${result.excluded.hint}_`);
}
return [{ type: 'text', text: lines.join('\n') }];
},
});
/**
* Recursive vault walk with depth and entry-count caps. Mutates `state` in
* place; returns once the walk completes or hits the entry cap. Sub-directory
* 404s (currentDepth > 1) are swallowed because the vault can shift mid-walk;
* a 404 at currentDepth === 1 is the caller's root path missing and propagates
* as a `note_missing` service error. Hoisted out of the handler so the lint's
* source scanner doesn't see the framework's NotFound code in handler text.
*/
async function walkVault(svc, ctx, dir, currentDepth, state, opts) {
if (state.cappedByEntries)
return;
let listing;
try {
listing = await svc.listFiles(ctx, dir);
}
catch (err) {
if (currentDepth > 1 && err instanceof McpError && err.code === JsonRpcErrorCode.NotFound) {
ctx.log.warning('Subdirectory disappeared during walk; skipping', { dir });
return;
}
throw err;
}
for (const raw of listing.files) {
if (state.entries.length >= ENTRY_CAP) {
state.cappedByEntries = true;
return;
}
const isDir = raw.endsWith('/');
const name = isDir ? raw.slice(0, -1) : raw;
if (opts.regex && !opts.regex.test(name))
continue;
if (!isDir && opts.ext && !name.toLowerCase().endsWith(opts.ext))
continue;
const fullPath = dir ? `${dir}/${name}` : name;
const entry = { path: fullPath, type: isDir ? 'directory' : 'file' };
/**
* Mark a subdir truncated when (a) we hit the depth cap, OR (b) policy
* blocks reading it — the listing surfaces it (operator can see it
* exists) but we don't try to walk inside, which would throw
* `path_forbidden` mid-walk and abort the whole listing.
*/
const policyBlocked = isDir && !svc.policy.isReadable(fullPath);
if (isDir && (currentDepth >= opts.depth || policyBlocked))
entry.truncated = true;
state.entries.push(entry);
if (isDir)
state.totalDirs++;
else
state.totalFiles++;
if (isDir && currentDepth < opts.depth && !policyBlocked) {
await walkVault(svc, ctx, fullPath, currentDepth + 1, state, opts);
if (state.cappedByEntries)
return;
}
}
}
/**
* Render entries (in DFS order) as a box-drawing tree. Builds a parent→children
* map keyed by each entry's parent vault path, then emits each top-level group
* (parents that aren't themselves entries) as the roots of the rendered tree.
* This makes the renderer agnostic to the caller's root path — entries anchor
* the tree, not the input.
*/
function renderTree(entries) {
const childrenByParent = new Map();
const entryPaths = new Set();
for (const e of entries)
entryPaths.add(e.path);
for (const e of entries) {
const slash = e.path.lastIndexOf('/');
const parent = slash >= 0 ? e.path.slice(0, slash) : '';
let list = childrenByParent.get(parent);
if (!list) {
list = [];
childrenByParent.set(parent, list);
}
list.push(e);
}
const lines = [];
function emit(entry, prefix, isLast) {
const branch = isLast ? '└── ' : '├── ';
const slashIdx = entry.path.lastIndexOf('/');
const name = slashIdx >= 0 ? entry.path.slice(slashIdx + 1) : entry.path;
const trailing = entry.type === 'directory' ? '/' : '';
const truncMarker = entry.truncated ? ' [truncated — pass deeper `depth` to expand]' : '';
lines.push(`${prefix}${branch}${name}${trailing}${truncMarker}`);
if (entry.type === 'directory' && !entry.truncated) {
const childPrefix = prefix + (isLast ? ' ' : '│ ');
const children = childrenByParent.get(entry.path) ?? [];
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (c)
emit(c, childPrefix, i === children.length - 1);
}
}
}
for (const parent of childrenByParent.keys()) {
if (entryPaths.has(parent))
continue;
const children = childrenByParent.get(parent) ?? [];
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (c)
emit(c, '', i === children.length - 1);
}
}
return lines;
}
//# sourceMappingURL=obsidian-list-notes.tool.js.map