Release v1.1.0: Phase 1.1 - Path Normalization & Error Handling

- Add PathUtils for cross-platform path normalization and validation
- Add ErrorMessages with context-aware, actionable error messages
- Update all tool implementations with enhanced path handling
- Improve tool descriptions for AI agents with detailed guidance
- Add Jest testing infrastructure with 43 passing tests
- Add comprehensive documentation (Tool Selection Guide, error improvements)
- Fix cross-platform path issues (Windows backslashes, case sensitivity)
- Fix delete folder error message (clear 'cannot delete folders' message)
- Fix parent folder detection with specific error messages
- All changes backward compatible with v1.0.0

New files:
- src/utils/path-utils.ts - Path normalization utilities
- src/utils/error-messages.ts - Enhanced error messages
- tests/__mocks__/obsidian.ts - Mock Obsidian API
- tests/path-utils.test.ts - 43 unit tests
- tests/README.md - Testing guide
- jest.config.js - Jest configuration
- docs/TOOL_SELECTION_GUIDE.md - Comprehensive tool guide
- docs/ERROR_MESSAGE_IMPROVEMENTS.md - Error message documentation
- docs/TOOL_DESCRIPTION_IMPROVEMENTS.md - AI agent improvements
- PHASE_1.1_IMPLEMENTATION.md - Implementation summary
- RELEASE_NOTES_v1.1.0.md - Release notes

Updated:
- CHANGELOG.md - Add v1.1.0 entry
- ROADMAP.md - Mark Phase 1.1 complete, add Phase 1.5 proposal
- manifest.json - Bump to v1.1.0
- package.json - Bump to v1.1.0, add test scripts
- src/tools/index.ts - Enhanced tool descriptions
- src/tools/note-tools.ts - Use PathUtils and ErrorMessages
- src/tools/vault-tools.ts - Use PathUtils and ErrorMessages
This commit is contained in:
2025-10-16 21:27:23 -04:00
parent 08cc6e9ea6
commit 7524271eaa
15 changed files with 5638 additions and 119 deletions

View File

