@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.
@@ -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
- });
@@ -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
- // Check if path matches any ignored pattern
41
- for (const pattern of this.ignoredPatterns) {
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
  // ============================================================================
@@ -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
- // Convert absolute path back to relative path
22
- const relativePath = fullPath.substring(this.vaultPath.length + 1).replace(/\\/g, '/');
23
- if (!this.pathFilter.isAllowed(relativePath))
24
- continue;
25
- if (results.length >= maxLimit)
26
- break;
27
- try {
28
- const content = await readFile(fullPath, 'utf-8');
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
- const searchQuery = caseSensitive ? query : query.toLowerCase();
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(searchQuery);
51
- // Check content match
52
- const index = searchIn.indexOf(searchQuery);
53
- if (index !== -1 || filenameMatch) {
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
- if (index !== -1) {
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, index - 21);
60
- const excerptEnd = Math.min(searchableText.length, index + searchQuery.length + 21);
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
- let searchIndex = 0;
69
- while ((searchIndex = searchIn.indexOf(searchQuery, searchIndex)) !== -1) {
70
- matchCount++;
71
- searchIndex += searchQuery.length;
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, index).split('\n');
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
- results.push({
89
- p: relativePath,
90
- t: title,
91
- ex: excerpt,
92
- mc: matchCount,
93
- ln: lineNumber,
94
- uri: generateObsidianUri(this.vaultPath, relativePath)
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.7.5",
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": "./dist/server.js",
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": "^24.10.1",
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",