@mauricio.wolff/mcp-obsidian 0.7.5 → 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 +283 -25
- package/dist/server.js +79 -16
- package/dist/src/filesystem.js +131 -8
- package/dist/src/filesystem.test.js +218 -1
- 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 +19 -5
- package/dist/src/pathfilter.test.js +18 -0
- package/dist/src/search.js +94 -38
- package/dist/src/search.test.js +212 -0
- package/package.json +4 -5
|
@@ -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
|
@@ -17,6 +17,8 @@ export class PathFilter {
|
|
|
17
17
|
'.md',
|
|
18
18
|
'.markdown',
|
|
19
19
|
'.txt',
|
|
20
|
+
'.base', // Obsidian Bases (YAML)
|
|
21
|
+
'.canvas', // Obsidian Canvas (JSON)
|
|
20
22
|
...config?.allowedExtensions || []
|
|
21
23
|
];
|
|
22
24
|
}
|
|
@@ -37,11 +39,8 @@ export class PathFilter {
|
|
|
37
39
|
isAllowed(path) {
|
|
38
40
|
// Normalize path separators
|
|
39
41
|
const normalizedPath = path.replace(/\\/g, '/');
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
42
|
+
if (this.isIgnoredPath(normalizedPath)) {
|
|
43
|
+
return false;
|
|
45
44
|
}
|
|
46
45
|
// For files, check extension if allowedExtensions is configured
|
|
47
46
|
if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
|
|
@@ -52,6 +51,21 @@ export class PathFilter {
|
|
|
52
51
|
}
|
|
53
52
|
return true;
|
|
54
53
|
}
|
|
54
|
+
isAllowedForListing(path) {
|
|
55
|
+
// Normalize path separators
|
|
56
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
57
|
+
// Listing includes non-note files, but still blocks restricted system paths
|
|
58
|
+
return !this.isIgnoredPath(normalizedPath);
|
|
59
|
+
}
|
|
60
|
+
isIgnoredPath(normalizedPath) {
|
|
61
|
+
// Check if path matches any ignored pattern
|
|
62
|
+
for (const pattern of this.ignoredPatterns) {
|
|
63
|
+
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
55
69
|
isFile(path) {
|
|
56
70
|
// A path is a file if it has a file extension at the end
|
|
57
71
|
// Paths ending with '/' are always directories
|
|
@@ -38,6 +38,19 @@ describe("PathFilter", () => {
|
|
|
38
38
|
expect(filter.isAllowed("data.json")).toBe(false);
|
|
39
39
|
expect(filter.isAllowed("image.png")).toBe(false);
|
|
40
40
|
});
|
|
41
|
+
test("allows non-note files for directory listing", () => {
|
|
42
|
+
const filter = new PathFilter();
|
|
43
|
+
expect(filter.isAllowedForListing("image.png")).toBe(true);
|
|
44
|
+
expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
|
|
45
|
+
expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
test("blocks restricted paths in directory listing", () => {
|
|
48
|
+
const filter = new PathFilter();
|
|
49
|
+
expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
|
|
50
|
+
expect(filter.isAllowedForListing(".git/config")).toBe(false);
|
|
51
|
+
expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
|
|
52
|
+
expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
|
|
53
|
+
});
|
|
41
54
|
// ============================================================================
|
|
42
55
|
// REGEX SPECIAL CHARACTERS - SECURITY TESTS
|
|
43
56
|
// ============================================================================
|
|
@@ -163,6 +176,11 @@ describe("PathFilter", () => {
|
|
|
163
176
|
expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
|
|
164
177
|
});
|
|
165
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
|
+
});
|
|
166
184
|
// ============================================================================
|
|
167
185
|
// FILTER PATHS BATCH OPERATION
|
|
168
186
|
// ============================================================================
|
package/dist/src/search.js
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
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;
|
|
13
13
|
if (!query || query.trim().length === 0) {
|
|
14
14
|
throw new Error('Search query cannot be empty');
|
|
15
15
|
}
|
|
16
|
-
const results = [];
|
|
17
16
|
const maxLimit = Math.min(limit, 20);
|
|
17
|
+
// Corpus stats for reranking
|
|
18
|
+
let totalDocLength = 0;
|
|
19
|
+
let docCount = 0;
|
|
20
|
+
const termDocFreq = new Map();
|
|
21
|
+
const candidates = [];
|
|
22
|
+
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
|
23
|
+
const terms = searchQuery.split(/\s+/).filter(t => t.length > 0);
|
|
24
|
+
const scoringTerms = terms.length > 1 ? [...terms, searchQuery] : terms;
|
|
18
25
|
// Recursively find all .md files
|
|
19
26
|
const markdownFiles = await this.findMarkdownFiles(this.vaultPath);
|
|
27
|
+
// Pre-filter by pathFilter before I/O
|
|
28
|
+
const prefixLen = this.vaultPath.length + 1;
|
|
29
|
+
const allowedFiles = [];
|
|
20
30
|
for (const fullPath of markdownFiles) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const relativePath = fullPath.substring(prefixLen).replace(/\\/g, '/');
|
|
32
|
+
if (this.pathFilter.isAllowed(relativePath)) {
|
|
33
|
+
allowedFiles.push({ fullPath, relativePath });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Read files in parallel batches
|
|
37
|
+
const BATCH_SIZE = 5;
|
|
38
|
+
for (let start = 0; start < allowedFiles.length; start += BATCH_SIZE) {
|
|
39
|
+
const batch = allowedFiles.slice(start, start + BATCH_SIZE);
|
|
40
|
+
const contents = await Promise.all(batch.map(f => readFile(f.fullPath, 'utf-8').catch(() => null)));
|
|
41
|
+
for (let i = 0; i < batch.length; i++) {
|
|
42
|
+
const content = contents[i];
|
|
43
|
+
if (content === null || content === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
const { relativePath } = batch[i];
|
|
29
46
|
let searchableText = '';
|
|
30
47
|
// Prepare search text based on options
|
|
31
48
|
if (searchContent && searchFrontmatter) {
|
|
@@ -42,36 +59,57 @@ export class SearchService {
|
|
|
42
59
|
searchableText = frontmatterMatch ? frontmatterMatch[1] || '' : '';
|
|
43
60
|
}
|
|
44
61
|
const searchIn = caseSensitive ? searchableText : searchableText.toLowerCase();
|
|
45
|
-
|
|
62
|
+
// Collect corpus stats for reranking
|
|
63
|
+
const docLength = searchIn.split(/\s+/).filter(w => w.length > 0).length;
|
|
64
|
+
totalDocLength += docLength;
|
|
65
|
+
docCount++;
|
|
66
|
+
for (const term of scoringTerms) {
|
|
67
|
+
if (searchIn.includes(term)) {
|
|
68
|
+
termDocFreq.set(term, (termDocFreq.get(term) || 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
46
71
|
// Extract title from filename
|
|
47
72
|
const title = relativePath.split('/').pop()?.replace(/\.md$/, '') || relativePath;
|
|
48
|
-
// Check filename match
|
|
73
|
+
// Check filename match (any term)
|
|
49
74
|
const filenameToSearch = caseSensitive ? title : title.toLowerCase();
|
|
50
|
-
const filenameMatch = filenameToSearch.includes(
|
|
51
|
-
// Check content match
|
|
52
|
-
const
|
|
53
|
-
|
|
75
|
+
const filenameMatch = terms.some(term => filenameToSearch.includes(term));
|
|
76
|
+
// Check content match (any term)
|
|
77
|
+
const termIndices = terms.map(term => searchIn.indexOf(term));
|
|
78
|
+
const anyTermFound = termIndices.some(idx => idx !== -1);
|
|
79
|
+
const firstIndex = anyTermFound
|
|
80
|
+
? Math.min(...termIndices.filter(idx => idx !== -1))
|
|
81
|
+
: -1;
|
|
82
|
+
if (firstIndex !== -1 || filenameMatch) {
|
|
54
83
|
let excerpt;
|
|
55
84
|
let matchCount = 0;
|
|
56
85
|
let lineNumber = 0;
|
|
57
|
-
|
|
86
|
+
const termFreqs = new Map();
|
|
87
|
+
if (firstIndex !== -1) {
|
|
88
|
+
// Find the term that matched first for excerpt
|
|
89
|
+
const firstTermIdx = termIndices.indexOf(firstIndex);
|
|
90
|
+
const firstTerm = terms[firstTermIdx];
|
|
58
91
|
// Extract excerpt around first content match
|
|
59
|
-
const excerptStart = Math.max(0,
|
|
60
|
-
const excerptEnd = Math.min(searchableText.length,
|
|
92
|
+
const excerptStart = Math.max(0, firstIndex - 21);
|
|
93
|
+
const excerptEnd = Math.min(searchableText.length, firstIndex + firstTerm.length + 21);
|
|
61
94
|
excerpt = searchableText.slice(excerptStart, excerptEnd).trim();
|
|
62
95
|
// Add ellipsis if excerpt is truncated
|
|
63
96
|
if (excerptStart > 0)
|
|
64
97
|
excerpt = '...' + excerpt;
|
|
65
98
|
if (excerptEnd < searchableText.length)
|
|
66
99
|
excerpt = excerpt + '...';
|
|
67
|
-
// Count total content matches
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
searchIndex
|
|
100
|
+
// Count total content matches across all terms
|
|
101
|
+
for (const term of scoringTerms) {
|
|
102
|
+
let count = 0;
|
|
103
|
+
let searchIndex = 0;
|
|
104
|
+
while ((searchIndex = searchIn.indexOf(term, searchIndex)) !== -1) {
|
|
105
|
+
count++;
|
|
106
|
+
searchIndex += term.length;
|
|
107
|
+
}
|
|
108
|
+
termFreqs.set(term, count);
|
|
109
|
+
matchCount += count;
|
|
72
110
|
}
|
|
73
111
|
// Find line number of first match
|
|
74
|
-
const lines = searchableText.slice(0,
|
|
112
|
+
const lines = searchableText.slice(0, firstIndex).split('\n');
|
|
75
113
|
lineNumber = lines.length;
|
|
76
114
|
}
|
|
77
115
|
else {
|
|
@@ -85,21 +123,22 @@ export class SearchService {
|
|
|
85
123
|
// Add filename match to count
|
|
86
124
|
if (filenameMatch)
|
|
87
125
|
matchCount++;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
candidates.push({
|
|
127
|
+
result: {
|
|
128
|
+
p: relativePath,
|
|
129
|
+
t: title,
|
|
130
|
+
ex: excerpt,
|
|
131
|
+
mc: matchCount,
|
|
132
|
+
ln: lineNumber,
|
|
133
|
+
uri: generateObsidianUri(this.vaultPath, relativePath)
|
|
134
|
+
},
|
|
135
|
+
termFreqs,
|
|
136
|
+
docLength
|
|
95
137
|
});
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
// Skip files that can't be read
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
140
|
}
|
|
141
|
+
const results = this.rerank(candidates, scoringTerms, termDocFreq, docCount, totalDocLength, maxLimit);
|
|
103
142
|
return results;
|
|
104
143
|
}
|
|
105
144
|
async findMarkdownFiles(dirPath) {
|
|
@@ -123,4 +162,21 @@ export class SearchService {
|
|
|
123
162
|
}
|
|
124
163
|
return markdownFiles;
|
|
125
164
|
}
|
|
165
|
+
rerank(candidates, terms, termDocFreq, docCount, totalDocLength, maxLimit) {
|
|
166
|
+
const avgdl = docCount > 0 ? totalDocLength / docCount : 1;
|
|
167
|
+
const k1 = 1.2;
|
|
168
|
+
const b = 0.75;
|
|
169
|
+
const scored = candidates.map(c => {
|
|
170
|
+
let score = 0;
|
|
171
|
+
for (const term of terms) {
|
|
172
|
+
const tf = c.termFreqs.get(term) || 0;
|
|
173
|
+
const df = termDocFreq.get(term) || 0;
|
|
174
|
+
const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
|
|
175
|
+
score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * c.docLength / avgdl));
|
|
176
|
+
}
|
|
177
|
+
return { score, result: c.result };
|
|
178
|
+
});
|
|
179
|
+
scored.sort((a, b) => b.score - a.score);
|
|
180
|
+
return scored.slice(0, maxLimit).map(s => s.result);
|
|
181
|
+
}
|
|
126
182
|
}
|
|
@@ -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.
|
|
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",
|