@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 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
- #### "Usage: node server.ts /path/to/vault"
375
+ #### "File not found" when paths look correct
370
376
 
371
- - **Cause:** No vault path provided
372
- - **Solution:** Specify the full path to your Obsidian vault directory
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 `.txt` files are accessible by default
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 <vault-path>
31
+ npx @mauricio.wolff/mcp-obsidian [vault-path]
32
32
 
33
33
  Arguments:
34
- <vault-path> Path to your Obsidian vault directory
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 all trailing args to support vault paths with spaces
48
- const vaultPath = cliArgs.join(' ').trim();
49
- if (!vaultPath) {
50
- console.error("Usage: npx @mauricio.wolff/mcp-obsidian /path/to/vault");
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: trimmedArgs.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: trimmedArgs.frontmatter,
603
+ frontmatter: fm,
599
604
  merge: trimmedArgs.merge
600
605
  });
601
606
  return {
@@ -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
- // Also check if directory contents would be filtered (e.g., .obsidian/**)
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";
@@ -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
- });
@@ -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
  }
@@ -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
  // ============================================================================
@@ -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.1",
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",