@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/dist/server.js CHANGED
@@ -3,51 +3,54 @@ 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);
15
15
  const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
16
16
  const VERSION = packageJson.version;
17
17
  // Handle --version and --help flags
18
- const arg = process.argv[2];
19
- if (arg === "--version" || arg === "-v") {
18
+ const cliArgs = process.argv.slice(2);
19
+ const firstArg = cliArgs[0];
20
+ if (firstArg === "--version" || firstArg === "-v") {
20
21
  console.log(VERSION);
21
22
  process.exit(0);
22
23
  }
23
- if (arg === "--help" || arg === "-h") {
24
+ if (firstArg === "--help" || firstArg === "-h") {
24
25
  console.log(`
25
26
  @mauricio.wolff/mcp-obsidian v${VERSION}
26
27
 
27
28
  Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
28
29
 
29
30
  Usage:
30
- npx @mauricio.wolff/mcp-obsidian <vault-path>
31
+ npx @mauricio.wolff/mcp-obsidian [vault-path]
31
32
 
32
33
  Arguments:
33
- <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
34
36
 
35
37
  Options:
36
38
  --version, -v Show version number
37
39
  --help, -h Show this help message
38
40
 
39
41
  Examples:
42
+ npx @mauricio.wolff/mcp-obsidian
40
43
  npx @mauricio.wolff/mcp-obsidian ~/Documents/MyVault
44
+ npx @mauricio.wolff/mcp-obsidian ./Vault
41
45
  npx @mauricio.wolff/mcp-obsidian /path/to/obsidian/vault
46
+ npx @mauricio.wolff/mcp-obsidian "/path/with spaces/Obsidian Vault"
42
47
  `);
43
48
  process.exit(0);
44
49
  }
45
- const vaultPath = arg;
46
- if (!vaultPath) {
47
- console.error("Usage: npx @mauricio.wolff/mcp-obsidian /path/to/vault");
48
- console.error("Run 'npx @mauricio.wolff/mcp-obsidian --help' for more information");
49
- process.exit(1);
50
- }
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());
51
54
  // Initialize services
52
55
  const pathFilter = new PathFilter();
53
56
  const frontmatterHandler = new FrontmatterHandler();
@@ -140,7 +143,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
140
143
  },
141
144
  {
142
145
  name: "list_directory",
143
- description: "List files and directories in the vault",
146
+ description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
144
147
  inputSchema: {
145
148
  type: "object",
146
149
  properties: {
@@ -237,6 +240,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
237
240
  required: ["oldPath", "newPath"]
238
241
  }
239
242
  },
243
+ {
244
+ name: "move_file",
245
+ description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ oldPath: {
250
+ type: "string",
251
+ description: "Current path of the file"
252
+ },
253
+ newPath: {
254
+ type: "string",
255
+ description: "New path for the file"
256
+ },
257
+ confirmOldPath: {
258
+ type: "string",
259
+ description: "Confirmation: must exactly match oldPath"
260
+ },
261
+ confirmNewPath: {
262
+ type: "string",
263
+ description: "Confirmation: must exactly match newPath"
264
+ },
265
+ overwrite: {
266
+ type: "boolean",
267
+ description: "Allow overwriting existing file (default: false)",
268
+ default: false
269
+ }
270
+ },
271
+ required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
272
+ }
273
+ },
240
274
  {
241
275
  name: "read_multiple_notes",
242
276
  description: "Read multiple notes in a batch (max 10 files)",
@@ -392,6 +426,12 @@ function trimPaths(args) {
392
426
  if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') {
393
427
  trimmed.confirmPath = trimmed.confirmPath.trim();
394
428
  }
429
+ if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') {
430
+ trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
431
+ }
432
+ if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') {
433
+ trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
434
+ }
395
435
  // Trim path arrays
396
436
  if (trimmed.paths && Array.isArray(trimmed.paths)) {
397
437
  trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
@@ -419,10 +459,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
419
459
  };
420
460
  }
421
461
  case "write_note": {
462
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
422
463
  await fileSystem.writeNote({
423
464
  path: trimmedArgs.path,
424
465
  content: trimmedArgs.content,
425
- frontmatter: trimmedArgs.frontmatter,
466
+ ...(fm !== undefined && { frontmatter: fm }),
426
467
  mode: trimmedArgs.mode || 'overwrite'
427
468
  });
428
469
  return {
@@ -515,6 +556,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
515
556
  isError: !result.success
516
557
  };
517
558
  }
