@mauricio.wolff/mcp-obsidian 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ A universal AI bridge for Obsidian vaults using the Model Context Protocol (MCP)
16
16
 
17
17
  [![GitHub Stars](https://img.shields.io/github/stars/bitbonsai/mcp-obsidian?style=flat&logo=github&logoColor=white&color=9065ea&labelColor=262626)](https://github.com/bitbonsai/mcp-obsidian)
18
18
  [![npm version](https://img.shields.io/npm/v/@mauricio.wolff/mcp-obsidian?style=flat&logo=npm&logoColor=white&color=9065ea&labelColor=262626)](https://www.npmjs.com/package/@mauricio.wolff/mcp-obsidian)
19
+ [![npm downloads](https://img.shields.io/npm/dt/@mauricio.wolff/mcp-obsidian?style=flat&logo=npm&logoColor=white&color=9065ea&labelColor=262626)](https://www.npmjs.com/package/@mauricio.wolff/mcp-obsidian)
19
20
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/BitBonsai?style=flat&logo=github&logoColor=white&color=9065ea&labelColor=262626)](https://github.com/sponsors/bitbonsai)
20
21
  [![Ko-Fi](https://img.shields.io/badge/Ko--fi-Support%20Me-9065ea?style=flat&logo=ko-fi&logoColor=white&labelColor=262626)](https://ko-fi.com/bitbonsai)
21
22
  [![Liberapay](https://img.shields.io/badge/Liberapay-Weekly%20Support-9065ea?style=flat&logo=liberapay&logoColor=white&labelColor=262626)](https://liberapay.com/bitbonsai/)
@@ -114,7 +115,6 @@ MCP is an open protocol. You're not tied to any specific vendor or platform. You
114
115
  - ✅ **Optional pretty-printing**: Set `prettyPrint: true` for human-readable debugging
115
116
  - ✅ **Performance optimized**: No unnecessary token consumption, efficient for large vaults
116
117
  - ✅ **Zero dependencies**: No Obsidian plugins required, works with any vault structure
117
- - ✅ **Intelligent link handling**: Smart processing of internal links and references
118
118
 
119
119
  ## Prerequisites
120
120
 
@@ -135,12 +135,16 @@ npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/obsidian/vault
135
135
  ### For Developers
136
136
 
137
137
  1. Clone this repository
138
- 2. Install dependencies with npm:
138
+ 2. Use the correct Node.js version:
139
139
  ```bash
140
- npm install
140
+ nvm use # Uses Node 24 from .nvmrc
141
+ ```
142
+ 3. Install dependencies with npm:
143
+ ```bash
144
+ npm install # Corepack automatically uses npm 10.9.0
141
145
  ```
142
146
 
143
- 3. Test locally with MCP inspector:
147
+ 4. Test locally with MCP inspector:
144
148
  ```bash
145
149
  npx @modelcontextprotocol/inspector npm start /path/to/your/vault
146
150
  ```
package/dist/server.js CHANGED
@@ -353,6 +353,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
353
353
  },
354
354
  required: ["path", "operation"]
355
355
  }
356
+ },
357
+ {
358
+ name: "get_vault_stats",
359
+ description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
360
+ inputSchema: {
361
+ type: "object",
362
+ properties: {
363
+ recentCount: {
364
+ type: "number",
365
+ description: "Number of recently modified files to return (default: 5, max: 20)",
366
+ default: 5
367
+ },
368
+ prettyPrint: {
369
+ type: "boolean",
370
+ description: "Format JSON response with indentation (default: false)",
371
+ default: false
372
+ }
373
+ }
374
+ }
356
375
  }
357
376
  ]
358
377
  };
@@ -570,6 +589,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
570
589
  isError: !result.success
571
590
  };
572
591
  }
592
+ case "get_vault_stats": {
593
+ const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
594
+ const stats = await fileSystem.getVaultStats(recentCount);
595
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
596
+ return {
597
+ content: [
598
+ {
599
+ type: "text",
600
+ text: JSON.stringify({
601
+ notes: stats.totalNotes,
602
+ folders: stats.totalFolders,
603
+ size: stats.totalSize,
604
+ recent: stats.recentlyModified
605
+ }, null, indent)
606
+ }
607
+ ]
608
+ };
609
+ }
573
610
  default:
574
611
  throw new Error(`Unknown tool: ${name}`);
575
612
  }
@@ -29,14 +29,14 @@ export class FileSystemService {
29
29
  // Security check: ensure path is within vault
30
30
  const relativeToVault = relative(this.vaultPath, fullPath);
31
31
  if (relativeToVault.startsWith('..')) {
32
- throw new Error(`Path traversal not allowed: ${relativePath}`);
32
+ throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
33
33
  }
34
34
  return fullPath;
35
35
  }
36
36
  async readNote(path) {
37
37
  const fullPath = this.resolvePath(path);
38
38
  if (!this.pathFilter.isAllowed(path)) {
39
- throw new Error(`Access denied: ${path}`);
39
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
40
40
  }
41
41
  // Check if the path is a directory first
42
42
  const isDir = await this.isDirectory(path);
@@ -50,10 +50,10 @@ export class FileSystemService {
50
50
  catch (error) {
51
51
  if (error instanceof Error && 'code' in error) {
52
52
  if (error.code === 'ENOENT') {
53
- throw new Error(`File not found: ${path}`);
53
+ throw new Error(`File not found: ${path}. Use list_directory to see available files, or check the path spelling.`);
54
54
  }
55
55
  if (error.code === 'EACCES') {
56
- throw new Error(`Permission denied: ${path}`);
56
+ throw new Error(`Permission denied: ${path}. The file exists but cannot be read due to filesystem permissions.`);
57
57
  }
58
58
  if (error.code === 'EISDIR') {
59
59
  throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
@@ -66,7 +66,7 @@ export class FileSystemService {
66
66
  const { path, content, frontmatter, mode = 'overwrite' } = params;
67
67
  const fullPath = this.resolvePath(path);
68
68
  if (!this.pathFilter.isAllowed(path)) {
69
- throw new Error(`Access denied: ${path}`);
69
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
70
70
  }
71
71
  // Validate frontmatter if provided
72
72
  if (frontmatter) {
@@ -130,7 +130,7 @@ export class FileSystemService {
130
130
  return {
131
131
  success: false,
132
132
  path,
133
- message: `Access denied: ${path}`
133
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
134
134
  };
135
135
  }
136
136
  // Validate that strings are not empty
@@ -231,13 +231,13 @@ export class FileSystemService {
231
231
  catch (error) {
232
232
  if (error instanceof Error) {
233
233
  if (error.message.includes('not found') || error.message.includes('ENOENT')) {
234
- throw new Error(`Directory not found: ${path}`);
234
+ throw new Error(`Directory not found: ${path}. Use list_directory with no path or '/' to see root folders.`);
235
235
  }
236
236
  if (error.message.includes('permission') || error.message.includes('access')) {
237
- throw new Error(`Permission denied: ${path}`);
237
+ throw new Error(`Permission denied: ${path}. The directory exists but cannot be read due to filesystem permissions.`);
238
238
  }
239
239
  if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
240
- throw new Error(`Not a directory: ${path}`);
240
+ throw new Error(`Not a directory: ${path}. This path points to a file, not a folder. Use read_note to read files.`);
241
241
  }
242
242
  }
243
243
  throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -284,7 +284,7 @@ export class FileSystemService {
284
284
  return {
285
285
  success: false,
286
286
  path: path,
287
- message: `Access denied: ${path}`
287
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
288
288
  };
289
289
  }
290
290
  try {
@@ -311,14 +311,14 @@ export class FileSystemService {
311
311
  return {
312
312
  success: false,
313
313
  path: path,
314
- message: `File not found: ${path}`
314
+ message: `File not found: ${path}. Use list_directory to see available files.`
315
315
  };
316
316
  }
317
317
  if (error.code === 'EACCES') {
318
318
  return {
319
319
  success: false,
320
320
  path: path,
321
- message: `Permission denied: ${path}`
321
+ message: `Permission denied: ${path}. The file exists but cannot be deleted due to filesystem permissions.`
322
322
  };
323
323
  }
324
324
  }
@@ -336,7 +336,7 @@ export class FileSystemService {
336
336
  success: false,
337
337
  oldPath,
338
338
  newPath,
339
- message: `Access denied: ${oldPath}`
339
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
340
340
  };
341
341
  }
342
342
  if (!this.pathFilter.isAllowed(newPath)) {
@@ -344,7 +344,7 @@ export class FileSystemService {
344
344
  success: false,
345
345
  oldPath,
346
346
  newPath,
347
- message: `Access denied: ${newPath}`
347
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
348
348
  };
349
349
  }
350
350
  const oldFullPath = this.resolvePath(oldPath);
@@ -361,7 +361,7 @@ export class FileSystemService {
361
361
  success: false,
362
362
  oldPath,
363
363
  newPath,
364
- message: `Source file not found: ${oldPath}`
364
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
365
365
  };
366
366
  }
367
367
  throw error;
@@ -414,7 +414,7 @@ export class FileSystemService {
414
414
  }
415
415
  const results = await Promise.allSettled(paths.map(async (path) => {
416
416
  if (!this.pathFilter.isAllowed(path)) {
417
- throw new Error(`Access denied: ${path}`);
417
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
418
418
  }
419
419
  const note = await this.readNote(path);
420
420
  const result = {
@@ -447,7 +447,7 @@ export class FileSystemService {
447
447
  async updateFrontmatter(params) {
448
448
  const { path, frontmatter, merge = true } = params;
449
449
  if (!this.pathFilter.isAllowed(path)) {
450
- throw new Error(`Access denied: ${path}`);
450
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
451
451
  }
452
452
  // Read the existing note
453
453
  const note = await this.readNote(path);
@@ -470,7 +470,7 @@ export class FileSystemService {
470
470
  async getNotesInfo(paths) {
471
471
  const results = await Promise.allSettled(paths.map(async (path) => {
472
472
  if (!this.pathFilter.isAllowed(path)) {
473
- throw new Error(`Access denied: ${path}`);
473
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
474
474
  }
475
475
  const fullPath = this.resolvePath(path);
476
476
  let stats;
@@ -510,7 +510,7 @@ export class FileSystemService {
510
510
  operation,
511
511
  tags: [],
512
512
  success: false,
513
- message: `Access denied: ${path}`
513
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
514
514
  };
515
515
  }
516
516
  try {
@@ -586,4 +586,55 @@ export class FileSystemService {
586
586
  getVaultPath() {
587
587
  return this.vaultPath;
588
588
  }
589
+ async getVaultStats(recentCount = 5) {
590
+ let totalNotes = 0;
591
+ let totalFolders = 0;
592
+ let totalSize = 0;
593
+ const recentFiles = [];
594
+ const scanDirectory = async (dirPath, relativePath = '') => {
595
+ const entries = await readdir(dirPath, { withFileTypes: true });
596
+ for (const entry of entries) {
597
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
598
+ if (!this.pathFilter.isAllowed(entryRelativePath)) {
599
+ continue;
600
+ }
601
+ const fullEntryPath = join(dirPath, entry.name);
602
+ if (entry.isDirectory()) {
603
+ // Also check if directory contents would be filtered (e.g., .obsidian/**)
604
+ if (!this.pathFilter.isAllowed(`${entryRelativePath}/test.md`)) {
605
+ continue;
606
+ }
607
+ totalFolders++;
608
+ await scanDirectory(fullEntryPath, entryRelativePath);
609
+ }
610
+ else if (entry.isFile()) {
611
+ totalNotes++;
612
+ const stats = await stat(fullEntryPath);
613
+ totalSize += stats.size;
614
+ // Track recent files
615
+ const fileInfo = { path: entryRelativePath, modified: stats.mtime.getTime() };
616
+ // Insert in sorted order (most recent first)
617
+ const insertIndex = recentFiles.findIndex(f => f.modified < fileInfo.modified);
618
+ if (insertIndex === -1) {
619
+ if (recentFiles.length < recentCount) {
620
+ recentFiles.push(fileInfo);
621
+ }
622
+ }
623
+ else {
624
+ recentFiles.splice(insertIndex, 0, fileInfo);
625
+ if (recentFiles.length > recentCount) {
626
+ recentFiles.pop();
627
+ }
628
+ }
629
+ }
630
+ }
631
+ };
632
+ await scanDirectory(this.vaultPath);
633
+ return {
634
+ totalNotes,
635
+ totalFolders,
636
+ totalSize,
637
+ recentlyModified: recentFiles
638
+ };
639
+ }
589
640
  }
@@ -650,3 +650,80 @@ test("handles emoji in file paths", async () => {
650
650
  const note = await fileSystem.readNote(testPath);
651
651
  expect(note.content).toContain("Emoji note");
652
652
  });
653
+ // ============================================================================
654
+ // VAULT STATS TESTS
655
+ // ============================================================================
656
+ test("get vault stats with empty vault", async () => {
657
+ const stats = await fileSystem.getVaultStats();
658
+ expect(stats.totalNotes).toBe(0);
659
+ expect(stats.totalFolders).toBe(0);
660
+ expect(stats.totalSize).toBe(0);
661
+ expect(stats.recentlyModified).toHaveLength(0);
662
+ });
663
+ test("get vault stats counts notes and folders", async () => {
664
+ await mkdir(join(testVaultPath, "folder1"), { recursive: true });
665
+ await mkdir(join(testVaultPath, "folder2/nested"), { recursive: true });
666
+ await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
667
+ await writeFile(join(testVaultPath, "folder1/note2.md"), "# Note 2");
668
+ await writeFile(join(testVaultPath, "folder2/nested/note3.md"), "# Note 3");
669
+ const stats = await fileSystem.getVaultStats();
670
+ expect(stats.totalNotes).toBe(3);
671
+ expect(stats.totalFolders).toBe(3); // folder1, folder2, folder2/nested
672
+ expect(stats.totalSize).toBeGreaterThan(0);
673
+ });
674
+ test("get vault stats returns recently modified files in order", async () => {
675
+ // Create files with slight delays to ensure different modification times
676
+ await writeFile(join(testVaultPath, "old.md"), "# Old");
677
+ await new Promise(resolve => setTimeout(resolve, 10));
678
+ await writeFile(join(testVaultPath, "middle.md"), "# Middle");
679
+ await new Promise(resolve => setTimeout(resolve, 10));
680
+ await writeFile(join(testVaultPath, "recent.md"), "# Recent");
681
+ const stats = await fileSystem.getVaultStats(3);
682
+ expect(stats.recentlyModified).toHaveLength(3);
683
+ expect(stats.recentlyModified[0]?.path).toBe("recent.md");
684
+ expect(stats.recentlyModified[1]?.path).toBe("middle.md");
685
+ expect(stats.recentlyModified[2]?.path).toBe("old.md");
686
+ });
687
+ test("get vault stats respects recentCount limit", async () => {
688
+ await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
689
+ await writeFile(join(testVaultPath, "note2.md"), "# Note 2");
690
+ await writeFile(join(testVaultPath, "note3.md"), "# Note 3");
691
+ const stats = await fileSystem.getVaultStats(2);
692
+ expect(stats.recentlyModified).toHaveLength(2);
693
+ });
694
+ test("get vault stats excludes filtered paths", async () => {
695
+ await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
696
+ await mkdir(join(testVaultPath, ".git"), { recursive: true });
697
+ await writeFile(join(testVaultPath, ".obsidian/config.json"), "{}");
698
+ await writeFile(join(testVaultPath, ".git/config"), "git config");
699
+ await writeFile(join(testVaultPath, "visible.md"), "# Visible");
700
+ const stats = await fileSystem.getVaultStats();
701
+ expect(stats.totalNotes).toBe(1);
702
+ expect(stats.totalFolders).toBe(0); // .obsidian and .git are filtered
703
+ expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
704
+ expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
705
+ });
706
+ test("get vault stats calculates total size correctly", async () => {
707
+ const content1 = "# Note 1 with some content";
708
+ const content2 = "# Note 2 with more content here";
709
+ await writeFile(join(testVaultPath, "note1.md"), content1);
710
+ await writeFile(join(testVaultPath, "note2.md"), content2);
711
+ const stats = await fileSystem.getVaultStats();
712
+ const expectedSize = Buffer.byteLength(content1) + Buffer.byteLength(content2);
713
+ expect(stats.totalSize).toBe(expectedSize);
714
+ });
715
+ // ============================================================================
716
+ // ERROR MESSAGE TESTS
717
+ // ============================================================================
718
+ test("error messages include remediation suggestions for file not found", async () => {
719
+ await expect(fileSystem.readNote("nonexistent.md"))
720
+ .rejects.toThrow(/list_directory/);
721
+ });
722
+ test("error messages include remediation suggestions for access denied", async () => {
723
+ await expect(fileSystem.readNote(".obsidian/config.json"))
724
+ .rejects.toThrow(/restricted/);
725
+ });
726
+ test("error messages include remediation suggestions for path traversal", async () => {
727
+ await expect(fileSystem.readNote("../outside.md"))
728
+ .rejects.toThrow(/within the vault/);
729
+ });
@@ -196,8 +196,9 @@ describe("Performance: Post-PR#12 Overhead", () => {
196
196
  }
197
197
  }
198
198
  const duration = performance.now() - start;
199
- // Should complete in reasonable time (< 100ms for 8000 checks)
200
- expect(duration).toBeLessThan(100);
199
+ // Should complete in reasonable time (< 200ms for 8000 checks)
200
+ // Increased threshold to account for CI runner variability
201
+ expect(duration).toBeLessThan(200);
201
202
  });
202
203
  test("large batch operations performance", async () => {
203
204
  // Create 50 notes
@@ -50,7 +50,26 @@ export class PathFilter {
50
50
  return true;
51
51
  }
52
52
  isFile(path) {
53
- return path.includes('.') && !path.endsWith('/');
53
+ // A path is a file if it has a file extension at the end
54
+ // Paths ending with '/' are always directories
55
+ if (path.endsWith('/')) {
56
+ return false;
57
+ }
58
+ // Get the last component of the path
59
+ const lastSlashIndex = path.lastIndexOf('/');
60
+ const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);
61
+ // Check if the last component has a file extension
62
+ // A file extension is a dot followed by 1-10 alphanumeric characters at the end
63
+ // This distinguishes "file.md" (file) from "1. Project" (directory with dot in name)
64
+ const lastDotIndex = lastComponent.lastIndexOf('.');
65
+ if (lastDotIndex === -1 || lastDotIndex === 0) {
66
+ // No dot, or dot at the start (like .gitignore) - treat as no extension
67
+ return false;
68
+ }
69
+ const extension = lastComponent.substring(lastDotIndex + 1);
70
+ // Extension should be 1-10 characters and contain only alphanumeric characters
71
+ // This allows .md, .txt, .markdown but not ". Project" (space after dot)
72
+ return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
54
73
  }
55
74
  filterPaths(paths) {
56
75
  return paths.filter(path => this.isAllowed(path));
@@ -228,5 +228,16 @@ describe("PathFilter", () => {
228
228
  expect(filter.isAllowed("folder/subfolder/")).toBe(true);
229
229
  expect(filter.isAllowed("notes")).toBe(true);
230
230
  });
231
+ test("handles directories with dots in their names", () => {
232
+ const filter = new PathFilter();
233
+ // Folders with dots should be allowed (common pattern: "1. Project", "2.5 Notes")
234
+ expect(filter.isAllowed("1. Project")).toBe(true);
235
+ expect(filter.isAllowed("2. Archive")).toBe(true);
236
+ expect(filter.isAllowed("3.5 Research")).toBe(true);
237
+ expect(filter.isAllowed("1. Project/subfolder")).toBe(true);
238
+ expect(filter.isAllowed("1. Project/note.md")).toBe(true);
239
+ // But files in those folders should still need proper extensions
240
+ expect(filter.isAllowed("1. Project/file.js")).toBe(false);
241
+ });
231
242
  });
232
243
  });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@mauricio.wolff/mcp-obsidian",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "author": "bitbonsai",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "packageManager": "npm@10.9.0+sha512.65a9c38a8172948f617a53619762cd77e12b9950fe1f9239debcb8d62c652f2081824b986fee7c0af6c0a7df615becebe4bf56e17ec27214a87aa29d9e038b4b",
8
9
  "main": "dist/server.js",
9
10
  "bin": {
10
11
  "mcp-obsidian": "./dist/server.js",
@@ -31,7 +32,7 @@
31
32
  "gray-matter": "^4.0.3"
32
33
  },
33
34
  "devDependencies": {
34
- "@types/node": "^20.19.21",
35
+ "@types/node": "^24.10.1",
35
36
  "tsx": "^4.20.6",
36
37
  "typescript": "^5.9.3",
37
38
  "vitest": "^4.0.15"