@bitbonsai/mcpvault 0.12.1 → 0.12.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAMnE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAI7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAoZzF"}
1
+ {"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAMnE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CA0azF"}
@@ -4,6 +4,7 @@ import { FileSystemService } from "./filesystem.js";
4
4
  import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
5
5
  import { PathFilter } from "./pathfilter.js";
6
6
  import { SearchService } from "./search.js";
7
+ import { handleWikiLinkTool } from "./wikilink/index.js";
7
8
  import { resolve } from "path";
8
9
  export function createServer(vaultPath, options = {}) {
9
10
  const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), } = options;
@@ -210,6 +211,25 @@ export function createServer(vaultPath, options = {}) {
210
211
  prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
211
212
  }
212
213
  }
214
+ },
215
+ {
216
+ name: "wiki_link",
217
+ description: "Read an Obsidian wiki link. Accepts the same syntax as Obsidian: [[Document Name]] or [[Document Name|Display Text]], including table-authored escapes like [[Document Name\\|Display]] and path-qualified links like [[folder/Document Name]]. A #fragment suffix in the input is ignored. Searches the vault for an exact basename match (or exact vault-relative path match when the name contains '/') and returns the file's content. When multiple files share the basename, picks the first (vault root first, then alphabetical by path) and lists the other paths in structuredContent.alternatives. Content is returned bare — ready for direct use in context.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {
221
+ document: {
222
+ type: "string",
223
+ description: "The document name — what goes inside [[ ]]. e.g. 'My-Document'. Brackets and display text (|...) are stripped if present. The .md extension is always appended (never include it)."
224
+ },
225
+ prettyPrint: {
226
+ type: "boolean",
227
+ description: "Format JSON response with indentation (default: false)",
228
+ default: false
229
+ }
230
+ },
231
+ required: ["document"]
232
+ }
213
233
  }
214
234
  ]
215
235
  };
@@ -372,6 +392,8 @@ export function createServer(vaultPath, options = {}) {
372
392
  content: [{ type: "text", text: JSON.stringify(tags, null, indent) }]
373
393
  };
374
394
  }
395
+ case "wiki_link":
396
+ return await handleWikiLinkTool(fileSystem, trimmedArgs);
375
397
  default:
376
398
  throw new Error(`Unknown tool: ${toolName}`);
377
399
  }