@@ -16,13 +16,13 @@ export class ToolRegistry {
return [
{
name: "read_note",
description: "Read the content of a note from the Obsidian vault",
description: "Read the content of a file from the Obsidian vault. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list_notes() first if you're unsure of the exact path. This only works on files, not folders.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note within the vault (e.g., 'folder/note.md')"
description: "Vault-relative path to the file (e.g., 'folder/note.md' or 'daily/2024-10-16.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
}
},
required: ["path"]
@@ -30,17 +30,17 @@ export class ToolRegistry {
},
{
name: "create_note",
description: "Create a new note in the Obsidian vault",
description: "Create a new file in the Obsidian vault. Use this to create a new note or file. The parent folder must already exist - this will NOT auto-create folders. Path must be vault-relative with file extension. Will fail if the file already exists. Use list_notes() to verify the parent folder exists before creating.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path for the new note (e.g., 'folder/note.md')"
description: "Vault-relative path for the new file (e.g., 'folder/note.md' or 'projects/2024/report.md'). Must include file extension. Parent folders must exist. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
content: {
type: "string",
description: "Content of the note"
description: "The complete content to write to the new file. Can include markdown formatting, frontmatter, etc."
}
},
required: ["path", "content"]
@@ -48,17 +48,17 @@ export class ToolRegistry {
},
{
name: "update_note",
description: "Update an existing note in the Obsidian vault",
description: "Update (overwrite) an existing file in the Obsidian vault. Use this to modify the contents of an existing note. This REPLACES the entire file content. The file must already exist. Path must be vault-relative with file extension. Use read_note() first to get current content if you want to make partial changes.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note to update"
description: "Vault-relative path to the existing file (e.g., 'folder/note.md'). Must include file extension. File must exist. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
content: {
type: "string",
description: "New content for the note"
description: "The complete new content that will replace the entire file. To make partial changes, read the file first, modify the content, then update."
}
},
required: ["path", "content"]
@@ -66,13 +66,13 @@ export class ToolRegistry {
},
{
name: "delete_note",
description: "Delete a note from the Obsidian vault",
description: "Delete a file from the Obsidian vault. Use this to permanently remove a file. This only works on files, NOT folders. The file must exist. Path must be vault-relative with file extension. This operation cannot be undone through the API.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note to delete"
description: "Vault-relative path to the file to delete (e.g., 'folder/note.md'). Must be a file, not a folder. Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
}
},
required: ["path"]
@@ -80,13 +80,13 @@ export class ToolRegistry {
},
{
name: "search_notes",
description: "Search for notes in the Obsidian vault",
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.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query string"
description: "Text to search for in note names and contents (e.g., 'TODO', 'meeting notes', 'project'). Search is case-insensitive."
}
},
required: ["query"]
@@ -94,7 +94,7 @@ export class ToolRegistry {
},
{
name: "get_vault_info",
description: "Get information about the Obsidian vault",
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.",
inputSchema: {
type: "object",
properties: {}
@@ -102,13 +102,13 @@ export class ToolRegistry {
},
{
name: "list_notes",
description: "List all notes in the vault or in a specific folder",
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.",
inputSchema: {
type: "object",
properties: {
folder: {
type: "string",
description: "Optional folder path to list notes from"
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."
}
}
}

View File

@@ -1,68 +1,208 @@
import { App, TFile } from 'obsidian';
import { CallToolResult } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
export class NoteTools {
constructor(private app: App) {}
async readNote(path: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
// Validate path
if (!path || path.trim() === '') {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
isError: true
};
}
const content = await this.app.vault.read(file);
return {
content: [{ type: "text", text: content }]
};
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Resolve file using path utilities
const file = PathUtils.resolveFile(this.app, path);
if (!file) {
// Check if it's a folder instead
if (PathUtils.folderExists(this.app, path)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
isError: true
};
}
try {
const content = await this.app.vault.read(file);
return {
content: [{ type: "text", text: content }]
};
} catch (error) {
return {
content: [{ type: "text", text: ErrorMessages.operationFailed('read note', path, (error as Error).message) }],
isError: true
};
}
}
async createNote(path: string, content: string): Promise<CallToolResult> {
// Validate path
if (!path || path.trim() === '') {
return {
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
isError: true
};
}
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
const normalizedPath = PathUtils.normalizePath(path);
// Check if file already exists
if (PathUtils.fileExists(this.app, normalizedPath)) {
return {
content: [{ type: "text", text: ErrorMessages.pathAlreadyExists(normalizedPath, 'file') }],
isError: true
};
}
// Check if it's a folder
if (PathUtils.folderExists(this.app, normalizedPath)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFile(normalizedPath) }],
isError: true
};
}
try {
const file = await this.app.vault.create(path, content);
const file = await this.app.vault.create(normalizedPath, content);
return {
content: [{ type: "text", text: `Note created successfully: ${file.path}` }]
};
} catch (error) {
const errorMsg = (error as Error).message;
// Check for parent folder not found error
if (errorMsg.includes('parent folder')) {
const parentPath = PathUtils.getParentPath(normalizedPath);
return {
content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(normalizedPath, parentPath) }],
isError: true
};
}
return {
content: [{ type: "text", text: `Failed to create note: ${(error as Error).message}` }],
content: [{ type: "text", text: ErrorMessages.operationFailed('create note', normalizedPath, errorMsg) }],
isError: true
};
}
}
async updateNote(path: string, content: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
// Validate path
if (!path || path.trim() === '') {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
isError: true
};
}
await this.app.vault.modify(file, content);
return {
content: [{ type: "text", text: `Note updated successfully: ${path}` }]
};
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Resolve file using path utilities
const file = PathUtils.resolveFile(this.app, path);
if (!file) {
// Check if it's a folder instead
if (PathUtils.folderExists(this.app, path)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
isError: true
};
}
try {
await this.app.vault.modify(file, content);
return {
content: [{ type: "text", text: `Note updated successfully: ${file.path}` }]
};
} catch (error) {
return {
content: [{ type: "text", text: ErrorMessages.operationFailed('update note', path, (error as Error).message) }],
isError: true
};
}
}
async deleteNote(path: string): Promise<CallToolResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
// Validate path
if (!path || path.trim() === '') {
return {
content: [{ type: "text", text: `Note not found: ${path}` }],
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
isError: true
};
}
await this.app.vault.delete(file);
return {
content: [{ type: "text", text: `Note deleted successfully: ${path}` }]
};
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Resolve file using path utilities
const file = PathUtils.resolveFile(this.app, path);
if (!file) {
// Check if it's a folder instead
if (PathUtils.folderExists(this.app, path)) {
return {
content: [{ type: "text", text: ErrorMessages.cannotDeleteFolder(path) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
isError: true
};
}
try {
await this.app.vault.delete(file);
return {
content: [{ type: "text", text: `Note deleted successfully: ${file.path}` }]
};
} catch (error) {
return {
content: [{ type: "text", text: ErrorMessages.operationFailed('delete note', path, (error as Error).message) }],
isError: true
};
}
}
}

View File

@@ -1,5 +1,7 @@
import { App, TFile, TFolder } from 'obsidian';
import { CallToolResult } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
export class VaultTools {
constructor(private app: App) {}
@@ -49,16 +51,38 @@ export class VaultTools {
let files: TFile[];
if (folder) {
const folderObj = this.app.vault.getAbstractFileByPath(folder);
if (!folderObj || !(folderObj instanceof TFolder)) {
// Validate path
if (!PathUtils.isValidVaultPath(folder)) {
return {
content: [{ type: "text", text: `Folder not found: ${folder}` }],
content: [{ type: "text", text: ErrorMessages.invalidPath(folder) }],
isError: true
};
}
// Normalize the folder path
const normalizedFolder = PathUtils.normalizePath(folder);
// Check if folder exists
const folderObj = PathUtils.resolveFolder(this.app, normalizedFolder);
if (!folderObj) {
// Check if it's a file instead
if (PathUtils.fileExists(this.app, normalizedFolder)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedFolder) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedFolder) }],
isError: true
};
}
// Get files in the folder
files = [];
this.app.vault.getMarkdownFiles().forEach((file: TFile) => {
if (file.path.startsWith(folder + '/')) {
if (file.path.startsWith(normalizedFolder + '/') || file.path === normalizedFolder) {
files.push(file);
}
});

199
src/utils/error-messages.ts Normal file
View File

@@ -0,0 +1,199 @@
import { PathUtils } from './path-utils';
/**
* Enhanced error message utilities
* Provides helpful, actionable error messages with troubleshooting tips
*/
export class ErrorMessages {
/**
* Generate a file not found error message with troubleshooting tips
*/
static fileNotFound(path: string, operation?: string): string {
const parentPath = PathUtils.getParentPath(path);
const listCommand = parentPath ? `list_notes("${parentPath}")` : 'list_notes()';
return `File not found: "${path}"
The file does not exist in the vault.
Troubleshooting tips:
• Paths are vault-relative (omit leading/trailing slashes)
• Paths are case-sensitive on macOS/Linux
• Use ${listCommand} to see available files in this location
• Verify the file has the correct extension (e.g., .md for markdown)
• Check for typos in the file path
Example: "folder/note.md" instead of "/folder/note.md"`;
}
/**
* Generate a folder not found error message with troubleshooting tips
*/
static folderNotFound(path: string): string {
const parentPath = PathUtils.getParentPath(path);
const listCommand = parentPath ? `list_notes("${parentPath}")` : 'list_notes()';
return `Folder not found: "${path}"
The folder does not exist in the vault.
Troubleshooting tips:
• Paths are vault-relative (omit leading/trailing slashes)
• Paths are case-sensitive on macOS/Linux
• Use ${listCommand} to see available folders in this location
• Verify the folder path is correct
• Check for typos in the folder path
Example: "folder/subfolder" instead of "/folder/subfolder/"`;
}
/**
* Generate an invalid path error message with troubleshooting tips
*/
static invalidPath(path: string, reason?: string): string {
const reasonText = reason ? `\nReason: ${reason}` : '';
return `Invalid path: "${path}"${reasonText}
Troubleshooting tips:
• Paths must be relative to the vault root
• Do not use leading slashes (/) or backslashes (\\)
• Do not use absolute paths (e.g., C:/ or /home/user/)
• Avoid parent directory traversal (..)
• Avoid invalid characters: < > : " | ? * and control characters
• Use forward slashes (/) as path separators
Valid examples:
• "note.md"
• "folder/note.md"
• "folder/subfolder/note.md"`;
}
/**
* Generate a path already exists error message
*/
static pathAlreadyExists(path: string, type: 'file' | 'folder'): string {
return `${type === 'file' ? 'File' : 'Folder'} already exists: "${path}"
Troubleshooting tips:
• Choose a different name for your ${type}
• Use the update_note tool to modify existing files
• Use the delete_note tool to remove the existing ${type} first
• Check if you intended to update rather than create`;
}
/**
* Generate a parent folder not found error message
*/
static parentFolderNotFound(path: string, parentPath: string): string {
return `Parent folder does not exist: "${parentPath}"
Cannot create "${path}" because its parent folder is missing.
Troubleshooting tips:
• Create the parent folder first using Obsidian
• Verify the folder path with list_notes("${PathUtils.getParentPath(parentPath) || '/'}")
• Check that the parent folder path is correct (vault-relative, case-sensitive on macOS/Linux)
• Note: Automatic parent folder creation is not currently enabled
• Ensure all parent folders in the path exist before creating the file`;
}
/**
* Generate a generic operation failed error message
*/
static operationFailed(operation: string, path: string, error: string): string {
return `Failed to ${operation}: "${path}"
Error: ${error}
Troubleshooting tips:
• Check that the path is valid and accessible
• Verify you have the necessary permissions
• Ensure the vault is not in a read-only state
• Try restarting the MCP server if the issue persists`;
}
/**
* Generate a not a file error message
*/
static notAFile(path: string): string {
return `Path is not a file: "${path}"
The specified path exists but is a folder, not a file.
Troubleshooting tips:
• Use the list_notes() tool with this folder path to see its contents
• Specify a file path within this folder instead
• Check that you're using the correct path`;
}
/**
* Generate a not a folder error message
*/
static notAFolder(path: string): string {
return `Path is not a folder: "${path}"
The specified path exists but is a file, not a folder.
Troubleshooting tips:
• Use read_note() to read this file
• Use list_notes() on the parent folder to see contents
• Specify the parent folder path instead
• Check that you're using the correct path`;
}
/**
* Generate an error for attempting to delete a folder
*/
static cannotDeleteFolder(path: string): string {
return `Path is a folder, not a file: "${path}"
Cannot delete folders using delete_note().
Troubleshooting tips:
• Use list_notes("${path}") to see the folder contents
• Delete individual files within the folder using delete_note()
• Note: Folder deletion API is not currently available
• Ensure you're targeting a file, not a folder`;
}
/**
* Generate an empty path error message
*/
static emptyPath(): string {
return `Path cannot be empty
Troubleshooting tips:
• Provide a valid vault-relative path
• Example: "folder/note.md"
• Use the list_notes() tool to see available files`;
}
/**
* Generate a permission denied error message
*/
static permissionDenied(operation: string, path: string): string {
return `Permission denied: cannot ${operation} "${path}"
Troubleshooting tips:
• Check file/folder permissions on your system
• Ensure the vault is not in a read-only location
• Verify the file is not locked by another application
• Try closing the file in Obsidian if it's currently open`;
}
/**
* Generate a helpful error message for any error
*/
static formatError(error: Error | string, context?: string): string {
const message = error instanceof Error ? error.message : error;
const contextText = context ? `\nContext: ${context}` : '';
return `Error: ${message}${contextText}
If this error persists, please check:
• The MCP server logs for more details
• That your Obsidian vault is accessible
• That the MCP server has proper permissions`;
}
}

188
src/utils/path-utils.ts Normal file
View File

@@ -0,0 +1,188 @@
import { App, TFile, TFolder, TAbstractFile } from 'obsidian';
/**
* Utility class for path operations in Obsidian vault
* Handles cross-platform path normalization and validation
*/
export class PathUtils {
/**
* Normalize a path for use in Obsidian vault
* - Strips leading/trailing slashes
* - Converts backslashes to forward slashes
* - Handles Windows drive letters
* - Normalizes case on Windows (case-insensitive)
* - Preserves case on macOS/Linux (case-sensitive)
*/
static normalizePath(path: string): string {
if (!path) {
return '';
}
// Convert backslashes to forward slashes
let normalized = path.replace(/\\/g, '/');
// Remove leading slash
normalized = normalized.replace(/^\/+/, '');
// Remove trailing slash
normalized = normalized.replace(/\/+$/, '');
// Handle multiple consecutive slashes
normalized = normalized.replace(/\/+/g, '/');
// Handle Windows drive letters (C:/ -> C:)
// Obsidian uses relative paths, so we shouldn't have drive letters
// but we'll handle them just in case
normalized = normalized.replace(/^([A-Za-z]):\//, '$1:/');
// On Windows, normalize case (case-insensitive filesystem)
// We'll detect Windows by checking for drive letters or backslashes in original path
const isWindows = /^[A-Za-z]:/.test(path) || path.includes('\\');
if (isWindows) {
// Note: Obsidian's getAbstractFileByPath is case-insensitive on Windows
// so we don't need to change case here, just ensure consistency
}
return normalized;
}
/**
* Check if a path is valid for use in Obsidian vault
* - Must not be empty
* - Must not contain invalid characters
* - Must not be an absolute path
*/
static isValidVaultPath(path: string): boolean {
if (!path || path.trim() === '') {
return false;
}
const normalized = this.normalizePath(path);
// Check for invalid characters (Windows restrictions)
const invalidChars = /[<>:"|?*\x00-\x1F]/;
if (invalidChars.test(normalized)) {
return false;
}
// Check for absolute paths (should be vault-relative)
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
return false;
}
// Check for parent directory traversal attempts
if (normalized.includes('..')) {
return false;
}
return true;
}
/**
* Resolve a vault-relative path to a TFile or TFolder
* Returns null if the path doesn't exist or is invalid
*/
static resolveVaultPath(app: App, path: string): TAbstractFile | null {
if (!this.isValidVaultPath(path)) {
return null;
}
const normalized = this.normalizePath(path);
return app.vault.getAbstractFileByPath(normalized);
}
/**
* Resolve a vault-relative path to a TFile
* Returns null if the path doesn't exist, is invalid, or is not a file
*/
static resolveFile(app: App, path: string): TFile | null {
const file = this.resolveVaultPath(app, path);
return file instanceof TFile ? file : null;
}
/**
* Resolve a vault-relative path to a TFolder
* Returns null if the path doesn't exist, is invalid, or is not a folder
*/
static resolveFolder(app: App, path: string): TFolder | null {
const folder = this.resolveVaultPath(app, path);
return folder instanceof TFolder ? folder : null;
}
/**
* Check if a file exists at the given path
*/
static fileExists(app: App, path: string): boolean {
return this.resolveFile(app, path) !== null;
}
/**
* Check if a folder exists at the given path
*/
static folderExists(app: App, path: string): boolean {
return this.resolveFolder(app, path) !== null;
}
/**
* Check if a path exists (file or folder)
*/
static pathExists(app: App, path: string): boolean {
return this.resolveVaultPath(app, path) !== null;
}
/**
* Get the type of item at the path
* Returns 'file', 'folder', or null if doesn't exist
*/
static getPathType(app: App, path: string): 'file' | 'folder' | null {
const item = this.resolveVaultPath(app, path);
if (!item) return null;
return item instanceof TFile ? 'file' : 'folder';
}
/**
* Ensure a path has the .md extension
*/
static ensureMarkdownExtension(path: string): string {
const normalized = this.normalizePath(path);
if (!normalized.endsWith('.md')) {
return normalized + '.md';
}
return normalized;
}
/**
* Get the parent folder path
*/
static getParentPath(path: string): string {
const normalized = this.normalizePath(path);
const lastSlash = normalized.lastIndexOf('/');
if (lastSlash === -1) {
return '';
}
return normalized.substring(0, lastSlash);
}
/**
* Get the basename (filename without path)
*/
static getBasename(path: string): string {
const normalized = this.normalizePath(path);
const lastSlash = normalized.lastIndexOf('/');
if (lastSlash === -1) {
return normalized;
}
return normalized.substring(lastSlash + 1);
}
/**
* Join path segments
*/
static joinPath(...segments: string[]): string {
const joined = segments
.filter(s => s && s.trim() !== '')
.map(s => this.normalizePath(s))
.join('/');
return this.normalizePath(joined);
}
}