@mauricio.wolff/mcp-obsidian 0.3.0

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.
@@ -0,0 +1,602 @@
1
+ import { join, resolve, relative, dirname } from 'path';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { FrontmatterHandler } from './frontmatter.js';
4
+ import { PathFilter } from './pathfilter.js';
5
+ import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult } from './types.js';
6
+
7
+ export class FileSystemService {
8
+ private frontmatterHandler: FrontmatterHandler;
9
+ private pathFilter: PathFilter;
10
+
11
+ constructor(
12
+ private vaultPath: string,
13
+ pathFilter?: PathFilter,
14
+ frontmatterHandler?: FrontmatterHandler
15
+ ) {
16
+ this.vaultPath = resolve(vaultPath);
17
+ this.pathFilter = pathFilter || new PathFilter();
18
+ this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
19
+ }
20
+
21
+ private resolvePath(relativePath: string): string {
22
+ // Handle undefined or null path
23
+ if (!relativePath) {
24
+ relativePath = '';
25
+ }
26
+
27
+ // Trim whitespace from path
28
+ relativePath = relativePath.trim();
29
+
30
+ // Normalize and resolve the path within the vault
31
+ const normalizedPath = relativePath.startsWith('/')
32
+ ? relativePath.slice(1)
33
+ : relativePath;
34
+
35
+ const fullPath = resolve(join(this.vaultPath, normalizedPath));
36
+
37
+ // Security check: ensure path is within vault
38
+ const relativeToVault = relative(this.vaultPath, fullPath);
39
+ if (relativeToVault.startsWith('..')) {
40
+ throw new Error(`Path traversal not allowed: ${relativePath}`);
41
+ }
42
+
43
+ return fullPath;
44
+ }
45
+
46
+ async readNote(path: string): Promise<ParsedNote> {
47
+ const fullPath = this.resolvePath(path);
48
+
49
+ if (!this.pathFilter.isAllowed(path)) {
50
+ throw new Error(`Access denied: ${path}`);
51
+ }
52
+
53
+ // Check if the path is a directory first
54
+ const isDir = await this.isDirectory(path);
55
+ if (isDir) {
56
+ throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
57
+ }
58
+
59
+ try {
60
+ const file = Bun.file(fullPath);
61
+ const exists = await file.exists();
62
+
63
+ if (!exists) {
64
+ throw new Error(`File not found: ${path}`);
65
+ }
66
+
67
+ const content = await file.text();
68
+ return this.frontmatterHandler.parse(content);
69
+ } catch (error) {
70
+ if (error instanceof Error) {
71
+ if (error.message.includes('File not found')) {
72
+ throw error;
73
+ }
74
+ if (error.message.includes('permission') || error.message.includes('access')) {
75
+ throw new Error(`Permission denied: ${path}`);
76
+ }
77
+ if (error.message.includes('Cannot read directory')) {
78
+ throw error;
79
+ }
80
+ }
81
+ throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
82
+ }
83
+ }
84
+
85
+ async writeNote(params: NoteWriteParams): Promise<void> {
86
+ const { path, content, frontmatter, mode = 'overwrite' } = params;
87
+ const fullPath = this.resolvePath(path);
88
+
89
+ if (!this.pathFilter.isAllowed(path)) {
90
+ throw new Error(`Access denied: ${path}`);
91
+ }
92
+
93
+ // Validate frontmatter if provided
94
+ if (frontmatter) {
95
+ const validation = this.frontmatterHandler.validate(frontmatter);
96
+ if (!validation.isValid) {
97
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
98
+ }
99
+ }
100
+
101
+ try {
102
+ let finalContent: string;
103
+
104
+ if (mode === 'overwrite') {
105
+ // Original behavior - replace entire content
106
+ finalContent = frontmatter
107
+ ? this.frontmatterHandler.stringify(frontmatter, content)
108
+ : content;
109
+ } else {
110
+ // For append/prepend, we need to read existing content
111
+ let existingNote: ParsedNote;
112
+ try {
113
+ existingNote = await this.readNote(path);
114
+ } catch (error) {
115
+ // File doesn't exist, treat as overwrite
116
+ finalContent = frontmatter
117
+ ? this.frontmatterHandler.stringify(frontmatter, content)
118
+ : content;
119
+ }
120
+
121
+ if (existingNote!) {
122
+ // Merge frontmatter if provided
123
+ const mergedFrontmatter = frontmatter
124
+ ? { ...existingNote.frontmatter, ...frontmatter }
125
+ : existingNote.frontmatter;
126
+
127
+ if (mode === 'append') {
128
+ finalContent = this.frontmatterHandler.stringify(
129
+ mergedFrontmatter,
130
+ existingNote.content + content
131
+ );
132
+ } else if (mode === 'prepend') {
133
+ finalContent = this.frontmatterHandler.stringify(
134
+ mergedFrontmatter,
135
+ content + existingNote.content
136
+ );
137
+ }
138
+ }
139
+ }
140
+
141
+ // Bun.write automatically creates directories if they don't exist
142
+ await Bun.write(fullPath, finalContent!);
143
+ } catch (error) {
144
+ if (error instanceof Error) {
145
+ if (error.message.includes('permission') || error.message.includes('access')) {
146
+ throw new Error(`Permission denied: ${path}`);
147
+ }
148
+ if (error.message.includes('space') || error.message.includes('ENOSPC')) {
149
+ throw new Error(`No space left on device: ${path}`);
150
+ }
151
+ }
152
+ throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
153
+ }
154
+ }
155
+
156
+ async listDirectory(path: string = ''): Promise<DirectoryListing> {
157
+ const fullPath = this.resolvePath(path);
158
+
159
+ try {
160
+ const entries = await readdir(fullPath, { withFileTypes: true });
161
+ const files: string[] = [];
162
+ const directories: string[] = [];
163
+
164
+ for (const entry of entries) {
165
+ const entryPath = path ? `${path}/${entry.name}` : entry.name;
166
+
167
+ if (!this.pathFilter.isAllowed(entryPath)) {
168
+ continue;
169
+ }
170
+
171
+ if (entry.isDirectory()) {
172
+ directories.push(entry.name);
173
+ } else if (entry.isFile()) {
174
+ files.push(entry.name);
175
+ }
176
+ // Skip other types (symlinks, etc.)
177
+ }
178
+
179
+ return {
180
+ files: files.sort(),
181
+ directories: directories.sort()
182
+ };
183
+ } catch (error) {
184
+ if (error instanceof Error) {
185
+ if (error.message.includes('not found') || error.message.includes('ENOENT')) {
186
+ throw new Error(`Directory not found: ${path}`);
187
+ }
188
+ if (error.message.includes('permission') || error.message.includes('access')) {
189
+ throw new Error(`Permission denied: ${path}`);
190
+ }
191
+ if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
192
+ throw new Error(`Not a directory: ${path}`);
193
+ }
194
+ }
195
+ throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
196
+ }
197
+ }
198
+
199
+ async exists(path: string): Promise<boolean> {
200
+ const fullPath = this.resolvePath(path);
201
+
202
+ if (!this.pathFilter.isAllowed(path)) {
203
+ return false;
204
+ }
205
+
206
+ try {
207
+ const file = Bun.file(fullPath);
208
+ return await file.exists();
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ async isDirectory(path: string): Promise<boolean> {
215
+ const fullPath = this.resolvePath(path);
216
+
217
+ if (!this.pathFilter.isAllowed(path)) {
218
+ return false;
219
+ }
220
+
221
+ try {
222
+ const stats = await stat(fullPath);
223
+ return stats.isDirectory();
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ async deleteNote(params: DeleteNoteParams): Promise<DeleteResult> {
230
+ const { path, confirmPath } = params;
231
+
232
+ // Confirmation check - paths must match exactly
233
+ if (path !== confirmPath) {
234
+ return {
235
+ success: false,
236
+ path: path,
237
+ message: "Deletion cancelled: confirmation path does not match. For safety, both 'path' and 'confirmPath' must be identical."
238
+ };
239
+ }
240
+
241
+ const fullPath = this.resolvePath(path);
242
+
243
+ if (!this.pathFilter.isAllowed(path)) {
244
+ return {
245
+ success: false,
246
+ path: path,
247
+ message: `Access denied: ${path}`
248
+ };
249
+ }
250
+
251
+ try {
252
+ // Check if it's a directory first (can't delete directories with this method)
253
+ const isDir = await this.isDirectory(path);
254
+ if (isDir) {
255
+ return {
256
+ success: false,
257
+ path: path,
258
+ message: `Cannot delete: ${path} is not a file`
259
+ };
260
+ }
261
+
262
+ // Check if file exists
263
+ const file = Bun.file(fullPath);
264
+ const exists = await file.exists();
265
+
266
+ if (!exists) {
267
+ return {
268
+ success: false,
269
+ path: path,
270
+ message: `File not found: ${path}`
271
+ };
272
+ }
273
+
274
+ // Perform the deletion using Bun's native API
275
+ await Bun.file(fullPath).delete();
276
+
277
+ return {
278
+ success: true,
279
+ path: path,
280
+ message: `Successfully deleted note: ${path}. This action cannot be undone.`
281
+ };
282
+
283
+ } catch (error) {
284
+ if (error instanceof Error && 'code' in error) {
285
+ if (error.code === 'ENOENT') {
286
+ return {
287
+ success: false,
288
+ path: path,
289
+ message: `File not found: ${path}`
290
+ };
291
+ }
292
+ if (error.code === 'EACCES') {
293
+ return {
294
+ success: false,
295
+ path: path,
296
+ message: `Permission denied: ${path}`
297
+ };
298
+ }
299
+ }
300
+ return {
301
+ success: false,
302
+ path: path,
303
+ message: `Failed to delete file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`
304
+ };
305
+ }
306
+ }
307
+
308
+ async moveNote(params: MoveNoteParams): Promise<MoveResult> {
309
+ const { oldPath, newPath, overwrite = false } = params;
310
+
311
+ if (!this.pathFilter.isAllowed(oldPath)) {
312
+ return {
313
+ success: false,
314
+ oldPath,
315
+ newPath,
316
+ message: `Access denied: ${oldPath}`
317
+ };
318
+ }
319
+
320
+ if (!this.pathFilter.isAllowed(newPath)) {
321
+ return {
322
+ success: false,
323
+ oldPath,
324
+ newPath,
325
+ message: `Access denied: ${newPath}`
326
+ };
327
+ }
328
+
329
+ const oldFullPath = this.resolvePath(oldPath);
330
+ const newFullPath = this.resolvePath(newPath);
331
+
332
+ try {
333
+ // Check if source file exists
334
+ const sourceFile = Bun.file(oldFullPath);
335
+ const sourceExists = await sourceFile.exists();
336
+
337
+ if (!sourceExists) {
338
+ return {
339
+ success: false,
340
+ oldPath,
341
+ newPath,
342
+ message: `Source file not found: ${oldPath}`
343
+ };
344
+ }
345
+
346
+ // Check if target already exists
347
+ const targetFile = Bun.file(newFullPath);
348
+ const targetExists = await targetFile.exists();
349
+
350
+ if (targetExists && !overwrite) {
351
+ return {
352
+ success: false,
353
+ oldPath,
354
+ newPath,
355
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
356
+ };
357
+ }
358
+
359
+ // Read source content
360
+ const content = await sourceFile.text();
361
+
362
+ // Write to new location (auto-creates directories)
363
+ await Bun.write(newFullPath, content);
364
+
365
+ // Verify the write was successful
366
+ const newFile = Bun.file(newFullPath);
367
+ const newExists = await newFile.exists();
368
+
369
+ if (!newExists) {
370
+ return {
371
+ success: false,
372
+ oldPath,
373
+ newPath,
374
+ message: `Failed to create target file: ${newPath}`
375
+ };
376
+ }
377
+
378
+ // Delete the source file
379
+ await sourceFile.delete();
380
+
381
+ return {
382
+ success: true,
383
+ oldPath,
384
+ newPath,
385
+ message: `Successfully moved note from ${oldPath} to ${newPath}`
386
+ };
387
+
388
+ } catch (error) {
389
+ return {
390
+ success: false,
391
+ oldPath,
392
+ newPath,
393
+ message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
394
+ };
395
+ }
396
+ }
397
+
398
+ async readMultipleNotes(params: BatchReadParams): Promise<BatchReadResult> {
399
+ const { paths, includeContent = true, includeFrontmatter = true } = params;
400
+
401
+ if (paths.length > 10) {
402
+ throw new Error('Maximum 10 files per batch read request');
403
+ }
404
+
405
+ const results = await Promise.allSettled(
406
+ paths.map(async (path) => {
407
+ if (!this.pathFilter.isAllowed(path)) {
408
+ throw new Error(`Access denied: ${path}`);
409
+ }
410
+
411
+ const note = await this.readNote(path);
412
+ const result: any = { path };
413
+
414
+ if (includeFrontmatter) {
415
+ result.frontmatter = note.frontmatter;
416
+ }
417
+
418
+ if (includeContent) {
419
+ result.content = note.content;
420
+ }
421
+
422
+ return result;
423
+ })
424
+ );
425
+
426
+ const successful: Array<{ path: string; frontmatter?: Record<string, any>; content?: string; }> = [];
427
+ const failed: Array<{ path: string; error: string; }> = [];
428
+
429
+ results.forEach((result, index) => {
430
+ if (result.status === 'fulfilled') {
431
+ successful.push(result.value);
432
+ } else {
433
+ failed.push({
434
+ path: paths[index],
435
+ error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
436
+ });
437
+ }
438
+ });
439
+
440
+ return { successful, failed };
441
+ }
442
+
443
+ async updateFrontmatter(params: UpdateFrontmatterParams): Promise<void> {
444
+ const { path, frontmatter, merge = true } = params;
445
+
446
+ if (!this.pathFilter.isAllowed(path)) {
447
+ throw new Error(`Access denied: ${path}`);
448
+ }
449
+
450
+ // Read the existing note
451
+ const note = await this.readNote(path);
452
+
453
+ // Prepare new frontmatter
454
+ const newFrontmatter = merge
455
+ ? { ...note.frontmatter, ...frontmatter }
456
+ : frontmatter;
457
+
458
+ // Validate the new frontmatter
459
+ const validation = this.frontmatterHandler.validate(newFrontmatter);
460
+ if (!validation.isValid) {
461
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
462
+ }
463
+
464
+ // Update the note with new frontmatter, preserving content
465
+ await this.writeNote({
466
+ path,
467
+ content: note.content,
468
+ frontmatter: newFrontmatter
469
+ });
470
+ }
471
+
472
+ async getNotesInfo(paths: string[]): Promise<NoteInfo[]> {
473
+ const results = await Promise.allSettled(
474
+ paths.map(async (path): Promise<NoteInfo> => {
475
+ if (!this.pathFilter.isAllowed(path)) {
476
+ throw new Error(`Access denied: ${path}`);
477
+ }
478
+
479
+ const fullPath = this.resolvePath(path);
480
+ const file = Bun.file(fullPath);
481
+
482
+ const exists = await file.exists();
483
+ if (!exists) {
484
+ throw new Error(`File not found: ${path}`);
485
+ }
486
+
487
+ const size = file.size;
488
+ const lastModified = file.lastModified;
489
+
490
+ // Quick check for frontmatter without reading full content
491
+ const firstChunk = await file.slice(0, 100).text();
492
+ const hasFrontmatter = firstChunk.startsWith('---\n');
493
+
494
+ return {
495
+ path,
496
+ size,
497
+ modified: lastModified,
498
+ hasFrontmatter
499
+ };
500
+ })
501
+ );
502
+
503
+ // Return only successful results, filter out failed ones
504
+ return results
505
+ .filter((result): result is PromiseFulfilledResult<NoteInfo> => result.status === 'fulfilled')
506
+ .map(result => result.value);
507
+ }
508
+
509
+ async manageTags(params: TagManagementParams): Promise<TagManagementResult> {
510
+ const { path, operation, tags = [] } = params;
511
+
512
+ if (!this.pathFilter.isAllowed(path)) {
513
+ return {
514
+ path,
515
+ operation,
516
+ tags: [],
517
+ success: false,
518
+ message: `Access denied: ${path}`
519
+ };
520
+ }
521
+
522
+ try {
523
+ const note = await this.readNote(path);
524
+ let currentTags: string[] = [];
525
+
526
+ // Extract tags from frontmatter
527
+ if (note.frontmatter.tags) {
528
+ if (Array.isArray(note.frontmatter.tags)) {
529
+ currentTags = note.frontmatter.tags;
530
+ } else if (typeof note.frontmatter.tags === 'string') {
531
+ currentTags = [note.frontmatter.tags];
532
+ }
533
+ }
534
+
535
+ // Also extract inline tags from content
536
+ const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
537
+ const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
538
+ currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
539
+
540
+ if (operation === 'list') {
541
+ return {
542
+ path,
543
+ operation,
544
+ tags: currentTags,
545
+ success: true
546
+ };
547
+ }
548
+
549
+ let newTags = [...currentTags];
550
+
551
+ if (operation === 'add') {
552
+ for (const tag of tags) {
553
+ if (!newTags.includes(tag)) {
554
+ newTags.push(tag);
555
+ }
556
+ }
557
+ } else if (operation === 'remove') {
558
+ newTags = newTags.filter(tag => !tags.includes(tag));
559
+ }
560
+
561
+ // Update frontmatter with new tags
562
+ const updatedFrontmatter = {
563
+ ...note.frontmatter,
564
+ tags: newTags.length > 0 ? newTags : undefined
565
+ };
566
+
567
+ // Remove undefined values
568
+ if (updatedFrontmatter.tags === undefined) {
569
+ delete updatedFrontmatter.tags;
570
+ }
571
+
572
+ // Write back the note with updated frontmatter
573
+ await this.writeNote({
574
+ path,
575
+ content: note.content,
576
+ frontmatter: updatedFrontmatter,
577
+ mode: 'overwrite'
578
+ });
579
+
580
+ return {
581
+ path,
582
+ operation,
583
+ tags: newTags,
584
+ success: true,
585
+ message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
586
+ };
587
+
588
+ } catch (error) {
589
+ return {
590
+ path,
591
+ operation,
592
+ tags: [],
593
+ success: false,
594
+ message: error instanceof Error ? error.message : 'Unknown error'
595
+ };
596
+ }
597
+ }
598
+
599
+ getVaultPath(): string {
600
+ return this.vaultPath;
601
+ }
602
+ }