@@ -16,6 +16,12 @@ export declare class FileSystemService {
16
16
  private frontmatterHandler;
17
17
  private pathFilter;
18
18
  constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
19
+ /**
20
+ * Normalize an incoming path to be vault-relative. Strips leading slashes
21
+ * and the vault path prefix when a caller accidentally passes an absolute path
22
+ * (e.g. "/Users/me/vault/wiki/note.md" instead of "wiki/note.md").
23
+ */
24
+ private normalizePath;
19
25
  private resolvePath;
20
26
  readNote(path: string): Promise<ParsedNote>;
21
27
  writeNote(params: NoteWriteParams): Promise<void>;
@@ -31,6 +37,22 @@ export declare class FileSystemService {
31
37
  getNotesInfo(paths: string[]): Promise<NoteInfo[]>;
32
38
  manageTags(params: TagManagementParams): Promise<TagManagementResult>;
33
39
  getVaultPath(): string;
40
+ /**
41
+ * Resolve an Obsidian wiki link name to its vault-relative paths.
42
+ * Scans the vault for exact filename matches (name + .md).
43
+ *
44
+ * A name containing `/` is path-qualified (Obsidian emits these when a
45
+ * basename is ambiguous, e.g. [[folder/Note]]): it must match the full
46
+ * vault-relative path instead of just the basename.
47
+ *
48
+ * Returns all matches sorted root-first (by path depth ascending), with
49
+ * alphabetical tiebreak at equal depth. Empty array on zero matches.
50
+ * The caller decides how to handle zero/single/multi — this function does
51
+ * not throw on lookup outcomes.
52
+ *
53
+ * Throws only on caller misuse (empty name).
54
+ */
55
+ findPathForWikiLink(wikiLinkName: string): Promise<string[]>;
34
56
  getVaultStats(recentCount?: number): Promise<VaultStats>;
35
57
  listAllTags(): Promise<Array<{
36
58
  tag: string;
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAIA,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;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAED,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;IA2EjD,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;IAyG3D,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;IAqCjE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA2ClD,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAsG3E,YAAY,IAAI,MAAM;IAIhB,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAyD3D,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAkDpE"}
1
+ {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAKA,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;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAED,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;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;IAkDb,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAiC3C,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IA4EjD,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IA8F5D,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8D3D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgBtC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAgB3C,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC;IA2G3D,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IAsFrD,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IAiIrD,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAiDpE,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA4ClD,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAuG3E,YAAY,IAAI,MAAM;IAItB;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAiD5D,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAyD3D,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAkDpE"}
@@ -1,4 +1,5 @@
1
1
  import { join, resolve, relative, dirname } from 'path';
2
+ import { homedir } from 'os';
2
3
  import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
3
4
  import { constants, realpathSync } from 'node:fs';
4
5
  import trash from 'trash';
@@ -49,17 +50,37 @@ export class FileSystemService {
49
50
  this.pathFilter = pathFilter || new PathFilter();
50
51
  this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
51
52
  }
53
+ /**
54
+ * Normalize an incoming path to be vault-relative. Strips leading slashes
55
+ * and the vault path prefix when a caller accidentally passes an absolute path
56
+ * (e.g. "/Users/me/vault/wiki/note.md" instead of "wiki/note.md").
57
+ */
58
+ normalizePath(inputPath) {
59
+ if (!inputPath)
60
+ return '';
61
+ let p = inputPath.trim();
62
+ // Expand ~ to home directory so "~/vault/note.md" can be matched
63
+ if (p.startsWith('~/') || p === '~') {
64
+ p = p.replace('~', homedir());
65
+ }
66
+ // Normalize path separators for cross-platform comparison (Windows backslashes)
67
+ const normalized = p.replace(/\\/g, '/');
68
+ const vaultPrefix = this.vaultPath.replace(/\\/g, '/');
69
+ // Strip vault path prefix before stripping leading slash, so absolute paths
70
+ // like "/Users/me/vault/wiki/note.md" are handled correctly.
71
+ if (normalized.startsWith(vaultPrefix + '/')) {
72
+ p = normalized.slice(vaultPrefix.length + 1);
73
+ }
74
+ else if (normalized === vaultPrefix) {
75
+ p = '';
76
+ }
77
+ else if (p.startsWith('/')) {
78
+ p = p.slice(1);
79
+ }
80
+ return p;
81
+ }
52
82
  resolvePath(relativePath) {
53
- // Handle undefined or null path
54
- if (!relativePath) {
55
- relativePath = '';
56
- }
57
- // Trim whitespace from path
58
- relativePath = relativePath.trim();
59
- // Normalize and resolve the path within the vault
60
- const normalizedPath = relativePath.startsWith('/')
61
- ? relativePath.slice(1)
62
- : relativePath;
83
+ const normalizedPath = this.normalizePath(relativePath);
63
84
  const fullPath = resolve(join(this.vaultPath, normalizedPath));
64
85
  // Security check: ensure path is within vault (lexical)
65
86
  const relativeToVault = relative(this.vaultPath, fullPath);
@@ -110,6 +131,7 @@ export class FileSystemService {
110
131
  return fullPath;
111
132
  }
112
133
  async readNote(path) {
134
+ path = this.normalizePath(path);
113
135
  const fullPath = this.resolvePath(path);
114
136
  if (!this.pathFilter.isAllowed(path)) {
115
137
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
@@ -139,7 +161,8 @@ export class FileSystemService {
139
161
  }
140
162
  }
141
163
  async writeNote(params) {
142
- const { path, content, frontmatter, mode = 'overwrite' } = params;
164
+ const { content, frontmatter, mode = 'overwrite' } = params;
165
+ const path = this.normalizePath(params.path);
143
166
  const fullPath = this.resolvePath(path);
144
167
  if (!this.pathFilter.isAllowed(path)) {
145
168
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
@@ -201,7 +224,8 @@ export class FileSystemService {
201
224
  }
202
225
  }
203
226
  async patchNote(params) {
204
- const { path, oldString, newString, replaceAll = false } = params;
227
+ const { oldString, newString, replaceAll = false } = params;
228
+ const path = this.normalizePath(params.path);
205
229
  if (!this.pathFilter.isAllowed(path)) {
206
230
  return {
207
231
  success: false,
@@ -257,9 +281,11 @@ export class FileSystemService {
257
281
  };
258
282
  }
259
283
  // Perform the replacement
284
+ // Use a replacer function so newString is inserted literally,
285
+ // without $ replacement pattern expansion ($$, $&, $`, $')
260
286
  const updatedContent = replaceAll
261
287
  ? fullContent.split(oldString).join(newString)
262
- : fullContent.replace(oldString, newString);
288
+ : fullContent.replace(oldString, () => newString);
263
289
  // Write the updated content
264
290
  const fullPath = this.resolvePath(path);
265
291
  await writeFile(fullPath, updatedContent, 'utf-8');
@@ -279,8 +305,8 @@ export class FileSystemService {
279
305
  }
280
306
  }
281
307
  async listDirectory(path = '') {
282
- // Normalize path: treat '.' as root directory
283
- const normalizedPath = path === '.' ? '' : path;
308
+ // Normalize path: treat '.' as root directory, strip vault prefix
309
+ const normalizedPath = path === '.' ? '' : this.normalizePath(path);
284
310
  const fullPath = this.resolvePath(normalizedPath);
285
311
  try {
286
312
  const entries = await readdir(fullPath, { withFileTypes: true });
@@ -340,6 +366,7 @@ export class FileSystemService {
340
366
  }
341
367
  }
342
368
  async exists(path) {
369
+ path = this.normalizePath(path);
343
370
  const fullPath = this.resolvePath(path);
344
371
  if (!this.pathFilter.isAllowed(path)) {
345
372
  return false;
@@ -353,6 +380,7 @@ export class FileSystemService {
353
380
  }
354
381
  }
355
382
  async isDirectory(path) {
383
+ path = this.normalizePath(path);
356
384
  const fullPath = this.resolvePath(path);
357
385
  if (!this.pathFilter.isAllowed(path)) {
358
386
  return false;
@@ -366,7 +394,9 @@ export class FileSystemService {
366
394
  }
367
395
  }
368
396
  async deleteNote(params) {
369
- const { path, confirmPath, trashMode = 'none' } = params;
397
+ const { trashMode = 'none' } = params;
398
+ const path = this.normalizePath(params.path);
399
+ const confirmPath = this.normalizePath(params.confirmPath);
370
400
  // Confirmation check - paths must match exactly
371
401
  if (path !== confirmPath) {
372
402
  return {
@@ -459,7 +489,9 @@ export class FileSystemService {
459
489
  }
460
490
  }
461
491
  async moveNote(params) {
462
- const { oldPath, newPath, overwrite = false } = params;
492
+ const { overwrite = false } = params;
493
+ const oldPath = this.normalizePath(params.oldPath);
494
+ const newPath = this.normalizePath(params.newPath);
463
495
  if (!this.pathFilter.isAllowed(oldPath)) {
464
496
  return {
465
497
  success: false,
@@ -537,7 +569,11 @@ export class FileSystemService {
537
569
  }
538
570
  }
539
571
  async moveFile(params) {
540
- const { oldPath, newPath, confirmOldPath, confirmNewPath, overwrite = false } = params;
572
+ const { overwrite = false } = params;
573
+ const oldPath = this.normalizePath(params.oldPath);
574
+ const newPath = this.normalizePath(params.newPath);
575
+ const confirmOldPath = this.normalizePath(params.confirmOldPath);
576
+ const confirmNewPath = this.normalizePath(params.confirmNewPath);
541
577
  if (oldPath !== confirmOldPath || newPath !== confirmNewPath) {
542
578
  return {
543
579
  success: false,
@@ -661,7 +697,8 @@ export class FileSystemService {
661
697
  if (paths.length > 10) {
662
698
  throw new Error('Maximum 10 files per batch read request');
663
699
  }
664
- const results = await Promise.allSettled(paths.map(async (path) => {
700
+ const results = await Promise.allSettled(paths.map(async (rawPath) => {
701
+ const path = this.normalizePath(rawPath);
665
702
  if (!this.pathFilter.isAllowed(path)) {
666
703
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
667
704
  }
@@ -694,7 +731,8 @@ export class FileSystemService {
694
731
  return { successful, failed };
695
732
  }
696
733
  async updateFrontmatter(params) {
697
- const { path, frontmatter, merge = true } = params;
734
+ const { frontmatter, merge = true } = params;
735
+ const path = this.normalizePath(params.path);
698
736
  if (!this.pathFilter.isAllowed(path)) {
699
737
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
700
738
  }
@@ -725,7 +763,8 @@ export class FileSystemService {
725
763
  }
726
764
  }
727
765
  async getNotesInfo(paths) {
728
- const results = await Promise.allSettled(paths.map(async (path) => {
766
+ const results = await Promise.allSettled(paths.map(async (rawPath) => {
767
+ const path = this.normalizePath(rawPath);
729
768
  if (!this.pathFilter.isAllowed(path)) {
730
769
  throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
731
770
  }
@@ -760,7 +799,8 @@ export class FileSystemService {
760
799
  .map(result => result.value);
761
800
  }
762
801
  async manageTags(params) {
763
- const { path, operation, tags = [] } = params;
802
+ const { operation, tags = [] } = params;
803
+ const path = this.normalizePath(params.path);
764
804
  if (!this.pathFilter.isAllowed(path)) {
765
805
  return {
766
806
  path,
@@ -851,6 +891,61 @@ export class FileSystemService {
851
891
  getVaultPath() {
852
892
  return this.vaultPath;
853
893
  }
894
+ /**
895
+ * Resolve an Obsidian wiki link name to its vault-relative paths.
896
+ * Scans the vault for exact filename matches (name + .md).
897
+ *
898
+ * A name containing `/` is path-qualified (Obsidian emits these when a
899
+ * basename is ambiguous, e.g. [[folder/Note]]): it must match the full
900
+ * vault-relative path instead of just the basename.
901
+ *
902
+ * Returns all matches sorted root-first (by path depth ascending), with
903
+ * alphabetical tiebreak at equal depth. Empty array on zero matches.
904
+ * The caller decides how to handle zero/single/multi — this function does
905
+ * not throw on lookup outcomes.
906
+ *
907
+ * Throws only on caller misuse (empty name).
908
+ */
909
+ async findPathForWikiLink(wikiLinkName) {
910
+ if (!wikiLinkName.trim()) {
911
+ throw new Error('Empty wiki link — provide a document name inside [[ ]].');
912
+ }
913
+ const normalizedName = `${wikiLinkName}.md`;
914
+ const isPathQualified = wikiLinkName.includes('/');
915
+ const matches = [];
916
+ const scan = async (dirPath, relativePath = '') => {
917
+ const entries = await readdir(dirPath, { withFileTypes: true });
918
+ for (const entry of entries) {
919
+ const entryRelativePath = relativePath
920
+ ? `${relativePath}/${entry.name}`
921
+ : entry.name;
922
+ if (!this.pathFilter.isAllowed(entryRelativePath)) {
923
+ continue;
924
+ }
925
+ if (entry.isDirectory()) {
926
+ if (!this.pathFilter.isAllowed(`${entryRelativePath}/test.md`)) {
927
+ continue;
928
+ }
929
+ await scan(join(dirPath, entry.name), entryRelativePath);
930
+ }
931
+ else if (entry.isFile() &&
932
+ (isPathQualified
933
+ ? entryRelativePath === normalizedName
934
+ : entry.name === normalizedName)) {
935
+ matches.push(entryRelativePath);
936
+ }
937
+ }
938
+ };
939
+ await scan(this.vaultPath);
940
+ // Depth-ascending (root-first), alphabetical tiebreak at equal depth.
941
+ // No current-folder context exists for a standalone MCP tool.
942
+ matches.sort((a, b) => {
943
+ const da = a.split('/').length;
944
+ const db = b.split('/').length;
945
+ return da !== db ? da - db : a.localeCompare(b);
946
+ });
947
+ return matches;
948
+ }
854
949
  async getVaultStats(recentCount = 5) {
855
950
  let totalNotes = 0;
856
951
  let totalFolders = 0;
@@ -1 +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;IAOrB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAMxC;IAEH,OAAO,CAAC,eAAe,CAAW;IAClC,OAAO,CAAC,iBAAiB,CAAW;gBAExB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAuB9C,OAAO,CAAC,eAAe;IAoBvB;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAQ5B,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAyBhC,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAY1C,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,MAAM;IA0Bd,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;CAGvC"}
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;IAOrB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAMxC;IAEH,OAAO,CAAC,eAAe,CAAW;IAClC,OAAO,CAAC,iBAAiB,CAAW;gBAExB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAyB9C,OAAO,CAAC,eAAe;IAoBvB;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAQ5B,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAyBhC,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAY1C,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,MAAM;IA0Bd,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;CAGvC"}
@@ -20,6 +20,8 @@ export class PathFilter {
20
20
  '.obsidian/**',
21
21
  '.git',
22
22
  '.git/**',
23
+ '.trash',
24
+ '.trash/**',
23
25
  'node_modules',
24
26
  'node_modules/**',
25
27
  '.DS_Store',
@@ -0,0 +1,5 @@
1
+ export { parseWikiLink } from './resolveWikiLink.js';
2
+ export { handleWikiLinkTool } from './wikiLinkTool.js';
3
+ export type { ParsedWikiLink } from './resolveWikiLink.js';
4
+ export type { WikiLinkToolArgs } from './wikiLinkTool.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/wikilink/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,YAAY,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { parseWikiLink } from './resolveWikiLink.js';
2
+ export { handleWikiLinkTool } from './wikiLinkTool.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The parsed components of an Obsidian wiki link.
3
+ *
4
+ * @example
5
+ * parseWikiLink('[[Doc Name#Heading|Display]]')
6
+ * // { document: 'Doc Name' }
7
+ */
8
+ export interface ParsedWikiLink {
9
+ /**
10
+ * The document name as it appears inside [[ ]] — without brackets,
11
+ * without display text, without any `#fragment` suffix.
12
+ * Append `.md` to get the filesystem filename.
13
+ */
14
+ document: string;
15
+ }
16
+ /**
17
+ * Parse an Obsidian wiki link string into its components.
18
+ *
19
+ * Handles all wiki link forms:
20
+ * - `[[Document Name]]`
21
+ * - `[[Document Name|Display Text]]`
22
+ * - `[[Document Name\|Display]]` — table-authored escape; `\|` unescapes to `|`
23
+ * - Bare names without brackets (backslashes preserved as-is)
24
+ *
25
+ * A `#Heading` or `#^block-id` suffix is accepted but ignored: the fragment
26
+ * is stripped and only the document name is returned.
27
+ *
28
+ * Throws on malformed input: if the parsed `document` still contains a
29
+ * literal `\` after processing, the input is rejected rather than
30
+ * heuristically interpreted.
31
+ *
32
+ * @param wikiLinkText - The raw wiki link string, with or without brackets
33
+ * @returns The parsed basename
34
+ * @throws Error when parsed components contain an unexpected backslash
35
+ *
36
+ * @see {@link ParsedWikiLink}
37
+ */
38
+ export declare const parseWikiLink: (wikiLinkText: string) => ParsedWikiLink;
39
+ //# sourceMappingURL=resolveWikiLink.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolveWikiLink.d.ts","sourceRoot":"","sources":["../../../src/wikilink/resolveWikiLink.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,aAAa,GACxB,cAAc,MAAM,KACnB,cAmCF,CAAC"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Parse an Obsidian wiki link string into its components.
3
+ *
4
+ * Handles all wiki link forms:
5
+ * - `[[Document Name]]`
6
+ * - `[[Document Name|Display Text]]`
7
+ * - `[[Document Name\|Display]]` — table-authored escape; `\|` unescapes to `|`
8
+ * - Bare names without brackets (backslashes preserved as-is)
9
+ *
10
+ * A `#Heading` or `#^block-id` suffix is accepted but ignored: the fragment
11
+ * is stripped and only the document name is returned.
12
+ *
13
+ * Throws on malformed input: if the parsed `document` still contains a
14
+ * literal `\` after processing, the input is rejected rather than
15
+ * heuristically interpreted.
16
+ *
17
+ * @param wikiLinkText - The raw wiki link string, with or without brackets
18
+ * @returns The parsed basename
19
+ * @throws Error when parsed components contain an unexpected backslash
20
+ *
21
+ * @see {@link ParsedWikiLink}
22
+ */
23
+ export const parseWikiLink = (wikiLinkText) => {
24
+ let text = wikiLinkText.trim();
25
+ const wasBracketed = text.startsWith('[[');
26
+ if (wasBracketed) {
27
+ text = text.slice(2);
28
+ }
29
+ if (text.endsWith(']]')) {
30
+ text = text.slice(0, -2);
31
+ }
32
+ // Inside [[ ]] only: unescape Obsidian's table-authoring artifact `\|` → `|`.
33
+ // Pipe is a reserved grammar character, so `\|` is unambiguous there.
34
+ if (wasBracketed) {
35
+ text = text.replace(/\\\|/g, '|');
36
+ }
37
+ // Strip |display text if present
38
+ const pipeIndex = text.indexOf('|');
39
+ if (pipeIndex !== -1) {
40
+ text = text.slice(0, pipeIndex);
41
+ }
42
+ // Strip and ignore any #fragment suffix
43
+ const hashIndex = text.indexOf('#');
44
+ const rest = hashIndex !== -1 ? text.slice(hashIndex + 1) : '';
45
+ const document = (hashIndex !== -1 ? text.slice(0, hashIndex) : text).trim();
46
+ if (document.includes('\\') || rest.includes('\\')) {
47
+ throw new Error(`Invalid wiki-link syntax: unexpected backslash in [[${wikiLinkText}]]. Only \\| inside [[ ]] is a recognized escape.`);
48
+ }
49
+ return { document };
50
+ };
@@ -0,0 +1,21 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { FileSystemService } from '../filesystem.js';
3
+ export interface WikiLinkToolArgs {
4
+ document: string;
5
+ prettyPrint?: boolean;
6
+ }
7
+ /**
8
+ * Handle the `wiki_link` MCP tool call.
9
+ *
10
+ * Resolves an Obsidian wiki-link reference against the vault and returns the
11
+ * matching note. Designed to keep the MCP server request handler slim — all
12
+ * wiki-link specific concerns live here.
13
+ *
14
+ * Response channels per MCP spec 2025-11-25:
15
+ * - Invalid syntax or no match → `isError: true` with an actionable message.
16
+ * - One or more matches → success; `structuredContent` carries `document`,
17
+ * `path` (the resolved pick), and `alternatives` (the other matched paths,
18
+ * present only when more than one file shares the basename).
19
+ */
20
+ export declare function handleWikiLinkTool(fileSystem: FileSystemService, args: WikiLinkToolArgs): Promise<CallToolResult>;
21
+ //# sourceMappingURL=wikiLinkTool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wikiLinkTool.d.ts","sourceRoot":"","sources":["../../../src/wikilink/wikiLinkTool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACzE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,iBAAiB,EAC7B,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
@@ -0,0 +1,60 @@
1
+ import { parseWikiLink } from './resolveWikiLink.js';
2
+ /**
3
+ * Handle the `wiki_link` MCP tool call.
4
+ *
5
+ * Resolves an Obsidian wiki-link reference against the vault and returns the
6
+ * matching note. Designed to keep the MCP server request handler slim — all
7
+ * wiki-link specific concerns live here.
8
+ *
9
+ * Response channels per MCP spec 2025-11-25:
10
+ * - Invalid syntax or no match → `isError: true` with an actionable message.
11
+ * - One or more matches → success; `structuredContent` carries `document`,
12
+ * `path` (the resolved pick), and `alternatives` (the other matched paths,
13
+ * present only when more than one file shares the basename).
14
+ */
15
+ export async function handleWikiLinkTool(fileSystem, args) {
16
+ const indent = args.prettyPrint ? 2 : undefined;
17
+ let parsed;
18
+ try {
19
+ parsed = parseWikiLink(args.document);
20
+ }
21
+ catch (err) {
22
+ const message = err instanceof Error ? err.message : 'Invalid wiki-link syntax';
23
+ return {
24
+ content: [{ type: 'text', text: message }],
25
+ structuredContent: { rawInput: args.document },
26
+ isError: true,
27
+ };
28
+ }
29
+ const paths = await fileSystem.findPathForWikiLink(parsed.document);
30
+ if (paths.length === 0) {
31
+ return {
32
+ content: [{
33
+ type: 'text',
34
+ text: `No file found for [[${parsed.document}]]. Use search_notes or list_directory to find the correct name.`,
35
+ }],
36
+ structuredContent: {
37
+ document: parsed.document,
38
+ },
39
+ isError: true,
40
+ };
41
+ }
42
+ const resolvedPath = paths[0];
43
+ const alternatives = paths.slice(1);
44
+ const note = await fileSystem.readNote(resolvedPath);
45
+ return {
46
+ content: [{
47
+ type: 'text',
48
+ text: JSON.stringify({
49
+ path: resolvedPath,
50
+ fm: note.frontmatter,
51
+ content: note.content,
52
+ }, null, indent),
53
+ }],
54
+ structuredContent: {
55
+ document: parsed.document,
56
+ path: resolvedPath,
57
+ ...(alternatives.length > 0 && { alternatives }),
58
+ },
59
+ };
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbonsai/mcpvault",
3
- "version": "0.12.1",
3
+ "version": "0.12.4",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "homepage": "https://mcpvault.org",
6
6
  "author": "bitbonsai",