559
+ case "move_file": {
560
+ const result = await fileSystem.moveFile({
561
+ oldPath: trimmedArgs.oldPath,
562
+ newPath: trimmedArgs.newPath,
563
+ confirmOldPath: trimmedArgs.confirmOldPath,
564
+ confirmNewPath: trimmedArgs.confirmNewPath,
565
+ overwrite: trimmedArgs.overwrite
566
+ });
567
+ return {
568
+ content: [
569
+ {
570
+ type: "text",
571
+ text: JSON.stringify(result, null, 2)
572
+ }
573
+ ],
574
+ isError: !result.success
575
+ };
576
+ }
518
577
  case "read_multiple_notes": {
519
578
  const result = await fileSystem.readMultipleNotes({
520
579
  paths: trimmedArgs.paths,
@@ -535,9 +594,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
535
594
  };
536
595
  }
537
596
  case "update_frontmatter": {
597
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
598
+ if (!fm) {
599
+ throw new Error('frontmatter is required');
600
+ }
538
601
  await fileSystem.updateFrontmatter({
539
602
  path: trimmedArgs.path,
540
- frontmatter: trimmedArgs.frontmatter,
603
+ frontmatter: fm,
541
604
  merge: trimmedArgs.merge
542
605
  });
543
606
  return {
@@ -1,5 +1,5 @@
1
1
  import { join, resolve, relative, dirname } from 'path';
2
- import { readdir, stat, readFile, writeFile, unlink, mkdir, access } from 'node:fs/promises';
2
+ import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
3
3
  import { constants } from 'node:fs';
4
4
  import { FrontmatterHandler } from './frontmatter.js';
5
5
  import { PathFilter } from './pathfilter.js';
@@ -68,6 +68,10 @@ export class FileSystemService {
68
68
  if (!this.pathFilter.isAllowed(path)) {
69
69
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
70
70
  }
71
+ // Validate content is a defined string to prevent writing literal "undefined"
72
+ if (content === undefined || content === null) {
73
+ throw new Error(`Content is required for writing a note: ${path}. The content parameter must be a string.`);
74
+ }
71
75
  // Validate frontmatter if provided
72
76
  if (frontmatter) {
73
77
  const validation = this.frontmatterHandler.validate(frontmatter);
@@ -141,7 +145,7 @@ export class FileSystemService {
141
145
  message: 'oldString cannot be empty'
142
146
  };
143
147
  }
144
- if (newString === '') {
148
+ if (!newString) {
145
149
  return {
146
150
  success: false,
147
151
  path,
@@ -212,7 +216,7 @@ export class FileSystemService {
212
216
  const directories = [];
213
217
  for (const entry of entries) {
214
218
  const entryPath = normalizedPath ? `${normalizedPath}/${entry.name}` : entry.name;
215
- if (!this.pathFilter.isAllowed(entryPath)) {
219
+ if (!this.pathFilter.isAllowedForListing(entryPath)) {
216
220
  continue;
217
221
  }
218
222
  if (entry.isDirectory()) {
@@ -407,6 +411,126 @@ export class FileSystemService {
407
411
  };
408
412
  }
409
413
  }
414
+ async moveFile(params) {
415
+ const { oldPath, newPath, confirmOldPath, confirmNewPath, overwrite = false } = params;
416
+ if (oldPath !== confirmOldPath || newPath !== confirmNewPath) {
417
+ return {
418
+ success: false,
419
+ oldPath,
420
+ newPath,
421
+ message: "Move cancelled: confirmation paths do not match. For safety, oldPath must equal confirmOldPath and newPath must equal confirmNewPath."
422
+ };
423
+ }
424
+ if (!this.pathFilter.isAllowedForListing(oldPath)) {
425
+ return {
426
+ success: false,
427
+ oldPath,
428
+ newPath,
429
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
430
+ };
431
+ }
432
+ if (!this.pathFilter.isAllowedForListing(newPath)) {
433
+ return {
434
+ success: false,
435
+ oldPath,
436
+ newPath,
437
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
438
+ };
439
+ }
440
+ const oldFullPath = this.resolvePath(oldPath);
441
+ const newFullPath = this.resolvePath(newPath);
442
+ try {
443
+ const sourceStat = await stat(oldFullPath);
444
+ if (sourceStat.isDirectory()) {
445
+ return {
446
+ success: false,
447
+ oldPath,
448
+ newPath,
449
+ message: `Source path is a directory: ${oldPath}. move_file currently supports files only.`
450
+ };
451
+ }
452
+ }
453
+ catch (error) {
454
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
455
+ return {
456
+ success: false,
457
+ oldPath,
458
+ newPath,
459
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
460
+ };
461
+ }
462
+ return {
463
+ success: false,
464
+ oldPath,
465
+ newPath,
466
+ message: `Failed to inspect source file: ${error instanceof Error ? error.message : 'Unknown error'}`
467
+ };
468
+ }
469
+ try {
470
+ if (!overwrite) {
471
+ try {
472
+ await access(newFullPath, constants.F_OK);
473
+ return {
474
+ success: false,
475
+ oldPath,
476
+ newPath,
477
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
478
+ };
479
+ }
480
+ catch (error) {
481
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
482
+ throw error;
483
+ }
484
+ }
485
+ }
486
+ await mkdir(dirname(newFullPath), { recursive: true });
487
+ if (overwrite) {
488
+ try {
489
+ const targetStat = await stat(newFullPath);
490
+ if (targetStat.isDirectory()) {
491
+ return {
492
+ success: false,
493
+ oldPath,
494
+ newPath,
495
+ message: `Target path is a directory: ${newPath}. Please provide a file path.`
496
+ };
497
+ }
498
+ await unlink(newFullPath);
499
+ }
500
+ catch (error) {
501
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
502
+ throw error;
503
+ }
504
+ }
505
+ }
506
+ try {
507
+ await rename(oldFullPath, newFullPath);
508
+ }
509
+ catch (error) {
510
+ if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
511
+ await copyFile(oldFullPath, newFullPath);
512
+ await unlink(oldFullPath);
513
+ }
514
+ else {
515
+ throw error;
516
+ }
517
+ }
518
+ return {
519
+ success: true,
520
+ oldPath,
521
+ newPath,
522
+ message: `Successfully moved file from ${oldPath} to ${newPath}`
523
+ };
524
+ }
525
+ catch (error) {
526
+ return {
527
+ success: false,
528
+ oldPath,
529
+ newPath,
530
+ message: `Failed to move file: ${error instanceof Error ? error.message : 'Unknown error'}`
531
+ };
532
+ }
533
+ }
410
534
  async readMultipleNotes(params) {
411
535
  const { paths, includeContent = true, includeFrontmatter = true } = params;
412
536
  if (paths.length > 10) {
@@ -595,19 +719,18 @@ export class FileSystemService {
595
719
  const entries = await readdir(dirPath, { withFileTypes: true });
596
720
  for (const entry of entries) {
597
721
  const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
598
- if (!this.pathFilter.isAllowed(entryRelativePath)) {
599
- continue;
600
- }
601
722
  const fullEntryPath = join(dirPath, entry.name);
602
723
  if (entry.isDirectory()) {
603
- // Also check if directory contents would be filtered (e.g., .obsidian/**)
604
- if (!this.pathFilter.isAllowed(`${entryRelativePath}/test.md`)) {
724
+ if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
605
725
  continue;
606
726
  }
607
727
  totalFolders++;
608
728
  await scanDirectory(fullEntryPath, entryRelativePath);
609
729
  }
610
730
  else if (entry.isFile()) {
731
+ if (!this.pathFilter.isAllowed(entryRelativePath)) {
732
+ continue;
733
+ }
611
734
  totalNotes++;
612
735
  const stats = await stat(fullEntryPath);
613
736
  totalSize += stats.size;
@@ -1,6 +1,7 @@
1
1
  import { test, expect, beforeEach, afterEach } from "vitest";
2
2
  import { FileSystemService } from "./filesystem.js";
3
- import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
3
+ import { PathFilter } from "./pathfilter.js";
4
+ import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
4
5
  import { join } from "path";
5
6
  import { tmpdir } from "os";
6
7
  let testVaultPath;
@@ -184,6 +185,68 @@ test("patch note fails with empty newString", async () => {
184
185
  expect(result.success).toBe(false);
185
186
  expect(result.message).toMatch(/empty|filled|required/i);
186
187
  });
188
+ test("patch note fails with undefined newString", async () => {
189
+ const testPath = "test-note.md";
190
+ const content = "# Test Note\n\nSome content.";
191
+ await writeFile(join(testVaultPath, testPath), content);
192
+ const result = await fileSystem.patchNote({
193
+ path: testPath,
194
+ oldString: "content",
195
+ newString: undefined,
196
+ replaceAll: false
197
+ });
198
+ expect(result.success).toBe(false);
199
+ expect(result.message).toMatch(/empty|filled|required/i);
200
+ // Verify the note was NOT corrupted
201
+ const note = await fileSystem.readNote(testPath);
202
+ expect(note.content).not.toContain("undefined");
203
+ expect(note.content).toContain("Some content.");
204
+ });
205
+ test("patch note fails with null newString", async () => {
206
+ const testPath = "test-note.md";
207
+ const content = "# Test Note\n\nSome content.";
208
+ await writeFile(join(testVaultPath, testPath), content);
209
+ const result = await fileSystem.patchNote({
210
+ path: testPath,
211
+ oldString: "content",
212
+ newString: null,
213
+ replaceAll: false
214
+ });
215
+ expect(result.success).toBe(false);
216
+ expect(result.message).toMatch(/empty|filled|required/i);
217
+ // Verify the note was NOT corrupted
218
+ const note = await fileSystem.readNote(testPath);
219
+ expect(note.content).not.toContain("null");
220
+ expect(note.content).toContain("Some content.");
221
+ });
222
+ test("writeNote rejects undefined content", async () => {
223
+ const testPath = "test-note.md";
224
+ await expect(fileSystem.writeNote({
225
+ path: testPath,
226
+ content: undefined
227
+ })).rejects.toThrow(/Content is required/);
228
+ });
229
+ test("writeNote rejects null content", async () => {
230
+ const testPath = "test-note.md";
231
+ await expect(fileSystem.writeNote({
232
+ path: testPath,
233
+ content: null
234
+ })).rejects.toThrow(/Content is required/);
235
+ });
236
+ test("writeNote append with undefined content does not corrupt note", async () => {
237
+ const testPath = "test-note.md";
238
+ const content = "# Test Note\n\nOriginal content.";
239
+ await writeFile(join(testVaultPath, testPath), content);
240
+ await expect(fileSystem.writeNote({
241
+ path: testPath,
242
+ content: undefined,
243
+ mode: 'append'
244
+ })).rejects.toThrow(/Content is required/);
245
+ // Verify the note was NOT corrupted
246
+ const note = await fileSystem.readNote(testPath);
247
+ expect(note.content).not.toContain("undefined");
248
+ expect(note.content).toContain("Original content.");
249
+ });
187
250
  test("patch note handles regex special characters literally", async () => {
188
251
  const testPath = "test-note.md";
189
252
  const content = "Price: $10.50 (special)";
@@ -199,6 +262,34 @@ test("patch note handles regex special characters literally", async () => {
199
262
  expect(updatedNote.content).toContain("$15.75");
200
263
  expect(updatedNote.content).not.toContain("$10.50");
201
264
  });
265
+ test("patch note works with fenced code blocks", async () => {
266
+ const testPath = "code-fence-test.md";
267
+ const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
268
+ await writeFile(join(testVaultPath, testPath), content);
269
+ const result = await fileSystem.patchNote({
270
+ path: testPath,
271
+ oldString: "println!(\"hello\");",
272
+ newString: "println!(\"hello world\");",
273
+ replaceAll: false
274
+ });
275
+ expect(result.success).toBe(true);
276
+ const updatedNote = await fileSystem.readNote(testPath);
277
+ expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
278
+ });
279
+ test("patch note works with markdown tables", async () => {
280
+ const testPath = "table-test.md";
281
+ const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
282
+ await writeFile(join(testVaultPath, testPath), content);
283
+ const result = await fileSystem.patchNote({
284
+ path: testPath,
285
+ oldString: "| patch_note | flaky |",
286
+ newString: "| patch_note | stable |",
287
+ replaceAll: false
288
+ });
289
+ expect(result.success).toBe(true);
290
+ const updatedNote = await fileSystem.readNote(testPath);
291
+ expect(updatedNote.originalContent).toContain("| patch_note | stable |");
292
+ });
202
293
  test("patch note preserves tabs and spaces", async () => {
203
294
  const testPath = "test-note.md";
204
295
  const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
@@ -514,6 +605,14 @@ test("frontmatter validation with invalid data", async () => {
514
605
  }
515
606
  })).rejects.toThrow(/Invalid frontmatter/);
516
607
  });
608
+ test("listDirectory includes non-note files but readNote still blocks them", async () => {
609
+ const imagePath = "assets/diagram.png";
610
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
611
+ await writeFile(join(testVaultPath, imagePath), "fake-png-content");
612
+ const listing = await fileSystem.listDirectory("assets");
613
+ expect(listing.files).toContain("diagram.png");
614
+ await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
615
+ });
517
616
  // ============================================================================
518
617
  // NON-EXISTENT VAULT TESTS
519
618
  // ============================================================================
@@ -615,6 +714,96 @@ test("move note with special chars in both paths", async () => {
615
714
  const note = await fileSystem.readNote(newPath);
616
715
  expect(note.content).toContain("Moving note");
617
716
  });
717
+ test("move_file moves binary files without corruption", async () => {
718
+ const oldPath = "attachments/original image.png";
719
+ const newPath = "assets/original image.png";
720
+ const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
721
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
722
+ await writeFile(join(testVaultPath, oldPath), binaryContent);
723
+ const result = await fileSystem.moveFile({
724
+ oldPath,
725
+ newPath,
726
+ confirmOldPath: oldPath,
727
+ confirmNewPath: newPath
728
+ });
729
+ expect(result.success).toBe(true);
730
+ const moved = await readFile(join(testVaultPath, newPath));
731
+ expect(Buffer.compare(moved, binaryContent)).toBe(0);
732
+ await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
733
+ });
734
+ test("move_file respects overwrite=false", async () => {
735
+ const oldPath = "attachments/image.png";
736
+ const newPath = "assets/image.png";
737
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
738
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
739
+ await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
740
+ await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
741
+ const result = await fileSystem.moveFile({
742
+ oldPath,
743
+ newPath,
744
+ confirmOldPath: oldPath,
745
+ confirmNewPath: newPath,
746
+ overwrite: false
747
+ });
748
+ expect(result.success).toBe(false);
749
+ expect(result.message).toContain("Target file already exists");
750
+ });
751
+ test("move_file overwrites existing file when overwrite=true", async () => {
752
+ const oldPath = "attachments/image.png";
753
+ const newPath = "assets/image.png";
754
+ const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
755
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
756
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
757
+ await writeFile(join(testVaultPath, oldPath), replacement);
758
+ await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
759
+ const result = await fileSystem.moveFile({
760
+ oldPath,
761
+ newPath,
762
+ confirmOldPath: oldPath,
763
+ confirmNewPath: newPath,
764
+ overwrite: true
765
+ });
766
+ expect(result.success).toBe(true);
767
+ const moved = await readFile(join(testVaultPath, newPath));
768
+ expect(Buffer.compare(moved, replacement)).toBe(0);
769
+ });
770
+ test("move_file rejects directory sources", async () => {
771
+ await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
772
+ const result = await fileSystem.moveFile({
773
+ oldPath: "attachments/folder",
774
+ newPath: "assets/folder",
775
+ confirmOldPath: "attachments/folder",
776
+ confirmNewPath: "assets/folder"
777
+ });
778
+ expect(result.success).toBe(false);
779
+ expect(result.message).toContain("supports files only");
780
+ });
781
+ test("move_file blocks restricted system paths", async () => {
782
+ const result = await fileSystem.moveFile({
783
+ oldPath: ".obsidian/plugins/data.json",
784
+ newPath: "assets/data.json",
785
+ confirmOldPath: ".obsidian/plugins/data.json",
786
+ confirmNewPath: "assets/data.json"
787
+ });
788
+ expect(result.success).toBe(false);
789
+ expect(result.message).toContain("Access denied");
790
+ });
791
+ test("move_file requires matching confirmation paths", async () => {
792
+ const oldPath = "attachments/check.png";
793
+ const newPath = "assets/check.png";
794
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
795
+ await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
796
+ const result = await fileSystem.moveFile({
797
+ oldPath,
798
+ newPath,
799
+ confirmOldPath: "attachments/other.png",
800
+ confirmNewPath: newPath
801
+ });
802
+ expect(result.success).toBe(false);
803
+ expect(result.message).toContain("confirmation paths do not match");
804
+ const stillExists = await readFile(join(testVaultPath, oldPath));
805
+ expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
806
+ });
618
807
  test("patch note with regex special chars in oldString", async () => {
619
808
  const testPath = "regex-test.md";
620
809
  const content = "Price: $10.50 (discount)";
@@ -703,6 +892,34 @@ test("get vault stats excludes filtered paths", async () => {
703
892
  expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
704
893
  expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
705
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
+ });
706
923
  test("get vault stats calculates total size correctly", async () => {
707
924
  const content1 = "# Note 1 with some content";
708
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 {