@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 +8 -4
- package/dist/server.js +37 -0
- package/dist/src/filesystem.js +70 -19
- package/dist/src/filesystem.test.js +77 -0
- package/dist/src/integration.test.js +3 -2
- package/dist/src/pathfilter.js +20 -1
- package/dist/src/pathfilter.test.js +11 -0
- package/package.json +3 -2
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
|
[](https://github.com/bitbonsai/mcp-obsidian)
|
|
18
18
|
[](https://www.npmjs.com/package/@mauricio.wolff/mcp-obsidian)
|
|
19
|
+
[](https://www.npmjs.com/package/@mauricio.wolff/mcp-obsidian)
|
|
19
20
|
[](https://github.com/sponsors/bitbonsai)
|
|
20
21
|
[](https://ko-fi.com/bitbonsai)
|
|
21
22
|
[](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.
|
|
138
|
+
2. Use the correct Node.js version:
|
|
139
139
|
```bash
|
|
140
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/filesystem.js
CHANGED
|
@@ -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 (<
|
|
200
|
-
|
|
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
|
package/dist/src/pathfilter.js
CHANGED
|
@@ -50,7 +50,26 @@ export class PathFilter {
|
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
isFile(path) {
|
|
53
|
-
|
|
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.
|
|
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": "^
|
|
35
|
+
"@types/node": "^24.10.1",
|
|
35
36
|
"tsx": "^4.20.6",
|
|
36
37
|
"typescript": "^5.9.3",
|
|
37
38
|
"vitest": "^4.0.15"
|