feat: Phase 4 - Enhanced List Operations (v3.0.0)

- Replace list_notes with powerful new list tool
- Add recursive directory traversal
- Implement glob pattern filtering (*, **, ?, [abc], {a,b})
- Add cursor-based pagination for large result sets
- Support frontmatter summary extraction using metadata cache
- Add type filtering (files, directories, any)
- Create GlobUtils for pattern matching
- Add new types: FrontmatterSummary, FileMetadataWithFrontmatter, ListResult
- Update version to 3.0.0 (breaking change)
- Add comprehensive documentation and changelog
- Add Phase 10: UI Notifications to roadmap

BREAKING CHANGE: list_notes tool removed, replaced with list tool.
Migration: Replace list_notes({ path }) with list({ path }).
Response structure now wrapped in ListResult object.
This commit is contained in:
2025-10-16 23:10:31 -04:00
parent 83ac6bedfa
commit aff7c6bd0a
10 changed files with 1242 additions and 378 deletions

View File

@@ -105,14 +105,45 @@ export class ToolRegistry {
}
},
{
name: "list_notes",
description: "List files and directories in the vault or in a specific folder. Returns structured JSON with file metadata (name, path, size, dates) and directory metadata (name, path, child count). Use this to explore vault structure, verify paths exist, or see what files are available. Returns direct children only (non-recursive). Items are sorted with directories first, then files, alphabetically (case-insensitive) within each group.",
name: "list",
description: "List files and/or directories with advanced filtering, recursion, and pagination. Returns structured JSON with file/directory metadata and optional frontmatter summaries. Supports glob patterns for includes/excludes, recursive traversal, type filtering, and cursor-based pagination. Use this to explore vault structure with fine-grained control.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Optional vault-relative folder path to list items from (e.g., 'projects' or 'daily/2024'). To list root-level items, omit this parameter, use empty string '', or use '.'. Do NOT use leading slashes (e.g., '/' or '/folder') as they are invalid and will cause an error. Paths are case-sensitive on macOS/Linux."
description: "Optional vault-relative folder path to list from (e.g., 'projects' or 'daily/2024'). Omit or use empty string for root. Paths are case-sensitive on macOS/Linux."
},
recursive: {
type: "boolean",
description: "If true, recursively list all descendants. If false (default), list only direct children."
},
includes: {
type: "array",
items: { type: "string" },
description: "Glob patterns to include (e.g., ['*.md', 'projects/**']). Supports *, **, ?, [abc], {a,b}. If empty, includes all."
},
excludes: {
type: "array",
items: { type: "string" },
description: "Glob patterns to exclude (e.g., ['.obsidian/**', '*.tmp']). Takes precedence over includes."
},
only: {
type: "string",
enum: ["files", "directories", "any"],
description: "Filter by type: 'files' (only files), 'directories' (only folders), 'any' (both, default)."
},
limit: {
type: "number",
description: "Maximum number of items to return per page. Use with cursor for pagination."
},
cursor: {
type: "string",
description: "Pagination cursor from previous response's nextCursor field. Continue from where the last page ended."
},
withFrontmatterSummary: {
type: "boolean",
description: "If true, include parsed frontmatter (title, tags, aliases) for markdown files without reading full content. Default: false."
}
}
}
@@ -163,8 +194,17 @@ export class ToolRegistry {
return await this.vaultTools.searchNotes(args.query);
case "get_vault_info":
return await this.vaultTools.getVaultInfo();
case "list_notes":
return await this.vaultTools.listNotes(args.path);
case "list":
return await this.vaultTools.list({
path: args.path,
recursive: args.recursive,
includes: args.includes,
excludes: args.excludes,
only: args.only,
limit: args.limit,
cursor: args.cursor,
withFrontmatterSummary: args.withFrontmatterSummary
});
case "stat":
return await this.vaultTools.stat(args.path);
case "exists":

View File

@@ -1,7 +1,8 @@
import { App, TFile, TFolder } from 'obsidian';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult } from '../types/mcp-types';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
import { GlobUtils } from '../utils/glob-utils';
export class VaultTools {
constructor(private app: App) {}
@@ -202,6 +203,229 @@ export class VaultTools {
};
}
// Phase 4: Enhanced List Operations
async list(options: {
path?: string;
recursive?: boolean;
includes?: string[];
excludes?: string[];
only?: 'files' | 'directories' | 'any';
limit?: number;
cursor?: string;
withFrontmatterSummary?: boolean;
}): Promise<CallToolResult> {
const {
path,
recursive = false,
includes,
excludes,
only = 'any',
limit,
cursor,
withFrontmatterSummary = false
} = options;
let items: Array<FileMetadataWithFrontmatter | DirectoryMetadata> = [];
// Normalize root path: undefined, empty string "", or "." all mean root
const isRootPath = !path || path === '' || path === '.';
let normalizedPath = '';
if (!isRootPath) {
// Validate non-root path
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
normalizedPath = PathUtils.normalizePath(path);
// Check if it's a folder
const folderObj = PathUtils.resolveFolder(this.app, normalizedPath);
if (!folderObj) {
// Check if it's a file instead
if (PathUtils.fileExists(this.app, normalizedPath)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedPath) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedPath) }],
isError: true
};
}
}
// Collect items based on recursive flag
const allFiles = this.app.vault.getAllLoadedFiles();
for (const item of allFiles) {
// Skip the vault root itself
if (item.path === '' || item.path === '/' || (item instanceof TFolder && item.isRoot())) {
continue;
}
// Determine if this item should be included based on path
let shouldIncludeItem = false;
if (isRootPath) {
if (recursive) {
// Include all items in the vault
shouldIncludeItem = true;
} else {
// Include only direct children of root
const itemParent = item.parent?.path || '';
shouldIncludeItem = (itemParent === '' || itemParent === '/');
}
} else {
if (recursive) {
// Include items that are descendants of the target folder
shouldIncludeItem = item.path.startsWith(normalizedPath + '/') || item.path === normalizedPath;
// Exclude the folder itself
if (item.path === normalizedPath) {
shouldIncludeItem = false;
}
} else {
// Include only direct children of the target folder
const itemParent = item.parent?.path || '';
shouldIncludeItem = (itemParent === normalizedPath);
}
}
if (!shouldIncludeItem) {
continue;
}
// Apply glob filtering
if (!GlobUtils.shouldInclude(item.path, includes, excludes)) {
continue;
}
// Apply type filtering
if (item instanceof TFile) {
if (only === 'directories') {
continue;
}
const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary);
items.push(fileMetadata);
} else if (item instanceof TFolder) {
if (only === 'files') {
continue;
}
items.push(this.createDirectoryMetadata(item));
}
}
// Sort: directories first, then files, alphabetically within each group
items.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === 'directory' ? -1 : 1;
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Handle cursor-based pagination
let startIndex = 0;
if (cursor) {
// Cursor is the path of the last item from the previous page
const cursorIndex = items.findIndex(item => item.path === cursor);
if (cursorIndex !== -1) {
startIndex = cursorIndex + 1;
}
}
// Apply limit and pagination
const totalCount = items.length;
let paginatedItems = items.slice(startIndex);
let hasMore = false;
let nextCursor: string | undefined;
if (limit && limit > 0 && paginatedItems.length > limit) {
paginatedItems = paginatedItems.slice(0, limit);
hasMore = true;
// Set cursor to the path of the last item in this page
nextCursor = paginatedItems[paginatedItems.length - 1].path;
}
const result: ListResult = {
items: paginatedItems,
totalCount: totalCount,
hasMore: hasMore,
nextCursor: nextCursor
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
private async createFileMetadataWithFrontmatter(
file: TFile,
withFrontmatterSummary: boolean
): Promise<FileMetadataWithFrontmatter> {
const baseMetadata = this.createFileMetadata(file);
if (!withFrontmatterSummary || file.extension !== 'md') {
return baseMetadata;
}
// Extract frontmatter without reading full content
try {
const cache = this.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const summary: FrontmatterSummary = {};
// Extract common frontmatter fields
if (cache.frontmatter.title) {
summary.title = cache.frontmatter.title;
}
if (cache.frontmatter.tags) {
// Tags can be string or array
if (Array.isArray(cache.frontmatter.tags)) {
summary.tags = cache.frontmatter.tags;
} else if (typeof cache.frontmatter.tags === 'string') {
summary.tags = [cache.frontmatter.tags];
}
}
if (cache.frontmatter.aliases) {
// Aliases can be string or array
if (Array.isArray(cache.frontmatter.aliases)) {
summary.aliases = cache.frontmatter.aliases;
} else if (typeof cache.frontmatter.aliases === 'string') {
summary.aliases = [cache.frontmatter.aliases];
}
}
// Include all other frontmatter fields
for (const key in cache.frontmatter) {
if (key !== 'title' && key !== 'tags' && key !== 'aliases' && key !== 'position') {
summary[key] = cache.frontmatter[key];
}
}
return {
...baseMetadata,
frontmatterSummary: summary
};
}
} catch (error) {
// If frontmatter extraction fails, just return base metadata
console.error(`Failed to extract frontmatter for ${file.path}:`, error);
}
return baseMetadata;
}
private createFileMetadata(file: TFile): FileMetadata {
return {
kind: "file",

View File

@@ -121,3 +121,22 @@ export interface ExistsResult {
exists: boolean;
kind?: ItemKind;
}
// Phase 4: Enhanced List Operations Types
export interface FrontmatterSummary {
title?: string;
tags?: string[];
aliases?: string[];
[key: string]: any;
}
export interface FileMetadataWithFrontmatter extends FileMetadata {
frontmatterSummary?: FrontmatterSummary;
}
export interface ListResult {
items: Array<FileMetadataWithFrontmatter | DirectoryMetadata>;
totalCount: number;
hasMore: boolean;
nextCursor?: string;
}

154
src/utils/glob-utils.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Glob pattern matching utilities for filtering files and folders
* Supports *, **, ?, and negation patterns
*/
export class GlobUtils {
/**
* Convert a glob pattern to a regular expression
* Supports:
* - * matches any characters except /
* - ** matches any characters including /
* - ? matches a single character except /
* - [abc] matches any character in the set
* - {a,b} matches any of the alternatives
*/
private static globToRegex(pattern: string): RegExp {
let regexStr = '^';
let i = 0;
while (i < pattern.length) {
const char = pattern[i];
switch (char) {
case '*':
// Check for **
if (pattern[i + 1] === '*') {
// ** matches everything including /
regexStr += '.*';
i += 2;
// Skip optional trailing /
if (pattern[i] === '/') {
regexStr += '/?';
i++;
}
} else {
// * matches anything except /
regexStr += '[^/]*';
i++;
}
break;
case '?':
// ? matches a single character except /
regexStr += '[^/]';
i++;
break;
case '[':
// Character class
const closeIdx = pattern.indexOf(']', i);
if (closeIdx === -1) {
// No closing bracket, treat as literal
regexStr += '\\[';
i++;
} else {
regexStr += '[' + pattern.substring(i + 1, closeIdx) + ']';
i = closeIdx + 1;
}
break;
case '{':
// Alternatives {a,b,c}
const closeIdx2 = pattern.indexOf('}', i);
if (closeIdx2 === -1) {
// No closing brace, treat as literal
regexStr += '\\{';
i++;
} else {
const alternatives = pattern.substring(i + 1, closeIdx2).split(',');
regexStr += '(' + alternatives.map(alt =>
this.escapeRegex(alt)
).join('|') + ')';
i = closeIdx2 + 1;
}
break;
case '/':
case '.':
case '(':
case ')':
case '+':
case '^':
case '$':
case '|':
case '\\':
// Escape special regex characters
regexStr += '\\' + char;
i++;
break;
default:
regexStr += char;
i++;
}
}
regexStr += '$';
return new RegExp(regexStr);
}
private static escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Check if a path matches a glob pattern
*/
static matches(path: string, pattern: string): boolean {
const regex = this.globToRegex(pattern);
return regex.test(path);
}
/**
* Check if a path matches any of the include patterns
* If no includes are specified, returns true
*/
static matchesIncludes(path: string, includes?: string[]): boolean {
if (!includes || includes.length === 0) {
return true;
}
return includes.some(pattern => this.matches(path, pattern));
}
/**
* Check if a path matches any of the exclude patterns
* If no excludes are specified, returns false
*/
static matchesExcludes(path: string, excludes?: string[]): boolean {
if (!excludes || excludes.length === 0) {
return false;
}
return excludes.some(pattern => this.matches(path, pattern));
}
/**
* Check if a path should be included based on include and exclude patterns
* Returns true if the path matches includes and doesn't match excludes
*/
static shouldInclude(path: string, includes?: string[], excludes?: string[]): boolean {
// Must match includes (if specified)
if (!this.matchesIncludes(path, includes)) {
return false;
}
// Must not match excludes (if specified)
if (this.matchesExcludes(path, excludes)) {
return false;
}
return true;
}
}