@bitbonsai/mcpvault 0.8.2 → 0.10.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,383 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
+ import { FileSystemService } from "./filesystem.js";
4
+ import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
5
+ import { PathFilter } from "./pathfilter.js";
6
+ import { SearchService } from "./search.js";
7
+ import { resolve } from "path";
8
+ export function createServer(vaultPath, options = {}) {
9
+ const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), } = options;
10
+ const resolvedVaultPath = resolve(vaultPath);
11
+ const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
12
+ const searchService = new SearchService(resolvedVaultPath, pathFilter);
13
+ const server = new Server({ name, version }, {
14
+ capabilities: { tools: {} },
15
+ });
16
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
17
+ return {
18
+ tools: [
19
+ {
20
+ name: "read_note",
21
+ description: "Read a note from the Obsidian vault",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ path: { type: "string", description: "Path to the note relative to vault root" },
26
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
27
+ },
28
+ required: ["path"]
29
+ }
30
+ },
31
+ {
32
+ name: "write_note",
33
+ description: "Write a note to the Obsidian vault",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ path: { type: "string", description: "Path to the note relative to vault root" },
38
+ content: { type: "string", description: "Content of the note" },
39
+ frontmatter: { type: "object", description: "Frontmatter object (optional)" },
40
+ mode: { type: "string", enum: ["overwrite", "append", "prepend"], description: "Write mode: 'overwrite' (default), 'append', or 'prepend'", default: "overwrite" }
41
+ },
42
+ required: ["path", "content"]
43
+ }
44
+ },
45
+ {
46
+ name: "patch_note",
47
+ description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ path: { type: "string", description: "Path to the note relative to vault root" },
52
+ oldString: { type: "string", description: "The exact string to replace. Must match exactly including whitespace and line breaks." },
53
+ newString: { type: "string", description: "The new string to insert in place of oldString" },
54
+ replaceAll: { type: "boolean", description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.", default: false }
55
+ },
56
+ required: ["path", "oldString", "newString"]
57
+ }
58
+ },
59
+ {
60
+ name: "list_directory",
61
+ description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ path: { type: "string", description: "Path relative to vault root (default: '/')", default: "/" },
66
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
67
+ }
68
+ }
69
+ },
70
+ {
71
+ name: "delete_note",
72
+ description: "Delete a note from the Obsidian vault (requires confirmation)",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ path: { type: "string", description: "Path to the note relative to vault root" },
77
+ confirmPath: { type: "string", description: "Confirmation: must exactly match the path parameter to proceed with deletion" }
78
+ },
79
+ required: ["path", "confirmPath"]
80
+ }
81
+ },
82
+ {
83
+ name: "search_notes",
84
+ description: "Search for notes in the vault by content or frontmatter",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ query: { type: "string", description: "Search query text" },
89
+ limit: { type: "number", description: "Maximum number of results (default: 5, max: 20)", default: 5 },
90
+ searchContent: { type: "boolean", description: "Search in note content (default: true)", default: true },
91
+ searchFrontmatter: { type: "boolean", description: "Search in frontmatter (default: false)", default: false },
92
+ caseSensitive: { type: "boolean", description: "Case sensitive search (default: false)", default: false },
93
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
94
+ },
95
+ required: ["query"]
96
+ }
97
+ },
98
+ {
99
+ name: "move_note",
100
+ description: "Move or rename a note in the vault",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ oldPath: { type: "string", description: "Current path of the note" },
105
+ newPath: { type: "string", description: "New path for the note" },
106
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
107
+ },
108
+ required: ["oldPath", "newPath"]
109
+ }
110
+ },
111
+ {
112
+ name: "move_file",
113
+ description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ oldPath: { type: "string", description: "Current path of the file" },
118
+ newPath: { type: "string", description: "New path for the file" },
119
+ confirmOldPath: { type: "string", description: "Confirmation: must exactly match oldPath" },
120
+ confirmNewPath: { type: "string", description: "Confirmation: must exactly match newPath" },
121
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
122
+ },
123
+ required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
124
+ }
125
+ },
126
+ {
127
+ name: "read_multiple_notes",
128
+ description: "Read multiple notes in a batch (max 10 files)",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to read", maxItems: 10 },
133
+ includeContent: { type: "boolean", description: "Include note content (default: true)", default: true },
134
+ includeFrontmatter: { type: "boolean", description: "Include frontmatter (default: true)", default: true },
135
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
136
+ },
137
+ required: ["paths"]
138
+ }
139
+ },
140
+ {
141
+ name: "update_frontmatter",
142
+ description: "Update frontmatter of a note without changing content",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {
146
+ path: { type: "string", description: "Path to the note" },
147
+ frontmatter: { type: "object", description: "Frontmatter object to update" },
148
+ merge: { type: "boolean", description: "Merge with existing frontmatter (default: true)", default: true }
149
+ },
150
+ required: ["path", "frontmatter"]
151
+ }
152
+ },
153
+ {
154
+ name: "get_notes_info",
155
+ description: "Get metadata for notes without reading full content",
156
+ inputSchema: {
157
+ type: "object",
158
+ properties: {
159
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to get info for" },
160
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
161
+ },
162
+ required: ["paths"]
163
+ }
164
+ },
165
+ {
166
+ name: "get_frontmatter",
167
+ description: "Extract frontmatter from a note without reading the content",
168
+ inputSchema: {
169
+ type: "object",
170
+ properties: {
171
+ path: { type: "string", description: "Path to the note relative to vault root" },
172
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
173
+ },
174
+ required: ["path"]
175
+ }
176
+ },
177
+ {
178
+ name: "manage_tags",
179
+ description: "Add, remove, or list tags in a note",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ path: { type: "string", description: "Path to the note relative to vault root" },
184
+ operation: { type: "string", enum: ["add", "remove", "list"], description: "Operation to perform: 'add', 'remove', or 'list'" },
185
+ tags: { type: "array", items: { type: "string" }, description: "Array of tags (required for 'add' and 'remove' operations)" }
186
+ },
187
+ required: ["path", "operation"]
188
+ }
189
+ },
190
+ {
191
+ name: "get_vault_stats",
192
+ description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ recentCount: { type: "number", description: "Number of recently modified files to return (default: 5, max: 20)", default: 5 },
197
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
198
+ }
199
+ }
200
+ }
201
+ ]
202
+ };
203
+ });
204
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
205
+ const { name: toolName, arguments: args } = request.params;
206
+ const trimmedArgs = trimPaths(args);
207
+ try {
208
+ switch (toolName) {
209
+ case "read_note": {
210
+ const note = await fileSystem.readNote(trimmedArgs.path);
211
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
212
+ return {
213
+ content: [{ type: "text", text: JSON.stringify({ fm: note.frontmatter, content: note.content }, null, indent) }]
214
+ };
215
+ }
216
+ case "write_note": {
217
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
218
+ await fileSystem.writeNote({
219
+ path: trimmedArgs.path,
220
+ content: trimmedArgs.content,
221
+ ...(fm !== undefined && { frontmatter: fm }),
222
+ mode: trimmedArgs.mode || 'overwrite'
223
+ });
224
+ return {
225
+ content: [{ type: "text", text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})` }]
226
+ };
227
+ }
228
+ case "patch_note": {
229
+ const result = await fileSystem.patchNote({
230
+ path: trimmedArgs.path,
231
+ oldString: trimmedArgs.oldString,
232
+ newString: trimmedArgs.newString,
233
+ replaceAll: trimmedArgs.replaceAll
234
+ });
235
+ return {
236
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
237
+ isError: !result.success
238
+ };
239
+ }
240
+ case "list_directory": {
241
+ const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
242
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
243
+ return {
244
+ content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
245
+ };
246
+ }
247
+ case "delete_note": {
248
+ const result = await fileSystem.deleteNote({
249
+ path: trimmedArgs.path,
250
+ confirmPath: trimmedArgs.confirmPath
251
+ });
252
+ return {
253
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
254
+ isError: !result.success
255
+ };
256
+ }
257
+ case "search_notes": {
258
+ const results = await searchService.search({
259
+ query: trimmedArgs.query,
260
+ limit: trimmedArgs.limit,
261
+ searchContent: trimmedArgs.searchContent,
262
+ searchFrontmatter: trimmedArgs.searchFrontmatter,
263
+ caseSensitive: trimmedArgs.caseSensitive
264
+ });
265
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
266
+ return {
267
+ content: [{ type: "text", text: JSON.stringify(results, null, indent) }]
268
+ };
269
+ }
270
+ case "move_note": {
271
+ const result = await fileSystem.moveNote({
272
+ oldPath: trimmedArgs.oldPath,
273
+ newPath: trimmedArgs.newPath,
274
+ overwrite: trimmedArgs.overwrite
275
+ });
276
+ return {
277
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
278
+ isError: !result.success
279
+ };
280
+ }
281
+ case "move_file": {
282
+ const result = await fileSystem.moveFile({
283
+ oldPath: trimmedArgs.oldPath,
284
+ newPath: trimmedArgs.newPath,
285
+ confirmOldPath: trimmedArgs.confirmOldPath,
286
+ confirmNewPath: trimmedArgs.confirmNewPath,
287
+ overwrite: trimmedArgs.overwrite
288
+ });
289
+ return {
290
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
291
+ isError: !result.success
292
+ };
293
+ }
294
+ case "read_multiple_notes": {
295
+ const result = await fileSystem.readMultipleNotes({
296
+ paths: trimmedArgs.paths,
297
+ includeContent: trimmedArgs.includeContent,
298
+ includeFrontmatter: trimmedArgs.includeFrontmatter
299
+ });
300
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
301
+ return {
302
+ content: [{ type: "text", text: JSON.stringify({ ok: result.successful, err: result.failed }, null, indent) }]
303
+ };
304
+ }
305
+ case "update_frontmatter": {
306
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
307
+ if (!fm) {
308
+ throw new Error('frontmatter is required');
309
+ }
310
+ await fileSystem.updateFrontmatter({
311
+ path: trimmedArgs.path,
312
+ frontmatter: fm,
313
+ merge: trimmedArgs.merge
314
+ });
315
+ return {
316
+ content: [{ type: "text", text: `Successfully updated frontmatter for: ${trimmedArgs.path}` }]
317
+ };
318
+ }
319
+ case "get_notes_info": {
320
+ const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
321
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
322
+ return {
323
+ content: [{ type: "text", text: JSON.stringify(result, null, indent) }]
324
+ };
325
+ }
326
+ case "get_frontmatter": {
327
+ const note = await fileSystem.readNote(trimmedArgs.path);
328
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
329
+ return {
330
+ content: [{ type: "text", text: JSON.stringify(note.frontmatter, null, indent) }]
331
+ };
332
+ }
333
+ case "manage_tags": {
334
+ const result = await fileSystem.manageTags({
335
+ path: trimmedArgs.path,
336
+ operation: trimmedArgs.operation,
337
+ tags: trimmedArgs.tags
338
+ });
339
+ return {
340
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
341
+ isError: !result.success
342
+ };
343
+ }
344
+ case "get_vault_stats": {
345
+ const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
346
+ const stats = await fileSystem.getVaultStats(recentCount);
347
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
348
+ return {
349
+ content: [{ type: "text", text: JSON.stringify({ notes: stats.totalNotes, folders: stats.totalFolders, size: stats.totalSize, recent: stats.recentlyModified }, null, indent) }]
350
+ };
351
+ }
352
+ default:
353
+ throw new Error(`Unknown tool: ${toolName}`);
354
+ }
355
+ }
356
+ catch (error) {
357
+ return {
358
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
359
+ isError: true
360
+ };
361
+ }
362
+ });
363
+ return server;
364
+ }
365
+ function trimPaths(args) {
366
+ const trimmed = { ...args };
367
+ if (trimmed.path && typeof trimmed.path === 'string')
368
+ trimmed.path = trimmed.path.trim();
369
+ if (trimmed.oldPath && typeof trimmed.oldPath === 'string')
370
+ trimmed.oldPath = trimmed.oldPath.trim();
371
+ if (trimmed.newPath && typeof trimmed.newPath === 'string')
372
+ trimmed.newPath = trimmed.newPath.trim();
373
+ if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string')
374
+ trimmed.confirmPath = trimmed.confirmPath.trim();
375
+ if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string')
376
+ trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
377
+ if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string')
378
+ trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
379
+ if (trimmed.paths && Array.isArray(trimmed.paths)) {
380
+ trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
381
+ }
382
+ return trimmed;
383
+ }
@@ -0,0 +1,26 @@
1
+ import { FrontmatterHandler } from './frontmatter.js';
2
+ import { PathFilter } from './pathfilter.js';
3
+ import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveFileParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult, PatchNoteParams, PatchNoteResult, VaultStats } from './types.js';
4
+ export declare class FileSystemService {
5
+ private vaultPath;
6
+ private frontmatterHandler;
7
+ private pathFilter;
8
+ constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
9
+ private resolvePath;
10
+ readNote(path: string): Promise<ParsedNote>;
11
+ writeNote(params: NoteWriteParams): Promise<void>;
12
+ patchNote(params: PatchNoteParams): Promise<PatchNoteResult>;
13
+ listDirectory(path?: string): Promise<DirectoryListing>;
14
+ exists(path: string): Promise<boolean>;
15
+ isDirectory(path: string): Promise<boolean>;
16
+ deleteNote(params: DeleteNoteParams): Promise<DeleteResult>;
17
+ moveNote(params: MoveNoteParams): Promise<MoveResult>;
18
+ moveFile(params: MoveFileParams): Promise<MoveResult>;
19
+ readMultipleNotes(params: BatchReadParams): Promise<BatchReadResult>;
20
+ updateFrontmatter(params: UpdateFrontmatterParams): Promise<void>;
21
+ getNotesInfo(paths: string[]): Promise<NoteInfo[]>;
22
+ manageTags(params: TagManagementParams): Promise<TagManagementResult>;
23
+ getVaultPath(): string;
24
+ getVaultStats(recentCount?: number): Promise<VaultStats>;
25
+ }
26
+ //# sourceMappingURL=filesystem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEzT,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;gBAGrB,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB;IAazC,OAAO,CAAC,WAAW;IA6Db,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAgC3C,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IA6EjD,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IA2F5D,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8D3D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAetC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAe3C,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC;IAmE3D,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IAoFrD,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IA6HrD,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAgDpE,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BjE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA2ClD,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IA0F3E,YAAY,IAAI,MAAM;IAIhB,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC;CAwDlE"}
@@ -1,6 +1,6 @@
1
1
  import { join, resolve, relative, dirname } from 'path';
2
2
  import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
3
- import { constants } from 'node:fs';
3
+ import { constants, realpathSync } from 'node:fs';
4
4
  import { FrontmatterHandler } from './frontmatter.js';
5
5
  import { PathFilter } from './pathfilter.js';
6
6
  import { generateObsidianUri } from './uri.js';
@@ -10,7 +10,14 @@ export class FileSystemService {
10
10
  pathFilter;
11
11
  constructor(vaultPath, pathFilter, frontmatterHandler) {
12
12
  this.vaultPath = vaultPath;
13
- this.vaultPath = resolve(vaultPath);
13
+ const resolved = resolve(vaultPath);
14
+ try {
15
+ this.vaultPath = realpathSync(resolved);
16
+ }
17
+ catch {
18
+ // Vault path doesn't exist yet or is inaccessible; fall back to lexical resolution
19
+ this.vaultPath = resolved;
20
+ }
14
21
  this.pathFilter = pathFilter || new PathFilter();
15
22
  this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
16
23
  }
@@ -26,11 +33,52 @@ export class FileSystemService {
26
33
  ? relativePath.slice(1)
27
34
  : relativePath;
28
35
  const fullPath = resolve(join(this.vaultPath, normalizedPath));
29
- // Security check: ensure path is within vault
36
+ // Security check: ensure path is within vault (lexical)
30
37
  const relativeToVault = relative(this.vaultPath, fullPath);
31
38
  if (relativeToVault.startsWith('..')) {
32
39
  throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
33
40
  }
41
+ // Security check: ensure symlinks don't escape vault boundary
42
+ try {
43
+ const realPath = realpathSync(fullPath);
44
+ const realRelative = relative(this.vaultPath, realPath);
45
+ if (realRelative.startsWith('..')) {
46
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
47
+ }
48
+ }
49
+ catch (err) {
50
+ if (err instanceof Error && 'code' in err) {
51
+ const code = err.code;
52
+ if (code === 'ENOENT') {
53
+ // File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
54
+ try {
55
+ const parentReal = realpathSync(dirname(fullPath));
56
+ const parentRelative = relative(this.vaultPath, parentReal);
57
+ if (parentRelative.startsWith('..')) {
58
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
59
+ }
60
+ }
61
+ catch (parentErr) {
62
+ // Parent doesn't exist either (will be created by mkdir). Lexical check above is sufficient.
63
+ if (parentErr instanceof Error && parentErr.message.includes('outside vault')) {
64
+ throw parentErr;
65
+ }
66
+ }
67
+ }
68
+ else if (code === 'ELOOP') {
69
+ throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
70
+ }
71
+ else if (code === 'EACCES') {
72
+ throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
73
+ }
74
+ else {
75
+ throw err;
76
+ }
77
+ }
78
+ else {
79
+ throw err;
80
+ }
81
+ }
34
82
  return fullPath;
35
83
  }
36
84
  async readNote(path) {
@@ -219,13 +267,33 @@ export class FileSystemService {
219
267
  if (!this.pathFilter.isAllowedForListing(entryPath)) {
220
268
  continue;
221
269
  }
222
- if (entry.isDirectory()) {
270
+ if (entry.isSymbolicLink()) {
271
+ // Follow symlinks that resolve inside the vault
272
+ try {
273
+ const entryFullPath = join(fullPath, entry.name);
274
+ const realPath = realpathSync(entryFullPath);
275
+ const realRelative = relative(this.vaultPath, realPath);
276
+ if (realRelative.startsWith('..')) {
277
+ continue; // Symlink target outside vault, skip silently
278
+ }
279
+ const targetStat = await stat(entryFullPath);
280
+ if (targetStat.isDirectory()) {
281
+ directories.push(entry.name);
282
+ }
283
+ else if (targetStat.isFile()) {
284
+ files.push(entry.name);
285
+ }
286
+ }
287
+ catch {
288
+ continue; // Broken/circular/inaccessible symlink, skip silently
289
+ }
290
+ }
291
+ else if (entry.isDirectory()) {
223
292
  directories.push(entry.name);
224
293
  }
225
294
  else if (entry.isFile()) {
226
295
  files.push(entry.name);
227
296
  }
228
- // Skip other types (symlinks, etc.)
229
297
  }
230
298
  return {
231
299
  files: files.sort(),
@@ -0,0 +1,16 @@
1
+ import type { ParsedNote, FrontmatterValidationResult } from './types.js';
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 declare function parseFrontmatter(value: any): Record<string, any> | undefined;
8
+ export declare class FrontmatterHandler {
9
+ parse(content: string): ParsedNote;
10
+ stringify(frontmatterData: Record<string, any>, content: string): string;
11
+ validate(frontmatterData: Record<string, any>): FrontmatterValidationResult;
12
+ private checkForProblematicValues;
13
+ extractFrontmatter(content: string): Record<string, any>;
14
+ updateFrontmatter(content: string, updates: Record<string, any>): string;
15
+ }
16
+ //# sourceMappingURL=frontmatter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAE1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAkBlC,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAaxE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B;IAqB3E,OAAO,CAAC,yBAAyB;IAmDjC,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAKxD,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM;CAWzE"}
@@ -0,0 +1,8 @@
1
+ export { createServer } from './createServer.js';
2
+ export type { CreateServerOptions } from './createServer.js';
3
+ export { FileSystemService } from './filesystem.js';
4
+ export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
5
+ export { PathFilter } from './pathfilter.js';
6
+ export { SearchService } from './search.js';
7
+ export * from './types.js';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,cAAc,YAAY,CAAC"}
@@ -0,0 +1,6 @@
1
+ export { createServer } from './createServer.js';
2
+ export { FileSystemService } from './filesystem.js';
3
+ export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
4
+ export { PathFilter } from './pathfilter.js';
5
+ export { SearchService } from './search.js';
6
+ export * from './types.js';
@@ -0,0 +1,13 @@
1
+ import type { PathFilterConfig } from "./types.js";
2
+ export declare class PathFilter {
3
+ private ignoredPatterns;
4
+ private allowedExtensions;
5
+ constructor(config?: Partial<PathFilterConfig>);
6
+ private simpleGlobMatch;
7
+ isAllowed(path: string): boolean;
8
+ isAllowedForListing(path: string): boolean;
9
+ private isIgnoredPath;
10
+ private isFile;
11
+ filterPaths(paths: string[]): string[];
12
+ }
13
+ //# sourceMappingURL=pathfilter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pathfilter.d.ts","sourceRoot":"","sources":["../../src/pathfilter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,qBAAa,UAAU;IACrB,OAAO,CAAC,eAAe,CAAW;IAClC,OAAO,CAAC,iBAAiB,CAAW;gBAExB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAuB9C,OAAO,CAAC,eAAe;IAkBvB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAqBhC,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAQ1C,OAAO,CAAC,aAAa;IAYrB,OAAO,CAAC,MAAM;IA0Bd,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;CAGvC"}
@@ -0,0 +1,11 @@
1
+ import type { PathFilter } from './pathfilter.js';
2
+ import type { SearchParams, SearchResult } from './types.js';
3
+ export declare class SearchService {
4
+ private pathFilter;
5
+ private vaultPath;
6
+ constructor(vaultPath: string, pathFilter: PathFilter);
7
+ search(params: SearchParams): Promise<SearchResult[]>;
8
+ private findMarkdownFiles;
9
+ private rerank;
10
+ }
11
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/search.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAiB,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG5E,qBAAa,aAAa;IAKtB,OAAO,CAAC,UAAU;IAJpB,OAAO,CAAC,SAAS,CAAS;gBAGxB,SAAS,EAAE,MAAM,EACT,UAAU,EAAE,UAAU;IAK1B,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YA8J7C,iBAAiB;IAwB/B,OAAO,CAAC,MAAM;CA0Bf"}