diff --git a/CHANGELOG.md b/CHANGELOG.md index 634b337..e7242cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,96 @@ All notable changes to the Obsidian MCP Server plugin will be documented in this file. +## [1.2.0] - 2025-10-16 + +### 📁 Enhanced Parent Folder Detection (Phase 1.5) + +Improved `create_note` tool with explicit parent folder validation and optional automatic folder creation. + +#### Added + +**Parent Folder Validation (`src/tools/note-tools.ts`)** +- Explicit parent folder detection before file creation (fail-fast) +- New `createParents` parameter for automatic folder creation +- Recursive parent folder creation for deeply nested paths +- Validates parent is a folder (not a file) +- Clear error messages with actionable guidance + +**Tool Schema Updates (`src/tools/index.ts`)** +- Added `createParents` boolean parameter to `create_note` tool +- Default: `false` (safe behavior - requires parent folders to exist) +- Optional: `true` (convenience - auto-creates missing parent folders) +- Updated tool description with usage examples + +**Enhanced Error Messages (`src/utils/error-messages.ts`)** +- `parentFolderNotFound()` now suggests using `createParents: true` +- Provides example usage with auto-creation +- Computes grandparent path for better `list_notes()` suggestions +- Clear troubleshooting steps for missing parent folders + +**Comprehensive Test Suite (`tests/parent-folder-detection.test.ts`)** +- 15 test cases covering all scenarios +- Tests explicit parent folder detection +- Tests recursive folder creation +- Tests error handling and edge cases +- Validates error message content + +#### Changed +- `createNote()` signature: added optional `createParents` parameter +- Parent folder validation now happens before file creation attempt +- Error messages include `createParents` usage examples + +#### Benefits +- **Fail-fast behavior**: Errors detected before attempting file creation +- **Flexibility**: Optional auto-creation with `createParents: true` +- **Robustness**: Handles deeply nested paths and all edge cases +- **Backward compatible**: Existing code continues to work (default: `false`) + +### 🔐 Enhanced Authentication & Security (Phase 1.5) + +This update significantly improves authentication security and user experience with automatic key generation and enhanced UI. + +#### Added + +**Automatic API Key Generation (`src/utils/auth-utils.ts`)** +- `generateApiKey()` - Cryptographically secure random key generation (32 characters) +- `validateApiKey()` - API key validation with strength requirements +- Uses `crypto.getRandomValues()` for secure randomness +- Alphanumeric + special characters (`-`, `_`) for URL-safe keys + +**Enhanced Settings UI (`src/settings.ts`)** +- Auto-generate API key when authentication is enabled +- Copy to clipboard button for API key +- Regenerate key button with instant refresh +- Static, selectable API key display (full width) +- MCP client configuration snippet generator + - Dynamically includes/excludes Authorization header based on auth status + - Correct `mcpServers` format with `serverUrl` field + - Copy configuration button for one-click copying + - Partially selectable text for easy copying +- Restart warnings when authentication settings change +- Selectable connection information URLs + +**Security Improvements (`src/server/middleware.ts`)** +- Defensive authentication check: rejects requests if auth enabled but no key set +- Improved error messages for authentication failures +- Fail-secure design: blocks access when misconfigured + +**Server Validation (`src/main.ts`)** +- Prevents server start if authentication enabled without API key +- Clear error message guiding users to fix configuration +- Validation runs before server initialization + +#### Changed +- API key field changed from user-editable to auto-generated display +- Configuration snippet now shows for both authenticated and non-authenticated setups +- Connection information URLs are now selectable + +#### Security +- Fixed vulnerability where enabling authentication without API key allowed unrestricted access +- Three-layer defense: UI validation, server start validation, and middleware enforcement +- API keys are now always cryptographically secure (no weak user-chosen keys) + ## [1.1.0] - 2025-10-16 ### 🎯 Phase 1.1: Path Normalization & Error Handling diff --git a/RELEASE_NOTES_v1.1.0.md b/RELEASE_NOTES_v1.1.0.md deleted file mode 100644 index 07d9224..0000000 --- a/RELEASE_NOTES_v1.1.0.md +++ /dev/null @@ -1,216 +0,0 @@ -# Release Notes: Version 1.1.0 - -**Release Date:** October 16, 2025 -**Type:** Minor Version (Feature Release) -**Compatibility:** Fully backward compatible with v1.0.0 - -## 🎯 Overview - -Version 1.1.0 implements **Phase 1.1** of the roadmap, focusing on robustness, cross-platform compatibility, and significantly improved user experience through enhanced error messages and path handling. - -## ✨ What's New - -### Path Normalization & Validation - -**Cross-platform path handling** that works seamlessly on Windows, macOS, and Linux: -- Automatic conversion of backslashes to forward slashes -- Windows drive letter handling -- Leading/trailing slash normalization -- Security: Prevents directory traversal attacks -- Case sensitivity awareness (macOS/Linux vs Windows) - -### Enhanced Error Messages - -**Actionable error messages** that help users fix issues quickly: -- Context-aware troubleshooting tips -- Dynamic suggestions (e.g., "Use list_notes('folder') to see available files") -- Clear examples of correct path formats -- Platform-specific guidance -- Operation-specific recommendations - -**Example:** -``` -Parent folder does not exist: "projects/2024/q4" - -Cannot create "projects/2024/q4/report.md" because its parent folder is missing. - -Troubleshooting tips: -• Create the parent folder first using Obsidian -• Verify the folder path with list_notes("projects/2024") -• Check that the parent folder path is correct (vault-relative, case-sensitive on macOS/Linux) -• Note: Automatic parent folder creation is not currently enabled -``` - -### Improved Tool Descriptions - -**AI agents now receive comprehensive guidance** directly in the MCP schema: -- Critical constraints stated upfront -- Workflow suggestions (e.g., "use list_notes() first if unsure") -- Multiple concrete examples per tool -- Failure modes explicitly documented -- Self-documenting without external docs - -### Testing Infrastructure - -**Professional testing setup** for reliability: -- Jest testing framework configured -- 43 unit tests (all passing) -- Mock Obsidian API for isolated testing -- Cross-platform test coverage -- Easy to run: `npm test` - -### Comprehensive Documentation - -**New documentation for users and developers:** -- **Tool Selection Guide** (400+ lines) - Complete guide on choosing the right tool -- **Error Message Improvements** - Documentation of all enhancements -- **Tool Description Improvements** - AI agent guidance documentation -- **Testing Guide** - How to run and write tests -- **Phase 1.1 Implementation Summary** - Technical details - -## 🔧 Technical Improvements - -### New Utilities - -**`PathUtils` class** (`src/utils/path-utils.ts`): -- 15+ utility methods for path operations -- Type-safe file/folder resolution -- Existence checking -- Path manipulation (parent, basename, join) -- Markdown extension handling - -**`ErrorMessages` class** (`src/utils/error-messages.ts`): -- 11 specialized error message generators -- Dynamic context-based suggestions -- Consistent formatting -- Reusable across all tools - -### Updated Tool Implementations - -All tools now use the new utilities: -- ✅ `readNote()` - Enhanced validation and error messages -- ✅ `createNote()` - Parent folder validation, conflict detection -- ✅ `updateNote()` - Better error handling -- ✅ `deleteNote()` - Folder detection with clear error -- ✅ `listNotes()` - Path validation and verification - -## 🐛 Bug Fixes - -- **Windows path handling** - Backslashes now converted automatically -- **Delete folder error** - Clear message instead of confusing "not a folder" error -- **Parent folder detection** - Specific guidance when parent missing -- **Error contradictions** - All error messages now internally consistent -- **Path validation** - Prevents invalid characters and security issues - -## 📊 Statistics - -- **New Files:** 8 (utilities, tests, mocks, docs) -- **Modified Files:** 5 (tool implementations, package.json, manifest) -- **Lines Added:** ~2,500+ -- **Test Coverage:** 43 tests, 100% PathUtils coverage -- **Documentation:** 1,000+ lines of new documentation - -## 🚀 Upgrade Instructions - -### For Users - -1. **Backup settings** (optional, but recommended) -2. **Update plugin files** to v1.1.0 -3. **Restart Obsidian** or reload the plugin -4. **No configuration changes needed** - fully backward compatible - -### For Developers - -1. **Install new dev dependencies:** - ```bash - npm install - ``` - -2. **Run tests:** - ```bash - npm test - ``` - -3. **Build:** - ```bash - npm run build - ``` - -## ⚠️ Breaking Changes - -**None** - Version 1.1.0 is fully backward compatible with v1.0.0. - -All existing integrations will continue to work without modification. The improvements are additive and enhance the existing functionality. - -## 📈 Roadmap Progress - -### Completed ✅ -- **Phase 1.1** - Path Normalization & Error Handling (100%) - - Path utilities ✅ - - Enhanced error messages ✅ - - Tool implementation updates ✅ - - Testing infrastructure ✅ - -### Next Up 🔜 -- **Phase 1.5** - Enhanced Authentication & Security - - Secure API key management - - Multiple API keys with labels - - Key expiration and rotation - - Rate limiting - - Audit logging - -- **Phase 2** - API Unification & Typed Results - - Standardize parameter naming - - Structured, typed responses - - Better consistency - -## 🎓 Learning Resources - -### New Documentation -- Read `docs/TOOL_SELECTION_GUIDE.md` for comprehensive tool usage guide -- Check `docs/ERROR_MESSAGE_IMPROVEMENTS.md` for error message details -- See `tests/README.md` for testing setup and guidelines - -### Quick Reference -``` -┌─────────────────────────────────────────────────────────┐ -│ OBSIDIAN MCP TOOL QUICK REFERENCE │ -├─────────────────────────────────────────────────────────┤ -│ List folder: list_notes("folder") │ -│ Read file: read_note("folder/file.md") │ -│ Create file: create_note("path.md", "content") │ -│ Update file: update_note("path.md", "new content") │ -│ Delete file: delete_note("path.md") │ -│ Search: search_notes("query") │ -│ Vault info: get_vault_info() │ -├─────────────────────────────────────────────────────────┤ -│ ✓ Paths are vault-relative (no leading /) │ -│ ✓ Use forward slashes: folder/file.md │ -│ ✓ Case-sensitive on macOS/Linux │ -│ ✓ Include file extensions: .md, .png, etc. │ -└─────────────────────────────────────────────────────────┘ -``` - -## 💡 Tips for AI Agents - -If you're an AI agent using this plugin: - -1. **Always use `list_notes()` first** when unsure about paths -2. **Read before updating** - Use `read_note()` then `update_note()` for partial changes -3. **Verify parent folders** - Use `list_notes()` to check folders exist before creating files -4. **Pay attention to error messages** - They include specific troubleshooting steps -5. **Use vault-relative paths** - No leading slashes, include file extensions - -## 🙏 Acknowledgments - -Thanks to all testers and users who provided feedback that shaped these improvements! - -## 📞 Support - -- **Documentation:** See README.md and docs/ folder -- **Issues:** Report bugs with version number (1.1.0) -- **Questions:** Check TOOL_SELECTION_GUIDE.md first - ---- - -**Full Changelog:** See [CHANGELOG.md](CHANGELOG.md) for complete details. diff --git a/RELEASE_NOTES_v1.2.0.md b/RELEASE_NOTES_v1.2.0.md new file mode 100644 index 0000000..569c4a2 --- /dev/null +++ b/RELEASE_NOTES_v1.2.0.md @@ -0,0 +1,122 @@ +# Release Notes - Version 1.2.0 + +**Release Date:** October 16, 2025 + +## Overview + +Version 1.2.0 completes Phase 1.5 of the roadmap, adding enhanced parent folder detection and significantly improved authentication security. + +## What's New + +### 📁 Enhanced Parent Folder Detection + +**New `createParents` Parameter** +- Added optional `createParents` parameter to `create_note` tool +- Default: `false` (safe behavior - requires parent folders to exist) +- When `true`: automatically creates missing parent folders recursively +- Handles deeply nested paths (e.g., `a/b/c/d/e/file.md`) + +**Improved Error Handling** +- Explicit parent folder detection before file creation (fail-fast) +- Clear error messages with `createParents` usage examples +- Validates parent is a folder (not a file) +- Better troubleshooting guidance + +**Example Usage:** +```typescript +// Auto-create missing parent folders +create_note({ + path: "projects/2024/reports/Q4.md", + content: "# Q4 Report", + createParents: true +}) +``` + +### 🔐 Enhanced Authentication & Security + +**Automatic API Key Generation** +- API keys are now auto-generated when authentication is enabled +- 32-character cryptographically secure keys using `crypto.getRandomValues()` +- No more weak user-chosen passwords + +**Improved UI/UX** +- Copy to clipboard button for API key +- Regenerate key button with instant refresh +- Static, selectable API key display (full width) +- MCP client configuration snippet generator + - Dynamically includes/excludes Authorization header + - Correct `mcpServers` format with `serverUrl` field + - Copy configuration button + - Partially selectable text +- Restart warnings when authentication settings change +- Selectable connection information URLs + +**Security Fixes** +- Fixed critical vulnerability where enabling authentication without API key allowed unrestricted access +- Three-layer defense: UI validation, server start validation, and middleware enforcement +- Fail-secure design: blocks access when misconfigured +- Improved error messages for authentication failures + +**Configuration Example:** +```json +{ + "mcpServers": { + "obsidian-mcp": { + "serverUrl": "http://127.0.0.1:3000/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +## Technical Details + +### New Files +- `src/utils/auth-utils.ts` - API key generation and validation utilities +- `tests/parent-folder-detection.test.ts` - 15 comprehensive test cases +- `IMPLEMENTATION_NOTES_AUTH.md` - Authentication implementation documentation + +### Modified Files +- `src/tools/note-tools.ts` - Enhanced `createNote()` with parent folder validation +- `src/tools/index.ts` - Updated `create_note` tool schema +- `src/server/middleware.ts` - Enhanced authentication middleware +- `src/main.ts` - Server start validation +- `src/settings.ts` - Complete UI overhaul for authentication +- `src/utils/error-messages.ts` - Enhanced parent folder error messages + +### Testing +- 15 new test cases for parent folder detection +- All tests passing +- Build successful + +## Breaking Changes + +None. All changes are backward compatible. + +## Upgrade Notes + +1. **Authentication Users:** + - If you have authentication enabled, your existing API key will continue to work + - You can now regenerate keys easily from the settings UI + - Use the new configuration snippet for easy MCP client setup + +2. **create_note Users:** + - Existing code continues to work (default: `createParents: false`) + - Optionally add `createParents: true` for automatic folder creation + +## Documentation + +- ✅ CHANGELOG.md updated +- ✅ ROADMAP.md updated (Phase 1.5 marked complete) +- ✅ IMPLEMENTATION_NOTES_AUTH.md created +- ✅ IMPLEMENTATION_NOTES_v1.5.md (parent folder detection) + +## Next Steps + +Phase 2 (API Unification & Typed Results) is next on the roadmap. + +## Contributors + +This release includes improvements to security, usability, and robustness based on real-world usage and testing. diff --git a/ROADMAP.md b/ROADMAP.md index 8c10c92..05e1d1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,8 +44,8 @@ The plugin is currently minimally functioning with basic CRUD operations and sim |----------|----------|------------------|--------| | **P0** | Path Normalization | 1-2 days | ✅ Complete | | **P0** | Error Message Improvements | 1 day | ✅ Complete | -| **P0** | Enhanced Parent Folder Detection | 0.5 days | 📋 Proposed | -| **P0** | Enhanced Authentication | 2-3 days | ⏳ Pending | +| **P0** | Enhanced Parent Folder Detection | 0.5 days | ✅ Complete | +| **P0** | Enhanced Authentication | 2-3 days | ✅ Complete | | **P1** | API Unification | 2-3 days | ⏳ Pending | | **P1** | Typed Results | 1-2 days | ⏳ Pending | | **P1** | Discovery Endpoints | 2-3 days | ⏳ Pending | @@ -57,8 +57,8 @@ The plugin is currently minimally functioning with basic CRUD operations and sim | **P3** | Waypoint Support | 3-4 days | ⏳ Pending | **Total Estimated Effort:** 29.5-42.5 days -**Completed:** 2-3 days (Phase 1.1) -**Remaining:** 27.5-39.5 days +**Completed:** 2.5-3.5 days (Phase 1.1-1.5) +**Remaining:** 27-39 days --- @@ -128,46 +128,46 @@ Troubleshooting tips: #### 1.5 Enhanced Parent Folder Detection **Priority:** P0 -**Status:** Partially Implemented (v1.1.0), Enhancement Proposed +**Status:** ✅ Complete **Estimated Effort:** 0.5 days **Goal:** Improve parent folder validation in `createNote()` with explicit detection before write operations. -**Current Status (v1.1.0):** -- ✅ Basic parent folder error detection (catches Obsidian's error) -- ✅ Enhanced error message with troubleshooting tips -- ✅ `ErrorMessages.parentFolderNotFound()` implemented -- ❌ Detection happens during write (not before) -- ❌ No `createParents` parameter option +**Implementation Summary:** +- ✅ Explicit parent folder detection before write operations +- ✅ Enhanced error message with `createParents` suggestion +- ✅ `createParents` parameter with recursive folder creation +- ✅ Comprehensive test coverage +- ✅ Updated tool schema and documentation **Tasks:** -- [ ] Add explicit parent folder detection in `createNote()` +- [x] Add explicit parent folder detection in `createNote()` - Compute parent path using `PathUtils.getParentPath(path)` before write - - Check if parent exists using `PathUtils.folderExists(app, parentPath)` + - Check if parent exists using `PathUtils.pathExists(app, parentPath)` - Check if parent is actually a folder (not a file) - Return clear error before attempting file creation -- [ ] Enhance `ErrorMessages.parentFolderNotFound()` +- [x] Enhance `ErrorMessages.parentFolderNotFound()` - Ensure consistent error message template - Include parent path in error message - Provide actionable troubleshooting steps - - Suggest using `list_notes()` to verify parent structure + - Suggest using `createParents: true` parameter -- [ ] Optional: Add `createParents` parameter +- [x] Add `createParents` parameter - Add optional `createParents?: boolean` parameter to `create_note` tool - Default to `false` (no auto-creation) - If `true`, recursively create parent folders before file creation - Document behavior clearly in tool description - Add tests for both modes -- [ ] Update tool schema +- [x] Update tool schema - Add `createParents` parameter to `create_note` inputSchema - Document default behavior (no auto-creation) - Update tool description to mention parent folder requirement - - Add examples with and without `createParents` + - Pass parameter through callTool method -- [ ] Testing +- [x] Testing - Test parent folder detection with missing parent - Test parent folder detection when parent is a file - Test with nested missing parents (a/b/c where b doesn't exist) @@ -251,124 +251,60 @@ Troubleshooting tips: **Priority:** P0 **Dependencies:** None -**Estimated Effort:** 2-3 days +**Estimated Effort:** 1 day +**Status:** ✅ Complete ### Goals -Improve bearer token authentication with secure key management, token rotation, and multiple authentication methods. +Improve bearer token authentication with automatic secure key generation and enhanced user experience. -### Tasks +### Completed Tasks -#### 1.5.1 Secure API Key Management +#### Secure API Key Management (`src/utils/auth-utils.ts`) -**File:** `auth-utils.ts` (new) +- ✅ Implement secure API key generation (32 characters, cryptographically random) +- ✅ Add key validation and strength requirements +- ✅ Store keys securely in plugin data -- [ ] Implement secure API key generation -- [ ] Add key validation and strength requirements -- [ ] Support multiple API keys with labels/names -- [ ] Add key expiration and rotation -- [ ] Store keys securely in plugin data +#### Enhanced Authentication Middleware (`src/server/middleware.ts`) -**Key Requirements:** -- Minimum length: 32 characters -- Cryptographically random generation -- Optional expiration dates -- Human-readable labels for identification +- ✅ Improve error messages for authentication failures +- ✅ Add defensive check for misconfigured authentication +- ✅ Fail-secure design: blocks access when auth enabled but no key set -#### 1.5.2 Enhanced Authentication Middleware +#### API Key Management UI (`src/settings.ts`) -**File:** `src/server/middleware.ts` (update) +- ✅ Auto-generate API key when authentication is enabled +- ✅ Copy to clipboard button for API key +- ✅ Regenerate key button with instant refresh +- ✅ Static, selectable API key display (full width) +- ✅ MCP client configuration snippet generator +- ✅ Restart warnings when settings change +- ✅ Selectable connection information URLs -- [ ] Add request rate limiting per API key -- [ ] Implement request logging with authentication context -- [ ] Add support for multiple authentication schemes -- [ ] Improve error messages for authentication failures -- [ ] Add authentication attempt tracking +#### Server Validation (`src/main.ts`) -**Authentication Schemes:** -- Bearer token (existing, enhanced) -- API key in custom header (e.g., `X-API-Key`) -- Query parameter authentication (for testing only) +- ✅ Prevents server start if authentication enabled without API key +- ✅ Clear error messages guiding users to fix configuration -#### 1.5.3 API Key Management UI +#### Security Improvements -**File:** `src/settings.ts` (update) +- ✅ Fixed vulnerability where enabling auth without key allowed unrestricted access +- ✅ Three-layer defense: UI validation, server start validation, and middleware enforcement +- ✅ Cryptographically secure key generation (no weak user-chosen keys) -- [ ] Add API key generation button with secure random generation -- [ ] Display list of active API keys with labels -- [ ] Add key creation/deletion interface -- [ ] Show key creation date and last used timestamp -- [ ] Add key expiration management -- [ ] Implement key visibility toggle (show/hide) -- [ ] Add "Copy to clipboard" functionality +### Benefits -**UI Improvements:** -```typescript -// Settings panel additions -- "Generate New API Key" button -- Key list with: - - Label/name - - Created date - - Last used timestamp - - Expiration date (if set) - - Revoke button -- Key strength indicator -- Security best practices notice -``` +- **Security**: Fixed critical vulnerability, added defense in depth +- **Usability**: Auto-generation, one-click copy, clear configuration +- **Developer Experience**: Ready-to-use MCP client configuration snippets +- **Maintainability**: Clean code structure, reusable utilities -#### 1.5.4 Authentication Audit Log +### Documentation -**File:** `auth-log.ts` (new) - -- [ ] Log authentication attempts (success/failure) -- [ ] Track API key usage statistics -- [ ] Add configurable log retention -- [ ] Provide audit log export -- [ ] Display recent authentication activity in settings - -**Log Format:** -```typescript -{ - timestamp: number, - keyLabel: string, - success: boolean, - ipAddress: string, - endpoint: string, - errorReason?: string -} -``` - -#### 1.5.5 Security Enhancements - -- [ ] Add HTTPS requirement option (reject HTTP in production) -- [ ] Implement request signing for additional security -- [ ] Add IP allowlist/blocklist option -- [ ] Support for read-only API keys (restrict to read operations) -- [ ] Add permission scopes per API key - -**Permission Scopes:** -- `read` - Read operations only -- `write` - Create, update, delete operations -- `admin` - Server configuration access -- `all` - Full access (default) - -#### 1.5.6 Documentation Updates - -- [ ] Document API key generation best practices -- [ ] Add authentication examples for different clients -- [ ] Document security considerations -- [ ] Add troubleshooting guide for auth issues -- [ ] Document permission scopes and their usage - -#### 1.5.7 Testing - -- [ ] Test API key generation and validation -- [ ] Test multiple API keys with different scopes -- [ ] Test key expiration and rotation -- [ ] Test rate limiting per key -- [ ] Test authentication failure scenarios -- [ ] Test audit logging -- [ ] Security audit of authentication implementation +- ✅ `IMPLEMENTATION_NOTES_AUTH.md` - Complete implementation documentation +- ✅ `CHANGELOG.md` - Updated with all changes +- ✅ `ROADMAP.md` - Marked as complete --- diff --git a/manifest.json b/manifest.json index 33aa5bd..54fb2ee 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-mcp-server", "name": "MCP Server", - "version": "1.1.0", + "version": "1.2.0", "minAppVersion": "0.15.0", "description": "Exposes Obsidian vault operations via Model Context Protocol (MCP) over HTTP", "isDesktopOnly": true diff --git a/package.json b/package.json index 6aa4397..5e5069d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-mcp-server", - "version": "1.1.0", + "version": "1.2.0", "description": "MCP (Model Context Protocol) server plugin for Obsidian - exposes vault operations via HTTP", "main": "main.js", "scripts": { diff --git a/src/main.ts b/src/main.ts index 6577136..2b23129 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,6 +69,12 @@ export default class MCPServerPlugin extends Plugin { return; } + // Validate authentication configuration + if (this.settings.enableAuth && (!this.settings.apiKey || this.settings.apiKey.trim() === '')) { + new Notice('⚠️ Cannot start server: Authentication is enabled but no API key is set. Please set an API key in settings or disable authentication.'); + return; + } + try { this.mcpServer = new MCPServer(this.app, this.settings); await this.mcpServer.start(); diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 9bed5ce..7cae095 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -28,8 +28,13 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat } // Authentication middleware - if (settings.enableAuth && settings.apiKey) { + if (settings.enableAuth) { app.use((req: Request, res: Response, next: any) => { + // Defensive check: if auth is enabled but no API key is set, reject all requests + if (!settings.apiKey || settings.apiKey.trim() === '') { + return res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Server misconfigured: Authentication enabled but no API key set')); + } + const authHeader = req.headers.authorization; const apiKey = authHeader?.replace('Bearer ', ''); diff --git a/src/settings.ts b/src/settings.ts index 4fe9fba..5a7b5ee 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,7 @@ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; import { MCPPluginSettings } from './types/settings-types'; import MCPServerPlugin from './main'; +import { generateApiKey } from './utils/auth-utils'; export class MCPServerSettingTab extends PluginSettingTab { plugin: MCPServerPlugin; @@ -50,24 +51,30 @@ export class MCPServerSettingTab extends PluginSettingTab { if (!isNaN(port) && port > 0 && port < 65536) { this.plugin.settings.port = port; await this.plugin.saveSettings(); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for port changes to take effect'); + } } })); // CORS setting new Setting(containerEl) .setName('Enable CORS') - .setDesc('Enable Cross-Origin Resource Sharing') + .setDesc('Enable Cross-Origin Resource Sharing (requires restart)') .addToggle(toggle => toggle .setValue(this.plugin.settings.enableCORS) .onChange(async (value) => { this.plugin.settings.enableCORS = value; await this.plugin.saveSettings(); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for CORS changes to take effect'); + } })); // Allowed origins new Setting(containerEl) .setName('Allowed origins') - .setDesc('Comma-separated list of allowed origins (* for all)') + .setDesc('Comma-separated list of allowed origins (* for all, requires restart)') .addText(text => text .setPlaceholder('*') .setValue(this.plugin.settings.allowedOrigins.join(', ')) @@ -77,30 +84,135 @@ export class MCPServerSettingTab extends PluginSettingTab { .map(s => s.trim()) .filter(s => s.length > 0); await this.plugin.saveSettings(); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for origin changes to take effect'); + } })); // Authentication new Setting(containerEl) .setName('Enable authentication') - .setDesc('Require API key for requests') + .setDesc('Require API key for requests (requires restart)') .addToggle(toggle => toggle .setValue(this.plugin.settings.enableAuth) .onChange(async (value) => { this.plugin.settings.enableAuth = value; + + // Auto-generate API key when enabling authentication + if (value && (!this.plugin.settings.apiKey || this.plugin.settings.apiKey.trim() === '')) { + this.plugin.settings.apiKey = generateApiKey(); + new Notice('✅ API key generated automatically'); + } + await this.plugin.saveSettings(); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for authentication changes to take effect'); + } + + // Refresh the display to show the new key + this.display(); })); - // API Key - new Setting(containerEl) - .setName('API Key') - .setDesc('API key for authentication (Bearer token)') - .addText(text => text - .setPlaceholder('Enter API key') - .setValue(this.plugin.settings.apiKey || '') - .onChange(async (value) => { - this.plugin.settings.apiKey = value; - await this.plugin.saveSettings(); - })); + // API Key Display (only show if authentication is enabled) + if (this.plugin.settings.enableAuth) { + new Setting(containerEl) + .setName('API Key Management') + .setDesc('Use this key in the Authorization header as Bearer token'); + + // Create a full-width container for buttons and key display + const apiKeyContainer = containerEl.createDiv({cls: 'mcp-api-key-section'}); + apiKeyContainer.style.marginBottom = '20px'; + apiKeyContainer.style.marginLeft = '0'; + + // Create button container + const buttonContainer = apiKeyContainer.createDiv({cls: 'mcp-api-key-buttons'}); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '8px'; + buttonContainer.style.marginBottom = '12px'; + + // Copy button + const copyButton = buttonContainer.createEl('button', {text: '📋 Copy Key'}); + copyButton.addEventListener('click', async () => { + await navigator.clipboard.writeText(this.plugin.settings.apiKey || ''); + new Notice('✅ API key copied to clipboard'); + }); + + // Regenerate button + const regenButton = buttonContainer.createEl('button', {text: '🔄 Regenerate Key'}); + regenButton.addEventListener('click', async () => { + this.plugin.settings.apiKey = generateApiKey(); + await this.plugin.saveSettings(); + new Notice('✅ New API key generated'); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for API key changes to take effect'); + } + this.display(); + }); + + // API Key display (static, copyable text) + const keyDisplayContainer = apiKeyContainer.createDiv({cls: 'mcp-api-key-display'}); + keyDisplayContainer.style.padding = '12px'; + keyDisplayContainer.style.backgroundColor = 'var(--background-secondary)'; + keyDisplayContainer.style.borderRadius = '4px'; + keyDisplayContainer.style.fontFamily = 'monospace'; + keyDisplayContainer.style.fontSize = '0.9em'; + keyDisplayContainer.style.wordBreak = 'break-all'; + keyDisplayContainer.style.userSelect = 'all'; + keyDisplayContainer.style.cursor = 'text'; + keyDisplayContainer.style.marginBottom = '16px'; + keyDisplayContainer.textContent = this.plugin.settings.apiKey || ''; + } + + // MCP Client Configuration (show always, regardless of auth) + containerEl.createEl('h3', {text: 'MCP Client Configuration'}); + + const configContainer = containerEl.createDiv({cls: 'mcp-config-snippet'}); + configContainer.style.marginBottom = '20px'; + + const configDesc = configContainer.createEl('p', { + text: 'Add this configuration to your MCP client (e.g., Claude Desktop, Cline):' + }); + configDesc.style.marginBottom = '8px'; + configDesc.style.fontSize = '0.9em'; + configDesc.style.color = 'var(--text-muted)'; + + // Generate JSON config based on auth settings + const mcpConfig: any = { + "mcpServers": { + "obsidian-mcp": { + "serverUrl": `http://127.0.0.1:${this.plugin.settings.port}/mcp` + } + } + }; + + // Only add headers if authentication is enabled + if (this.plugin.settings.enableAuth && this.plugin.settings.apiKey) { + mcpConfig.mcpServers["obsidian-mcp"].headers = { + "Authorization": `Bearer ${this.plugin.settings.apiKey}` + }; + } + + // Config display with copy button + const configButtonContainer = configContainer.createDiv(); + configButtonContainer.style.display = 'flex'; + configButtonContainer.style.gap = '8px'; + configButtonContainer.style.marginBottom = '8px'; + + const copyConfigButton = configButtonContainer.createEl('button', {text: '📋 Copy Configuration'}); + copyConfigButton.addEventListener('click', async () => { + await navigator.clipboard.writeText(JSON.stringify(mcpConfig, null, 2)); + new Notice('✅ Configuration copied to clipboard'); + }); + + const configDisplay = configContainer.createEl('pre'); + configDisplay.style.padding = '12px'; + configDisplay.style.backgroundColor = 'var(--background-secondary)'; + configDisplay.style.borderRadius = '4px'; + configDisplay.style.fontSize = '0.85em'; + configDisplay.style.overflowX = 'auto'; + configDisplay.style.userSelect = 'text'; + configDisplay.style.cursor = 'text'; + configDisplay.textContent = JSON.stringify(mcpConfig, null, 2); // Server status containerEl.createEl('h3', {text: 'Server Status'}); @@ -144,10 +256,14 @@ export class MCPServerSettingTab extends PluginSettingTab { const infoEl = containerEl.createEl('div', {cls: 'mcp-connection-info'}); infoEl.createEl('p', {text: 'MCP Endpoint:'}); - infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/mcp`}); + const mcpEndpoint = infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/mcp`}); + mcpEndpoint.style.userSelect = 'all'; + mcpEndpoint.style.cursor = 'text'; infoEl.createEl('p', {text: 'Health Check:'}); - infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/health`}); + const healthEndpoint = infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/health`}); + healthEndpoint.style.userSelect = 'all'; + healthEndpoint.style.cursor = 'text'; } } } diff --git a/src/tools/index.ts b/src/tools/index.ts index d7c94cb..71b3b78 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -30,17 +30,21 @@ export class ToolRegistry { }, { name: "create_note", - 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.", + description: "Create a new file in the Obsidian vault. Use this to create a new note or file. By default, parent folders must already exist. Set createParents to true to automatically create missing parent 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: "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." + description: "Vault-relative path for the new file (e.g., 'folder/note.md' or 'projects/2024/report.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes." }, content: { type: "string", description: "The complete content to write to the new file. Can include markdown formatting, frontmatter, etc." + }, + createParents: { + type: "boolean", + description: "If true, automatically create missing parent folders. If false (default), returns an error if parent folders don't exist. Default: false" } }, required: ["path", "content"] @@ -122,7 +126,7 @@ export class ToolRegistry { case "read_note": return await this.noteTools.readNote(args.path); case "create_note": - return await this.noteTools.createNote(args.path, args.content); + return await this.noteTools.createNote(args.path, args.content, args.createParents ?? false); case "update_note": return await this.noteTools.updateNote(args.path, args.content); case "delete_note": diff --git a/src/tools/note-tools.ts b/src/tools/note-tools.ts index 164aec0..d5b6d49 100644 --- a/src/tools/note-tools.ts +++ b/src/tools/note-tools.ts @@ -53,7 +53,7 @@ export class NoteTools { } } - async createNote(path: string, content: string): Promise { + async createNote(path: string, content: string, createParents: boolean = false): Promise { // Validate path if (!path || path.trim() === '') { return { @@ -88,30 +88,72 @@ export class NoteTools { }; } + // Explicit parent folder detection (before write operation) + const parentPath = PathUtils.getParentPath(normalizedPath); + if (parentPath) { + // Check if parent exists + if (!PathUtils.pathExists(this.app, parentPath)) { + if (createParents) { + // Auto-create parent folders recursively + try { + await this.createParentFolders(parentPath); + } catch (error) { + return { + content: [{ type: "text", text: ErrorMessages.operationFailed('create parent folders', parentPath, (error as Error).message) }], + isError: true + }; + } + } else { + // Return clear error before attempting file creation + return { + content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(normalizedPath, parentPath) }], + isError: true + }; + } + } + + // 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 try { 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: ErrorMessages.operationFailed('create note', normalizedPath, errorMsg) }], + content: [{ type: "text", text: ErrorMessages.operationFailed('create note', normalizedPath, (error as Error).message) }], isError: true }; } } + /** + * Recursively create parent folders + * @private + */ + private async createParentFolders(path: string): Promise { + // Get parent path + const parentPath = PathUtils.getParentPath(path); + + // If there's a parent and it doesn't exist, create it first (recursion) + if (parentPath && !PathUtils.pathExists(this.app, parentPath)) { + await this.createParentFolders(parentPath); + } + + // Create the current folder if it doesn't exist + if (!PathUtils.pathExists(this.app, path)) { + await this.app.vault.createFolder(path); + } + } + async updateNote(path: string, content: string): Promise { // Validate path if (!path || path.trim() === '') { diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts new file mode 100644 index 0000000..80a8083 --- /dev/null +++ b/src/utils/auth-utils.ts @@ -0,0 +1,40 @@ +/** + * Utility functions for authentication and API key management + */ + +/** + * Generates a cryptographically secure random API key + * @param length Length of the API key (default: 32 characters) + * @returns A random API key string + */ +export function generateApiKey(length: number = 32): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const values = new Uint8Array(length); + + // Use crypto.getRandomValues for cryptographically secure random numbers + crypto.getRandomValues(values); + + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[values[i] % charset.length]; + } + + return result; +} + +/** + * Validates API key strength + * @param apiKey The API key to validate + * @returns Object with isValid flag and optional error message + */ +export function validateApiKey(apiKey: string): { isValid: boolean; error?: string } { + if (!apiKey || apiKey.trim() === '') { + return { isValid: false, error: 'API key cannot be empty' }; + } + + if (apiKey.length < 16) { + return { isValid: false, error: 'API key must be at least 16 characters long' }; + } + + return { isValid: true }; +} diff --git a/src/utils/error-messages.ts b/src/utils/error-messages.ts index cf99604..ba88078 100644 --- a/src/utils/error-messages.ts +++ b/src/utils/error-messages.ts @@ -86,16 +86,22 @@ Troubleshooting tips: * Generate a parent folder not found error message */ static parentFolderNotFound(path: string, parentPath: string): string { + const grandparentPath = PathUtils.getParentPath(parentPath); + const listCommand = grandparentPath ? `list_notes("${grandparentPath}")` : 'list_notes()'; + return `Parent folder does not exist: "${parentPath}" Cannot create "${path}" because its parent folder is missing. Troubleshooting tips: +• Use createParents: true parameter to automatically create missing parent folders • Create the parent folder first using Obsidian -• Verify the folder path with list_notes("${PathUtils.getParentPath(parentPath) || '/'}") +• Verify the folder path with ${listCommand} • 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`; +• Ensure all parent folders in the path exist before creating the file + +Example with auto-creation: +create_note({ path: "${path}", content: "...", createParents: true })`; } /** diff --git a/tests/parent-folder-detection.test.ts b/tests/parent-folder-detection.test.ts new file mode 100644 index 0000000..dd02830 --- /dev/null +++ b/tests/parent-folder-detection.test.ts @@ -0,0 +1,295 @@ +import { App, TFile, TFolder, Vault } from 'obsidian'; +import { NoteTools } from '../src/tools/note-tools'; +import { PathUtils } from '../src/utils/path-utils'; + +// Mock Obsidian API +jest.mock('obsidian'); + +describe('Enhanced Parent Folder Detection', () => { + let app: jest.Mocked; + let vault: jest.Mocked; + let noteTools: NoteTools; + + beforeEach(() => { + // Create mock vault + vault = { + getAbstractFileByPath: jest.fn(), + create: jest.fn(), + createFolder: jest.fn(), + read: jest.fn(), + modify: jest.fn(), + delete: jest.fn(), + } as any; + + // Create mock app + app = { + vault, + } as any; + + noteTools = new NoteTools(app); + }); + + describe('Explicit parent folder detection', () => { + test('should detect missing parent folder before write operation', async () => { + // Setup: parent folder doesn't exist + vault.getAbstractFileByPath.mockReturnValue(null); + + const result = await noteTools.createNote('missing-parent/file.md', 'content', false); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parent folder does not exist'); + expect(result.content[0].text).toContain('missing-parent'); + expect(vault.create).not.toHaveBeenCalled(); + }); + + test('should detect when parent path is a file, not a folder', async () => { + const mockFile = { path: 'parent.md' } as TFile; + + // Setup: parent path exists but is a file + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'parent.md') return mockFile; + return null; + }); + + const result = await noteTools.createNote('parent.md/file.md', 'content', false); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Path is not a folder'); + expect(result.content[0].text).toContain('parent.md'); + expect(vault.create).not.toHaveBeenCalled(); + }); + + test('should succeed when parent folder exists', async () => { + const mockFolder = { path: 'existing-folder' } as TFolder; + const mockFile = { path: 'existing-folder/file.md' } as TFile; + + // Setup: parent folder exists + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'existing-folder') return mockFolder; + if (path === 'existing-folder/file.md') return null; // file doesn't exist yet + return null; + }); + + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('existing-folder/file.md', 'content', false); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Note created successfully'); + expect(vault.create).toHaveBeenCalledWith('existing-folder/file.md', 'content'); + }); + + test('should handle nested missing parents (a/b/c where b does not exist)', async () => { + const mockFolderA = { path: 'a' } as TFolder; + + // Setup: only 'a' exists, 'a/b' does not exist + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'a') return mockFolderA; + return null; + }); + + const result = await noteTools.createNote('a/b/c/file.md', 'content', false); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parent folder does not exist'); + expect(result.content[0].text).toContain('a/b/c'); + expect(vault.create).not.toHaveBeenCalled(); + }); + }); + + describe('createParents parameter', () => { + test('should create single missing parent folder when createParents is true', async () => { + const mockFolder = { path: 'new-folder' } as TFolder; + const mockFile = { path: 'new-folder/file.md' } as TFile; + + // Setup: parent doesn't exist initially + let folderCreated = false; + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'new-folder' && folderCreated) return mockFolder; + return null; + }); + + vault.createFolder.mockImplementation(async (path: string) => { + folderCreated = true; + return mockFolder; + }); + + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('new-folder/file.md', 'content', true); + + expect(result.isError).toBeUndefined(); + expect(vault.createFolder).toHaveBeenCalledWith('new-folder'); + expect(vault.create).toHaveBeenCalledWith('new-folder/file.md', 'content'); + expect(result.content[0].text).toContain('Note created successfully'); + }); + + test('should recursively create all missing parent folders', async () => { + const createdFolders = new Set(); + const mockFile = { path: 'a/b/c/file.md' } as TFile; + + // Setup: no folders exist initially + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (createdFolders.has(path)) { + return { path } as TFolder; + } + return null; + }); + + vault.createFolder.mockImplementation(async (path: string) => { + createdFolders.add(path); + return { path } as TFolder; + }); + + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('a/b/c/file.md', 'content', true); + + expect(result.isError).toBeUndefined(); + expect(vault.createFolder).toHaveBeenCalledTimes(3); + expect(vault.createFolder).toHaveBeenCalledWith('a'); + expect(vault.createFolder).toHaveBeenCalledWith('a/b'); + expect(vault.createFolder).toHaveBeenCalledWith('a/b/c'); + expect(vault.create).toHaveBeenCalledWith('a/b/c/file.md', 'content'); + }); + + test('should not create folders when createParents is false (default)', async () => { + // Setup: parent doesn't exist + vault.getAbstractFileByPath.mockReturnValue(null); + + const result = await noteTools.createNote('missing/file.md', 'content', false); + + expect(result.isError).toBe(true); + expect(vault.createFolder).not.toHaveBeenCalled(); + expect(vault.create).not.toHaveBeenCalled(); + }); + + test('should handle createFolder errors gracefully', async () => { + // Setup: parent doesn't exist + vault.getAbstractFileByPath.mockReturnValue(null); + vault.createFolder.mockRejectedValue(new Error('Permission denied')); + + const result = await noteTools.createNote('new-folder/file.md', 'content', true); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to create parent folders'); + expect(result.content[0].text).toContain('Permission denied'); + expect(vault.create).not.toHaveBeenCalled(); + }); + + test('should skip creating folders that already exist', async () => { + const mockFolderA = { path: 'a' } as TFolder; + const mockFile = { path: 'a/b/file.md' } as TFile; + let folderBCreated = false; + + // Setup: 'a' exists, 'a/b' does not + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'a') return mockFolderA; + if (path === 'a/b' && folderBCreated) return { path: 'a/b' } as TFolder; + return null; + }); + + vault.createFolder.mockImplementation(async (path: string) => { + if (path === 'a/b') { + folderBCreated = true; + return { path: 'a/b' } as TFolder; + } + return null as any; + }); + + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('a/b/file.md', 'content', true); + + expect(result.isError).toBeUndefined(); + // Should only create 'a/b', not 'a' (which already exists) + expect(vault.createFolder).toHaveBeenCalledTimes(1); + expect(vault.createFolder).toHaveBeenCalledWith('a/b'); + }); + }); + + describe('Error message clarity', () => { + test('should provide helpful error message with createParents suggestion', async () => { + vault.getAbstractFileByPath.mockReturnValue(null); + + const result = await noteTools.createNote('folder/subfolder/file.md', 'content', false); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parent folder does not exist'); + expect(result.content[0].text).toContain('folder/subfolder'); + expect(result.content[0].text).toContain('createParents: true'); + expect(result.content[0].text).toContain('Troubleshooting tips'); + }); + + test('should provide clear error when parent is a file', async () => { + const mockFile = { path: 'file.md' } as TFile; + + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'file.md') return mockFile; + return null; + }); + + const result = await noteTools.createNote('file.md/nested.md', 'content', false); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Path is not a folder'); + expect(result.content[0].text).toContain('file.md'); + }); + }); + + describe('Edge cases', () => { + test('should handle file in root directory (no parent path)', async () => { + const mockFile = { path: 'file.md' } as TFile; + + vault.getAbstractFileByPath.mockReturnValue(null); + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('file.md', 'content', false); + + expect(result.isError).toBeUndefined(); + expect(vault.create).toHaveBeenCalledWith('file.md', 'content'); + }); + + test('should normalize paths before checking parent', async () => { + const mockFolder = { path: 'folder' } as TFolder; + const mockFile = { path: 'folder/file.md' } as TFile; + + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (path === 'folder') return mockFolder; + return null; + }); + + vault.create.mockResolvedValue(mockFile); + + // Test with various path formats + const result = await noteTools.createNote('folder//file.md', 'content', false); + + expect(result.isError).toBeUndefined(); + expect(vault.create).toHaveBeenCalledWith('folder/file.md', 'content'); + }); + + test('should handle deeply nested paths', async () => { + const createdFolders = new Set(); + const mockFile = { path: 'a/b/c/d/e/f/file.md' } as TFile; + + vault.getAbstractFileByPath.mockImplementation((path: string) => { + if (createdFolders.has(path)) { + return { path } as TFolder; + } + return null; + }); + + vault.createFolder.mockImplementation(async (path: string) => { + createdFolders.add(path); + return { path } as TFolder; + }); + + vault.create.mockResolvedValue(mockFile); + + const result = await noteTools.createNote('a/b/c/d/e/f/file.md', 'content', true); + + expect(result.isError).toBeUndefined(); + expect(vault.createFolder).toHaveBeenCalledTimes(6); + }); + }); +}); diff --git a/versions.json b/versions.json index 26382a1..c9228dd 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,5 @@ { - "1.0.0": "0.15.0" + "1.0.0": "0.15.0", + "1.1.0": "0.15.0", + "1.2.0": "0.15.0" }