@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.
- package/dist/src/createServer.d.ts.map +1 -1
- package/dist/src/createServer.js +22 -0
- package/dist/src/filesystem.d.ts +22 -0
- package/dist/src/filesystem.d.ts.map +1 -1
- package/dist/src/filesystem.js +117 -22
- package/dist/src/pathfilter.d.ts.map +1 -1
- package/dist/src/pathfilter.js +2 -0
- package/dist/src/wikilink/index.d.ts +5 -0
- package/dist/src/wikilink/index.d.ts.map +1 -0
- package/dist/src/wikilink/index.js +2 -0
- package/dist/src/wikilink/resolveWikiLink.d.ts +39 -0
- package/dist/src/wikilink/resolveWikiLink.d.ts.map +1 -0
- package/dist/src/wikilink/resolveWikiLink.js +50 -0
- package/dist/src/wikilink/wikiLinkTool.d.ts +21 -0
- package/dist/src/wikilink/wikiLinkTool.d.ts.map +1 -0
- package/dist/src/wikilink/wikiLinkTool.js +60 -0
- package/package.json +1 -1
|
@@ -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;
|
|
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"}
|
package/dist/src/createServer.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/filesystem.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/src/filesystem.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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 {
|
|
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 (
|
|
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 {
|
|
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;
|
|
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"}
|
package/dist/src/pathfilter.js
CHANGED
|
@@ -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,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