@mauricio.wolff/mcp-obsidian 0.8.1 → 0.8.2
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 +13 -7
- package/dist/server.js +18 -13
- package/dist/src/filesystem.js +4 -5
- package/dist/src/filesystem.test.js +29 -0
- package/dist/src/frontmatter.js +26 -0
- package/dist/src/frontmatter.test.js +34 -2
- package/dist/src/integration.test.js +0 -93
- package/dist/src/pathfilter.js +2 -0
- package/dist/src/pathfilter.test.js +5 -0
- package/dist/src/search.js +3 -3
- package/dist/src/search.test.js +212 -0
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ MCP is an open protocol. You're not tied to any specific vendor or platform. You
|
|
|
143
143
|
## Prerequisites
|
|
144
144
|
|
|
145
145
|
- [Node.js](https://nodejs.org) runtime (v18.0.0 or later)
|
|
146
|
-
- An Obsidian vault (local directory with `.md` files)
|
|
146
|
+
- An Obsidian vault (local directory with `.md`, `.markdown`, `.txt`, `.base`, or `.canvas` files)
|
|
147
147
|
- MCP-compatible AI client (Claude Desktop, ChatGPT Desktop, Claude Code, etc.)
|
|
148
148
|
|
|
149
149
|
## Installation
|
|
@@ -156,6 +156,8 @@ No installation needed! Use `npx` to run directly:
|
|
|
156
156
|
npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/obsidian/vault
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
+
If you omit the vault path, the server uses your current working directory as the vault root.
|
|
160
|
+
|
|
159
161
|
### For Developers
|
|
160
162
|
|
|
161
163
|
1. Clone this repository
|
|
@@ -194,13 +196,17 @@ mcp-inspector npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/vault
|
|
|
194
196
|
**End users:**
|
|
195
197
|
|
|
196
198
|
```bash
|
|
199
|
+
npx @mauricio.wolff/mcp-obsidian@latest
|
|
197
200
|
npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/obsidian/vault
|
|
201
|
+
npx @mauricio.wolff/mcp-obsidian@latest ./Vault
|
|
198
202
|
```
|
|
199
203
|
|
|
200
204
|
**Developers:**
|
|
201
205
|
|
|
202
206
|
```bash
|
|
207
|
+
npm start
|
|
203
208
|
npm start /path/to/your/obsidian/vault
|
|
209
|
+
npm start ./Vault
|
|
204
210
|
```
|
|
205
211
|
|
|
206
212
|
### AI Client Configuration
|
|
@@ -366,10 +372,10 @@ Most modern MCP clients use similar JSON configuration patterns. Refer to your s
|
|
|
366
372
|
- **Solution:** Install Node.js runtime from [nodejs.org](https://nodejs.org)
|
|
367
373
|
- **Alternative:** Use global install: `npm install -g @mauricio.wolff/mcp-obsidian`
|
|
368
374
|
|
|
369
|
-
#### "
|
|
375
|
+
#### "File not found" when paths look correct
|
|
370
376
|
|
|
371
|
-
- **Cause:**
|
|
372
|
-
- **Solution:**
|
|
377
|
+
- **Cause:** The server is using the wrong vault root
|
|
378
|
+
- **Solution:** Either run the command from your vault directory or pass the vault path explicitly
|
|
373
379
|
|
|
374
380
|
#### "Permission denied" errors
|
|
375
381
|
|
|
@@ -553,7 +559,7 @@ Efficiently replace an exact string inside an existing note without rewriting th
|
|
|
553
559
|
|
|
554
560
|
List files and directories in the vault.
|
|
555
561
|
|
|
556
|
-
Note: this includes non-note filenames (for example `pdf`, `png`, `jpg`) so AI assistants can see vault structure, but note tools like `read_note` and `write_note` still operate on note files only (`.md`, `.markdown`, `.txt`).
|
|
562
|
+
Note: this includes non-note filenames (for example `pdf`, `png`, `jpg`) so AI assistants can see vault structure, but note tools like `read_note` and `write_note` still operate on note files only (`.md`, `.markdown`, `.txt`, `.base`, `.canvas`).
|
|
557
563
|
|
|
558
564
|
**Request:**
|
|
559
565
|
|
|
@@ -740,7 +746,7 @@ Search for notes in the vault by content or frontmatter with multi-word matching
|
|
|
740
746
|
|
|
741
747
|
### `move_note`
|
|
742
748
|
|
|
743
|
-
Move or rename a note in the vault (`.md`, `.markdown`, `.txt`).
|
|
749
|
+
Move or rename a note in the vault (`.md`, `.markdown`, `.txt`, `.base`, `.canvas`).
|
|
744
750
|
|
|
745
751
|
**Request:**
|
|
746
752
|
|
|
@@ -939,7 +945,7 @@ This MCP server implements several security measures to protect your Obsidian va
|
|
|
939
945
|
### File Filtering
|
|
940
946
|
|
|
941
947
|
- **Automatic Exclusions:** `.obsidian`, `.git`, `node_modules`, and system files are filtered
|
|
942
|
-
- **Extension Whitelist:** Only `.md`, `.markdown`, and `.
|
|
948
|
+
- **Extension Whitelist:** Only `.md`, `.markdown`, `.txt`, `.base`, and `.canvas` files are accessible by default
|
|
943
949
|
- **Hidden File Protection:** Dot files and system directories are automatically excluded
|
|
944
950
|
|
|
945
951
|
### Content Validation
|
package/dist/server.js
CHANGED
|
@@ -3,12 +3,12 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { FileSystemService } from "./src/filesystem.js";
|
|
6
|
-
import { FrontmatterHandler } from "./src/frontmatter.js";
|
|
6
|
+
import { FrontmatterHandler, parseFrontmatter } from "./src/frontmatter.js";
|
|
7
7
|
import { PathFilter } from "./src/pathfilter.js";
|
|
8
8
|
import { SearchService } from "./src/search.js";
|
|
9
9
|
import { readFileSync } from "fs";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
-
import { dirname, join } from "path";
|
|
11
|
+
import { dirname, join, resolve } from "path";
|
|
12
12
|
// Get package.json version
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
14
|
const __dirname = dirname(__filename);
|
|
@@ -28,29 +28,29 @@ if (firstArg === "--help" || firstArg === "-h") {
|
|
|
28
28
|
Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
|
|
29
29
|
|
|
30
30
|
Usage:
|
|
31
|
-
npx @mauricio.wolff/mcp-obsidian
|
|
31
|
+
npx @mauricio.wolff/mcp-obsidian [vault-path]
|
|
32
32
|
|
|
33
33
|
Arguments:
|
|
34
|
-
|
|
34
|
+
[vault-path] Optional path to your Obsidian vault directory
|
|
35
|
+
Defaults to current working directory when omitted
|
|
35
36
|
|
|
36
37
|
Options:
|
|
37
38
|
--version, -v Show version number
|
|
38
39
|
--help, -h Show this help message
|
|
39
40
|
|
|
40
41
|
Examples:
|
|
42
|
+
npx @mauricio.wolff/mcp-obsidian
|
|
41
43
|
npx @mauricio.wolff/mcp-obsidian ~/Documents/MyVault
|
|
44
|
+
npx @mauricio.wolff/mcp-obsidian ./Vault
|
|
42
45
|
npx @mauricio.wolff/mcp-obsidian /path/to/obsidian/vault
|
|
43
46
|
npx @mauricio.wolff/mcp-obsidian "/path/with spaces/Obsidian Vault"
|
|
44
47
|
`);
|
|
45
48
|
process.exit(0);
|
|
46
49
|
}
|
|
47
|
-
// Join
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.error("Run 'npx @mauricio.wolff/mcp-obsidian --help' for more information");
|
|
52
|
-
process.exit(1);
|
|
53
|
-
}
|
|
50
|
+
// Join trailing args to support vault paths with spaces.
|
|
51
|
+
// When omitted, default to current working directory.
|
|
52
|
+
const vaultPathArg = cliArgs.join(' ').trim();
|
|
53
|
+
const vaultPath = resolve(vaultPathArg || process.cwd());
|
|
54
54
|
// Initialize services
|
|
55
55
|
const pathFilter = new PathFilter();
|
|
56
56
|
const frontmatterHandler = new FrontmatterHandler();
|
|
@@ -459,10 +459,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
459
459
|
};
|
|
460
460
|
}
|
|
461
461
|
case "write_note": {
|
|
462
|
+
const fm = parseFrontmatter(trimmedArgs.frontmatter);
|
|
462
463
|
await fileSystem.writeNote({
|
|
463
464
|
path: trimmedArgs.path,
|
|
464
465
|
content: trimmedArgs.content,
|
|
465
|
-
frontmatter:
|
|
466
|
+
...(fm !== undefined && { frontmatter: fm }),
|
|
466
467
|
mode: trimmedArgs.mode || 'overwrite'
|
|
467
468
|
});
|
|
468
469
|
return {
|
|
@@ -593,9 +594,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
593
594
|
};
|
|
594
595
|
}
|
|
595
596
|
case "update_frontmatter": {
|
|
597
|
+
const fm = parseFrontmatter(trimmedArgs.frontmatter);
|
|
598
|
+
if (!fm) {
|
|
599
|
+
throw new Error('frontmatter is required');
|
|
600
|
+
}
|
|
596
601
|
await fileSystem.updateFrontmatter({
|
|
597
602
|
path: trimmedArgs.path,
|
|
598
|
-
frontmatter:
|
|
603
|
+
frontmatter: fm,
|
|
599
604
|
merge: trimmedArgs.merge
|
|
600
605
|
});
|
|
601
606
|
return {
|
package/dist/src/filesystem.js
CHANGED
|
@@ -719,19 +719,18 @@ export class FileSystemService {
|
|
|
719
719
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
720
720
|
for (const entry of entries) {
|
|
721
721
|
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
722
|
-
if (!this.pathFilter.isAllowed(entryRelativePath)) {
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
722
|
const fullEntryPath = join(dirPath, entry.name);
|
|
726
723
|
if (entry.isDirectory()) {
|
|
727
|
-
|
|
728
|
-
if (!this.pathFilter.isAllowed(`${entryRelativePath}/test.md`)) {
|
|
724
|
+
if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
|
|
729
725
|
continue;
|
|
730
726
|
}
|
|
731
727
|
totalFolders++;
|
|
732
728
|
await scanDirectory(fullEntryPath, entryRelativePath);
|
|
733
729
|
}
|
|
734
730
|
else if (entry.isFile()) {
|
|
731
|
+
if (!this.pathFilter.isAllowed(entryRelativePath)) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
735
734
|
totalNotes++;
|
|
736
735
|
const stats = await stat(fullEntryPath);
|
|
737
736
|
totalSize += stats.size;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { FileSystemService } from "./filesystem.js";
|
|
3
|
+
import { PathFilter } from "./pathfilter.js";
|
|
3
4
|
import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { tmpdir } from "os";
|
|
@@ -891,6 +892,34 @@ test("get vault stats excludes filtered paths", async () => {
|
|
|
891
892
|
expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
|
|
892
893
|
expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
|
|
893
894
|
});
|
|
895
|
+
test("get vault stats excludes files matched by custom ** ignored patterns", async () => {
|
|
896
|
+
const customFilter = new PathFilter({
|
|
897
|
+
ignoredPatterns: ["ignored/**"]
|
|
898
|
+
});
|
|
899
|
+
const customFileSystem = new FileSystemService(testVaultPath, customFilter);
|
|
900
|
+
await mkdir(join(testVaultPath, "ignored"), { recursive: true });
|
|
901
|
+
await mkdir(join(testVaultPath, "ignored/nested"), { recursive: true });
|
|
902
|
+
await writeFile(join(testVaultPath, "ignored/something.md"), "# Disallowed 1");
|
|
903
|
+
await writeFile(join(testVaultPath, "ignored/nested/something.md"), "# Disallowed 2");
|
|
904
|
+
await writeFile(join(testVaultPath, "visible.md"), "# Visible");
|
|
905
|
+
const stats = await customFileSystem.getVaultStats(10);
|
|
906
|
+
const recentPaths = stats.recentlyModified.map(file => file.path);
|
|
907
|
+
expect(stats.totalNotes).toBe(1);
|
|
908
|
+
expect(recentPaths).toContain("visible.md");
|
|
909
|
+
expect(recentPaths).not.toContain("ignored/something.md");
|
|
910
|
+
expect(recentPaths).not.toContain("ignored/nested/something.md");
|
|
911
|
+
});
|
|
912
|
+
test("get vault stats includes notes inside directories that contain dots", async () => {
|
|
913
|
+
await mkdir(join(testVaultPath, "2026.03"), { recursive: true });
|
|
914
|
+
await writeFile(join(testVaultPath, "2026.03/nested.md"), "# Nested");
|
|
915
|
+
await writeFile(join(testVaultPath, "root.md"), "# Root");
|
|
916
|
+
const stats = await fileSystem.getVaultStats(10);
|
|
917
|
+
const recentPaths = stats.recentlyModified.map(file => file.path);
|
|
918
|
+
expect(stats.totalNotes).toBe(2);
|
|
919
|
+
expect(stats.totalFolders).toBe(1);
|
|
920
|
+
expect(recentPaths).toContain("2026.03/nested.md");
|
|
921
|
+
expect(recentPaths).toContain("root.md");
|
|
922
|
+
});
|
|
894
923
|
test("get vault stats calculates total size correctly", async () => {
|
|
895
924
|
const content1 = "# Note 1 with some content";
|
|
896
925
|
const content2 = "# Note 2 with more content here";
|
package/dist/src/frontmatter.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a frontmatter value that may be a JSON string (LLM clients sometimes
|
|
4
|
+
* pass frontmatter as a serialized JSON string instead of an object).
|
|
5
|
+
* Returns undefined if the value is null/undefined, or throws if invalid.
|
|
6
|
+
*/
|
|
7
|
+
export function parseFrontmatter(value) {
|
|
8
|
+
if (value === undefined || value === null) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(value);
|
|
17
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// not valid JSON
|
|
23
|
+
}
|
|
24
|
+
throw new Error('frontmatter must be a JSON object, got a string that is not valid JSON');
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`frontmatter must be a JSON object, got ${typeof value}`);
|
|
27
|
+
}
|
|
2
28
|
export class FrontmatterHandler {
|
|
3
29
|
parse(content) {
|
|
4
30
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { test, expect } from "vitest";
|
|
2
|
-
import { FrontmatterHandler } from "./frontmatter.js";
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
|
|
3
3
|
const handler = new FrontmatterHandler();
|
|
4
4
|
test("parse note with frontmatter", () => {
|
|
5
5
|
const content = `---
|
|
@@ -84,3 +84,35 @@ Some content here.`;
|
|
|
84
84
|
expect(result).toContain("tags:");
|
|
85
85
|
expect(result).toContain("# Content");
|
|
86
86
|
});
|
|
87
|
+
describe("parseFrontmatter", () => {
|
|
88
|
+
test("returns undefined for null and undefined", () => {
|
|
89
|
+
expect(parseFrontmatter(null)).toBeUndefined();
|
|
90
|
+
expect(parseFrontmatter(undefined)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
test("passes through a plain object", () => {
|
|
93
|
+
const obj = { tags: ["test"], title: "Hello" };
|
|
94
|
+
expect(parseFrontmatter(obj)).toBe(obj);
|
|
95
|
+
});
|
|
96
|
+
test("parses a JSON string into an object", () => {
|
|
97
|
+
const input = '{"tags": ["test"], "title": "Hello"}';
|
|
98
|
+
expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
|
|
99
|
+
});
|
|
100
|
+
test("parses an empty JSON object string", () => {
|
|
101
|
+
expect(parseFrontmatter("{}")).toEqual({});
|
|
102
|
+
});
|
|
103
|
+
test("throws for a non-JSON string", () => {
|
|
104
|
+
expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
|
|
105
|
+
});
|
|
106
|
+
test("throws for a JSON array string", () => {
|
|
107
|
+
expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
|
|
108
|
+
});
|
|
109
|
+
test("throws for a JSON primitive string", () => {
|
|
110
|
+
expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
|
|
111
|
+
});
|
|
112
|
+
test("throws for an array value", () => {
|
|
113
|
+
expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
|
|
114
|
+
});
|
|
115
|
+
test("throws for a number value", () => {
|
|
116
|
+
expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -97,43 +97,6 @@ Pattern: backup.2024/**/*.md`;
|
|
|
97
97
|
expect(note.content).toContain("2 + 2 = 4");
|
|
98
98
|
expect(note.content).toContain("backup.2024/**/*.md");
|
|
99
99
|
});
|
|
100
|
-
test("unicode and emoji in paths and content", async () => {
|
|
101
|
-
// Create folders with unicode
|
|
102
|
-
await mkdir(join(testVaultPath, "日本語"), { recursive: true });
|
|
103
|
-
await mkdir(join(testVaultPath, "📁"), { recursive: true });
|
|
104
|
-
const testCases = [
|
|
105
|
-
{ path: "日本語/ノート.md", content: "# 日本語のメモ\n\nこんにちは世界" },
|
|
106
|
-
{ path: "📁/🎉.md", content: "# Celebration\n\n🎊 Party time! 🎈" }
|
|
107
|
-
];
|
|
108
|
-
// Write notes
|
|
109
|
-
for (const { path, content } of testCases) {
|
|
110
|
-
await fileSystem.writeNote({ path, content });
|
|
111
|
-
}
|
|
112
|
-
// Read back and verify
|
|
113
|
-
for (const { path, content } of testCases) {
|
|
114
|
-
const note = await fileSystem.readNote(path);
|
|
115
|
-
expect(note.content).toBe(content);
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
test("security: path traversal blocked", async () => {
|
|
119
|
-
const maliciousPaths = [
|
|
120
|
-
"../etc/passwd",
|
|
121
|
-
"../../secret.txt",
|
|
122
|
-
"folder/../../../outside.md"
|
|
123
|
-
];
|
|
124
|
-
for (const path of maliciousPaths) {
|
|
125
|
-
await expect(fileSystem.readNote(path))
|
|
126
|
-
.rejects.toThrow(/Path traversal not allowed|Access denied/);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
test("security: blocked directories not accessible", async () => {
|
|
130
|
-
// Try to access .obsidian
|
|
131
|
-
await expect(fileSystem.readNote(".obsidian/app.json"))
|
|
132
|
-
.rejects.toThrow(/Access denied/);
|
|
133
|
-
// Try to access .git
|
|
134
|
-
await expect(fileSystem.readNote(".git/config"))
|
|
135
|
-
.rejects.toThrow(/Access denied/);
|
|
136
|
-
});
|
|
137
100
|
test("search matches note filename even without content match", async () => {
|
|
138
101
|
// Issue #30: notes without a heading that rely on filename for discovery
|
|
139
102
|
await fileSystem.writeNote({
|
|
@@ -203,59 +166,3 @@ Pattern: backup.2024/**/*.md`;
|
|
|
203
166
|
}
|
|
204
167
|
});
|
|
205
168
|
});
|
|
206
|
-
// ============================================================================
|
|
207
|
-
// PERFORMANCE TESTS
|
|
208
|
-
// ============================================================================
|
|
209
|
-
describe("Performance: Post-PR#12 Overhead", () => {
|
|
210
|
-
test("pathfilter performance with many checks", () => {
|
|
211
|
-
const filter = new PathFilter();
|
|
212
|
-
const testPaths = [
|
|
213
|
-
"notes/daily/2024-01-01.md",
|
|
214
|
-
"projects/work/report (final).md",
|
|
215
|
-
"archive [2023]/old-note.md",
|
|
216
|
-
"C++/algorithms/sort.md",
|
|
217
|
-
"backup.2024/data.md",
|
|
218
|
-
".obsidian/app.json",
|
|
219
|
-
".git/config",
|
|
220
|
-
"node_modules/package/index.js"
|
|
221
|
-
];
|
|
222
|
-
const start = performance.now();
|
|
223
|
-
// Run 1000 iterations
|
|
224
|
-
for (let i = 0; i < 1000; i++) {
|
|
225
|
-
for (const path of testPaths) {
|
|
226
|
-
filter.isAllowed(path);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const duration = performance.now() - start;
|
|
230
|
-
// Should complete in reasonable time (< 200ms for 8000 checks)
|
|
231
|
-
// Increased threshold to account for CI runner variability
|
|
232
|
-
expect(duration).toBeLessThan(200);
|
|
233
|
-
});
|
|
234
|
-
test("large batch operations performance", async () => {
|
|
235
|
-
// Create 50 notes
|
|
236
|
-
const paths = [];
|
|
237
|
-
for (let i = 1; i <= 50; i++) {
|
|
238
|
-
const path = `batch/note-${i}.md`;
|
|
239
|
-
paths.push(path);
|
|
240
|
-
}
|
|
241
|
-
await mkdir(join(testVaultPath, "batch"), { recursive: true });
|
|
242
|
-
for (const path of paths) {
|
|
243
|
-
await writeFile(join(testVaultPath, path), `# Note\n\nContent for ${path}`);
|
|
244
|
-
}
|
|
245
|
-
const start = performance.now();
|
|
246
|
-
// Read all 50 notes (max batch size is 10, so this tests multiple batches)
|
|
247
|
-
const batches = [];
|
|
248
|
-
for (let i = 0; i < paths.length; i += 10) {
|
|
249
|
-
const batchPaths = paths.slice(i, i + 10);
|
|
250
|
-
batches.push(fileSystem.readMultipleNotes({
|
|
251
|
-
paths: batchPaths,
|
|
252
|
-
includeContent: true,
|
|
253
|
-
includeFrontmatter: true
|
|
254
|
-
}));
|
|
255
|
-
}
|
|
256
|
-
await Promise.all(batches);
|
|
257
|
-
const duration = performance.now() - start;
|
|
258
|
-
// Should complete in reasonable time (< 500ms for 50 files)
|
|
259
|
-
expect(duration).toBeLessThan(500);
|
|
260
|
-
});
|
|
261
|
-
});
|
package/dist/src/pathfilter.js
CHANGED
|
@@ -176,6 +176,11 @@ describe("PathFilter", () => {
|
|
|
176
176
|
expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
|
+
test("allows Obsidian first-party file types", () => {
|
|
180
|
+
const filter = new PathFilter();
|
|
181
|
+
expect(filter.isAllowed("_Bases/daily-notes.base")).toBe(true);
|
|
182
|
+
expect(filter.isAllowed("canvas/mindmap.canvas")).toBe(true);
|
|
183
|
+
});
|
|
179
184
|
// ============================================================================
|
|
180
185
|
// FILTER PATHS BATCH OPERATION
|
|
181
186
|
// ============================================================================
|
package/dist/src/search.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
1
|
+
import { join, resolve } from 'path';
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { generateObsidianUri } from './uri.js';
|
|
4
4
|
export class SearchService {
|
|
5
|
-
vaultPath;
|
|
6
5
|
pathFilter;
|
|
6
|
+
vaultPath;
|
|
7
7
|
constructor(vaultPath, pathFilter) {
|
|
8
|
-
this.vaultPath = vaultPath;
|
|
9
8
|
this.pathFilter = pathFilter;
|
|
9
|
+
this.vaultPath = resolve(vaultPath);
|
|
10
10
|
}
|
|
11
11
|
async search(params) {
|
|
12
12
|
const { query, limit = 5, searchContent = true, searchFrontmatter = false, caseSensitive = false } = params;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { SearchService } from "./search.js";
|
|
3
|
+
import { PathFilter } from "./pathfilter.js";
|
|
4
|
+
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
let testVaultPath;
|
|
8
|
+
let searchService;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-search-"));
|
|
11
|
+
searchService = new SearchService(testVaultPath, new PathFilter());
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
try {
|
|
15
|
+
await rm(testVaultPath, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Ignore cleanup errors
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
// Helper to write a note directly to disk
|
|
22
|
+
async function writeNote(path, content) {
|
|
23
|
+
const fullPath = join(testVaultPath, path);
|
|
24
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
25
|
+
if (dir !== testVaultPath) {
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
await writeFile(fullPath, content);
|
|
29
|
+
}
|
|
30
|
+
describe("SearchService", () => {
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// BASIC SEARCH
|
|
33
|
+
// ============================================================================
|
|
34
|
+
test("finds notes matching a query", async () => {
|
|
35
|
+
await writeNote("alpha.md", "# Alpha\n\nThis note has bananas.");
|
|
36
|
+
await writeNote("beta.md", "# Beta\n\nThis note has oranges.");
|
|
37
|
+
const results = await searchService.search({ query: "bananas" });
|
|
38
|
+
expect(results).toHaveLength(1);
|
|
39
|
+
expect(results[0].p).toBe("alpha.md");
|
|
40
|
+
});
|
|
41
|
+
test("returns empty array when no matches", async () => {
|
|
42
|
+
await writeNote("note.md", "# Note\n\nNothing relevant here.");
|
|
43
|
+
const results = await searchService.search({ query: "zzzznotfound" });
|
|
44
|
+
expect(results).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
test("returns empty array for empty vault", async () => {
|
|
47
|
+
const results = await searchService.search({ query: "anything" });
|
|
48
|
+
expect(results).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
test("throws on empty query", async () => {
|
|
51
|
+
await expect(searchService.search({ query: "" }))
|
|
52
|
+
.rejects.toThrow(/empty/);
|
|
53
|
+
});
|
|
54
|
+
test("throws on whitespace-only query", async () => {
|
|
55
|
+
await expect(searchService.search({ query: " " }))
|
|
56
|
+
.rejects.toThrow(/empty/);
|
|
57
|
+
});
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// LIMIT
|
|
60
|
+
// ============================================================================
|
|
61
|
+
test("respects limit parameter", async () => {
|
|
62
|
+
for (let i = 0; i < 5; i++) {
|
|
63
|
+
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
|
|
64
|
+
}
|
|
65
|
+
const results = await searchService.search({ query: "keyword", limit: 2 });
|
|
66
|
+
expect(results).toHaveLength(2);
|
|
67
|
+
});
|
|
68
|
+
test("caps limit at 20", async () => {
|
|
69
|
+
for (let i = 0; i < 25; i++) {
|
|
70
|
+
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
|
|
71
|
+
}
|
|
72
|
+
const results = await searchService.search({ query: "keyword", limit: 100 });
|
|
73
|
+
expect(results.length).toBeLessThanOrEqual(20);
|
|
74
|
+
});
|
|
75
|
+
test("defaults limit to 5", async () => {
|
|
76
|
+
for (let i = 0; i < 10; i++) {
|
|
77
|
+
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
|
|
78
|
+
}
|
|
79
|
+
const results = await searchService.search({ query: "keyword" });
|
|
80
|
+
expect(results).toHaveLength(5);
|
|
81
|
+
});
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// CASE SENSITIVITY
|
|
84
|
+
// ============================================================================
|
|
85
|
+
test("case-insensitive search by default", async () => {
|
|
86
|
+
await writeNote("upper.md", "# Upper\n\nBANANA is great.");
|
|
87
|
+
await writeNote("lower.md", "# Lower\n\nbanana is great.");
|
|
88
|
+
await writeNote("mixed.md", "# Mixed\n\nBanana is great.");
|
|
89
|
+
const results = await searchService.search({ query: "banana", limit: 10 });
|
|
90
|
+
expect(results).toHaveLength(3);
|
|
91
|
+
});
|
|
92
|
+
test("case-sensitive search when enabled", async () => {
|
|
93
|
+
await writeNote("upper.md", "# Upper\n\nBANANA is great.");
|
|
94
|
+
await writeNote("lower.md", "# Lower\n\nbanana is great.");
|
|
95
|
+
const results = await searchService.search({
|
|
96
|
+
query: "BANANA",
|
|
97
|
+
caseSensitive: true,
|
|
98
|
+
limit: 10
|
|
99
|
+
});
|
|
100
|
+
expect(results).toHaveLength(1);
|
|
101
|
+
expect(results[0].p).toBe("upper.md");
|
|
102
|
+
});
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// FRONTMATTER SEARCH
|
|
105
|
+
// ============================================================================
|
|
106
|
+
test("excludes frontmatter from content-only search", async () => {
|
|
107
|
+
await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
|
|
108
|
+
const results = await searchService.search({
|
|
109
|
+
query: "uniquetag",
|
|
110
|
+
searchContent: true,
|
|
111
|
+
searchFrontmatter: false,
|
|
112
|
+
limit: 10
|
|
113
|
+
});
|
|
114
|
+
expect(results).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
test("searches frontmatter when enabled", async () => {
|
|
117
|
+
await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
|
|
118
|
+
const results = await searchService.search({
|
|
119
|
+
query: "uniquetag",
|
|
120
|
+
searchFrontmatter: true,
|
|
121
|
+
limit: 10
|
|
122
|
+
});
|
|
123
|
+
expect(results).toHaveLength(1);
|
|
124
|
+
expect(results[0].p).toBe("note.md");
|
|
125
|
+
});
|
|
126
|
+
test("searches both content and frontmatter together", async () => {
|
|
127
|
+
await writeNote("fm-only.md", "---\nstatus: special\n---\n\n# Note\n\nPlain body.");
|
|
128
|
+
await writeNote("content-only.md", "# Note\n\nThis is special content.");
|
|
129
|
+
const results = await searchService.search({
|
|
130
|
+
query: "special",
|
|
131
|
+
searchContent: true,
|
|
132
|
+
searchFrontmatter: true,
|
|
133
|
+
limit: 10
|
|
134
|
+
});
|
|
135
|
+
expect(results).toHaveLength(2);
|
|
136
|
+
});
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// FILENAME MATCHING
|
|
139
|
+
// ============================================================================
|
|
140
|
+
test("matches by filename when content has no match", async () => {
|
|
141
|
+
await writeNote("Recipes.md", "Some unrelated content about cooking.");
|
|
142
|
+
const results = await searchService.search({ query: "recipes", limit: 10 });
|
|
143
|
+
expect(results).toHaveLength(1);
|
|
144
|
+
expect(results[0].p).toBe("Recipes.md");
|
|
145
|
+
expect(results[0].t).toBe("Recipes");
|
|
146
|
+
});
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// MULTI-TERM SEARCH
|
|
149
|
+
// ============================================================================
|
|
150
|
+
test("multi-term search matches notes with any term", async () => {
|
|
151
|
+
await writeNote("cats.md", "# Cats\n\nI love cats.");
|
|
152
|
+
await writeNote("dogs.md", "# Dogs\n\nI love dogs.");
|
|
153
|
+
await writeNote("fish.md", "# Fish\n\nI love fish.");
|
|
154
|
+
const results = await searchService.search({ query: "cats dogs", limit: 10 });
|
|
155
|
+
const paths = results.map(r => r.p);
|
|
156
|
+
expect(paths).toContain("cats.md");
|
|
157
|
+
expect(paths).toContain("dogs.md");
|
|
158
|
+
expect(paths).not.toContain("fish.md");
|
|
159
|
+
});
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// RANKING
|
|
162
|
+
// ============================================================================
|
|
163
|
+
test("ranks notes with more matches higher", async () => {
|
|
164
|
+
await writeNote("few.md", "# Few\n\napple once.");
|
|
165
|
+
await writeNote("many.md", "# Many\n\napple apple apple apple apple.");
|
|
166
|
+
const results = await searchService.search({ query: "apple", limit: 10 });
|
|
167
|
+
expect(results).toHaveLength(2);
|
|
168
|
+
expect(results[0].p).toBe("many.md");
|
|
169
|
+
});
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// RESULT SHAPE
|
|
172
|
+
// ============================================================================
|
|
173
|
+
test("results include expected fields", async () => {
|
|
174
|
+
await writeNote("folder/note.md", "# My Note\n\nSome content with target word.");
|
|
175
|
+
const results = await searchService.search({ query: "target", limit: 10 });
|
|
176
|
+
expect(results).toHaveLength(1);
|
|
177
|
+
const r = results[0];
|
|
178
|
+
expect(r.p).toBe("folder/note.md");
|
|
179
|
+
expect(r.t).toBe("note");
|
|
180
|
+
expect(r.ex).toBeDefined();
|
|
181
|
+
expect(r.mc).toBeGreaterThanOrEqual(1);
|
|
182
|
+
expect(r.ln).toBeGreaterThanOrEqual(1);
|
|
183
|
+
expect(r.uri).toMatch(/^obsidian:\/\//);
|
|
184
|
+
});
|
|
185
|
+
test("excerpt contains context around match", async () => {
|
|
186
|
+
await writeNote("note.md", "# Note\n\nSome words before target some words after.");
|
|
187
|
+
const results = await searchService.search({ query: "target", limit: 10 });
|
|
188
|
+
expect(results[0].ex).toContain("target");
|
|
189
|
+
});
|
|
190
|
+
// ============================================================================
|
|
191
|
+
// PATH FILTERING
|
|
192
|
+
// ============================================================================
|
|
193
|
+
test("excludes notes in filtered directories", async () => {
|
|
194
|
+
await writeNote("visible.md", "# Visible\n\nkeyword here.");
|
|
195
|
+
await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
|
|
196
|
+
await writeFile(join(testVaultPath, ".obsidian/config.md"), "keyword here.");
|
|
197
|
+
const results = await searchService.search({ query: "keyword", limit: 10 });
|
|
198
|
+
expect(results).toHaveLength(1);
|
|
199
|
+
expect(results[0].p).toBe("visible.md");
|
|
200
|
+
});
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// TRAILING SLASH IN VAULT PATH
|
|
203
|
+
// ============================================================================
|
|
204
|
+
test("vault path with trailing slash does not truncate result paths", async () => {
|
|
205
|
+
const trailingSlashService = new SearchService(testVaultPath + "/", new PathFilter());
|
|
206
|
+
await mkdir(join(testVaultPath, "sessions"), { recursive: true });
|
|
207
|
+
await writeNote("sessions/foo-bar.md", "# Foo Bar\n\nSome content here.");
|
|
208
|
+
const results = await trailingSlashService.search({ query: "foo", limit: 5 });
|
|
209
|
+
expect(results).toHaveLength(1);
|
|
210
|
+
expect(results[0].p).toBe("sessions/foo-bar.md");
|
|
211
|
+
});
|
|
212
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mauricio.wolff/mcp-obsidian",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
|
|
5
5
|
"author": "bitbonsai",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
"packageManager": "npm@10.9.0+sha512.65a9c38a8172948f617a53619762cd77e12b9950fe1f9239debcb8d62c652f2081824b986fee7c0af6c0a7df615becebe4bf56e17ec27214a87aa29d9e038b4b",
|
|
9
9
|
"main": "dist/server.js",
|
|
10
10
|
"bin": {
|
|
11
|
-
"mcp-obsidian": "
|
|
12
|
-
"@mauricio.wolff/mcp-obsidian": "./dist/server.js"
|
|
11
|
+
"mcp-obsidian": "dist/server.js"
|
|
13
12
|
},
|
|
14
13
|
"files": [
|
|
15
14
|
"dist/**/*",
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
"gray-matter": "^4.0.3"
|
|
34
33
|
},
|
|
35
34
|
"devDependencies": {
|
|
36
|
-
"@types/node": "^
|
|
35
|
+
"@types/node": "^25.3.3",
|
|
37
36
|
"tsx": "^4.20.6",
|
|
38
37
|
"typescript": "^5.9.3",
|
|
39
38
|
"vitest": "^4.0.15"
|
|
@@ -43,7 +42,7 @@
|
|
|
43
42
|
},
|
|
44
43
|
"repository": {
|
|
45
44
|
"type": "git",
|
|
46
|
-
"url": "https://github.com/bitbonsai/mcp-obsidian.git"
|
|
45
|
+
"url": "git+https://github.com/bitbonsai/mcp-obsidian.git"
|
|
47
46
|
},
|
|
48
47
|
"keywords": [
|
|
49
48
|
"mcp",
|