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:
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
154
src/utils/glob-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user