@mauricio.wolff/mcp-obsidian 0.5.5 → 0.6.1

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
@@ -6,9 +6,26 @@
6
6
 
7
7
  A universal AI bridge for Obsidian vaults using the Model Context Protocol (MCP) standard. Connect any MCP-compatible AI assistant to your knowledge base - works with Claude, ChatGPT, and future AI tools. This server provides safe read/write access to your notes while preventing YAML frontmatter corruption.
8
8
 
9
- 🌐 **Website:** [mcp-obsidian.org](https://mcp-obsidian.org)
9
+ <div align="center">
10
+
11
+ [https://mcp-obsidian.org](https://mcp-obsidian.org)
12
+
13
+ </div>
14
+
15
+ <div align="center">
16
+
17
+ [![GitHub Stars](https://img.shields.io/github/stars/bitbonsai/mcp-obsidian?style=flat&logo=github&logoColor=white&color=9065ea&labelColor=262626)](https://github.com/bitbonsai/mcp-obsidian)
18
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/BitBonsai?style=flat&logo=github&logoColor=white&color=9065ea&labelColor=262626)](https://github.com/sponsors/bitbonsai)
19
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/BitBonsai?style=flat&logo=github&logoColor=white&color=9065ea&labelColor=262626)](https://github.com/sponsors/bitbonsai)
20
+ [![Ko-Fi](https://img.shields.io/badge/Ko--fi-Support%20Me-9065ea?style=flat&logo=ko-fi&logoColor=white&labelColor=262626)](https://ko-fi.com/bitbonsai)
21
+ [![Liberapay](https://img.shields.io/badge/Liberapay-Weekly%20Support-9065ea?style=flat&logo=liberapay&logoColor=white&labelColor=262626)](https://liberapay.com/bitbonsai/)
22
+
23
+ </div>
10
24
 
11
- **Universal Compatibility:** Works with any MCP-compatible AI assistant including Claude Desktop, Claude Code, ChatGPT Desktop (Enterprise+), IntelliJ IDEA 2025.1+, Cursor IDE, Windsurf IDE, and future AI platforms that adopt the MCP standard.
25
+
26
+
27
+ ## Universal Compatibility
28
+ Works with any MCP-compatible AI assistant including Claude Desktop, Claude Code, ChatGPT Desktop (Enterprise+), IntelliJ IDEA 2025.1+, Cursor IDE, Windsurf IDE, and future AI platforms that adopt the MCP standard.
12
29
 
13
30
  https://github.com/user-attachments/assets/657ac4c6-1cd2-4cc3-829f-fd095a32f71c
14
31
 
@@ -242,6 +259,14 @@ Edit `.claude.json` in your project or add to the projects section:
242
259
  claude mcp add obsidian --scope user npx @mauricio.wolff/mcp-obsidian /path/to/your/vault
243
260
  ```
244
261
 
262
+ #### Goose Desktop
263
+
264
+ On Goose Desktop settings, click **Add custom extension**, and on the command field add:
265
+
266
+ ```bash
267
+ npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/vault
268
+ ```
269
+
245
270
  #### Other MCP-Compatible Clients (2025)
246
271
 
247
272
  **Confirmed MCP Support:**
package/dist/server.js CHANGED
@@ -18,7 +18,7 @@ const fileSystem = new FileSystemService(vaultPath, pathFilter, frontmatterHandl
18
18
  const searchService = new SearchService(vaultPath, pathFilter);
19
19
  const server = new Server({
20
20
  name: "mcp-obsidian",
21
- version: "0.5.4"
21
+ version: "0.6.1"
22
22
  }, {
23
23
  capabilities: {
24
24
  tools: {},
@@ -69,6 +69,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
69
69
  required: ["path", "content"]
70
70
  }
71
71
  },
72
+ {
73
+ name: "patch_note",
74
+ description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ path: {
79
+ type: "string",
80
+ description: "Path to the note relative to vault root"
81
+ },
82
+ oldString: {
83
+ type: "string",
84
+ description: "The exact string to replace. Must match exactly including whitespace and line breaks."
85
+ },
86
+ newString: {
87
+ type: "string",
88
+ description: "The new string to insert in place of oldString"
89
+ },
90
+ replaceAll: {
91
+ type: "boolean",
92
+ description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.",
93
+ default: false
94
+ }
95
+ },
96
+ required: ["path", "oldString", "newString"]
97
+ }
98
+ },
72
99
  {
73
100
  name: "list_directory",
74
101
  description: "List files and directories in the vault",
@@ -321,6 +348,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
321
348
  ]
322
349
  };
323
350
  }
351
+ case "patch_note": {
352
+ const result = await fileSystem.patchNote({
353
+ path: trimmedArgs.path,
354
+ oldString: trimmedArgs.oldString,
355
+ newString: trimmedArgs.newString,
356
+ replaceAll: trimmedArgs.replaceAll
357
+ });
358
+ return {
359
+ content: [
360
+ {
361
+ type: "text",
362
+ text: JSON.stringify(result, null, 2)
363
+ }
364
+ ],
365
+ isError: !result.success
366
+ };
367
+ }
324
368
  case "list_directory": {
325
369
  const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
326
370
  return {
@@ -129,6 +129,84 @@ export class FileSystemService {
129
129
  throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
130
130
  }
131
131
  }
132
+ async patchNote(params) {
133
+ const { path, oldString, newString, replaceAll = false } = params;
134
+ if (!this.pathFilter.isAllowed(path)) {
135
+ return {
136
+ success: false,
137
+ path,
138
+ message: `Access denied: ${path}`
139
+ };
140
+ }
141
+ // Validate that strings are not empty
142
+ if (!oldString || oldString.trim() === '') {
143
+ return {
144
+ success: false,
145
+ path,
146
+ message: 'oldString cannot be empty'
147
+ };
148
+ }
149
+ if (newString === '') {
150
+ return {
151
+ success: false,
152
+ path,
153
+ message: 'newString cannot be empty'
154
+ };
155
+ }
156
+ // Validate that oldString and newString are different
157
+ if (oldString === newString) {
158
+ return {
159
+ success: false,
160
+ path,
161
+ message: 'oldString and newString must be different'
162
+ };
163
+ }
164
+ try {
165
+ // Read the existing note
166
+ const note = await this.readNote(path);
167
+ // Get the full content with frontmatter
168
+ const fullContent = note.originalContent;
169
+ // Count occurrences of oldString
170
+ const occurrences = fullContent.split(oldString).length - 1;
171
+ if (occurrences === 0) {
172
+ return {
173
+ success: false,
174
+ path,
175
+ message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
176
+ matchCount: 0
177
+ };
178
+ }
179
+ // If not replaceAll and multiple occurrences exist, fail
180
+ if (!replaceAll && occurrences > 1) {
181
+ return {
182
+ success: false,
183
+ path,
184
+ message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
185
+ matchCount: occurrences
186
+ };
187
+ }
188
+ // Perform the replacement
189
+ const updatedContent = replaceAll
190
+ ? fullContent.split(oldString).join(newString)
191
+ : fullContent.replace(oldString, newString);
192
+ // Write the updated content
193
+ const fullPath = this.resolvePath(path);
194
+ await writeFile(fullPath, updatedContent, 'utf-8');
195
+ return {
196
+ success: true,
197
+ path,
198
+ message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
199
+ matchCount: occurrences
200
+ };
201
+ }
202
+ catch (error) {
203
+ return {
204
+ success: false,
205
+ path,
206
+ message: `Failed to patch note: ${error instanceof Error ? error.message : 'Unknown error'}`
207
+ };
208
+ }
209
+ }
132
210
  async listDirectory(path = '') {
133
211
  // Normalize path: treat '.' as root directory
134
212
  const normalizedPath = path === '.' ? '' : path;
@@ -0,0 +1,515 @@
1
+ import { test, expect, beforeEach, afterEach } from "vitest";
2
+ import { FileSystemService } from "./filesystem.js";
3
+ import { writeFile, mkdir, rmdir } from "fs/promises";
4
+ import { join } from "path";
5
+ const testVaultPath = "/tmp/test-vault-filesystem";
6
+ let fileSystem;
7
+ beforeEach(async () => {
8
+ await mkdir(testVaultPath, { recursive: true });
9
+ fileSystem = new FileSystemService(testVaultPath);
10
+ });
11
+ afterEach(async () => {
12
+ try {
13
+ await rmdir(testVaultPath, { recursive: true });
14
+ }
15
+ catch (error) {
16
+ // Ignore cleanup errors
17
+ }
18
+ });
19
+ // ============================================================================
20
+ // PATCH TESTS
21
+ // ============================================================================
22
+ test("patch note with single occurrence", async () => {
23
+ const testPath = "test-note.md";
24
+ const content = "# Test Note\n\nThis is the old content.\n\nMore text here.";
25
+ await writeFile(join(testVaultPath, testPath), content);
26
+ const result = await fileSystem.patchNote({
27
+ path: testPath,
28
+ oldString: "old content",
29
+ newString: "new content",
30
+ replaceAll: false
31
+ });
32
+ expect(result.success).toBe(true);
33
+ expect(result.matchCount).toBe(1);
34
+ expect(result.message).toContain("Successfully replaced 1 occurrence");
35
+ const updatedNote = await fileSystem.readNote(testPath);
36
+ expect(updatedNote.content).toContain("new content");
37
+ expect(updatedNote.content).not.toContain("old content");
38
+ });
39
+ test("patch note with multiple occurrences requires replaceAll", async () => {
40
+ const testPath = "test-note.md";
41
+ const content = "# Test\n\nrepeat word repeat word repeat";
42
+ await writeFile(join(testVaultPath, testPath), content);
43
+ const result = await fileSystem.patchNote({
44
+ path: testPath,
45
+ oldString: "repeat",
46
+ newString: "unique",
47
+ replaceAll: false
48
+ });
49
+ expect(result.success).toBe(false);
50
+ expect(result.matchCount).toBe(3);
51
+ expect(result.message).toContain("Found 3 occurrences");
52
+ expect(result.message).toContain("Use replaceAll=true");
53
+ });
54
+ test("patch note with replaceAll replaces all occurrences", async () => {
55
+ const testPath = "test-note.md";
56
+ const content = "# Test\n\nrepeat word repeat word repeat";
57
+ await writeFile(join(testVaultPath, testPath), content);
58
+ const result = await fileSystem.patchNote({
59
+ path: testPath,
60
+ oldString: "repeat",
61
+ newString: "unique",
62
+ replaceAll: true
63
+ });
64
+ expect(result.success).toBe(true);
65
+ expect(result.matchCount).toBe(3);
66
+ expect(result.message).toContain("Successfully replaced 3 occurrences");
67
+ const updatedNote = await fileSystem.readNote(testPath);
68
+ expect(updatedNote.content).not.toContain("repeat");
69
+ expect(updatedNote.content.match(/unique/g)?.length).toBe(3);
70
+ });
71
+ test("patch note fails when string not found", async () => {
72
+ const testPath = "test-note.md";
73
+ const content = "# Test Note\n\nSome content here.";
74
+ await writeFile(join(testVaultPath, testPath), content);
75
+ const result = await fileSystem.patchNote({
76
+ path: testPath,
77
+ oldString: "non-existent string",
78
+ newString: "replacement",
79
+ replaceAll: false
80
+ });
81
+ expect(result.success).toBe(false);
82
+ expect(result.matchCount).toBe(0);
83
+ expect(result.message).toContain("String not found");
84
+ });
85
+ test("patch note with multiline replacement", async () => {
86
+ const testPath = "test-note.md";
87
+ const content = "# Test\n\n## Section A\nOld content\nOld lines\n\n## Section B\nOther content";
88
+ await writeFile(join(testVaultPath, testPath), content);
89
+ const result = await fileSystem.patchNote({
90
+ path: testPath,
91
+ oldString: "## Section A\nOld content\nOld lines",
92
+ newString: "## Section A\nNew content\nNew improved lines",
93
+ replaceAll: false
94
+ });
95
+ expect(result.success).toBe(true);
96
+ expect(result.matchCount).toBe(1);
97
+ const updatedNote = await fileSystem.readNote(testPath);
98
+ expect(updatedNote.content).toContain("New content");
99
+ expect(updatedNote.content).toContain("New improved lines");
100
+ expect(updatedNote.content).not.toContain("Old content");
101
+ });
102
+ test("patch note with frontmatter preserved", async () => {
103
+ const testPath = "test-note.md";
104
+ const content = `---
105
+ title: My Note
106
+ tags: [test]
107
+ ---
108
+
109
+ # Content
110
+
111
+ Old text here.`;
112
+ await writeFile(join(testVaultPath, testPath), content);
113
+ const result = await fileSystem.patchNote({
114
+ path: testPath,
115
+ oldString: "Old text here.",
116
+ newString: "New text here.",
117
+ replaceAll: false
118
+ });
119
+ expect(result.success).toBe(true);
120
+ const updatedNote = await fileSystem.readNote(testPath);
121
+ expect(updatedNote.frontmatter.title).toBe("My Note");
122
+ expect(updatedNote.frontmatter.tags).toEqual(["test"]);
123
+ expect(updatedNote.content).toContain("New text here.");
124
+ });
125
+ test("patch note fails when oldString equals newString", async () => {
126
+ const testPath = "test-note.md";
127
+ const content = "# Test\n\nSome content";
128
+ await writeFile(join(testVaultPath, testPath), content);
129
+ const result = await fileSystem.patchNote({
130
+ path: testPath,
131
+ oldString: "same",
132
+ newString: "same",
133
+ replaceAll: false
134
+ });
135
+ expect(result.success).toBe(false);
136
+ expect(result.message).toContain("must be different");
137
+ });
138
+ test("patch note fails for filtered paths", async () => {
139
+ const testPath = ".obsidian/config.json";
140
+ const result = await fileSystem.patchNote({
141
+ path: testPath,
142
+ oldString: "old",
143
+ newString: "new",
144
+ replaceAll: false
145
+ });
146
+ expect(result.success).toBe(false);
147
+ expect(result.message).toContain("Access denied");
148
+ });
149
+ test("patch note fails when file doesn't exist", async () => {
150
+ const testPath = "non-existent-note.md";
151
+ const result = await fileSystem.patchNote({
152
+ path: testPath,
153
+ oldString: "old",
154
+ newString: "new",
155
+ replaceAll: false
156
+ });
157
+ expect(result.success).toBe(false);
158
+ expect(result.message).toContain("File not found");
159
+ });
160
+ test("patch note fails with empty oldString", async () => {
161
+ const testPath = "test-note.md";
162
+ const content = "# Test Note\n\nSome content.";
163
+ await writeFile(join(testVaultPath, testPath), content);
164
+ const result = await fileSystem.patchNote({
165
+ path: testPath,
166
+ oldString: "",
167
+ newString: "new",
168
+ replaceAll: false
169
+ });
170
+ expect(result.success).toBe(false);
171
+ expect(result.message).toMatch(/empty|filled|required/i);
172
+ });
173
+ test("patch note fails with empty newString", async () => {
174
+ const testPath = "test-note.md";
175
+ const content = "# Test Note\n\nSome content.";
176
+ await writeFile(join(testVaultPath, testPath), content);
177
+ const result = await fileSystem.patchNote({
178
+ path: testPath,
179
+ oldString: "content",
180
+ newString: "",
181
+ replaceAll: false
182
+ });
183
+ expect(result.success).toBe(false);
184
+ expect(result.message).toMatch(/empty|filled|required/i);
185
+ });
186
+ test("patch note handles regex special characters literally", async () => {
187
+ const testPath = "test-note.md";
188
+ const content = "Price: $10.50 (special)";
189
+ await writeFile(join(testVaultPath, testPath), content);
190
+ const result = await fileSystem.patchNote({
191
+ path: testPath,
192
+ oldString: "$10.50",
193
+ newString: "$15.75",
194
+ replaceAll: false
195
+ });
196
+ expect(result.success).toBe(true);
197
+ const updatedNote = await fileSystem.readNote(testPath);
198
+ expect(updatedNote.content).toContain("$15.75");
199
+ expect(updatedNote.content).not.toContain("$10.50");
200
+ });
201
+ test("patch note preserves tabs and spaces", async () => {
202
+ const testPath = "test-note.md";
203
+ const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
204
+ await writeFile(join(testVaultPath, testPath), content);
205
+ const result = await fileSystem.patchNote({
206
+ path: testPath,
207
+ oldString: "tabs",
208
+ newString: "TABS",
209
+ replaceAll: false
210
+ });
211
+ expect(result.success).toBe(true);
212
+ const updatedNote = await fileSystem.readNote(testPath);
213
+ expect(updatedNote.content).toContain("Line with\tTABS");
214
+ expect(updatedNote.content).toContain("\tTabbed line");
215
+ expect(updatedNote.content).toContain(" Line with spaces");
216
+ });
217
+ test("patch note is case sensitive", async () => {
218
+ const testPath = "test-note.md";
219
+ const content = "Hello world, hello again";
220
+ await writeFile(join(testVaultPath, testPath), content);
221
+ const result = await fileSystem.patchNote({
222
+ path: testPath,
223
+ oldString: "hello",
224
+ newString: "hi",
225
+ replaceAll: false
226
+ });
227
+ expect(result.success).toBe(true);
228
+ const updatedNote = await fileSystem.readNote(testPath);
229
+ expect(updatedNote.content).toContain("Hello world");
230
+ expect(updatedNote.content).toContain("hi again");
231
+ });
232
+ test("patch note handles many replacements efficiently", async () => {
233
+ const testPath = "test-note.md";
234
+ const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}: replace_me`);
235
+ const content = lines.join("\n");
236
+ await writeFile(join(testVaultPath, testPath), content);
237
+ const startTime = Date.now();
238
+ const result = await fileSystem.patchNote({
239
+ path: testPath,
240
+ oldString: "replace_me",
241
+ newString: "replaced",
242
+ replaceAll: true
243
+ });
244
+ const duration = Date.now() - startTime;
245
+ expect(result.success).toBe(true);
246
+ expect(result.matchCount).toBe(100);
247
+ expect(duration).toBeLessThan(1000);
248
+ const updatedNote = await fileSystem.readNote(testPath);
249
+ expect(updatedNote.content).not.toContain("replace_me");
250
+ expect(updatedNote.content.match(/replaced/g)?.length).toBe(100);
251
+ });
252
+ test("patch note works with path containing spaces", async () => {
253
+ const testPath = "folder name/note with spaces.md";
254
+ const content = "# Test Note\n\nOld content here.";
255
+ await mkdir(join(testVaultPath, "folder name"), { recursive: true });
256
+ await writeFile(join(testVaultPath, testPath), content);
257
+ const result = await fileSystem.patchNote({
258
+ path: testPath,
259
+ oldString: "Old content",
260
+ newString: "New content",
261
+ replaceAll: false
262
+ });
263
+ expect(result.success).toBe(true);
264
+ const updatedNote = await fileSystem.readNote(testPath);
265
+ expect(updatedNote.content).toContain("New content");
266
+ });
267
+ // ============================================================================
268
+ // DELETE TESTS
269
+ // ============================================================================
270
+ test("delete note with correct confirmation", async () => {
271
+ const testPath = "test-note.md";
272
+ const content = "# Test Note\n\nThis is a test note to be deleted.";
273
+ await writeFile(join(testVaultPath, testPath), content);
274
+ const result = await fileSystem.deleteNote({
275
+ path: testPath,
276
+ confirmPath: testPath
277
+ });
278
+ expect(result.success).toBe(true);
279
+ expect(result.path).toBe(testPath);
280
+ expect(result.message).toContain("Successfully deleted");
281
+ expect(result.message).toContain("cannot be undone");
282
+ });
283
+ test("reject deletion with incorrect confirmation", async () => {
284
+ const testPath = "test-note.md";
285
+ const content = "# Test Note\n\nThis note should not be deleted.";
286
+ await writeFile(join(testVaultPath, testPath), content);
287
+ const result = await fileSystem.deleteNote({
288
+ path: testPath,
289
+ confirmPath: "wrong-path.md"
290
+ });
291
+ expect(result.success).toBe(false);
292
+ expect(result.path).toBe(testPath);
293
+ expect(result.message).toContain("confirmation path does not match");
294
+ const fileStillExists = await fileSystem.exists(testPath);
295
+ expect(fileStillExists).toBe(true);
296
+ });
297
+ test("handle deletion of non-existent file", async () => {
298
+ const testPath = "non-existent.md";
299
+ const result = await fileSystem.deleteNote({
300
+ path: testPath,
301
+ confirmPath: testPath
302
+ });
303
+ expect(result.success).toBe(false);
304
+ expect(result.path).toBe(testPath);
305
+ expect(result.message).toContain("File not found");
306
+ });
307
+ test("reject deletion of filtered paths", async () => {
308
+ const testPath = ".obsidian/app.json";
309
+ const result = await fileSystem.deleteNote({
310
+ path: testPath,
311
+ confirmPath: testPath
312
+ });
313
+ expect(result.success).toBe(false);
314
+ expect(result.path).toBe(testPath);
315
+ expect(result.message).toContain("Access denied");
316
+ });
317
+ test("handle directory deletion attempt", async () => {
318
+ const testPath = "test-directory";
319
+ await mkdir(join(testVaultPath, testPath));
320
+ const result = await fileSystem.deleteNote({
321
+ path: testPath,
322
+ confirmPath: testPath
323
+ });
324
+ expect(result.success).toBe(false);
325
+ expect(result.path).toBe(testPath);
326
+ expect(result.message).toContain("is not a file");
327
+ });
328
+ test("delete note with frontmatter", async () => {
329
+ const testPath = "note-with-frontmatter.md";
330
+ const content = `---
331
+ title: Test Note
332
+ tags: [test, delete]
333
+ ---
334
+
335
+ # Test Note
336
+
337
+ This note has frontmatter and should be deleted successfully.`;
338
+ await writeFile(join(testVaultPath, testPath), content);
339
+ const result = await fileSystem.deleteNote({
340
+ path: testPath,
341
+ confirmPath: testPath
342
+ });
343
+ expect(result.success).toBe(true);
344
+ expect(result.path).toBe(testPath);
345
+ expect(result.message).toContain("Successfully deleted");
346
+ });
347
+ // ============================================================================
348
+ // FRONTMATTER INTEGRATION TESTS
349
+ // ============================================================================
350
+ test("write_note with frontmatter", async () => {
351
+ await fileSystem.writeNote({
352
+ path: "test.md",
353
+ content: "This is test content.",
354
+ frontmatter: {
355
+ title: "Test Note",
356
+ tags: ["test", "example"],
357
+ created: "2023-01-01"
358
+ }
359
+ });
360
+ const note = await fileSystem.readNote("test.md");
361
+ expect(note.frontmatter.title).toBe("Test Note");
362
+ expect(note.frontmatter.tags).toEqual(["test", "example"]);
363
+ expect(note.frontmatter.created).toBe("2023-01-01");
364
+ expect(note.content.trim()).toBe("This is test content.");
365
+ });
366
+ test("write_note with append mode preserves frontmatter", async () => {
367
+ await fileSystem.writeNote({
368
+ path: "append-test.md",
369
+ content: "Original content.",
370
+ frontmatter: { title: "Original", status: "draft" }
371
+ });
372
+ await fileSystem.writeNote({
373
+ path: "append-test.md",
374
+ content: "\nAppended content.",
375
+ frontmatter: { updated: "2023-12-01" },
376
+ mode: "append"
377
+ });
378
+ const note = await fileSystem.readNote("append-test.md");
379
+ expect(note.frontmatter.title).toBe("Original");
380
+ expect(note.frontmatter.status).toBe("draft");
381
+ expect(note.frontmatter.updated).toBe("2023-12-01");
382
+ expect(note.content.trim()).toBe("Original content.\n\nAppended content.");
383
+ });
384
+ test("update_frontmatter merges with existing", async () => {
385
+ await fileSystem.writeNote({
386
+ path: "update-test.md",
387
+ content: "Test content.",
388
+ frontmatter: {
389
+ title: "Original Title",
390
+ tags: ["original"],
391
+ status: "draft"
392
+ }
393
+ });
394
+ await fileSystem.updateFrontmatter({
395
+ path: "update-test.md",
396
+ frontmatter: {
397
+ title: "Updated Title",
398
+ priority: "high"
399
+ },
400
+ merge: true
401
+ });
402
+ const note = await fileSystem.readNote("update-test.md");
403
+ expect(note.frontmatter.title).toBe("Updated Title");
404
+ expect(note.frontmatter.tags).toEqual(["original"]);
405
+ expect(note.frontmatter.status).toBe("draft");
406
+ expect(note.frontmatter.priority).toBe("high");
407
+ expect(note.content.trim()).toBe("Test content.");
408
+ });
409
+ test("update_frontmatter replaces when merge is false", async () => {
410
+ await fileSystem.writeNote({
411
+ path: "replace-test.md",
412
+ content: "Test content.",
413
+ frontmatter: {
414
+ title: "Original Title",
415
+ tags: ["original"],
416
+ status: "draft"
417
+ }
418
+ });
419
+ await fileSystem.updateFrontmatter({
420
+ path: "replace-test.md",
421
+ frontmatter: {
422
+ title: "New Title",
423
+ priority: "high"
424
+ },
425
+ merge: false
426
+ });
427
+ const note = await fileSystem.readNote("replace-test.md");
428
+ expect(note.frontmatter.title).toBe("New Title");
429
+ expect(note.frontmatter.priority).toBe("high");
430
+ expect(note.frontmatter.tags).toBeUndefined();
431
+ expect(note.frontmatter.status).toBeUndefined();
432
+ });
433
+ test("manage_tags add operation", async () => {
434
+ await fileSystem.writeNote({
435
+ path: "tags-add-test.md",
436
+ content: "Test content.",
437
+ frontmatter: {
438
+ title: "Test",
439
+ tags: ["existing"]
440
+ }
441
+ });
442
+ const result = await fileSystem.manageTags({
443
+ path: "tags-add-test.md",
444
+ operation: "add",
445
+ tags: ["new", "important"]
446
+ });
447
+ expect(result.success).toBe(true);
448
+ expect(result.tags).toEqual(["existing", "new", "important"]);
449
+ const note = await fileSystem.readNote("tags-add-test.md");
450
+ expect(note.frontmatter.tags).toEqual(["existing", "new", "important"]);
451
+ });
452
+ test("manage_tags remove operation", async () => {
453
+ await fileSystem.writeNote({
454
+ path: "tags-remove-test.md",
455
+ content: "Test content.",
456
+ frontmatter: {
457
+ title: "Test",
458
+ tags: ["keep", "remove1", "remove2"]
459
+ }
460
+ });
461
+ const result = await fileSystem.manageTags({
462
+ path: "tags-remove-test.md",
463
+ operation: "remove",
464
+ tags: ["remove1", "remove2"]
465
+ });
466
+ expect(result.success).toBe(true);
467
+ expect(result.tags).toEqual(["keep"]);
468
+ const note = await fileSystem.readNote("tags-remove-test.md");
469
+ expect(note.frontmatter.tags).toEqual(["keep"]);
470
+ });
471
+ test("manage_tags list operation", async () => {
472
+ await fileSystem.writeNote({
473
+ path: "tags-list-test.md",
474
+ content: "Test content with #inline-tag.",
475
+ frontmatter: {
476
+ title: "Test",
477
+ tags: ["frontmatter-tag"]
478
+ }
479
+ });
480
+ const result = await fileSystem.manageTags({
481
+ path: "tags-list-test.md",
482
+ operation: "list"
483
+ });
484
+ expect(result.success).toBe(true);
485
+ expect(result.tags).toContain("frontmatter-tag");
486
+ expect(result.tags).toContain("inline-tag");
487
+ });
488
+ test("manage_tags removes tags array when empty", async () => {
489
+ await fileSystem.writeNote({
490
+ path: "tags-empty-test.md",
491
+ content: "Test content.",
492
+ frontmatter: {
493
+ title: "Test",
494
+ tags: ["remove-me"]
495
+ }
496
+ });
497
+ await fileSystem.manageTags({
498
+ path: "tags-empty-test.md",
499
+ operation: "remove",
500
+ tags: ["remove-me"]
501
+ });
502
+ const note = await fileSystem.readNote("tags-empty-test.md");
503
+ expect(note.frontmatter.tags).toBeUndefined();
504
+ expect(note.frontmatter.title).toBe("Test");
505
+ });
506
+ test("frontmatter validation with invalid data", async () => {
507
+ await expect(fileSystem.writeNote({
508
+ path: "invalid-test.md",
509
+ content: "Test content.",
510
+ frontmatter: {
511
+ title: "Test",
512
+ invalidFunction: () => "not allowed"
513
+ }
514
+ })).rejects.toThrow(/Invalid frontmatter/);
515
+ });
@@ -0,0 +1,86 @@
1
+ import { test, expect } from "vitest";
2
+ import { FrontmatterHandler } from "./frontmatter.js";
3
+ const handler = new FrontmatterHandler();
4
+ test("parse note with frontmatter", () => {
5
+ const content = `---
6
+ title: Test Note
7
+ tags: [test, example]
8
+ created: 2023-01-01
9
+ ---
10
+
11
+ # Test Note
12
+
13
+ This is a test note with frontmatter.`;
14
+ const result = handler.parse(content);
15
+ expect(result.frontmatter.title).toBe("Test Note");
16
+ expect(result.frontmatter.tags).toEqual(["test", "example"]);
17
+ expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
18
+ expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
19
+ });
20
+ test("parse note without frontmatter", () => {
21
+ const content = `# Test Note
22
+
23
+ This is a test note without frontmatter.`;
24
+ const result = handler.parse(content);
25
+ expect(result.frontmatter).toEqual({});
26
+ expect(result.content).toBe(content);
27
+ });
28
+ test("stringify with frontmatter", () => {
29
+ const frontmatter = {
30
+ title: "Test Note",
31
+ tags: ["test", "example"]
32
+ };
33
+ const content = "# Test Note\n\nContent here.";
34
+ const result = handler.stringify(frontmatter, content);
35
+ expect(result).toContain("---");
36
+ expect(result).toContain("title: Test Note");
37
+ expect(result).toContain("tags:");
38
+ expect(result).toContain("# Test Note");
39
+ });
40
+ test("stringify without frontmatter", () => {
41
+ const content = "# Test Note\n\nContent here.";
42
+ const result = handler.stringify({}, content);
43
+ expect(result).toBe(content);
44
+ });
45
+ test("validate valid frontmatter", () => {
46
+ const frontmatter = {
47
+ title: "Valid Title",
48
+ tags: ["tag1", "tag2"],
49
+ date: new Date("2023-01-01"),
50
+ count: 42,
51
+ enabled: true
52
+ };
53
+ const result = handler.validate(frontmatter);
54
+ expect(result.isValid).toBe(true);
55
+ expect(result.errors).toHaveLength(0);
56
+ });
57
+ test("validate invalid frontmatter with function", () => {
58
+ const frontmatter = {
59
+ title: "Invalid",
60
+ badFunction: () => "not allowed"
61
+ };
62
+ const result = handler.validate(frontmatter);
63
+ expect(result.isValid).toBe(false);
64
+ expect(result.errors.length).toBeGreaterThan(0);
65
+ // The specific error message may vary between YAML libraries
66
+ expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
67
+ });
68
+ test("update frontmatter in existing content", () => {
69
+ const content = `---
70
+ title: Old Title
71
+ tags: [old]
72
+ ---
73
+
74
+ # Content
75
+
76
+ Some content here.`;
77
+ const updates = {
78
+ title: "New Title",
79
+ modified: "2023-12-01"
80
+ };
81
+ const result = handler.updateFrontmatter(content, updates);
82
+ expect(result).toContain("title: New Title");
83
+ expect(result).toContain("modified: '2023-12-01'");
84
+ expect(result).toContain("tags:");
85
+ expect(result).toContain("# Content");
86
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauricio.wolff/mcp-obsidian",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "author": "bitbonsai",
6
6
  "license": "MIT",