Phase 2: API Unification & Typed Results + Phase 2.1 Fixes
Phase 2 - Breaking Changes (v2.0.0): - Added typed result interfaces (FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch) - Unified parameter naming: list_notes now uses 'path' parameter (removed 'folder') - Enhanced tool responses with structured JSON for all tools - list_notes: Returns array of FileMetadata/DirectoryMetadata with full metadata - search_notes: Returns SearchResult with line numbers, snippets, and match ranges - get_vault_info: Returns VaultInfo with comprehensive statistics - Updated all tool descriptions to document structured responses - Version bumped to 2.0.0 (breaking changes) Phase 2.1 - Post-Testing Fixes: - Fixed root listing to exclude vault root folder itself (handles path '', '/', and isRoot()) - Fixed alphabetical sorting to be case-insensitive for stable ordering - Improved directory metadata with better timestamp detection and error handling - Fixed parent folder validation order (check if file before checking existence) - Updated documentation with root path examples and leading slash warnings - Added comprehensive test suite for sorting and root listing behavior - Fixed test mocks to use proper TFile/TFolder instances Tests: All 64 tests passing Build: Successful, no errors
This commit is contained in:
@@ -59,7 +59,7 @@ export class MCPServer {
|
||||
},
|
||||
serverInfo: {
|
||||
name: "obsidian-mcp-server",
|
||||
version: "1.0.0"
|
||||
version: "2.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "search_notes",
|
||||
description: "Search for notes in the Obsidian vault by content or filename. Use this to find notes containing specific text or with specific names. Searches are case-insensitive and match against both file names and file contents. Returns a list of matching file paths.",
|
||||
description: "Search for notes in the Obsidian vault by content or filename. Returns structured JSON with detailed search results including file paths, line numbers, column positions, snippets with context, and match ranges for highlighting. Searches are case-insensitive and match against both file names and file contents. Use this to find notes containing specific text or with specific names.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -98,7 +98,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "get_vault_info",
|
||||
description: "Get information about the Obsidian vault including vault name, total file count, markdown file count, and root path. Use this to understand the vault structure and get an overview of available content. No parameters required.",
|
||||
description: "Get information about the Obsidian vault. Returns structured JSON with vault name, path, total file count, total folder count, markdown file count, and total size in bytes. Use this to understand the vault structure and get an overview of available content. No parameters required.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {}
|
||||
@@ -106,13 +106,13 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "list_notes",
|
||||
description: "List markdown files in the vault or in a specific folder. Use this to explore vault structure, verify paths exist, or see what files are available. Call without arguments to list all files in the vault, or provide a folder path to list files in that folder. This is essential for discovering what files exist before reading, updating, or deleting them.",
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
folder: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Optional vault-relative folder path to list files from (e.g., 'projects' or 'daily/2024'). Omit to list all files in vault. Do not use leading or trailing slashes. Paths are case-sensitive on macOS/Linux."
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export class ToolRegistry {
|
||||
case "get_vault_info":
|
||||
return await this.vaultTools.getVaultInfo();
|
||||
case "list_notes":
|
||||
return await this.vaultTools.listNotes(args.folder);
|
||||
return await this.vaultTools.listNotes(args.path);
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
||||
|
||||
@@ -91,7 +91,15 @@ export class NoteTools {
|
||||
// Explicit parent folder detection (before write operation)
|
||||
const parentPath = PathUtils.getParentPath(normalizedPath);
|
||||
if (parentPath) {
|
||||
// Check if parent exists
|
||||
// First check if parent path is actually a file (not a folder)
|
||||
if (PathUtils.fileExists(this.app, parentPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFolder(parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if parent folder exists
|
||||
if (!PathUtils.pathExists(this.app, parentPath)) {
|
||||
if (createParents) {
|
||||
// Auto-create parent folders recursively
|
||||
@@ -111,14 +119,6 @@ export class NoteTools {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parent is actually a folder (not a file)
|
||||
if (PathUtils.fileExists(this.app, parentPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFolder(parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with file creation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, TFile, TFolder } from 'obsidian';
|
||||
import { CallToolResult } from '../types/mcp-types';
|
||||
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch } from '../types/mcp-types';
|
||||
import { PathUtils } from '../utils/path-utils';
|
||||
import { ErrorMessages } from '../utils/error-messages';
|
||||
|
||||
@@ -8,22 +8,77 @@ export class VaultTools {
|
||||
|
||||
async searchNotes(query: string): Promise<CallToolResult> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const results: string[] = [];
|
||||
const matches: SearchMatch[] = [];
|
||||
let filesSearched = 0;
|
||||
const filesWithMatches = new Set<string>();
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
for (const file of files) {
|
||||
filesSearched++;
|
||||
const content = await this.app.vault.read(file);
|
||||
if (content.toLowerCase().includes(query.toLowerCase()) ||
|
||||
file.basename.toLowerCase().includes(query.toLowerCase())) {
|
||||
results.push(file.path);
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Search in content
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
const line = lines[lineIndex];
|
||||
const lineLower = line.toLowerCase();
|
||||
let columnIndex = lineLower.indexOf(queryLower);
|
||||
|
||||
while (columnIndex !== -1) {
|
||||
filesWithMatches.add(file.path);
|
||||
|
||||
// Extract snippet (50 chars before and after match)
|
||||
const snippetStart = Math.max(0, columnIndex - 50);
|
||||
const snippetEnd = Math.min(line.length, columnIndex + query.length + 50);
|
||||
const snippet = line.substring(snippetStart, snippetEnd);
|
||||
|
||||
matches.push({
|
||||
path: file.path,
|
||||
line: lineIndex + 1, // 1-indexed
|
||||
column: columnIndex + 1, // 1-indexed
|
||||
snippet: snippet,
|
||||
matchRanges: [{
|
||||
start: columnIndex - snippetStart,
|
||||
end: columnIndex - snippetStart + query.length
|
||||
}]
|
||||
});
|
||||
|
||||
// Find next occurrence in the same line
|
||||
columnIndex = lineLower.indexOf(queryLower, columnIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check filename
|
||||
if (file.basename.toLowerCase().includes(queryLower)) {
|
||||
filesWithMatches.add(file.path);
|
||||
// Add a match for the filename itself
|
||||
const nameIndex = file.basename.toLowerCase().indexOf(queryLower);
|
||||
matches.push({
|
||||
path: file.path,
|
||||
line: 0, // 0 indicates filename match
|
||||
column: nameIndex + 1,
|
||||
snippet: file.basename,
|
||||
matchRanges: [{
|
||||
start: nameIndex,
|
||||
end: nameIndex + query.length
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: SearchResult = {
|
||||
query: query,
|
||||
matches: matches,
|
||||
totalMatches: matches.length,
|
||||
filesSearched: filesSearched,
|
||||
filesWithMatches: filesWithMatches.size
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: results.length > 0
|
||||
? `Found ${results.length} notes:\n${results.join('\n')}`
|
||||
: 'No notes found matching the query'
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -31,12 +86,23 @@ export class VaultTools {
|
||||
async getVaultInfo(): Promise<CallToolResult> {
|
||||
const files = this.app.vault.getFiles();
|
||||
const markdownFiles = this.app.vault.getMarkdownFiles();
|
||||
const folders = this.app.vault.getAllLoadedFiles().filter(f => f instanceof TFolder);
|
||||
|
||||
const info = {
|
||||
// Calculate total size
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
if (file instanceof TFile) {
|
||||
totalSize += file.stat.size;
|
||||
}
|
||||
}
|
||||
|
||||
const info: VaultInfo = {
|
||||
name: this.app.vault.getName(),
|
||||
path: (this.app.vault.adapter as any).basePath || 'Unknown',
|
||||
totalFiles: files.length,
|
||||
totalFolders: folders.length,
|
||||
markdownFiles: markdownFiles.length,
|
||||
rootPath: (this.app.vault.adapter as any).basePath || 'Unknown'
|
||||
totalSize: totalSize
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -47,55 +113,130 @@ export class VaultTools {
|
||||
};
|
||||
}
|
||||
|
||||
async listNotes(folder?: string): Promise<CallToolResult> {
|
||||
let files: TFile[];
|
||||
async listNotes(path?: string): Promise<CallToolResult> {
|
||||
let items: Array<FileMetadata | DirectoryMetadata> = [];
|
||||
|
||||
if (folder) {
|
||||
// Validate path
|
||||
if (!PathUtils.isValidVaultPath(folder)) {
|
||||
// Normalize root path: undefined, empty string "", or "." all mean root
|
||||
const isRootPath = !path || path === '' || path === '.';
|
||||
|
||||
if (isRootPath) {
|
||||
// List direct children of the root
|
||||
const allFiles = this.app.vault.getAllLoadedFiles();
|
||||
for (const item of allFiles) {
|
||||
// Skip the vault root itself
|
||||
// The vault root can have path === '' or path === '/' depending on Obsidian version
|
||||
if (item.path === '' || item.path === '/' || (item instanceof TFolder && item.isRoot())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this item is a direct child of root
|
||||
// Root items have parent === null or parent.path === '' or parent.path === '/'
|
||||
const itemParent = item.parent?.path || '';
|
||||
if (itemParent === '' || itemParent === '/') {
|
||||
if (item instanceof TFile) {
|
||||
items.push(this.createFileMetadata(item));
|
||||
} else if (item instanceof TFolder) {
|
||||
items.push(this.createDirectoryMetadata(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validate non-root path
|
||||
if (!PathUtils.isValidVaultPath(path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(folder) }],
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize the folder path
|
||||
const normalizedFolder = PathUtils.normalizePath(folder);
|
||||
// Normalize the path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
|
||||
// Check if folder exists
|
||||
const folderObj = PathUtils.resolveFolder(this.app, normalizedFolder);
|
||||
// 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, normalizedFolder)) {
|
||||
if (PathUtils.fileExists(this.app, normalizedPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedFolder) }],
|
||||
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedFolder) }],
|
||||
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Get files in the folder
|
||||
files = [];
|
||||
this.app.vault.getMarkdownFiles().forEach((file: TFile) => {
|
||||
if (file.path.startsWith(normalizedFolder + '/') || file.path === normalizedFolder) {
|
||||
files.push(file);
|
||||
// Get direct children of the folder (non-recursive)
|
||||
const allFiles = this.app.vault.getAllLoadedFiles();
|
||||
for (const item of allFiles) {
|
||||
// Check if this item is a direct child of the target folder
|
||||
const itemParent = item.parent?.path || '';
|
||||
if (itemParent === normalizedPath) {
|
||||
if (item instanceof TFile) {
|
||||
items.push(this.createFileMetadata(item));
|
||||
} else if (item instanceof TFolder) {
|
||||
items.push(this.createDirectoryMetadata(item));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
files = this.app.vault.getMarkdownFiles();
|
||||
}
|
||||
}
|
||||
|
||||
const noteList = files.map(f => f.path).join('\n');
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
// Use case-insensitive comparison for stable, consistent ordering
|
||||
items.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === 'directory' ? -1 : 1;
|
||||
}
|
||||
// Case-insensitive alphabetical sort within each group
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Found ${files.length} notes:\n${noteList}`
|
||||
text: JSON.stringify(items, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
private createFileMetadata(file: TFile): FileMetadata {
|
||||
return {
|
||||
kind: "file",
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
extension: file.extension,
|
||||
size: file.stat.size,
|
||||
modified: file.stat.mtime,
|
||||
created: file.stat.ctime
|
||||
};
|
||||
}
|
||||
|
||||
private createDirectoryMetadata(folder: TFolder): DirectoryMetadata {
|
||||
// Count direct children
|
||||
const childrenCount = folder.children.length;
|
||||
|
||||
// Try to get modified time from filesystem if available
|
||||
// Note: Obsidian's TFolder doesn't have a stat property in the official API
|
||||
// We try to access it anyway in case it's populated at runtime
|
||||
// In most cases, this will be 0 for directories
|
||||
let modified = 0;
|
||||
try {
|
||||
if ((folder as any).stat && typeof (folder as any).stat.mtime === 'number') {
|
||||
modified = (folder as any).stat.mtime;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - modified will remain 0
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "directory",
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
childrenCount: childrenCount,
|
||||
modified: modified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,49 @@ export interface CallToolResult {
|
||||
content: ContentBlock[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
// Phase 2: Typed Result Interfaces
|
||||
export type ItemKind = "file" | "directory";
|
||||
|
||||
export interface FileMetadata {
|
||||
kind: "file";
|
||||
name: string;
|
||||
path: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
}
|
||||
|
||||
export interface DirectoryMetadata {
|
||||
kind: "directory";
|
||||
name: string;
|
||||
path: string;
|
||||
childrenCount: number;
|
||||
modified: number;
|
||||
}
|
||||
|
||||
export interface VaultInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
totalFiles: number;
|
||||
totalFolders: number;
|
||||
markdownFiles: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
snippet: string;
|
||||
matchRanges: Array<{ start: number; end: number }>;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
query: string;
|
||||
matches: SearchMatch[];
|
||||
totalMatches: number;
|
||||
filesSearched: number;
|
||||
filesWithMatches: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user