vault-mcp-tools 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,3005 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+
6
+ // src/config.ts
7
+ import fs from "fs";
8
+ import path from "path";
9
+
10
+ // src/errors.ts
11
+ var VaultError = class extends Error {
12
+ constructor(message, code) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "VaultError";
16
+ }
17
+ code;
18
+ };
19
+ function sanitizeErrorMessage(message, vaultRoot) {
20
+ const forwardSlash = vaultRoot.replace(/\\/g, "/");
21
+ const backSlash = vaultRoot.replace(/\//g, "\\");
22
+ let result = message;
23
+ result = result.replaceAll(forwardSlash, "<vault>");
24
+ result = result.replaceAll(backSlash, "<vault>");
25
+ result = result.replaceAll(vaultRoot, "<vault>");
26
+ return result;
27
+ }
28
+ function toolError(message, vaultRoot) {
29
+ const text = vaultRoot ? sanitizeErrorMessage(message, vaultRoot) : message;
30
+ return { content: [{ type: "text", text }], isError: true };
31
+ }
32
+ function toolResult(text) {
33
+ return { content: [{ type: "text", text }] };
34
+ }
35
+ function toolJson(data) {
36
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
37
+ }
38
+
39
+ // src/config.ts
40
+ function loadConfig() {
41
+ const vaultPath = process.env.OBSIDIAN_VAULT_PATH;
42
+ if (!vaultPath) {
43
+ throw new VaultError(
44
+ "OBSIDIAN_VAULT_PATH environment variable is required",
45
+ "CONFIG_MISSING"
46
+ );
47
+ }
48
+ const resolved = path.resolve(vaultPath);
49
+ try {
50
+ const stat = fs.statSync(resolved);
51
+ if (!stat.isDirectory()) {
52
+ throw new VaultError(
53
+ `OBSIDIAN_VAULT_PATH is not a directory: ${resolved}`,
54
+ "CONFIG_INVALID"
55
+ );
56
+ }
57
+ } catch (err) {
58
+ if (err instanceof VaultError) throw err;
59
+ throw new VaultError(
60
+ `OBSIDIAN_VAULT_PATH does not exist: ${resolved}`,
61
+ "CONFIG_INVALID"
62
+ );
63
+ }
64
+ const restToken = process.env.OBSIDIAN_REST_TOKEN;
65
+ const restPort = parseInt(process.env.OBSIDIAN_REST_PORT ?? "27124", 10);
66
+ const transportEnv = process.env.OBSIDIAN_TRANSPORT ?? "stdio";
67
+ if (transportEnv !== "stdio" && transportEnv !== "http") {
68
+ throw new VaultError(
69
+ `Invalid OBSIDIAN_TRANSPORT: "${transportEnv}". Must be "stdio" or "http".`,
70
+ "CONFIG_INVALID"
71
+ );
72
+ }
73
+ const httpPort = parseInt(process.env.OBSIDIAN_HTTP_PORT ?? "3000", 10);
74
+ if (isNaN(httpPort) || httpPort < 1 || httpPort > 65535) {
75
+ throw new VaultError(
76
+ `Invalid OBSIDIAN_HTTP_PORT: "${process.env.OBSIDIAN_HTTP_PORT}". Must be 1-65535.`,
77
+ "CONFIG_INVALID"
78
+ );
79
+ }
80
+ const httpAuthToken = process.env.OBSIDIAN_HTTP_AUTH_TOKEN;
81
+ return {
82
+ vaultPath: resolved,
83
+ restToken,
84
+ restPort,
85
+ restAvailable: false,
86
+ // Set by probe in index.ts after config loads
87
+ transport: transportEnv,
88
+ httpPort,
89
+ httpAuthToken
90
+ };
91
+ }
92
+
93
+ // src/cache/metadata-cache.ts
94
+ import path5 from "path";
95
+ import chokidar from "chokidar";
96
+
97
+ // src/fs/vault-io.ts
98
+ import fs2 from "fs/promises";
99
+ import path3 from "path";
100
+
101
+ // src/fs/vault-path.ts
102
+ import path2 from "path";
103
+ function normalizePath(inputPath) {
104
+ let result = inputPath.replace(/\\/g, "/");
105
+ result = result.replace(/^\/+/, "");
106
+ result = result.replace(/\/\/+/g, "/");
107
+ return result;
108
+ }
109
+ var SENSITIVE_DIRS = [".obsidian", ".git", ".trash", "node_modules"];
110
+ function assertNotSensitiveDir(normalizedPath) {
111
+ for (const dir of SENSITIVE_DIRS) {
112
+ if (normalizedPath === dir || normalizedPath.startsWith(dir + "/")) {
113
+ throw new VaultError(
114
+ `Access denied: targets sensitive directory "${dir}"`,
115
+ "SENSITIVE_PATH"
116
+ );
117
+ }
118
+ }
119
+ }
120
+ function resolveVaultPath(vaultRoot, relativePath, options) {
121
+ if (relativePath.includes("\0")) {
122
+ throw new VaultError("Path contains null byte", "INVALID_PATH");
123
+ }
124
+ const normalized = normalizePath(relativePath);
125
+ if (!options?.allowSensitive) {
126
+ assertNotSensitiveDir(normalized);
127
+ }
128
+ const resolved = path2.resolve(vaultRoot, normalized);
129
+ const normalizedRoot = path2.resolve(vaultRoot);
130
+ if (!resolved.startsWith(normalizedRoot + path2.sep) && resolved !== normalizedRoot) {
131
+ throw new VaultError(
132
+ `Path traversal detected: ${relativePath}`,
133
+ "PATH_TRAVERSAL"
134
+ );
135
+ }
136
+ return resolved;
137
+ }
138
+ function toRelativePath(vaultRoot, absolutePath) {
139
+ const rel = path2.relative(vaultRoot, absolutePath);
140
+ return rel.replace(/\\/g, "/");
141
+ }
142
+ function isMarkdownFile(filePath) {
143
+ return path2.extname(filePath).toLowerCase() === ".md";
144
+ }
145
+ function getNoteName(filePath) {
146
+ return path2.basename(filePath, path2.extname(filePath));
147
+ }
148
+
149
+ // src/fs/write-lock.ts
150
+ var WriteLock = class {
151
+ locks = /* @__PURE__ */ new Map();
152
+ async acquire(path9) {
153
+ const existing = this.locks.get(path9) ?? Promise.resolve();
154
+ let release;
155
+ const newPromise = new Promise((resolve) => {
156
+ release = resolve;
157
+ });
158
+ this.locks.set(path9, existing.then(() => newPromise));
159
+ await existing;
160
+ return release;
161
+ }
162
+ };
163
+
164
+ // src/fs/vault-io.ts
165
+ var writeLock = new WriteLock();
166
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([".obsidian", ".trash", ".git", "node_modules"]);
167
+ async function rejectSymlink(absolutePath) {
168
+ try {
169
+ const lstats = await fs2.lstat(absolutePath);
170
+ if (lstats.isSymbolicLink()) {
171
+ throw new VaultError("Symlinks are not allowed", "SYMLINK_DENIED");
172
+ }
173
+ } catch (err) {
174
+ if (err instanceof VaultError) throw err;
175
+ }
176
+ }
177
+ async function readFile(vaultRoot, relativePath) {
178
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
179
+ await rejectSymlink(absolutePath);
180
+ try {
181
+ return await fs2.readFile(absolutePath, "utf-8");
182
+ } catch {
183
+ throw new VaultError(`File not found: ${relativePath}`, "NOT_FOUND");
184
+ }
185
+ }
186
+ async function writeFile(vaultRoot, relativePath, content) {
187
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
188
+ await rejectSymlink(absolutePath);
189
+ const release = await writeLock.acquire(relativePath);
190
+ try {
191
+ await fs2.mkdir(path3.dirname(absolutePath), { recursive: true });
192
+ const tmpPath = `${absolutePath}.tmp`;
193
+ await fs2.writeFile(tmpPath, content, "utf-8");
194
+ await fs2.rename(tmpPath, absolutePath);
195
+ } finally {
196
+ release();
197
+ }
198
+ }
199
+ async function deleteFile(vaultRoot, relativePath) {
200
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
201
+ await rejectSymlink(absolutePath);
202
+ const release = await writeLock.acquire(relativePath);
203
+ try {
204
+ await fs2.unlink(absolutePath);
205
+ } catch {
206
+ throw new VaultError(`File not found: ${relativePath}`, "NOT_FOUND");
207
+ } finally {
208
+ release();
209
+ }
210
+ }
211
+ async function fileExists(vaultRoot, relativePath) {
212
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
213
+ try {
214
+ await fs2.access(absolutePath);
215
+ return true;
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+ async function listDirectory(vaultRoot, relativePath) {
221
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
222
+ let entries;
223
+ try {
224
+ entries = await fs2.readdir(absolutePath, { withFileTypes: true });
225
+ } catch {
226
+ throw new VaultError(`Directory not found: ${relativePath}`, "NOT_FOUND");
227
+ }
228
+ const results = [];
229
+ for (const entry of entries) {
230
+ if (IGNORED_DIRS.has(entry.name)) continue;
231
+ const fullPath = path3.join(absolutePath, entry.name);
232
+ const stat = await fs2.lstat(fullPath);
233
+ if (stat.isSymbolicLink()) continue;
234
+ const entryRelPath = toRelativePath(vaultRoot, fullPath);
235
+ if (stat.isDirectory()) {
236
+ results.push({ name: entry.name, path: entryRelPath, type: "folder" });
237
+ } else if (stat.isFile()) {
238
+ results.push({
239
+ name: entry.name,
240
+ path: entryRelPath,
241
+ type: "file",
242
+ size: stat.size,
243
+ modified: stat.mtimeMs
244
+ });
245
+ }
246
+ }
247
+ return results;
248
+ }
249
+ async function createDirectory(vaultRoot, relativePath) {
250
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
251
+ await fs2.mkdir(absolutePath, { recursive: true });
252
+ }
253
+ async function getFileStats(vaultRoot, relativePath) {
254
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
255
+ await rejectSymlink(absolutePath);
256
+ const stat = await fs2.stat(absolutePath);
257
+ return { size: stat.size, modified: stat.mtimeMs };
258
+ }
259
+ async function walkDirectory(vaultRoot, relativePath) {
260
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
261
+ let stat;
262
+ try {
263
+ stat = await fs2.stat(absolutePath);
264
+ } catch {
265
+ throw new VaultError(`Directory not found: ${relativePath}`, "NOT_FOUND");
266
+ }
267
+ if (!stat.isDirectory()) {
268
+ throw new VaultError(`Not a directory: ${relativePath}`, "NOT_FOUND");
269
+ }
270
+ const results = [];
271
+ async function walk(dir) {
272
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
273
+ for (const entry of entries) {
274
+ if (IGNORED_DIRS.has(entry.name)) continue;
275
+ const fullPath = path3.join(dir, entry.name);
276
+ const stat2 = await fs2.lstat(fullPath);
277
+ if (stat2.isSymbolicLink()) continue;
278
+ if (stat2.isDirectory()) {
279
+ await walk(fullPath);
280
+ } else if (stat2.isFile()) {
281
+ results.push(toRelativePath(vaultRoot, fullPath));
282
+ }
283
+ }
284
+ }
285
+ await walk(absolutePath);
286
+ return results;
287
+ }
288
+ async function moveDirectory(vaultRoot, oldRelPath, newRelPath) {
289
+ const oldAbsPath = resolveVaultPath(vaultRoot, oldRelPath);
290
+ const newAbsPath = resolveVaultPath(vaultRoot, newRelPath);
291
+ let stat;
292
+ try {
293
+ stat = await fs2.stat(oldAbsPath);
294
+ } catch {
295
+ throw new VaultError(`Directory not found: ${oldRelPath}`, "NOT_FOUND");
296
+ }
297
+ if (!stat.isDirectory()) {
298
+ throw new VaultError(`Not a directory: ${oldRelPath}`, "NOT_FOUND");
299
+ }
300
+ try {
301
+ await fs2.stat(newAbsPath);
302
+ throw new VaultError(`Destination already exists: ${newRelPath}`, "CONFLICT");
303
+ } catch (err) {
304
+ if (err instanceof VaultError) throw err;
305
+ }
306
+ await fs2.mkdir(path3.dirname(newAbsPath), { recursive: true });
307
+ await fs2.rename(oldAbsPath, newAbsPath);
308
+ }
309
+ async function deleteEmptyDirectory(vaultRoot, relativePath) {
310
+ const absolutePath = resolveVaultPath(vaultRoot, relativePath);
311
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
312
+ if (!normalized || normalized === ".") {
313
+ throw new VaultError("Cannot delete vault root", "INVALID_OPERATION");
314
+ }
315
+ let stat;
316
+ try {
317
+ stat = await fs2.stat(absolutePath);
318
+ } catch {
319
+ throw new VaultError(`Directory not found: ${relativePath}`, "NOT_FOUND");
320
+ }
321
+ if (!stat.isDirectory()) {
322
+ throw new VaultError(`Not a directory: ${relativePath}`, "NOT_FOUND");
323
+ }
324
+ const entries = await fs2.readdir(absolutePath);
325
+ if (entries.length > 0) {
326
+ throw new VaultError(`Directory is not empty: ${relativePath}`, "NOT_EMPTY");
327
+ }
328
+ await fs2.rmdir(absolutePath);
329
+ }
330
+ async function* walkMarkdownFiles(vaultRoot) {
331
+ async function* walk(dir) {
332
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
333
+ for (const entry of entries) {
334
+ if (IGNORED_DIRS.has(entry.name)) continue;
335
+ const fullPath = path3.join(dir, entry.name);
336
+ const stat = await fs2.lstat(fullPath);
337
+ if (stat.isSymbolicLink()) continue;
338
+ if (stat.isDirectory()) {
339
+ yield* walk(fullPath);
340
+ } else if (stat.isFile() && isMarkdownFile(entry.name)) {
341
+ yield {
342
+ relativePath: toRelativePath(vaultRoot, fullPath),
343
+ absolutePath: fullPath
344
+ };
345
+ }
346
+ }
347
+ }
348
+ yield* walk(vaultRoot);
349
+ }
350
+
351
+ // src/parsers/frontmatter.ts
352
+ import matter from "gray-matter";
353
+ function parseFrontmatter(content) {
354
+ const result = matter(content);
355
+ return {
356
+ frontmatter: result.data ?? {},
357
+ body: result.content
358
+ };
359
+ }
360
+ function extractAliases(frontmatter) {
361
+ const raw = frontmatter.aliases ?? frontmatter.alias;
362
+ if (!raw) return [];
363
+ const items = [];
364
+ if (typeof raw === "string") {
365
+ items.push(raw);
366
+ } else if (Array.isArray(raw)) {
367
+ for (const item of raw) {
368
+ if (typeof item === "string") items.push(item);
369
+ }
370
+ }
371
+ return [...new Set(items)];
372
+ }
373
+ function extractFrontmatterTags(frontmatter) {
374
+ const raw = frontmatter.tags ?? frontmatter.tag;
375
+ if (!raw) return [];
376
+ let items = [];
377
+ if (typeof raw === "string") {
378
+ items = raw.split(",").map((t) => t.trim()).filter(Boolean);
379
+ } else if (Array.isArray(raw)) {
380
+ for (const item of raw) {
381
+ if (typeof item === "string") items.push(item);
382
+ }
383
+ }
384
+ return [...new Set(
385
+ items.map((tag) => {
386
+ const normalized = tag.toLowerCase();
387
+ return normalized.startsWith("#") ? normalized : `#${normalized}`;
388
+ })
389
+ )];
390
+ }
391
+ function setFrontmatterProperty(content, key, value) {
392
+ const parsed = matter(content);
393
+ parsed.data[key] = value;
394
+ return matter.stringify(parsed.content, parsed.data);
395
+ }
396
+ function removeFrontmatterProperty(content, key) {
397
+ const parsed = matter(content);
398
+ delete parsed.data[key];
399
+ return matter.stringify(parsed.content, parsed.data);
400
+ }
401
+
402
+ // src/parsers/wikilinks.ts
403
+ var WIKILINK_REGEX = /(!?)\[\[([^\]]+?)\]\]/g;
404
+ function parseWikilinks(content) {
405
+ const links = [];
406
+ const lines = content.split("\n");
407
+ let inCodeBlock = false;
408
+ for (let i = 0; i < lines.length; i++) {
409
+ const line = lines[i];
410
+ if (line.trimStart().startsWith("```")) {
411
+ inCodeBlock = !inCodeBlock;
412
+ continue;
413
+ }
414
+ if (inCodeBlock) continue;
415
+ const cleaned = line.replace(/`[^`]*`/g, (match2) => " ".repeat(match2.length));
416
+ WIKILINK_REGEX.lastIndex = 0;
417
+ let match;
418
+ while ((match = WIKILINK_REGEX.exec(cleaned)) !== null) {
419
+ const isEmbed = match[1] === "!";
420
+ const inner = match[2];
421
+ const pipeIndex = inner.indexOf("|");
422
+ let target;
423
+ let alias;
424
+ if (pipeIndex !== -1) {
425
+ target = inner.slice(0, pipeIndex);
426
+ alias = inner.slice(pipeIndex + 1);
427
+ } else {
428
+ target = inner;
429
+ alias = null;
430
+ }
431
+ links.push({ target, alias, isEmbed, line: i });
432
+ }
433
+ }
434
+ return links;
435
+ }
436
+
437
+ // src/parsers/tags.ts
438
+ var INLINE_TAG_REGEX = /(?:^|(?<=\s))#([\w-]+(?:\/[\w-]+)*)/g;
439
+ function parseInlineTags(bodyContent) {
440
+ const tags = /* @__PURE__ */ new Set();
441
+ const lines = bodyContent.split("\n");
442
+ let inCodeBlock = false;
443
+ for (const line of lines) {
444
+ if (line.trimStart().startsWith("```")) {
445
+ inCodeBlock = !inCodeBlock;
446
+ continue;
447
+ }
448
+ if (inCodeBlock) continue;
449
+ const cleaned = line.replace(/`[^`]*`/g, (match2) => " ".repeat(match2.length));
450
+ INLINE_TAG_REGEX.lastIndex = 0;
451
+ let match;
452
+ while ((match = INLINE_TAG_REGEX.exec(cleaned)) !== null) {
453
+ const tagBody = match[1];
454
+ if (/^\d+$/.test(tagBody)) continue;
455
+ tags.add(`#${tagBody.toLowerCase()}`);
456
+ }
457
+ }
458
+ return [...tags];
459
+ }
460
+ function mergeTags(frontmatterTags, inlineTags) {
461
+ const seen = /* @__PURE__ */ new Set();
462
+ const result = [];
463
+ for (const tag of [...frontmatterTags, ...inlineTags]) {
464
+ const normalized = tag.toLowerCase();
465
+ const withHash = normalized.startsWith("#") ? normalized : `#${normalized}`;
466
+ if (!seen.has(withHash)) {
467
+ seen.add(withHash);
468
+ result.push(withHash);
469
+ }
470
+ }
471
+ return result;
472
+ }
473
+
474
+ // src/parsers/headings.ts
475
+ var HEADING_REGEX = /^(#{1,6})\s+(.+?)(?:\s+#+)?$/;
476
+ function parseHeadings(bodyContent) {
477
+ const headings = [];
478
+ const lines = bodyContent.split("\n");
479
+ let inCodeBlock = false;
480
+ for (let i = 0; i < lines.length; i++) {
481
+ const line = lines[i];
482
+ if (line.trimStart().startsWith("```")) {
483
+ inCodeBlock = !inCodeBlock;
484
+ continue;
485
+ }
486
+ if (inCodeBlock) continue;
487
+ const match = HEADING_REGEX.exec(line);
488
+ if (match) {
489
+ headings.push({
490
+ level: match[1].length,
491
+ text: match[2],
492
+ line: i
493
+ });
494
+ }
495
+ }
496
+ return headings;
497
+ }
498
+
499
+ // src/parsers/blocks.ts
500
+ var BLOCK_REF_REGEX = /\^([a-zA-Z0-9][\w-]*)$/;
501
+ function parseBlockRefs(bodyContent) {
502
+ const blocks = [];
503
+ const lines = bodyContent.split("\n");
504
+ let inCodeBlock = false;
505
+ for (let i = 0; i < lines.length; i++) {
506
+ const line = lines[i];
507
+ if (line.trimStart().startsWith("```")) {
508
+ inCodeBlock = !inCodeBlock;
509
+ continue;
510
+ }
511
+ if (inCodeBlock) continue;
512
+ const match = BLOCK_REF_REGEX.exec(line.trimEnd());
513
+ if (match) {
514
+ blocks.push({ id: match[1], line: i });
515
+ }
516
+ }
517
+ return blocks;
518
+ }
519
+
520
+ // src/parsers/note-parser.ts
521
+ import path4 from "path";
522
+ function parseNote(relativePath, content, stats) {
523
+ const { frontmatter, body } = parseFrontmatter(content);
524
+ const aliases = extractAliases(frontmatter);
525
+ const frontmatterTags = extractFrontmatterTags(frontmatter);
526
+ const inlineTags = parseInlineTags(body);
527
+ const tags = mergeTags(frontmatterTags, inlineTags);
528
+ const headings = parseHeadings(body);
529
+ const blocks = parseBlockRefs(body);
530
+ const outgoingLinks = parseWikilinks(body);
531
+ return {
532
+ path: relativePath,
533
+ name: getNoteName(relativePath),
534
+ extension: path4.extname(relativePath).slice(1),
535
+ frontmatter,
536
+ aliases,
537
+ tags,
538
+ headings,
539
+ blocks,
540
+ outgoingLinks,
541
+ created: stats.modified,
542
+ modified: stats.modified,
543
+ size: stats.size
544
+ };
545
+ }
546
+
547
+ // src/cache/metadata-cache.ts
548
+ var MetadataCache = class {
549
+ constructor(vaultRoot) {
550
+ this.vaultRoot = vaultRoot;
551
+ }
552
+ vaultRoot;
553
+ notes = /* @__PURE__ */ new Map();
554
+ nameIndex = /* @__PURE__ */ new Map();
555
+ aliasIndex = /* @__PURE__ */ new Map();
556
+ tagIndex = /* @__PURE__ */ new Map();
557
+ watcher = null;
558
+ mutedPaths = /* @__PURE__ */ new Set();
559
+ muteTimers = /* @__PURE__ */ new Map();
560
+ async initialize() {
561
+ for await (const { relativePath } of walkMarkdownFiles(this.vaultRoot)) {
562
+ await this._updateNote(relativePath);
563
+ }
564
+ }
565
+ startWatching() {
566
+ this.watcher = chokidar.watch(this.vaultRoot, {
567
+ ignored: /(^|[/\\])(\.(obsidian|trash|git)|node_modules)[/\\]/,
568
+ persistent: true,
569
+ ignoreInitial: true,
570
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
571
+ usePolling: false
572
+ });
573
+ this.watcher.on("add", (absPath) => this._handleFileEvent(absPath));
574
+ this.watcher.on("change", (absPath) => this._handleFileEvent(absPath));
575
+ this.watcher.on("unlink", (absPath) => this._handleUnlink(absPath));
576
+ }
577
+ /**
578
+ * Temporarily suppress watcher events for a path.
579
+ * Used by write operations to prevent redundant re-parse of files we just wrote.
580
+ * Auto-expires after 5 seconds as safety net.
581
+ */
582
+ mutePath(relativePath) {
583
+ this.mutedPaths.add(relativePath);
584
+ const existing = this.muteTimers.get(relativePath);
585
+ if (existing) clearTimeout(existing);
586
+ const timer = setTimeout(() => {
587
+ this.mutedPaths.delete(relativePath);
588
+ this.muteTimers.delete(relativePath);
589
+ }, 5e3);
590
+ this.muteTimers.set(relativePath, timer);
591
+ }
592
+ /**
593
+ * Clear all mute timers. Called during shutdown.
594
+ */
595
+ clearMutes() {
596
+ for (const timer of this.muteTimers.values()) {
597
+ clearTimeout(timer);
598
+ }
599
+ this.mutedPaths.clear();
600
+ this.muteTimers.clear();
601
+ }
602
+ async stopWatching() {
603
+ this.clearMutes();
604
+ if (this.watcher) {
605
+ await this.watcher.close();
606
+ this.watcher = null;
607
+ }
608
+ }
609
+ getNote(notePath) {
610
+ return this.notes.get(notePath);
611
+ }
612
+ getNoteByName(name) {
613
+ const paths = this.nameIndex.get(name.toLowerCase());
614
+ if (!paths || paths.length === 0) return void 0;
615
+ const sorted = [...paths].sort((a, b) => a.length - b.length || a.localeCompare(b));
616
+ return this.notes.get(sorted[0]);
617
+ }
618
+ resolveLink(target) {
619
+ let base = target.replace(/^\[\[/, "").replace(/\]\]$/, "");
620
+ const hashIndex = base.indexOf("#");
621
+ if (hashIndex !== -1) base = base.slice(0, hashIndex);
622
+ const caretIndex = base.indexOf("^");
623
+ if (caretIndex !== -1) base = base.slice(0, caretIndex);
624
+ base = base.trim();
625
+ if (!base) return null;
626
+ const lowerBase = base.toLowerCase();
627
+ if (base.includes("/")) {
628
+ const withMd = base.endsWith(".md") ? base : `${base}.md`;
629
+ const meta = this.notes.get(withMd);
630
+ if (meta) {
631
+ return {
632
+ path: meta.path,
633
+ name: meta.name,
634
+ isAmbiguous: false,
635
+ allMatches: [meta.path]
636
+ };
637
+ }
638
+ }
639
+ const nameKey = lowerBase.endsWith(".md") ? lowerBase.slice(0, -3) : lowerBase;
640
+ const nameMatches = this.nameIndex.get(nameKey);
641
+ if (nameMatches && nameMatches.length > 0) {
642
+ const sorted = [...nameMatches].sort((a, b) => a.length - b.length || a.localeCompare(b));
643
+ const best = this.notes.get(sorted[0]);
644
+ return {
645
+ path: best.path,
646
+ name: best.name,
647
+ isAmbiguous: sorted.length > 1,
648
+ allMatches: sorted
649
+ };
650
+ }
651
+ const aliasMatches = this.aliasIndex.get(lowerBase);
652
+ if (aliasMatches && aliasMatches.length > 0) {
653
+ const sorted = [...aliasMatches].sort((a, b) => a.length - b.length || a.localeCompare(b));
654
+ const best = this.notes.get(sorted[0]);
655
+ return {
656
+ path: best.path,
657
+ name: best.name,
658
+ isAmbiguous: sorted.length > 1,
659
+ allMatches: sorted
660
+ };
661
+ }
662
+ return null;
663
+ }
664
+ async searchContent(query, maxResults = 50) {
665
+ const lowerQuery = query.toLowerCase();
666
+ const results = [];
667
+ const MAX_MATCHES_PER_FILE = 5;
668
+ const MAX_LINE_LENGTH = 300;
669
+ for (const [notePath, metadata] of this.notes) {
670
+ if (results.length >= maxResults * 2) break;
671
+ try {
672
+ const content = await readFile(this.vaultRoot, notePath);
673
+ const lines = content.split("\n");
674
+ const matches = [];
675
+ for (let i = 0; i < lines.length; i++) {
676
+ if (matches.length >= MAX_MATCHES_PER_FILE) break;
677
+ const lowerLine = lines[i].toLowerCase();
678
+ const col = lowerLine.indexOf(lowerQuery);
679
+ if (col !== -1) {
680
+ const text = lines[i].length > MAX_LINE_LENGTH ? lines[i].slice(0, MAX_LINE_LENGTH) + "..." : lines[i];
681
+ matches.push({ line: i, text, column: col });
682
+ }
683
+ }
684
+ if (matches.length > 0) {
685
+ results.push({
686
+ path: notePath,
687
+ name: metadata.name,
688
+ matches,
689
+ score: matches.length
690
+ });
691
+ }
692
+ } catch {
693
+ }
694
+ }
695
+ return results.sort((a, b) => b.score - a.score).slice(0, maxResults);
696
+ }
697
+ getNotesWithTag(tag) {
698
+ const normalized = tag.toLowerCase();
699
+ const withHash = normalized.startsWith("#") ? normalized : `#${normalized}`;
700
+ const paths = this.tagIndex.get(withHash);
701
+ if (!paths) return [];
702
+ return [...paths].map((p) => this.notes.get(p)).filter((n) => n !== void 0);
703
+ }
704
+ getNotesWithFrontmatter(key, value) {
705
+ const results = [];
706
+ for (const meta of this.notes.values()) {
707
+ if (!(key in meta.frontmatter)) continue;
708
+ if (value === void 0) {
709
+ results.push(meta);
710
+ } else {
711
+ const fmValue = meta.frontmatter[key];
712
+ if (fmValue === value) {
713
+ results.push(meta);
714
+ } else if (Array.isArray(fmValue) && fmValue.includes(value)) {
715
+ results.push(meta);
716
+ }
717
+ }
718
+ }
719
+ return results;
720
+ }
721
+ getAllTags() {
722
+ const tags = [];
723
+ for (const [tag, paths] of this.tagIndex) {
724
+ tags.push({ tag, count: paths.size });
725
+ }
726
+ return tags.sort((a, b) => a.tag.localeCompare(b.tag));
727
+ }
728
+ getStats() {
729
+ let totalSize = 0;
730
+ let folderSet = /* @__PURE__ */ new Set();
731
+ let oldest = null;
732
+ let newest = null;
733
+ let largest = null;
734
+ for (const meta of this.notes.values()) {
735
+ totalSize += meta.size;
736
+ const dir = path5.dirname(meta.path);
737
+ if (dir !== ".") folderSet.add(dir);
738
+ if (!oldest || meta.modified < oldest.modified) {
739
+ oldest = { path: meta.path, modified: meta.modified };
740
+ }
741
+ if (!newest || meta.modified > newest.modified) {
742
+ newest = { path: meta.path, modified: meta.modified };
743
+ }
744
+ if (!largest || meta.size > largest.size) {
745
+ largest = { path: meta.path, size: meta.size };
746
+ }
747
+ }
748
+ return {
749
+ noteCount: this.notes.size,
750
+ folderCount: folderSet.size,
751
+ tagCount: this.tagIndex.size,
752
+ totalSize,
753
+ averageNoteSize: this.notes.size > 0 ? Math.round(totalSize / this.notes.size) : 0,
754
+ oldestNote: oldest,
755
+ newestNote: newest,
756
+ largestNote: largest
757
+ };
758
+ }
759
+ getVaultInfo() {
760
+ const stats = this.getStats();
761
+ return {
762
+ name: path5.basename(this.vaultRoot),
763
+ noteCount: stats.noteCount,
764
+ folderCount: stats.folderCount,
765
+ totalSize: stats.totalSize
766
+ };
767
+ }
768
+ getRecentNotes(limit) {
769
+ return [...this.notes.values()].sort((a, b) => b.modified - a.modified).slice(0, limit);
770
+ }
771
+ /**
772
+ * Reindex after a batch operation. Removes old paths and re-parses new paths.
773
+ * Used by folder move and other bulk operations.
774
+ */
775
+ async reindexPaths(removed, added) {
776
+ for (const relPath of removed) {
777
+ this._removeNote(relPath);
778
+ }
779
+ for (const relPath of added) {
780
+ await this._updateNote(relPath);
781
+ }
782
+ }
783
+ // --- Private methods ---
784
+ _addToIndex(metadata) {
785
+ const lowerName = metadata.name.toLowerCase();
786
+ const namePaths = this.nameIndex.get(lowerName) ?? [];
787
+ if (!namePaths.includes(metadata.path)) {
788
+ namePaths.push(metadata.path);
789
+ }
790
+ this.nameIndex.set(lowerName, namePaths);
791
+ for (const alias of metadata.aliases) {
792
+ const lowerAlias = alias.toLowerCase();
793
+ const aliasPaths = this.aliasIndex.get(lowerAlias) ?? [];
794
+ if (!aliasPaths.includes(metadata.path)) {
795
+ aliasPaths.push(metadata.path);
796
+ }
797
+ this.aliasIndex.set(lowerAlias, aliasPaths);
798
+ }
799
+ for (const tag of metadata.tags) {
800
+ const tagPaths = this.tagIndex.get(tag) ?? /* @__PURE__ */ new Set();
801
+ tagPaths.add(metadata.path);
802
+ this.tagIndex.set(tag, tagPaths);
803
+ }
804
+ }
805
+ _removeFromIndex(metadata) {
806
+ const lowerName = metadata.name.toLowerCase();
807
+ const namePaths = this.nameIndex.get(lowerName);
808
+ if (namePaths) {
809
+ const filtered = namePaths.filter((p) => p !== metadata.path);
810
+ if (filtered.length === 0) {
811
+ this.nameIndex.delete(lowerName);
812
+ } else {
813
+ this.nameIndex.set(lowerName, filtered);
814
+ }
815
+ }
816
+ for (const alias of metadata.aliases) {
817
+ const lowerAlias = alias.toLowerCase();
818
+ const aliasPaths = this.aliasIndex.get(lowerAlias);
819
+ if (aliasPaths) {
820
+ const filtered = aliasPaths.filter((p) => p !== metadata.path);
821
+ if (filtered.length === 0) {
822
+ this.aliasIndex.delete(lowerAlias);
823
+ } else {
824
+ this.aliasIndex.set(lowerAlias, filtered);
825
+ }
826
+ }
827
+ }
828
+ for (const tag of metadata.tags) {
829
+ const tagPaths = this.tagIndex.get(tag);
830
+ if (tagPaths) {
831
+ tagPaths.delete(metadata.path);
832
+ if (tagPaths.size === 0) {
833
+ this.tagIndex.delete(tag);
834
+ }
835
+ }
836
+ }
837
+ }
838
+ async _updateNote(relativePath) {
839
+ const existing = this.notes.get(relativePath);
840
+ if (existing) this._removeFromIndex(existing);
841
+ try {
842
+ const content = await readFile(this.vaultRoot, relativePath);
843
+ const stats = await getFileStats(this.vaultRoot, relativePath);
844
+ const metadata = parseNote(relativePath, content, stats);
845
+ this.notes.set(relativePath, metadata);
846
+ this._addToIndex(metadata);
847
+ } catch {
848
+ this.notes.delete(relativePath);
849
+ }
850
+ }
851
+ _removeNote(relativePath) {
852
+ const existing = this.notes.get(relativePath);
853
+ if (existing) {
854
+ this._removeFromIndex(existing);
855
+ this.notes.delete(relativePath);
856
+ }
857
+ }
858
+ _handleFileEvent(absPath) {
859
+ const relPath = toRelativePath(this.vaultRoot, absPath);
860
+ if (!isMarkdownFile(relPath)) return;
861
+ if (this.mutedPaths.has(relPath)) {
862
+ this.mutedPaths.delete(relPath);
863
+ const timer = this.muteTimers.get(relPath);
864
+ if (timer) clearTimeout(timer);
865
+ this.muteTimers.delete(relPath);
866
+ return;
867
+ }
868
+ this._updateNote(relPath).catch((err) => {
869
+ console.error(`[vault-mcp] Error updating cache for ${relPath}:`, err);
870
+ });
871
+ }
872
+ _handleUnlink(absPath) {
873
+ const relPath = toRelativePath(this.vaultRoot, absPath);
874
+ if (!isMarkdownFile(relPath)) return;
875
+ if (this.mutedPaths.has(relPath)) {
876
+ this.mutedPaths.delete(relPath);
877
+ const timer = this.muteTimers.get(relPath);
878
+ if (timer) clearTimeout(timer);
879
+ this.muteTimers.delete(relPath);
880
+ return;
881
+ }
882
+ this._removeNote(relPath);
883
+ }
884
+ };
885
+
886
+ // src/tools/register-tools.ts
887
+ import { z } from "zod";
888
+
889
+ // src/graph/link-updater.ts
890
+ async function updateLinksAfterMove(vaultRoot, cache, oldPath, newPath) {
891
+ const oldName = getNoteName(oldPath);
892
+ const newName = getNoteName(newPath);
893
+ const oldPathNoExt = oldPath.replace(/\.md$/, "");
894
+ const newPathNoExt = newPath.replace(/\.md$/, "");
895
+ const newNameLower = newName.toLowerCase();
896
+ const nameMatches = cache.nameIndex.get(newNameLower) ?? [];
897
+ const otherMatchCount = nameMatches.filter((p) => p !== oldPath && p !== newPath).length;
898
+ const isAmbiguous = otherMatchCount > 0;
899
+ const newRef = isAmbiguous ? newPathNoExt : newName;
900
+ const notesToUpdate = [];
901
+ for (const [notePath, note] of cache.notes) {
902
+ if (notePath === oldPath || notePath === newPath) continue;
903
+ for (const link of note.outgoingLinks) {
904
+ const resolved = cache.resolveLink(link.target);
905
+ if (resolved && resolved.path === oldPath) {
906
+ notesToUpdate.push(notePath);
907
+ break;
908
+ }
909
+ }
910
+ }
911
+ const updatedFiles = [];
912
+ let updatedCount = 0;
913
+ for (const notePath of notesToUpdate) {
914
+ const content = await readFile(vaultRoot, notePath);
915
+ const { result, count } = replaceLinksInContent(content, oldName, oldPathNoExt, newRef);
916
+ if (count > 0) {
917
+ cache.mutePath(notePath);
918
+ await writeFile(vaultRoot, notePath, result);
919
+ updatedFiles.push(notePath);
920
+ updatedCount += count;
921
+ }
922
+ }
923
+ return { updatedFiles, updatedCount };
924
+ }
925
+ async function updateLinksAfterBatchMove(vaultRoot, cache, moves) {
926
+ if (moves.size === 0) return { updatedFiles: [], updatedCount: 0 };
927
+ const moveInfo = /* @__PURE__ */ new Map();
928
+ for (const [oldPath, newPath] of moves) {
929
+ const oldName = getNoteName(oldPath);
930
+ const oldPathNoExt = oldPath.replace(/\.md$/, "");
931
+ const newName = getNoteName(newPath);
932
+ const newPathNoExt = newPath.replace(/\.md$/, "");
933
+ const newNameLower = newName.toLowerCase();
934
+ const nameMatches = cache.nameIndex.get(newNameLower) ?? [];
935
+ const otherMatchCount = nameMatches.filter(
936
+ (p) => !moves.has(p) && ![...moves.values()].includes(p)
937
+ ).length;
938
+ const isAmbiguous = otherMatchCount > 0;
939
+ const newRef = isAmbiguous ? newPathNoExt : newName;
940
+ moveInfo.set(oldPath, { oldName, oldPathNoExt, newRef });
941
+ }
942
+ const notesToUpdate = [];
943
+ const movedPaths = /* @__PURE__ */ new Set([...moves.keys(), ...moves.values()]);
944
+ for (const [notePath, note] of cache.notes) {
945
+ if (movedPaths.has(notePath)) continue;
946
+ let needsUpdate = false;
947
+ for (const link of note.outgoingLinks) {
948
+ const resolved = cache.resolveLink(link.target);
949
+ if (resolved && moves.has(resolved.path)) {
950
+ needsUpdate = true;
951
+ break;
952
+ }
953
+ }
954
+ if (needsUpdate) notesToUpdate.push(notePath);
955
+ }
956
+ const updatedFiles = [];
957
+ let updatedCount = 0;
958
+ for (const notePath of notesToUpdate) {
959
+ let content = await readFile(vaultRoot, notePath);
960
+ let fileCount = 0;
961
+ for (const [_oldPath, info] of moveInfo) {
962
+ const { result, count } = replaceLinksInContent(
963
+ content,
964
+ info.oldName,
965
+ info.oldPathNoExt,
966
+ info.newRef
967
+ );
968
+ content = result;
969
+ fileCount += count;
970
+ }
971
+ if (fileCount > 0) {
972
+ cache.mutePath(notePath);
973
+ await writeFile(vaultRoot, notePath, content);
974
+ updatedFiles.push(notePath);
975
+ updatedCount += fileCount;
976
+ }
977
+ }
978
+ return { updatedFiles, updatedCount };
979
+ }
980
+ function replaceLinksInContent(content, oldName, oldPathNoExt, newRef) {
981
+ const lines = content.split("\n");
982
+ let inCodeBlock = false;
983
+ let count = 0;
984
+ const escapedName = escapeRegex(oldName);
985
+ const escapedPath = escapeRegex(oldPathNoExt);
986
+ const pattern = new RegExp(
987
+ `(!?)\\[\\[(${escapedPath}|${escapedName})(\\|[^\\]]*)?\\]\\]`,
988
+ "g"
989
+ );
990
+ for (let i = 0; i < lines.length; i++) {
991
+ const line = lines[i];
992
+ if (line.trimStart().startsWith("```")) {
993
+ inCodeBlock = !inCodeBlock;
994
+ continue;
995
+ }
996
+ if (inCodeBlock) continue;
997
+ const inlineCodeSpans = [];
998
+ const inlineCodeRegex = /`[^`]*`/g;
999
+ let codeMatch;
1000
+ while ((codeMatch = inlineCodeRegex.exec(line)) !== null) {
1001
+ inlineCodeSpans.push({
1002
+ start: codeMatch.index,
1003
+ end: codeMatch.index + codeMatch[0].length,
1004
+ text: codeMatch[0]
1005
+ });
1006
+ }
1007
+ pattern.lastIndex = 0;
1008
+ let newLine = "";
1009
+ let lastIndex = 0;
1010
+ let linkMatch;
1011
+ while ((linkMatch = pattern.exec(line)) !== null) {
1012
+ const matchStart = linkMatch.index;
1013
+ const matchEnd = matchStart + linkMatch[0].length;
1014
+ const insideCode = inlineCodeSpans.some(
1015
+ (span) => matchStart >= span.start && matchEnd <= span.end
1016
+ );
1017
+ if (insideCode) continue;
1018
+ newLine += line.slice(lastIndex, matchStart);
1019
+ const embedPrefix = linkMatch[1];
1020
+ const alias = linkMatch[3];
1021
+ if (alias) {
1022
+ newLine += `${embedPrefix}[[${newRef}${alias}]]`;
1023
+ } else {
1024
+ newLine += `${embedPrefix}[[${newRef}]]`;
1025
+ }
1026
+ count++;
1027
+ lastIndex = matchEnd;
1028
+ }
1029
+ if (lastIndex > 0) {
1030
+ newLine += line.slice(lastIndex);
1031
+ lines[i] = newLine;
1032
+ }
1033
+ }
1034
+ return { result: lines.join("\n"), count };
1035
+ }
1036
+ function escapeRegex(str) {
1037
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1038
+ }
1039
+
1040
+ // src/tools/note-tools.ts
1041
+ import matter2 from "gray-matter";
1042
+ async function readNote(cache, config, args) {
1043
+ try {
1044
+ const content = await readFile(config.vaultPath, args.path);
1045
+ const { frontmatter, body } = parseFrontmatter(content);
1046
+ const meta = cache.getNote(args.path);
1047
+ return toolJson({
1048
+ path: args.path,
1049
+ name: meta?.name ?? args.path,
1050
+ frontmatter,
1051
+ content: body,
1052
+ tags: meta?.tags ?? [],
1053
+ headings: meta?.headings ?? []
1054
+ });
1055
+ } catch (err) {
1056
+ return toolError(`Failed to read note: ${err.message}`, config.vaultPath);
1057
+ }
1058
+ }
1059
+ async function createNote(cache, config, args) {
1060
+ try {
1061
+ if (await fileExists(config.vaultPath, args.path)) {
1062
+ return toolError(`Note already exists: ${args.path}`, config.vaultPath);
1063
+ }
1064
+ let fileContent = args.content;
1065
+ if (args.frontmatter && Object.keys(args.frontmatter).length > 0) {
1066
+ fileContent = matter2.stringify(args.content, args.frontmatter);
1067
+ }
1068
+ cache.mutePath(args.path);
1069
+ await writeFile(config.vaultPath, args.path, fileContent);
1070
+ return toolResult(`Created note: ${args.path}`);
1071
+ } catch (err) {
1072
+ return toolError(`Failed to create note: ${err.message}`, config.vaultPath);
1073
+ }
1074
+ }
1075
+ async function updateNote(cache, config, args) {
1076
+ try {
1077
+ if (!await fileExists(config.vaultPath, args.path)) {
1078
+ return toolError(`Note does not exist: ${args.path}`, config.vaultPath);
1079
+ }
1080
+ let fileContent;
1081
+ if (args.frontmatter) {
1082
+ fileContent = matter2.stringify(args.content, args.frontmatter);
1083
+ } else {
1084
+ const existing = await readFile(config.vaultPath, args.path);
1085
+ const { frontmatter } = parseFrontmatter(existing);
1086
+ if (Object.keys(frontmatter).length > 0) {
1087
+ fileContent = matter2.stringify(args.content, frontmatter);
1088
+ } else {
1089
+ fileContent = args.content;
1090
+ }
1091
+ }
1092
+ cache.mutePath(args.path);
1093
+ await writeFile(config.vaultPath, args.path, fileContent);
1094
+ return toolResult(`Updated note: ${args.path}`);
1095
+ } catch (err) {
1096
+ return toolError(`Failed to update note: ${err.message}`, config.vaultPath);
1097
+ }
1098
+ }
1099
+ async function patchNote(cache, config, args) {
1100
+ try {
1101
+ if (args.targetType && !args.target) {
1102
+ return toolError("target is required when targetType is set", config.vaultPath);
1103
+ }
1104
+ const existing = await readFile(config.vaultPath, args.path);
1105
+ const { frontmatter, body } = parseFrontmatter(existing);
1106
+ let newContent;
1107
+ if (!args.targetType) {
1108
+ switch (args.operation) {
1109
+ case "append":
1110
+ newContent = existing.trimEnd() + "\n\n" + args.content;
1111
+ break;
1112
+ case "prepend":
1113
+ if (Object.keys(frontmatter).length > 0) {
1114
+ newContent = matter2.stringify("\n" + args.content + "\n\n" + body, frontmatter);
1115
+ } else {
1116
+ newContent = args.content + "\n\n" + existing;
1117
+ }
1118
+ break;
1119
+ case "replace":
1120
+ if (Object.keys(frontmatter).length > 0) {
1121
+ newContent = matter2.stringify("\n" + args.content, frontmatter);
1122
+ } else {
1123
+ newContent = args.content;
1124
+ }
1125
+ break;
1126
+ }
1127
+ } else if (args.targetType === "heading") {
1128
+ const headings = parseHeadings(body);
1129
+ const target = headings.find(
1130
+ (h) => h.text.toLowerCase() === args.target.toLowerCase()
1131
+ );
1132
+ if (!target) {
1133
+ return toolError(`Heading not found: ${args.target}`, config.vaultPath);
1134
+ }
1135
+ const bodyLines = body.split("\n");
1136
+ const startLine = target.line;
1137
+ let endLine = bodyLines.length;
1138
+ for (let i = startLine + 1; i < bodyLines.length; i++) {
1139
+ const nextHeading = headings.find((h) => h.line === i);
1140
+ if (nextHeading && nextHeading.level <= target.level) {
1141
+ endLine = i;
1142
+ break;
1143
+ }
1144
+ }
1145
+ const sectionContent = bodyLines.slice(startLine + 1, endLine).join("\n");
1146
+ let newSection;
1147
+ switch (args.operation) {
1148
+ case "append":
1149
+ newSection = sectionContent.trimEnd() + "\n\n" + args.content;
1150
+ break;
1151
+ case "prepend":
1152
+ newSection = "\n" + args.content + "\n" + sectionContent;
1153
+ break;
1154
+ case "replace":
1155
+ newSection = "\n" + args.content;
1156
+ break;
1157
+ }
1158
+ const newBodyLines = [
1159
+ ...bodyLines.slice(0, startLine + 1),
1160
+ ...newSection.split("\n"),
1161
+ ...bodyLines.slice(endLine)
1162
+ ];
1163
+ const newBody = newBodyLines.join("\n");
1164
+ if (Object.keys(frontmatter).length > 0) {
1165
+ newContent = matter2.stringify("\n" + newBody, frontmatter);
1166
+ } else {
1167
+ newContent = newBody;
1168
+ }
1169
+ } else if (args.targetType === "block") {
1170
+ const blocks = parseBlockRefs(body);
1171
+ const target = blocks.find((b) => b.id === args.target);
1172
+ if (!target) {
1173
+ return toolError(`Block not found: ^${args.target}`, config.vaultPath);
1174
+ }
1175
+ const bodyLines = body.split("\n");
1176
+ const blockLine = target.line;
1177
+ switch (args.operation) {
1178
+ case "append":
1179
+ bodyLines.splice(blockLine + 1, 0, args.content);
1180
+ break;
1181
+ case "prepend":
1182
+ bodyLines.splice(blockLine, 0, args.content);
1183
+ break;
1184
+ case "replace":
1185
+ bodyLines[blockLine] = args.content;
1186
+ break;
1187
+ }
1188
+ const newBody = bodyLines.join("\n");
1189
+ if (Object.keys(frontmatter).length > 0) {
1190
+ newContent = matter2.stringify("\n" + newBody, frontmatter);
1191
+ } else {
1192
+ newContent = newBody;
1193
+ }
1194
+ } else if (args.targetType === "frontmatter") {
1195
+ if (args.operation === "replace") {
1196
+ newContent = setFrontmatterProperty(existing, args.target, args.content);
1197
+ } else {
1198
+ return toolError("Only 'replace' operation is supported for frontmatter targets", config.vaultPath);
1199
+ }
1200
+ } else {
1201
+ return toolError(`Unknown targetType: ${args.targetType}`, config.vaultPath);
1202
+ }
1203
+ cache.mutePath(args.path);
1204
+ await writeFile(config.vaultPath, args.path, newContent);
1205
+ return toolResult(`Patched note: ${args.path} (${args.operation}${args.targetType ? ` on ${args.targetType}:${args.target}` : ""})`);
1206
+ } catch (err) {
1207
+ return toolError(`Failed to patch note: ${err.message}`, config.vaultPath);
1208
+ }
1209
+ }
1210
+ async function deleteNote(cache, config, args) {
1211
+ try {
1212
+ if (!await fileExists(config.vaultPath, args.path)) {
1213
+ return toolError(`Note does not exist: ${args.path}`, config.vaultPath);
1214
+ }
1215
+ cache.mutePath(args.path);
1216
+ await deleteFile(config.vaultPath, args.path);
1217
+ return toolResult(`Deleted note: ${args.path}`);
1218
+ } catch (err) {
1219
+ return toolError(`Failed to delete note: ${err.message}`, config.vaultPath);
1220
+ }
1221
+ }
1222
+ async function moveNote(cache, config, args) {
1223
+ try {
1224
+ if (!await fileExists(config.vaultPath, args.path)) {
1225
+ return toolError(`Note does not exist: ${args.path}`, config.vaultPath);
1226
+ }
1227
+ if (await fileExists(config.vaultPath, args.newPath)) {
1228
+ return toolError(`Destination already exists: ${args.newPath}`, config.vaultPath);
1229
+ }
1230
+ const content = await readFile(config.vaultPath, args.path);
1231
+ const linkResult = await updateLinksAfterMove(
1232
+ config.vaultPath,
1233
+ cache,
1234
+ args.path,
1235
+ args.newPath
1236
+ );
1237
+ cache.mutePath(args.path);
1238
+ cache.mutePath(args.newPath);
1239
+ await writeFile(config.vaultPath, args.newPath, content);
1240
+ await deleteFile(config.vaultPath, args.path);
1241
+ return toolJson({
1242
+ moved: { from: args.path, to: args.newPath },
1243
+ linksUpdated: linkResult.updatedCount,
1244
+ filesUpdated: linkResult.updatedFiles
1245
+ });
1246
+ } catch (err) {
1247
+ return toolError(`Failed to move note: ${err.message}`, config.vaultPath);
1248
+ }
1249
+ }
1250
+ async function renameNote(cache, config, args) {
1251
+ try {
1252
+ if (!await fileExists(config.vaultPath, args.path)) {
1253
+ return toolError(`Note does not exist: ${args.path}`, config.vaultPath);
1254
+ }
1255
+ const parts = args.path.split("/");
1256
+ const newFileName = args.newName.endsWith(".md") ? args.newName : `${args.newName}.md`;
1257
+ parts[parts.length - 1] = newFileName;
1258
+ const newPath = parts.join("/");
1259
+ if (await fileExists(config.vaultPath, newPath)) {
1260
+ return toolError(`Note already exists: ${newPath}`, config.vaultPath);
1261
+ }
1262
+ const content = await readFile(config.vaultPath, args.path);
1263
+ const linkResult = await updateLinksAfterMove(
1264
+ config.vaultPath,
1265
+ cache,
1266
+ args.path,
1267
+ newPath
1268
+ );
1269
+ cache.mutePath(args.path);
1270
+ cache.mutePath(newPath);
1271
+ await writeFile(config.vaultPath, newPath, content);
1272
+ await deleteFile(config.vaultPath, args.path);
1273
+ return toolJson({
1274
+ renamed: { from: args.path, to: newPath },
1275
+ linksUpdated: linkResult.updatedCount,
1276
+ filesUpdated: linkResult.updatedFiles
1277
+ });
1278
+ } catch (err) {
1279
+ return toolError(`Failed to rename note: ${err.message}`, config.vaultPath);
1280
+ }
1281
+ }
1282
+
1283
+ // src/tools/search-tools.ts
1284
+ async function search(cache, config, args) {
1285
+ try {
1286
+ const limit = args.limit ?? 20;
1287
+ const results = await cache.searchContent(args.query, limit);
1288
+ return toolJson(results);
1289
+ } catch (err) {
1290
+ return toolError(`Search failed: ${err.message}`, config.vaultPath);
1291
+ }
1292
+ }
1293
+ async function searchByTag(cache, config, args) {
1294
+ try {
1295
+ const notes = cache.getNotesWithTag(args.tag);
1296
+ return toolJson(notes.map((n) => ({ path: n.path, name: n.name, tags: n.tags })));
1297
+ } catch (err) {
1298
+ return toolError(`Tag search failed: ${err.message}`, config.vaultPath);
1299
+ }
1300
+ }
1301
+ async function searchStructured(cache, config, args) {
1302
+ try {
1303
+ let candidates = [...cache.notes.values()];
1304
+ if (args.folder) {
1305
+ let folder = normalizePath(args.folder);
1306
+ if (!folder.endsWith("/")) folder += "/";
1307
+ candidates = candidates.filter((n) => n.path.startsWith(folder));
1308
+ }
1309
+ if (args.tags && args.tags.length > 0) {
1310
+ const normalizedTags = args.tags.map((t) => {
1311
+ const lower = t.toLowerCase();
1312
+ return lower.startsWith("#") ? lower : `#${lower}`;
1313
+ });
1314
+ candidates = candidates.filter(
1315
+ (n) => normalizedTags.every((tag) => n.tags.includes(tag))
1316
+ );
1317
+ }
1318
+ if (args.frontmatter && args.frontmatter.length > 0) {
1319
+ candidates = candidates.filter(
1320
+ (n) => args.frontmatter.every(({ key, value }) => {
1321
+ if (!(key in n.frontmatter)) return false;
1322
+ if (value === void 0) return true;
1323
+ const fmValue = n.frontmatter[key];
1324
+ if (fmValue === value) return true;
1325
+ if (Array.isArray(fmValue) && fmValue.includes(value)) return true;
1326
+ return false;
1327
+ })
1328
+ );
1329
+ }
1330
+ let results;
1331
+ if (args.query && args.query.trim()) {
1332
+ const lowerQuery = args.query.toLowerCase();
1333
+ const matched = [];
1334
+ const MAX_LINE_LENGTH = 300;
1335
+ for (const note of candidates) {
1336
+ try {
1337
+ const content = await readFile(config.vaultPath, note.path);
1338
+ const lines = content.split("\n");
1339
+ const matches = [];
1340
+ for (let i = 0; i < lines.length; i++) {
1341
+ const lowerLine = lines[i].toLowerCase();
1342
+ const col = lowerLine.indexOf(lowerQuery);
1343
+ if (col !== -1) {
1344
+ const text = lines[i].length > MAX_LINE_LENGTH ? lines[i].slice(0, MAX_LINE_LENGTH) + "..." : lines[i];
1345
+ matches.push({ line: i, text, column: col });
1346
+ if (matches.length >= 5) break;
1347
+ }
1348
+ }
1349
+ if (matches.length > 0) {
1350
+ matched.push({
1351
+ path: note.path,
1352
+ name: note.name,
1353
+ tags: note.tags,
1354
+ modified: note.modified,
1355
+ size: note.size,
1356
+ matches
1357
+ });
1358
+ }
1359
+ } catch {
1360
+ }
1361
+ }
1362
+ results = matched;
1363
+ } else {
1364
+ results = candidates.map((n) => ({
1365
+ path: n.path,
1366
+ name: n.name,
1367
+ tags: n.tags,
1368
+ modified: n.modified,
1369
+ size: n.size
1370
+ }));
1371
+ }
1372
+ const sortField = args.sort ?? "modified";
1373
+ const defaultOrder = sortField === "name" ? "asc" : "desc";
1374
+ const order = args.sortOrder ?? defaultOrder;
1375
+ const dir = order === "asc" ? 1 : -1;
1376
+ results.sort((a, b) => {
1377
+ switch (sortField) {
1378
+ case "modified":
1379
+ return (a.modified - b.modified) * dir;
1380
+ case "created":
1381
+ return (a.modified - b.modified) * dir;
1382
+ case "name":
1383
+ return a.name.localeCompare(b.name) * dir;
1384
+ case "size":
1385
+ return (a.size - b.size) * dir;
1386
+ default:
1387
+ return 0;
1388
+ }
1389
+ });
1390
+ const limit = Math.min(args.limit ?? 20, 100);
1391
+ return toolJson(results.slice(0, limit));
1392
+ } catch (err) {
1393
+ return toolError(`Structured search failed: ${err.message}`, config.vaultPath);
1394
+ }
1395
+ }
1396
+ async function searchByFrontmatter(cache, config, args) {
1397
+ try {
1398
+ const notes = cache.getNotesWithFrontmatter(args.key, args.value);
1399
+ return toolJson(notes.map((n) => ({ path: n.path, name: n.name, frontmatter: n.frontmatter })));
1400
+ } catch (err) {
1401
+ return toolError(`Frontmatter search failed: ${err.message}`, config.vaultPath);
1402
+ }
1403
+ }
1404
+
1405
+ // src/tools/frontmatter-tools.ts
1406
+ async function getProperties(_cache, config, args) {
1407
+ try {
1408
+ const content = await readFile(config.vaultPath, args.path);
1409
+ const { frontmatter } = parseFrontmatter(content);
1410
+ return toolJson(frontmatter);
1411
+ } catch (err) {
1412
+ return toolError(`Failed to get properties: ${err.message}`, config.vaultPath);
1413
+ }
1414
+ }
1415
+ async function setProperty(_cache, config, args) {
1416
+ try {
1417
+ const content = await readFile(config.vaultPath, args.path);
1418
+ const updated = setFrontmatterProperty(content, args.key, args.value);
1419
+ await writeFile(config.vaultPath, args.path, updated);
1420
+ return toolResult(`Set property '${args.key}' on ${args.path}`);
1421
+ } catch (err) {
1422
+ return toolError(`Failed to set property: ${err.message}`, config.vaultPath);
1423
+ }
1424
+ }
1425
+ async function removeProperty(_cache, config, args) {
1426
+ try {
1427
+ const content = await readFile(config.vaultPath, args.path);
1428
+ const updated = removeFrontmatterProperty(content, args.key);
1429
+ await writeFile(config.vaultPath, args.path, updated);
1430
+ return toolResult(`Removed property '${args.key}' from ${args.path}`);
1431
+ } catch (err) {
1432
+ return toolError(`Failed to remove property: ${err.message}`, config.vaultPath);
1433
+ }
1434
+ }
1435
+
1436
+ // src/tools/tag-tools.ts
1437
+ async function listTags(cache, config, _args) {
1438
+ try {
1439
+ return toolJson(cache.getAllTags());
1440
+ } catch (err) {
1441
+ return toolError(`Failed to list tags: ${err.message}`, config.vaultPath);
1442
+ }
1443
+ }
1444
+ async function getNoteTags(cache, config, args) {
1445
+ try {
1446
+ const meta = cache.getNote(args.path);
1447
+ if (!meta) {
1448
+ return toolError(`Note not found: ${args.path}`, config.vaultPath);
1449
+ }
1450
+ return toolJson({ path: meta.path, tags: meta.tags });
1451
+ } catch (err) {
1452
+ return toolError(`Failed to get note tags: ${err.message}`, config.vaultPath);
1453
+ }
1454
+ }
1455
+ async function renameTag(cache, config, args) {
1456
+ const oldNorm = normalizeTag(args.oldTag);
1457
+ const newNorm = normalizeTag(args.newTag);
1458
+ if (oldNorm === newNorm) {
1459
+ return toolError("Old and new tag are identical", config.vaultPath);
1460
+ }
1461
+ const oldBare = oldNorm.slice(1);
1462
+ const newBare = newNorm.slice(1);
1463
+ const notesWithTag = cache.getNotesWithTag(oldNorm);
1464
+ if (notesWithTag.length === 0) {
1465
+ return toolJson({ filesAffected: 0, dryRun: args.dryRun ?? false, oldTag: oldNorm, newTag: newNorm });
1466
+ }
1467
+ let filesAffected = 0;
1468
+ const affectedPaths = [];
1469
+ for (const note of notesWithTag) {
1470
+ const content = await readFile(config.vaultPath, note.path);
1471
+ const updated = replaceTagInContent(content, oldBare, newBare);
1472
+ if (updated !== content) {
1473
+ filesAffected++;
1474
+ affectedPaths.push(note.path);
1475
+ if (!args.dryRun) {
1476
+ await writeFile(config.vaultPath, note.path, updated);
1477
+ }
1478
+ }
1479
+ }
1480
+ return toolJson({
1481
+ filesAffected,
1482
+ affectedPaths,
1483
+ dryRun: args.dryRun ?? false,
1484
+ oldTag: oldNorm,
1485
+ newTag: newNorm
1486
+ });
1487
+ }
1488
+ function normalizeTag(tag) {
1489
+ const t = tag.toLowerCase().trim();
1490
+ return t.startsWith("#") ? t : `#${t}`;
1491
+ }
1492
+ function replaceTagInContent(content, oldBare, newBare) {
1493
+ const lines = content.split("\n");
1494
+ let inCodeBlock = false;
1495
+ let frontmatterStart = -1;
1496
+ let frontmatterEnd = -1;
1497
+ if (lines[0]?.trimEnd() === "---") {
1498
+ frontmatterStart = 0;
1499
+ for (let i = 1; i < lines.length; i++) {
1500
+ if (lines[i].trimEnd() === "---") {
1501
+ frontmatterEnd = i;
1502
+ break;
1503
+ }
1504
+ }
1505
+ }
1506
+ for (let i = 0; i < lines.length; i++) {
1507
+ if (frontmatterStart >= 0 && i > frontmatterStart && i < frontmatterEnd) {
1508
+ const escaped = escapeRegex2(oldBare);
1509
+ const pattern = new RegExp(`\\b${escaped}\\b`, "gi");
1510
+ lines[i] = lines[i].replace(pattern, newBare);
1511
+ continue;
1512
+ }
1513
+ if (i === frontmatterStart || i === frontmatterEnd) continue;
1514
+ if (lines[i].trimStart().startsWith("```")) {
1515
+ inCodeBlock = !inCodeBlock;
1516
+ continue;
1517
+ }
1518
+ if (inCodeBlock) continue;
1519
+ const tagPattern = new RegExp(
1520
+ `#${escapeRegex2(oldBare)}(?=[\\s,;:!?.)\\]}]|$)`,
1521
+ "gi"
1522
+ );
1523
+ lines[i] = lines[i].replace(tagPattern, `#${newBare}`);
1524
+ }
1525
+ return lines.join("\n");
1526
+ }
1527
+ function escapeRegex2(str) {
1528
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1529
+ }
1530
+
1531
+ // src/tools/folder-tools.ts
1532
+ async function listFolder(_cache, config, args) {
1533
+ try {
1534
+ const dirPath = args.path ?? "";
1535
+ const entries = await listDirectory(config.vaultPath, dirPath);
1536
+ return toolJson(entries);
1537
+ } catch (err) {
1538
+ return toolError(`Failed to list folder: ${err.message}`, config.vaultPath);
1539
+ }
1540
+ }
1541
+ async function moveFolder(cache, config, args) {
1542
+ try {
1543
+ const oldDir = normalizePath(args.path).replace(/\/+$/, "");
1544
+ const newDir = normalizePath(args.newPath).replace(/\/+$/, "");
1545
+ const files = await walkDirectory(config.vaultPath, oldDir);
1546
+ const moves = /* @__PURE__ */ new Map();
1547
+ for (const filePath of files) {
1548
+ const newFilePath = newDir + filePath.slice(oldDir.length);
1549
+ moves.set(filePath, newFilePath);
1550
+ }
1551
+ if (args.dryRun) {
1552
+ return toolJson({
1553
+ dryRun: true,
1554
+ from: oldDir,
1555
+ to: newDir,
1556
+ filesAffected: moves.size,
1557
+ moves: [...moves.entries()].map(([from, to]) => ({ from, to }))
1558
+ });
1559
+ }
1560
+ await moveDirectory(config.vaultPath, oldDir, newDir);
1561
+ for (const [oldPath, newPath] of moves) {
1562
+ cache.mutePath(oldPath);
1563
+ cache.mutePath(newPath);
1564
+ }
1565
+ let linkWarning;
1566
+ let cacheWarning;
1567
+ let linksUpdated = 0;
1568
+ let filesWithUpdatedLinks = [];
1569
+ try {
1570
+ const linkResult = await updateLinksAfterBatchMove(config.vaultPath, cache, moves);
1571
+ linksUpdated = linkResult.updatedCount;
1572
+ filesWithUpdatedLinks = linkResult.updatedFiles;
1573
+ } catch (err) {
1574
+ linkWarning = `Link update failed: ${err.message}. Links may need manual fixing.`;
1575
+ console.error(`[vault-mcp] ${linkWarning}`);
1576
+ }
1577
+ try {
1578
+ const mdMoves = [...moves.entries()].filter(([f]) => f.endsWith(".md"));
1579
+ await cache.reindexPaths(
1580
+ mdMoves.map(([old]) => old),
1581
+ mdMoves.map(([, newP]) => newP)
1582
+ );
1583
+ } catch (err) {
1584
+ cacheWarning = `Cache reindex failed: ${err.message}. Restart server to rebuild cache.`;
1585
+ console.error(`[vault-mcp] ${cacheWarning}`);
1586
+ }
1587
+ const result = {
1588
+ moved: { from: oldDir, to: newDir },
1589
+ filesAffected: moves.size,
1590
+ linksUpdated,
1591
+ filesWithUpdatedLinks
1592
+ };
1593
+ if (linkWarning || cacheWarning) {
1594
+ result.warnings = [linkWarning, cacheWarning].filter(Boolean);
1595
+ }
1596
+ return toolJson(result);
1597
+ } catch (err) {
1598
+ return toolError(`Failed to move folder: ${err.message}`, config.vaultPath);
1599
+ }
1600
+ }
1601
+ async function deleteFolder(_cache, config, args) {
1602
+ try {
1603
+ await deleteEmptyDirectory(config.vaultPath, args.path);
1604
+ return toolResult(`Deleted empty folder: ${args.path}`);
1605
+ } catch (err) {
1606
+ return toolError(`Failed to delete folder: ${err.message}`, config.vaultPath);
1607
+ }
1608
+ }
1609
+ async function createFolder(_cache, config, args) {
1610
+ try {
1611
+ await createDirectory(config.vaultPath, args.path);
1612
+ return toolResult(`Created folder: ${args.path}`);
1613
+ } catch (err) {
1614
+ return toolError(`Failed to create folder: ${err.message}`, config.vaultPath);
1615
+ }
1616
+ }
1617
+
1618
+ // src/tools/vault-tools.ts
1619
+ async function vaultInfo(cache, config, _args) {
1620
+ try {
1621
+ return toolJson(cache.getVaultInfo());
1622
+ } catch (err) {
1623
+ return toolError(`Failed to get vault info: ${err.message}`, config.vaultPath);
1624
+ }
1625
+ }
1626
+ async function vaultStats(cache, config, _args) {
1627
+ try {
1628
+ return toolJson(cache.getStats());
1629
+ } catch (err) {
1630
+ return toolError(`Failed to get vault stats: ${err.message}`, config.vaultPath);
1631
+ }
1632
+ }
1633
+ async function resolveLink(cache, config, args) {
1634
+ try {
1635
+ const result = cache.resolveLink(args.target);
1636
+ if (!result) {
1637
+ return toolError(`Could not resolve link: ${args.target}`, config.vaultPath);
1638
+ }
1639
+ return toolJson(result);
1640
+ } catch (err) {
1641
+ return toolError(`Failed to resolve link: ${err.message}`, config.vaultPath);
1642
+ }
1643
+ }
1644
+
1645
+ // src/graph/graph-builder.ts
1646
+ function getBacklinks(cache, targetPath) {
1647
+ const results = [];
1648
+ for (const [, note] of cache.notes) {
1649
+ for (const link of note.outgoingLinks) {
1650
+ const resolved = cache.resolveLink(link.target);
1651
+ if (resolved && resolved.path === targetPath) {
1652
+ results.push({
1653
+ sourcePath: note.path,
1654
+ sourceName: note.name,
1655
+ line: link.line,
1656
+ context: ""
1657
+ });
1658
+ }
1659
+ }
1660
+ }
1661
+ return results.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath) || a.line - b.line);
1662
+ }
1663
+ function getOutgoingLinks(cache, notePath) {
1664
+ const note = cache.getNote(notePath);
1665
+ if (!note) return { resolved: [], unresolved: [] };
1666
+ const resolved = [];
1667
+ const unresolved = [];
1668
+ for (const link of note.outgoingLinks) {
1669
+ const result = cache.resolveLink(link.target);
1670
+ if (result) {
1671
+ resolved.push({
1672
+ target: link.target,
1673
+ path: result.path,
1674
+ name: result.name,
1675
+ line: link.line,
1676
+ isEmbed: link.isEmbed
1677
+ });
1678
+ } else {
1679
+ unresolved.push({
1680
+ target: link.target,
1681
+ line: link.line,
1682
+ isEmbed: link.isEmbed
1683
+ });
1684
+ }
1685
+ }
1686
+ return { resolved, unresolved };
1687
+ }
1688
+ function getOrphans(cache) {
1689
+ const linkedTo = /* @__PURE__ */ new Set();
1690
+ for (const [, note] of cache.notes) {
1691
+ for (const link of note.outgoingLinks) {
1692
+ const resolved = cache.resolveLink(link.target);
1693
+ if (resolved) linkedTo.add(resolved.path);
1694
+ }
1695
+ }
1696
+ const orphans = [];
1697
+ for (const [notePath, note] of cache.notes) {
1698
+ if (!linkedTo.has(notePath)) {
1699
+ orphans.push({ path: notePath, name: note.name });
1700
+ }
1701
+ }
1702
+ return orphans.sort((a, b) => a.path.localeCompare(b.path));
1703
+ }
1704
+ function getDeadEnds(cache) {
1705
+ const deadEnds = [];
1706
+ for (const [, note] of cache.notes) {
1707
+ if (note.outgoingLinks.length === 0) {
1708
+ deadEnds.push({ path: note.path, name: note.name });
1709
+ }
1710
+ }
1711
+ return deadEnds.sort((a, b) => a.path.localeCompare(b.path));
1712
+ }
1713
+ function getUnresolvedLinks(cache) {
1714
+ const results = [];
1715
+ for (const [, note] of cache.notes) {
1716
+ for (const link of note.outgoingLinks) {
1717
+ const resolved = cache.resolveLink(link.target);
1718
+ if (!resolved) {
1719
+ results.push({
1720
+ sourcePath: note.path,
1721
+ sourceName: note.name,
1722
+ target: link.target,
1723
+ line: link.line
1724
+ });
1725
+ }
1726
+ }
1727
+ }
1728
+ return results.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath) || a.line - b.line);
1729
+ }
1730
+ function getGraphStats(cache) {
1731
+ let totalLinks = 0;
1732
+ let totalUnresolved = 0;
1733
+ const backlinkCounts = /* @__PURE__ */ new Map();
1734
+ const outgoingCounts = /* @__PURE__ */ new Map();
1735
+ const linkedTo = /* @__PURE__ */ new Set();
1736
+ for (const [notePath, note] of cache.notes) {
1737
+ let outCount = 0;
1738
+ for (const link of note.outgoingLinks) {
1739
+ const resolved = cache.resolveLink(link.target);
1740
+ if (resolved) {
1741
+ totalLinks++;
1742
+ outCount++;
1743
+ linkedTo.add(resolved.path);
1744
+ backlinkCounts.set(resolved.path, (backlinkCounts.get(resolved.path) ?? 0) + 1);
1745
+ } else {
1746
+ totalUnresolved++;
1747
+ }
1748
+ }
1749
+ outgoingCounts.set(notePath, outCount);
1750
+ }
1751
+ let orphanCount = 0;
1752
+ for (const notePath of cache.notes.keys()) {
1753
+ if (!linkedTo.has(notePath)) orphanCount++;
1754
+ }
1755
+ let deadEndCount = 0;
1756
+ for (const note of cache.notes.values()) {
1757
+ if (note.outgoingLinks.length === 0) deadEndCount++;
1758
+ }
1759
+ const mostLinkedTo = [...backlinkCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p, count]) => {
1760
+ const note = cache.getNote(p);
1761
+ return { path: p, name: note?.name ?? p, count };
1762
+ });
1763
+ const mostLinkingFrom = [...outgoingCounts.entries()].filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p, count]) => {
1764
+ const note = cache.getNote(p);
1765
+ return { path: p, name: note?.name ?? p, count };
1766
+ });
1767
+ return {
1768
+ totalNotes: cache.notes.size,
1769
+ totalLinks,
1770
+ totalUnresolved,
1771
+ orphanCount,
1772
+ deadEndCount,
1773
+ mostLinkedTo,
1774
+ mostLinkingFrom
1775
+ };
1776
+ }
1777
+
1778
+ // src/tools/graph-tools.ts
1779
+ async function getLinks(cache, config, args) {
1780
+ const note = cache.getNote(args.path);
1781
+ if (!note) {
1782
+ return toolError(`Note not found: ${args.path}`, config.vaultPath);
1783
+ }
1784
+ const links = getOutgoingLinks(cache, args.path);
1785
+ return toolJson(links);
1786
+ }
1787
+ async function getBacklinksHandler(cache, config, args) {
1788
+ const note = cache.getNote(args.path);
1789
+ if (!note) {
1790
+ return toolError(`Note not found: ${args.path}`, config.vaultPath);
1791
+ }
1792
+ const backlinks = getBacklinks(cache, args.path);
1793
+ if (args.includeContext) {
1794
+ for (const entry of backlinks) {
1795
+ try {
1796
+ const content = await readFile(config.vaultPath, entry.sourcePath);
1797
+ const { body } = parseFrontmatter(content);
1798
+ const bodyLines = body.split("\n");
1799
+ if (entry.line >= 0 && entry.line < bodyLines.length) {
1800
+ entry.context = bodyLines[entry.line];
1801
+ }
1802
+ } catch {
1803
+ }
1804
+ }
1805
+ }
1806
+ return toolJson(backlinks);
1807
+ }
1808
+ async function getOrphansHandler(cache, _config, _args) {
1809
+ return toolJson(getOrphans(cache));
1810
+ }
1811
+ async function getDeadEndsHandler(cache, _config, _args) {
1812
+ return toolJson(getDeadEnds(cache));
1813
+ }
1814
+ async function getUnresolvedHandler(cache, _config, _args) {
1815
+ return toolJson(getUnresolvedLinks(cache));
1816
+ }
1817
+
1818
+ // src/parsers/canvas.ts
1819
+ var VALID_NODE_TYPES = /* @__PURE__ */ new Set(["text", "file", "link", "group"]);
1820
+ var VALID_SIDES = /* @__PURE__ */ new Set(["top", "right", "bottom", "left"]);
1821
+ function parseCanvas(content) {
1822
+ let raw;
1823
+ try {
1824
+ raw = JSON.parse(content);
1825
+ } catch {
1826
+ return null;
1827
+ }
1828
+ if (typeof raw !== "object" || raw === null) return null;
1829
+ const obj = raw;
1830
+ const rawNodes = Array.isArray(obj.nodes) ? obj.nodes : [];
1831
+ const rawEdges = Array.isArray(obj.edges) ? obj.edges : [];
1832
+ const nodes = [];
1833
+ for (const n of rawNodes) {
1834
+ if (typeof n !== "object" || n === null) continue;
1835
+ const node = n;
1836
+ if (typeof node.id !== "string") continue;
1837
+ if (typeof node.type !== "string" || !VALID_NODE_TYPES.has(node.type)) continue;
1838
+ if (typeof node.x !== "number" || typeof node.y !== "number") continue;
1839
+ if (typeof node.width !== "number" || typeof node.height !== "number") continue;
1840
+ const parsed = {
1841
+ id: node.id,
1842
+ type: node.type,
1843
+ x: node.x,
1844
+ y: node.y,
1845
+ width: node.width,
1846
+ height: node.height
1847
+ };
1848
+ if (typeof node.text === "string") parsed.text = node.text;
1849
+ if (typeof node.file === "string") parsed.file = node.file;
1850
+ if (typeof node.url === "string") parsed.url = node.url;
1851
+ if (typeof node.label === "string") parsed.label = node.label;
1852
+ if (typeof node.color === "string") parsed.color = node.color;
1853
+ nodes.push(parsed);
1854
+ }
1855
+ const edges = [];
1856
+ for (const e of rawEdges) {
1857
+ if (typeof e !== "object" || e === null) continue;
1858
+ const edge = e;
1859
+ if (typeof edge.id !== "string") continue;
1860
+ if (typeof edge.fromNode !== "string" || typeof edge.toNode !== "string") continue;
1861
+ const parsed = {
1862
+ id: edge.id,
1863
+ fromNode: edge.fromNode,
1864
+ toNode: edge.toNode
1865
+ };
1866
+ if (typeof edge.fromSide === "string" && VALID_SIDES.has(edge.fromSide)) {
1867
+ parsed.fromSide = edge.fromSide;
1868
+ }
1869
+ if (typeof edge.toSide === "string" && VALID_SIDES.has(edge.toSide)) {
1870
+ parsed.toSide = edge.toSide;
1871
+ }
1872
+ if (typeof edge.label === "string") parsed.label = edge.label;
1873
+ if (typeof edge.color === "string") parsed.color = edge.color;
1874
+ edges.push(parsed);
1875
+ }
1876
+ return { nodes, edges };
1877
+ }
1878
+
1879
+ // src/tools/canvas-tools.ts
1880
+ import fs3 from "fs/promises";
1881
+ import path6 from "path";
1882
+ async function readCanvas(_cache, config, args) {
1883
+ try {
1884
+ if (!args.path.endsWith(".canvas")) {
1885
+ return toolError("Path must end with .canvas", config.vaultPath);
1886
+ }
1887
+ const absPath = resolveVaultPath(config.vaultPath, args.path);
1888
+ const content = await fs3.readFile(absPath, "utf-8");
1889
+ const parsed = parseCanvas(content);
1890
+ if (!parsed) {
1891
+ return toolError(`Failed to parse canvas file: ${args.path}`, config.vaultPath);
1892
+ }
1893
+ return toolJson(parsed);
1894
+ } catch (err) {
1895
+ return toolError(`Failed to read canvas: ${err.message}`, config.vaultPath);
1896
+ }
1897
+ }
1898
+ async function listCanvases(_cache, config, _args) {
1899
+ try {
1900
+ const canvases = [];
1901
+ await walkDir(config.vaultPath, config.vaultPath, canvases);
1902
+ return toolJson(canvases);
1903
+ } catch (err) {
1904
+ return toolError(`Failed to list canvases: ${err.message}`, config.vaultPath);
1905
+ }
1906
+ }
1907
+ async function walkDir(root, dir, results) {
1908
+ const entries = await fs3.readdir(dir, { withFileTypes: true });
1909
+ for (const entry of entries) {
1910
+ const fullPath = path6.join(dir, entry.name);
1911
+ if (entry.isDirectory()) {
1912
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1913
+ await walkDir(root, fullPath, results);
1914
+ } else if (entry.name.endsWith(".canvas")) {
1915
+ const relPath = path6.relative(root, fullPath).replace(/\\/g, "/");
1916
+ const stat = await fs3.stat(fullPath);
1917
+ results.push({
1918
+ path: relPath,
1919
+ name: path6.basename(entry.name, ".canvas"),
1920
+ size: stat.size
1921
+ });
1922
+ }
1923
+ }
1924
+ }
1925
+
1926
+ // src/parsers/bookmarks.ts
1927
+ var VALID_BOOKMARK_TYPES = /* @__PURE__ */ new Set(["file", "folder", "group", "search", "graph"]);
1928
+ function parseBookmarks(content) {
1929
+ let raw;
1930
+ try {
1931
+ raw = JSON.parse(content);
1932
+ } catch {
1933
+ return [];
1934
+ }
1935
+ if (typeof raw !== "object" || raw === null) return [];
1936
+ const obj = raw;
1937
+ if (!Array.isArray(obj.items)) return [];
1938
+ return parseItems(obj.items);
1939
+ }
1940
+ function parseItems(items) {
1941
+ const result = [];
1942
+ for (const item of items) {
1943
+ if (typeof item !== "object" || item === null) continue;
1944
+ const obj = item;
1945
+ if (typeof obj.type !== "string" || !VALID_BOOKMARK_TYPES.has(obj.type)) continue;
1946
+ const parsed = {
1947
+ type: obj.type
1948
+ };
1949
+ if (typeof obj.title === "string") parsed.title = obj.title;
1950
+ if (typeof obj.path === "string") parsed.path = obj.path;
1951
+ if (typeof obj.query === "string") parsed.query = obj.query;
1952
+ if (typeof obj.subpath === "string") parsed.subpath = obj.subpath;
1953
+ if (typeof obj.ctime === "number") parsed.ctime = obj.ctime;
1954
+ if (Array.isArray(obj.items)) {
1955
+ parsed.items = parseItems(obj.items);
1956
+ }
1957
+ result.push(parsed);
1958
+ }
1959
+ return result;
1960
+ }
1961
+
1962
+ // src/tools/bookmark-tools.ts
1963
+ import fs4 from "fs/promises";
1964
+ import path7 from "path";
1965
+ async function getBookmarksHandler(_cache, config, _args) {
1966
+ try {
1967
+ const absPath = path7.join(config.vaultPath, ".obsidian", "bookmarks.json");
1968
+ const content = await fs4.readFile(absPath, "utf-8");
1969
+ const bookmarks = parseBookmarks(content);
1970
+ return toolJson(bookmarks);
1971
+ } catch (err) {
1972
+ if (err.code === "ENOENT") {
1973
+ return toolJson([]);
1974
+ }
1975
+ return toolError(`Failed to read bookmarks: ${err.message}`, config.vaultPath);
1976
+ }
1977
+ }
1978
+
1979
+ // src/rest/rest-client.ts
1980
+ import https from "https";
1981
+ var agent = new https.Agent({ rejectUnauthorized: false });
1982
+ function restRequest(url, options) {
1983
+ return new Promise((resolve, reject) => {
1984
+ const parsed = new URL(url);
1985
+ const req = https.request(
1986
+ {
1987
+ hostname: parsed.hostname,
1988
+ port: parsed.port,
1989
+ path: parsed.pathname + parsed.search,
1990
+ method: options.method,
1991
+ headers: options.headers,
1992
+ agent,
1993
+ timeout: options.timeout
1994
+ },
1995
+ (res) => {
1996
+ const chunks = [];
1997
+ res.on("data", (chunk) => chunks.push(chunk));
1998
+ res.on("end", () => {
1999
+ resolve({
2000
+ status: res.statusCode ?? 0,
2001
+ body: Buffer.concat(chunks).toString("utf-8")
2002
+ });
2003
+ });
2004
+ }
2005
+ );
2006
+ req.on("error", reject);
2007
+ req.on("timeout", () => {
2008
+ req.destroy();
2009
+ reject(new Error("Request timed out"));
2010
+ });
2011
+ if (options.body) {
2012
+ req.write(options.body);
2013
+ }
2014
+ req.end();
2015
+ });
2016
+ }
2017
+ function buildHeaders(config) {
2018
+ const headers = {};
2019
+ if (config.restToken) {
2020
+ headers["Authorization"] = `Bearer ${config.restToken}`;
2021
+ }
2022
+ return headers;
2023
+ }
2024
+ function baseUrl(config) {
2025
+ return `https://127.0.0.1:${config.restPort}`;
2026
+ }
2027
+ async function restListCommands(config) {
2028
+ const url = `${baseUrl(config)}/commands/`;
2029
+ const response = await restRequest(url, {
2030
+ method: "GET",
2031
+ headers: buildHeaders(config)
2032
+ });
2033
+ if (response.status !== 200) {
2034
+ throw new Error(`REST list commands failed (${response.status}): ${response.body}`);
2035
+ }
2036
+ const data = JSON.parse(response.body);
2037
+ return data.commands ?? [];
2038
+ }
2039
+ async function restExecuteCommand(config, commandId) {
2040
+ const url = `${baseUrl(config)}/commands/${encodeURIComponent(commandId)}/`;
2041
+ const response = await restRequest(url, {
2042
+ method: "POST",
2043
+ headers: buildHeaders(config)
2044
+ });
2045
+ if (response.status !== 204 && response.status !== 200) {
2046
+ throw new Error(`REST execute command failed (${response.status}): ${response.body}`);
2047
+ }
2048
+ }
2049
+ async function restOpenNote(config, notePath) {
2050
+ const url = `${baseUrl(config)}/open/${encodeURIComponent(notePath)}`;
2051
+ const response = await restRequest(url, {
2052
+ method: "POST",
2053
+ headers: buildHeaders(config)
2054
+ });
2055
+ if (response.status !== 204 && response.status !== 200) {
2056
+ throw new Error(`REST open note failed (${response.status}): ${response.body}`);
2057
+ }
2058
+ }
2059
+
2060
+ // src/tools/rest-tools.ts
2061
+ async function executeCommand(config, args) {
2062
+ try {
2063
+ await restExecuteCommand(config, args.commandId);
2064
+ return toolResult(`Executed command: ${args.commandId}`);
2065
+ } catch (err) {
2066
+ return toolError(`Failed to execute command: ${err.message}`, config.vaultPath);
2067
+ }
2068
+ }
2069
+ async function listCommands(config, _args) {
2070
+ try {
2071
+ const commands = await restListCommands(config);
2072
+ return toolJson(commands);
2073
+ } catch (err) {
2074
+ return toolError(`Failed to list commands: ${err.message}`, config.vaultPath);
2075
+ }
2076
+ }
2077
+ async function openInObsidian(config, args) {
2078
+ try {
2079
+ await restOpenNote(config, args.path);
2080
+ return toolResult(`Opened in Obsidian: ${args.path}`);
2081
+ } catch (err) {
2082
+ return toolError(`Failed to open in Obsidian: ${err.message}`, config.vaultPath);
2083
+ }
2084
+ }
2085
+
2086
+ // src/tools/register-tools.ts
2087
+ var MAX_PATH = 4096;
2088
+ var MAX_CONTENT = 10 * 1024 * 1024;
2089
+ var MAX_QUERY = 1e4;
2090
+ var MAX_TAG = 200;
2091
+ var MAX_PROPERTY_KEY = 200;
2092
+ var MAX_COMMAND_ID = 200;
2093
+ function registerAllTools(server, cache, config) {
2094
+ server.registerTool("vault_read_note", {
2095
+ title: "Read Note",
2096
+ description: "Read a note's content and metadata by path. Returns frontmatter, body content, tags, and headings.",
2097
+ inputSchema: {
2098
+ path: z.string().max(MAX_PATH).describe("Relative path to the note, e.g. 'folder/note.md'")
2099
+ },
2100
+ annotations: { readOnlyHint: true }
2101
+ }, async (args) => readNote(cache, config, args));
2102
+ server.registerTool("vault_create_note", {
2103
+ title: "Create Note",
2104
+ description: "Create a new note at the specified path. Fails if the note already exists.",
2105
+ inputSchema: {
2106
+ path: z.string().max(MAX_PATH).describe("Relative path for the new note, e.g. 'folder/new-note.md'"),
2107
+ content: z.string().max(MAX_CONTENT).describe("Body content of the note (markdown)"),
2108
+ frontmatter: z.record(z.unknown()).optional().describe("Optional YAML frontmatter as key-value pairs")
2109
+ },
2110
+ annotations: { readOnlyHint: false }
2111
+ }, async (args) => createNote(cache, config, args));
2112
+ server.registerTool("vault_update_note", {
2113
+ title: "Update Note",
2114
+ description: "Replace a note's entire content. Optionally replace frontmatter. Fails if the note does not exist.",
2115
+ inputSchema: {
2116
+ path: z.string().max(MAX_PATH).describe("Relative path to the note"),
2117
+ content: z.string().max(MAX_CONTENT).describe("New body content (replaces everything below frontmatter)"),
2118
+ frontmatter: z.record(z.unknown()).optional().describe("Optional new frontmatter (replaces existing if provided)")
2119
+ },
2120
+ annotations: { readOnlyHint: false }
2121
+ }, async (args) => updateNote(cache, config, args));
2122
+ server.registerTool("vault_patch_note", {
2123
+ title: "Patch Note",
2124
+ description: "Surgical edit: append, prepend, or replace content at a target location (whole file, heading section, block, or frontmatter key). The precision editing tool.",
2125
+ inputSchema: {
2126
+ path: z.string().max(MAX_PATH).describe("Relative path to the note"),
2127
+ content: z.string().max(MAX_CONTENT).describe("Content to insert or replace with"),
2128
+ operation: z.enum(["append", "prepend", "replace"]).describe("How to apply the content"),
2129
+ targetType: z.enum(["heading", "block", "frontmatter"]).optional().describe("Type of target to operate on. Omit for whole-file operation."),
2130
+ target: z.string().max(MAX_PATH).optional().describe("Target identifier: heading text, ^block-id, or frontmatter key. Required when targetType is set.")
2131
+ },
2132
+ annotations: { readOnlyHint: false, idempotentHint: true }
2133
+ }, async (args) => patchNote(cache, config, args));
2134
+ server.registerTool("vault_delete_note", {
2135
+ title: "Delete Note",
2136
+ description: "Permanently delete a note. This cannot be undone.",
2137
+ inputSchema: {
2138
+ path: z.string().max(MAX_PATH).describe("Relative path to the note to delete")
2139
+ },
2140
+ annotations: { readOnlyHint: false, destructiveHint: true }
2141
+ }, async (args) => deleteNote(cache, config, args));
2142
+ server.registerTool("vault_search", {
2143
+ title: "Search Vault",
2144
+ description: "Full-text search across all notes in the vault. Returns matching notes with line-level context.",
2145
+ inputSchema: {
2146
+ query: z.string().max(MAX_QUERY).describe("Search query (case-insensitive substring match)"),
2147
+ limit: z.number().int().min(1).max(100).optional().describe("Max results to return (default: 20)")
2148
+ },
2149
+ annotations: { readOnlyHint: true }
2150
+ }, async (args) => search(cache, config, args));
2151
+ server.registerTool("vault_search_by_tag", {
2152
+ title: "Search by Tag",
2153
+ description: "Find all notes with a specific tag (checks both frontmatter and inline tags).",
2154
+ inputSchema: {
2155
+ tag: z.string().max(MAX_TAG).describe("Tag to search for, e.g. '#project' or 'project' (# prefix optional)")
2156
+ },
2157
+ annotations: { readOnlyHint: true }
2158
+ }, async (args) => searchByTag(cache, config, args));
2159
+ server.registerTool("vault_search_by_frontmatter", {
2160
+ title: "Search by Frontmatter",
2161
+ description: "Find notes where a specific frontmatter property exists or matches a value.",
2162
+ inputSchema: {
2163
+ key: z.string().max(MAX_PROPERTY_KEY).describe("Frontmatter property key to search for"),
2164
+ value: z.unknown().optional().describe("Optional value to match. If omitted, finds notes where the key exists.")
2165
+ },
2166
+ annotations: { readOnlyHint: true }
2167
+ }, async (args) => searchByFrontmatter(cache, config, args));
2168
+ server.registerTool("vault_search_structured", {
2169
+ title: "Structured Search",
2170
+ description: "Power-search combining text query, tag filter, frontmatter filter, and folder scope. All filters are AND-combined. Returns matching notes sorted by relevance or specified field.",
2171
+ inputSchema: {
2172
+ query: z.string().max(MAX_QUERY).optional().describe("Text search query (case-insensitive). Searches note content."),
2173
+ tags: z.array(z.string().max(MAX_TAG)).optional().describe("Filter to notes with ALL of these tags. # prefix optional."),
2174
+ frontmatter: z.array(z.object({
2175
+ key: z.string().max(MAX_PROPERTY_KEY).describe("Frontmatter property key"),
2176
+ value: z.unknown().optional().describe("Value to match. Omit to match any value.")
2177
+ })).optional().describe("Filter by frontmatter properties. All must match."),
2178
+ folder: z.string().max(MAX_PATH).optional().describe("Restrict search to this folder (recursive). e.g. 'Projects/'"),
2179
+ sort: z.enum(["modified", "created", "name", "size"]).optional().describe("Sort field (default: modified)"),
2180
+ sortOrder: z.enum(["asc", "desc"]).optional().describe("Sort direction (default: desc for dates/size, asc for name)"),
2181
+ limit: z.number().int().min(1).max(100).optional().describe("Max results (default: 20)")
2182
+ },
2183
+ annotations: { readOnlyHint: true }
2184
+ }, async (args) => searchStructured(cache, config, args));
2185
+ server.registerTool("vault_get_properties", {
2186
+ title: "Get Properties",
2187
+ description: "Get all frontmatter properties of a note as a JSON object.",
2188
+ inputSchema: {
2189
+ path: z.string().max(MAX_PATH).describe("Relative path to the note")
2190
+ },
2191
+ annotations: { readOnlyHint: true }
2192
+ }, async (args) => getProperties(cache, config, args));
2193
+ server.registerTool("vault_set_property", {
2194
+ title: "Set Property",
2195
+ description: "Set a frontmatter property on a note. Creates frontmatter if none exists. Overwrites existing values.",
2196
+ inputSchema: {
2197
+ path: z.string().max(MAX_PATH).describe("Relative path to the note"),
2198
+ key: z.string().max(MAX_PROPERTY_KEY).describe("Property key to set"),
2199
+ value: z.unknown().describe("Property value (string, number, boolean, array, or object)")
2200
+ },
2201
+ annotations: { readOnlyHint: false }
2202
+ }, async (args) => setProperty(cache, config, args));
2203
+ server.registerTool("vault_remove_property", {
2204
+ title: "Remove Property",
2205
+ description: "Remove a frontmatter property from a note.",
2206
+ inputSchema: {
2207
+ path: z.string().max(MAX_PATH).describe("Relative path to the note"),
2208
+ key: z.string().max(MAX_PROPERTY_KEY).describe("Property key to remove")
2209
+ },
2210
+ annotations: { readOnlyHint: false }
2211
+ }, async (args) => removeProperty(cache, config, args));
2212
+ server.registerTool("vault_list_tags", {
2213
+ title: "List Tags",
2214
+ description: "List all tags used across the vault with the number of notes each appears in.",
2215
+ inputSchema: {},
2216
+ annotations: { readOnlyHint: true }
2217
+ }, async () => listTags(cache, config, {}));
2218
+ server.registerTool("vault_get_note_tags", {
2219
+ title: "Get Note Tags",
2220
+ description: "Get all tags (frontmatter + inline) for a specific note.",
2221
+ inputSchema: {
2222
+ path: z.string().max(MAX_PATH).describe("Relative path to the note")
2223
+ },
2224
+ annotations: { readOnlyHint: true }
2225
+ }, async (args) => getNoteTags(cache, config, args));
2226
+ server.registerTool("vault_list_folder", {
2227
+ title: "List Folder",
2228
+ description: "List files and subfolders in a vault directory. Defaults to vault root.",
2229
+ inputSchema: {
2230
+ path: z.string().max(MAX_PATH).optional().describe("Relative folder path (default: vault root)")
2231
+ },
2232
+ annotations: { readOnlyHint: true }
2233
+ }, async (args) => listFolder(cache, config, args));
2234
+ server.registerTool("vault_create_folder", {
2235
+ title: "Create Folder",
2236
+ description: "Create a new folder in the vault. Creates parent directories if needed.",
2237
+ inputSchema: {
2238
+ path: z.string().max(MAX_PATH).describe("Relative path for the new folder")
2239
+ },
2240
+ annotations: { readOnlyHint: false }
2241
+ }, async (args) => createFolder(cache, config, args));
2242
+ server.registerTool("vault_move_folder", {
2243
+ title: "Move Folder",
2244
+ description: "Move or rename a folder. Automatically updates all wikilinks that reference files in the moved folder. Use dryRun to preview changes.",
2245
+ inputSchema: {
2246
+ path: z.string().max(MAX_PATH).describe("Current relative path of the folder to move"),
2247
+ newPath: z.string().max(MAX_PATH).describe("New relative path for the folder"),
2248
+ dryRun: z.boolean().optional().describe("Preview changes without moving (default: false)")
2249
+ },
2250
+ annotations: { readOnlyHint: false }
2251
+ }, async (args) => moveFolder(cache, config, args));
2252
+ server.registerTool("vault_delete_folder", {
2253
+ title: "Delete Folder",
2254
+ description: "Delete an empty folder. Refuses to delete folders that contain files \u2014 move or delete contents first.",
2255
+ inputSchema: {
2256
+ path: z.string().max(MAX_PATH).describe("Relative path of the empty folder to delete")
2257
+ },
2258
+ annotations: { readOnlyHint: false, destructiveHint: true }
2259
+ }, async (args) => deleteFolder(cache, config, args));
2260
+ server.registerTool("vault_info", {
2261
+ title: "Vault Info",
2262
+ description: "Get basic information about the connected vault: name and note/folder counts.",
2263
+ inputSchema: {},
2264
+ annotations: { readOnlyHint: true }
2265
+ }, async () => vaultInfo(cache, config, {}));
2266
+ server.registerTool("vault_stats", {
2267
+ title: "Vault Stats",
2268
+ description: "Get detailed statistics about the vault: note counts, tag counts, sizes, oldest/newest/largest notes.",
2269
+ inputSchema: {},
2270
+ annotations: { readOnlyHint: true }
2271
+ }, async () => vaultStats(cache, config, {}));
2272
+ server.registerTool("vault_resolve_link", {
2273
+ title: "Resolve Link",
2274
+ description: "Resolve a wikilink target to its actual file path using Obsidian's link resolution rules. Handles ambiguous names.",
2275
+ inputSchema: {
2276
+ target: z.string().max(MAX_PATH).describe("Link target to resolve, e.g. 'note', 'folder/note', '[[note]]', 'note#heading'"),
2277
+ fromPath: z.string().max(MAX_PATH).optional().describe("Path of the note containing the link (for relative resolution, reserved for future use)")
2278
+ },
2279
+ annotations: { readOnlyHint: true }
2280
+ }, async (args) => resolveLink(cache, config, args));
2281
+ server.registerTool("vault_move_note", {
2282
+ title: "Move Note",
2283
+ description: "Move a note to a new path. Automatically updates all wikilinks in other notes that point to it.",
2284
+ inputSchema: {
2285
+ path: z.string().max(MAX_PATH).describe("Current relative path of the note"),
2286
+ newPath: z.string().max(MAX_PATH).describe("Destination relative path")
2287
+ },
2288
+ annotations: { readOnlyHint: false }
2289
+ }, async (args) => moveNote(cache, config, args));
2290
+ server.registerTool("vault_rename_note", {
2291
+ title: "Rename Note",
2292
+ description: "Rename a note (stays in the same folder). Automatically updates all wikilinks in other notes.",
2293
+ inputSchema: {
2294
+ path: z.string().max(MAX_PATH).describe("Current relative path of the note"),
2295
+ newName: z.string().max(MAX_PATH).describe("New filename (with or without .md extension)")
2296
+ },
2297
+ annotations: { readOnlyHint: false }
2298
+ }, async (args) => renameNote(cache, config, args));
2299
+ server.registerTool("vault_rename_tag", {
2300
+ title: "Rename Tag",
2301
+ description: "Rename a tag across the entire vault. Supports dry-run mode to preview changes.",
2302
+ inputSchema: {
2303
+ oldTag: z.string().max(MAX_TAG).describe("Tag to rename (with or without #)"),
2304
+ newTag: z.string().max(MAX_TAG).describe("New tag name (with or without #)"),
2305
+ dryRun: z.boolean().optional().describe("Preview changes without writing (default: false)")
2306
+ },
2307
+ annotations: { readOnlyHint: false }
2308
+ }, async (args) => renameTag(cache, config, args));
2309
+ server.registerTool("vault_get_links", {
2310
+ title: "Get Links",
2311
+ description: "Get all outgoing links from a note, separated into resolved (found in vault) and unresolved (broken) links.",
2312
+ inputSchema: {
2313
+ path: z.string().max(MAX_PATH).describe("Relative path to the note")
2314
+ },
2315
+ annotations: { readOnlyHint: true }
2316
+ }, async (args) => getLinks(cache, config, args));
2317
+ server.registerTool("vault_get_backlinks", {
2318
+ title: "Get Backlinks",
2319
+ description: "Get all notes that link TO a given note. Optionally includes the line of text containing each link.",
2320
+ inputSchema: {
2321
+ path: z.string().max(MAX_PATH).describe("Relative path to the target note"),
2322
+ includeContext: z.boolean().optional().describe("Include the source line text for each backlink (default: false)")
2323
+ },
2324
+ annotations: { readOnlyHint: true }
2325
+ }, async (args) => getBacklinksHandler(cache, config, args));
2326
+ server.registerTool("vault_get_orphans", {
2327
+ title: "Get Orphans",
2328
+ description: "Find notes with zero incoming links \u2014 no other note links to them.",
2329
+ inputSchema: {},
2330
+ annotations: { readOnlyHint: true }
2331
+ }, async () => getOrphansHandler(cache, config, {}));
2332
+ server.registerTool("vault_get_dead_ends", {
2333
+ title: "Get Dead Ends",
2334
+ description: "Find notes with zero outgoing links \u2014 they don't link to anything.",
2335
+ inputSchema: {},
2336
+ annotations: { readOnlyHint: true }
2337
+ }, async () => getDeadEndsHandler(cache, config, {}));
2338
+ server.registerTool("vault_get_unresolved", {
2339
+ title: "Get Unresolved Links",
2340
+ description: "Find all broken wikilinks across the vault \u2014 links that don't match any note.",
2341
+ inputSchema: {},
2342
+ annotations: { readOnlyHint: true }
2343
+ }, async () => getUnresolvedHandler(cache, config, {}));
2344
+ server.registerTool("vault_read_canvas", {
2345
+ title: "Read Canvas",
2346
+ description: "Read and parse an Obsidian canvas file into structured JSON with nodes and edges.",
2347
+ inputSchema: {
2348
+ path: z.string().max(MAX_PATH).describe("Relative path to the .canvas file")
2349
+ },
2350
+ annotations: { readOnlyHint: true }
2351
+ }, async (args) => readCanvas(cache, config, args));
2352
+ server.registerTool("vault_list_canvases", {
2353
+ title: "List Canvases",
2354
+ description: "List all .canvas files in the vault.",
2355
+ inputSchema: {},
2356
+ annotations: { readOnlyHint: true }
2357
+ }, async () => listCanvases(cache, config, {}));
2358
+ server.registerTool("vault_get_bookmarks", {
2359
+ title: "Get Bookmarks",
2360
+ description: "Read the Obsidian bookmark tree from bookmarks.json.",
2361
+ inputSchema: {},
2362
+ annotations: { readOnlyHint: true }
2363
+ }, async () => getBookmarksHandler(cache, config, {}));
2364
+ if (config.restAvailable) {
2365
+ server.registerTool("vault_execute_command", {
2366
+ title: "Execute Command",
2367
+ description: "Execute an Obsidian command by ID. Requires Obsidian to be running with the REST API plugin.",
2368
+ inputSchema: {
2369
+ commandId: z.string().max(MAX_COMMAND_ID).regex(/^[a-zA-Z0-9_:.\-]+$/, "Invalid command ID format").describe("Command ID to execute (e.g. 'app:toggle-left-sidebar')")
2370
+ },
2371
+ annotations: { readOnlyHint: false, openWorldHint: true }
2372
+ }, async (args) => executeCommand(config, args));
2373
+ server.registerTool("vault_list_commands", {
2374
+ title: "List Commands",
2375
+ description: "List all registered Obsidian commands. Requires Obsidian to be running with the REST API plugin.",
2376
+ inputSchema: {},
2377
+ annotations: { readOnlyHint: true }
2378
+ }, async () => listCommands(config, {}));
2379
+ server.registerTool("vault_open_in_obsidian", {
2380
+ title: "Open in Obsidian",
2381
+ description: "Open a note in the Obsidian UI. Requires Obsidian to be running with the REST API plugin.",
2382
+ inputSchema: {
2383
+ path: z.string().max(MAX_PATH).describe("Relative path to the note to open")
2384
+ },
2385
+ annotations: { readOnlyHint: false, openWorldHint: true }
2386
+ }, async (args) => openInObsidian(config, args));
2387
+ }
2388
+ }
2389
+
2390
+ // src/resources/register-resources.ts
2391
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2392
+ function registerAllResources(server, cache, config) {
2393
+ server.registerResource(
2394
+ "vault-info",
2395
+ "obsidian://vault/info",
2396
+ {
2397
+ title: "Vault Info",
2398
+ description: "Basic information about the connected Obsidian vault",
2399
+ mimeType: "application/json"
2400
+ },
2401
+ async (uri) => ({
2402
+ contents: [{
2403
+ uri: uri.href,
2404
+ text: JSON.stringify(cache.getVaultInfo(), null, 2),
2405
+ mimeType: "application/json"
2406
+ }]
2407
+ })
2408
+ );
2409
+ server.registerResource(
2410
+ "note",
2411
+ new ResourceTemplate("obsidian://note/{path}", {
2412
+ list: async () => {
2413
+ const resources = Array.from(cache.notes.values()).map((note) => ({
2414
+ uri: `obsidian://note/${note.path}`,
2415
+ name: note.name,
2416
+ description: `Note: ${note.path}`,
2417
+ mimeType: "text/markdown"
2418
+ }));
2419
+ return { resources };
2420
+ }
2421
+ }),
2422
+ {
2423
+ title: "Note Content",
2424
+ description: "Read a specific note by its vault-relative path",
2425
+ mimeType: "text/markdown"
2426
+ },
2427
+ async (uri, { path: path9 }) => {
2428
+ const decodedPath = decodeURIComponent(path9);
2429
+ const content = await readFile(config.vaultPath, decodedPath);
2430
+ return {
2431
+ contents: [{
2432
+ uri: uri.href,
2433
+ text: content,
2434
+ mimeType: "text/markdown"
2435
+ }]
2436
+ };
2437
+ }
2438
+ );
2439
+ server.registerResource(
2440
+ "tags",
2441
+ "obsidian://tags",
2442
+ {
2443
+ title: "All Tags",
2444
+ description: "List of all tags used in the vault with note counts",
2445
+ mimeType: "application/json"
2446
+ },
2447
+ async (uri) => ({
2448
+ contents: [{
2449
+ uri: uri.href,
2450
+ text: JSON.stringify(cache.getAllTags(), null, 2),
2451
+ mimeType: "application/json"
2452
+ }]
2453
+ })
2454
+ );
2455
+ server.registerResource(
2456
+ "recent",
2457
+ "obsidian://recent",
2458
+ {
2459
+ title: "Recent Notes",
2460
+ description: "The 20 most recently modified notes in the vault",
2461
+ mimeType: "application/json"
2462
+ },
2463
+ async (uri) => {
2464
+ const recent = cache.getRecentNotes(20).map((note) => ({
2465
+ path: note.path,
2466
+ name: note.name,
2467
+ modified: note.modified
2468
+ }));
2469
+ return {
2470
+ contents: [{
2471
+ uri: uri.href,
2472
+ text: JSON.stringify(recent, null, 2),
2473
+ mimeType: "application/json"
2474
+ }]
2475
+ };
2476
+ }
2477
+ );
2478
+ }
2479
+
2480
+ // src/resources/register-subscriptions.ts
2481
+ import fs5 from "fs/promises";
2482
+ import path8 from "path";
2483
+ function registerPhase2Resources(server, cache, config) {
2484
+ server.registerResource(
2485
+ "vault-structure",
2486
+ "obsidian://vault/structure",
2487
+ {
2488
+ title: "Vault Structure",
2489
+ description: "Full folder tree of the vault as JSON",
2490
+ mimeType: "application/json"
2491
+ },
2492
+ async (uri) => {
2493
+ const folders = /* @__PURE__ */ new Set();
2494
+ for (const notePath of cache.notes.keys()) {
2495
+ const parts = notePath.split("/");
2496
+ for (let i = 1; i < parts.length; i++) {
2497
+ folders.add(parts.slice(0, i).join("/"));
2498
+ }
2499
+ }
2500
+ const sorted = [...folders].sort();
2501
+ return {
2502
+ contents: [{
2503
+ uri: uri.href,
2504
+ text: JSON.stringify({ folders: sorted, noteCount: cache.notes.size }, null, 2),
2505
+ mimeType: "application/json"
2506
+ }]
2507
+ };
2508
+ }
2509
+ );
2510
+ server.registerResource(
2511
+ "graph-summary",
2512
+ "obsidian://graph/summary",
2513
+ {
2514
+ title: "Graph Summary",
2515
+ description: "Knowledge graph statistics and most-connected notes",
2516
+ mimeType: "application/json"
2517
+ },
2518
+ async (uri) => {
2519
+ const stats = getGraphStats(cache);
2520
+ return {
2521
+ contents: [{
2522
+ uri: uri.href,
2523
+ text: JSON.stringify(stats, null, 2),
2524
+ mimeType: "application/json"
2525
+ }]
2526
+ };
2527
+ }
2528
+ );
2529
+ server.registerResource(
2530
+ "bookmarks",
2531
+ "obsidian://bookmarks",
2532
+ {
2533
+ title: "Bookmarks",
2534
+ description: "Obsidian bookmark tree from bookmarks.json",
2535
+ mimeType: "application/json"
2536
+ },
2537
+ async (uri) => {
2538
+ try {
2539
+ const absPath = path8.join(config.vaultPath, ".obsidian", "bookmarks.json");
2540
+ const content = await fs5.readFile(absPath, "utf-8");
2541
+ const bookmarks = parseBookmarks(content);
2542
+ return {
2543
+ contents: [{
2544
+ uri: uri.href,
2545
+ text: JSON.stringify(bookmarks, null, 2),
2546
+ mimeType: "application/json"
2547
+ }]
2548
+ };
2549
+ } catch {
2550
+ return {
2551
+ contents: [{
2552
+ uri: uri.href,
2553
+ text: JSON.stringify([], null, 2),
2554
+ mimeType: "application/json"
2555
+ }]
2556
+ };
2557
+ }
2558
+ }
2559
+ );
2560
+ }
2561
+
2562
+ // src/prompts/register-prompts.ts
2563
+ import { z as z2 } from "zod";
2564
+ function registerAllPrompts(server, cache, config) {
2565
+ server.registerPrompt(
2566
+ "daily_note_summary",
2567
+ {
2568
+ title: "Daily Note Summary",
2569
+ description: "Summarize today's daily note (or a specific date)",
2570
+ argsSchema: {
2571
+ date: z2.string().optional().describe("Date in YYYY-MM-DD format (default: today)")
2572
+ }
2573
+ },
2574
+ async ({ date }) => {
2575
+ const targetDate = date ?? (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2576
+ const notePath = `Daily Notes/${targetDate}.md`;
2577
+ let content;
2578
+ try {
2579
+ content = await readFile(config.vaultPath, notePath);
2580
+ } catch {
2581
+ content = `No daily note found for ${targetDate}.`;
2582
+ }
2583
+ return {
2584
+ messages: [{
2585
+ role: "user",
2586
+ content: { type: "text", text: `Summarize this daily note for ${targetDate}:
2587
+
2588
+ ${content}` }
2589
+ }]
2590
+ };
2591
+ }
2592
+ );
2593
+ server.registerPrompt(
2594
+ "vault_health_check",
2595
+ {
2596
+ title: "Vault Health Check",
2597
+ description: "Audit orphans, dead-ends, unresolved links, and tag hygiene"
2598
+ },
2599
+ async () => {
2600
+ const stats = getGraphStats(cache);
2601
+ const orphans = getOrphans(cache);
2602
+ const deadEnds = getDeadEnds(cache);
2603
+ const unresolved = getUnresolvedLinks(cache);
2604
+ const allTags = cache.getAllTags();
2605
+ const report = JSON.stringify({
2606
+ graphStats: stats,
2607
+ orphanSample: orphans.slice(0, 20),
2608
+ deadEndSample: deadEnds.slice(0, 20),
2609
+ unresolvedSample: unresolved.slice(0, 20),
2610
+ tagCount: allTags.length,
2611
+ singleUseTags: allTags.filter((t) => t.count === 1).map((t) => t.tag)
2612
+ }, null, 2);
2613
+ return {
2614
+ messages: [{
2615
+ role: "user",
2616
+ content: {
2617
+ type: "text",
2618
+ text: `Analyze this vault health report. Identify issues, suggest fixes, and prioritize by impact:
2619
+
2620
+ ${report}`
2621
+ }
2622
+ }]
2623
+ };
2624
+ }
2625
+ );
2626
+ server.registerPrompt(
2627
+ "note_connections",
2628
+ {
2629
+ title: "Note Connections",
2630
+ description: "Analyze a note's position in the knowledge graph and suggest new connections",
2631
+ argsSchema: {
2632
+ path: z2.string().describe("Path to the note to analyze")
2633
+ }
2634
+ },
2635
+ async ({ path: path9 }) => {
2636
+ const meta = cache.getNote(path9);
2637
+ if (!meta) {
2638
+ return {
2639
+ messages: [{
2640
+ role: "user",
2641
+ content: { type: "text", text: `Note not found: ${path9}` }
2642
+ }]
2643
+ };
2644
+ }
2645
+ const outgoing = getOutgoingLinks(cache, path9);
2646
+ const backlinks = getBacklinks(cache, path9);
2647
+ let content;
2648
+ try {
2649
+ content = await readFile(config.vaultPath, path9);
2650
+ } catch {
2651
+ content = "(could not read note content)";
2652
+ }
2653
+ const analysis = JSON.stringify({
2654
+ note: { path: meta.path, name: meta.name, tags: meta.tags },
2655
+ outgoingResolved: outgoing.resolved.length,
2656
+ outgoingUnresolved: outgoing.unresolved,
2657
+ backlinkCount: backlinks.length,
2658
+ backlinks: backlinks.slice(0, 10)
2659
+ }, null, 2);
2660
+ return {
2661
+ messages: [{
2662
+ role: "user",
2663
+ content: {
2664
+ type: "text",
2665
+ text: `Analyze this note's connections in the knowledge graph. Suggest new links to existing notes that would strengthen the graph:
2666
+
2667
+ Note content:
2668
+ ${content.substring(0, 2e3)}
2669
+
2670
+ Graph data:
2671
+ ${analysis}`
2672
+ }
2673
+ }]
2674
+ };
2675
+ }
2676
+ );
2677
+ server.registerPrompt(
2678
+ "research_and_link",
2679
+ {
2680
+ title: "Research and Link",
2681
+ description: "Find related notes on a topic and suggest wikilinks to add",
2682
+ argsSchema: {
2683
+ topic: z2.string().describe("Topic to research within the vault")
2684
+ }
2685
+ },
2686
+ async ({ topic }) => {
2687
+ const searchResults = await cache.searchContent(topic);
2688
+ const topResults = searchResults.slice(0, 15).map((r) => ({
2689
+ path: r.path,
2690
+ name: r.name,
2691
+ matchCount: r.matches.length,
2692
+ sampleMatch: r.matches[0]?.text ?? ""
2693
+ }));
2694
+ return {
2695
+ messages: [{
2696
+ role: "user",
2697
+ content: {
2698
+ type: "text",
2699
+ text: `I'm researching "${topic}" in my vault. Here are notes that mention it:
2700
+
2701
+ ${JSON.stringify(topResults, null, 2)}
2702
+
2703
+ Suggest which notes should link to each other and what wikilinks to add.`
2704
+ }
2705
+ }]
2706
+ };
2707
+ }
2708
+ );
2709
+ server.registerPrompt(
2710
+ "inbox_triage",
2711
+ {
2712
+ title: "Inbox Triage",
2713
+ description: "List unprocessed inbox files with routing suggestions"
2714
+ },
2715
+ async () => {
2716
+ const inboxNotes = [];
2717
+ for (const [path9, meta] of cache.notes) {
2718
+ if (path9.startsWith("_Inbox/")) {
2719
+ inboxNotes.push({ path: path9, name: meta.name, size: meta.size });
2720
+ }
2721
+ }
2722
+ if (inboxNotes.length === 0) {
2723
+ return {
2724
+ messages: [{
2725
+ role: "user",
2726
+ content: { type: "text", text: "Inbox is empty \u2014 nothing to triage." }
2727
+ }]
2728
+ };
2729
+ }
2730
+ const previews = [];
2731
+ for (const note of inboxNotes.slice(0, 20)) {
2732
+ try {
2733
+ const content = await readFile(config.vaultPath, note.path);
2734
+ previews.push({ path: note.path, name: note.name, preview: content.substring(0, 500) });
2735
+ } catch {
2736
+ previews.push({ path: note.path, name: note.name, preview: "(unreadable)" });
2737
+ }
2738
+ }
2739
+ return {
2740
+ messages: [{
2741
+ role: "user",
2742
+ content: {
2743
+ type: "text",
2744
+ text: `Triage these ${inboxNotes.length} inbox items. For each, suggest where it should be filed and what tags to apply:
2745
+
2746
+ ${JSON.stringify(previews, null, 2)}`
2747
+ }
2748
+ }]
2749
+ };
2750
+ }
2751
+ );
2752
+ server.registerPrompt(
2753
+ "weekly_review",
2754
+ {
2755
+ title: "Weekly Review",
2756
+ description: "Summarize the week's daily notes, tasks completed, and ideas captured",
2757
+ argsSchema: {
2758
+ startDate: z2.string().optional().describe("Start date (YYYY-MM-DD). Defaults to 7 days ago.")
2759
+ }
2760
+ },
2761
+ async ({ startDate }) => {
2762
+ const start = startDate ? new Date(startDate) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
2763
+ const dailyNotes = [];
2764
+ for (let i = 0; i < 7; i++) {
2765
+ const d = new Date(start.getTime() + i * 24 * 60 * 60 * 1e3);
2766
+ const dateStr = d.toISOString().split("T")[0];
2767
+ const notePath = `Daily Notes/${dateStr}.md`;
2768
+ try {
2769
+ const content = await readFile(config.vaultPath, notePath);
2770
+ dailyNotes.push({ date: dateStr, preview: content.substring(0, 1e3) });
2771
+ } catch {
2772
+ }
2773
+ }
2774
+ if (dailyNotes.length === 0) {
2775
+ return {
2776
+ messages: [{
2777
+ role: "user",
2778
+ content: { type: "text", text: "No daily notes found for this week." }
2779
+ }]
2780
+ };
2781
+ }
2782
+ return {
2783
+ messages: [{
2784
+ role: "user",
2785
+ content: {
2786
+ type: "text",
2787
+ text: `Summarize this week's activity from my daily notes. Highlight key accomplishments, recurring themes, and anything that seems unfinished:
2788
+
2789
+ ${JSON.stringify(dailyNotes, null, 2)}`
2790
+ }
2791
+ }]
2792
+ };
2793
+ }
2794
+ );
2795
+ }
2796
+
2797
+ // src/rest/rest-probe.ts
2798
+ import https2 from "https";
2799
+ var agent2 = new https2.Agent({ rejectUnauthorized: false });
2800
+ async function probeRestApi(config) {
2801
+ if (!config.restToken) {
2802
+ console.error("[vault-mcp] REST API: not configured (no OBSIDIAN_REST_TOKEN)");
2803
+ return false;
2804
+ }
2805
+ try {
2806
+ const result = await new Promise((resolve) => {
2807
+ const req = https2.request(
2808
+ {
2809
+ hostname: "127.0.0.1",
2810
+ port: config.restPort,
2811
+ path: "/",
2812
+ method: "GET",
2813
+ headers: { Authorization: `Bearer ${config.restToken}` },
2814
+ agent: agent2,
2815
+ timeout: 2e3
2816
+ },
2817
+ (res) => {
2818
+ res.on("data", () => {
2819
+ });
2820
+ res.on("end", () => {
2821
+ resolve(res.statusCode === 200);
2822
+ });
2823
+ }
2824
+ );
2825
+ req.on("error", () => resolve(false));
2826
+ req.on("timeout", () => {
2827
+ req.destroy();
2828
+ resolve(false);
2829
+ });
2830
+ req.end();
2831
+ });
2832
+ console.error(`[vault-mcp] REST API: ${result ? "available" : "not available"}`);
2833
+ return result;
2834
+ } catch {
2835
+ console.error("[vault-mcp] REST API: not available");
2836
+ return false;
2837
+ }
2838
+ }
2839
+
2840
+ // src/transport/create-transport.ts
2841
+ import http from "http";
2842
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2843
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2844
+ var MAX_BODY_SIZE = 10 * 1024 * 1024;
2845
+ async function connectTransport(server, config) {
2846
+ if (config.transport === "stdio") {
2847
+ const transport2 = new StdioServerTransport();
2848
+ await server.connect(transport2);
2849
+ return {
2850
+ shutdown: async () => {
2851
+ await transport2.close();
2852
+ }
2853
+ };
2854
+ }
2855
+ const transport = new StreamableHTTPServerTransport({
2856
+ sessionIdGenerator: void 0
2857
+ });
2858
+ await server.connect(transport);
2859
+ const httpServer = http.createServer(async (req, res) => {
2860
+ res.setHeader("Access-Control-Allow-Origin", "*");
2861
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
2862
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
2863
+ if (req.method === "OPTIONS") {
2864
+ res.writeHead(204);
2865
+ res.end();
2866
+ return;
2867
+ }
2868
+ const url = new URL(req.url ?? "/", `http://localhost:${config.httpPort}`);
2869
+ if (url.pathname !== "/mcp") {
2870
+ res.writeHead(404, { "Content-Type": "application/json" });
2871
+ res.end(JSON.stringify({ error: "Not found. Use /mcp endpoint." }));
2872
+ return;
2873
+ }
2874
+ if (config.httpAuthToken) {
2875
+ const authHeader = req.headers.authorization;
2876
+ if (!authHeader || authHeader !== `Bearer ${config.httpAuthToken}`) {
2877
+ res.writeHead(401, { "Content-Type": "application/json" });
2878
+ res.end(JSON.stringify({ error: "Unauthorized" }));
2879
+ return;
2880
+ }
2881
+ }
2882
+ try {
2883
+ if (req.method === "POST") {
2884
+ const body = await readRequestBody(req, MAX_BODY_SIZE);
2885
+ await transport.handleRequest(req, res, body);
2886
+ } else if (req.method === "GET" || req.method === "DELETE") {
2887
+ await transport.handleRequest(req, res);
2888
+ } else {
2889
+ res.writeHead(405, { "Content-Type": "application/json" });
2890
+ res.end(JSON.stringify({ error: "Method not allowed" }));
2891
+ }
2892
+ } catch (err) {
2893
+ if (!res.headersSent) {
2894
+ const message = err.message;
2895
+ if (message === "Request body too large") {
2896
+ res.writeHead(413, { "Content-Type": "application/json" });
2897
+ res.end(JSON.stringify({ error: "Request body too large (max 10MB)" }));
2898
+ } else {
2899
+ res.writeHead(500, { "Content-Type": "application/json" });
2900
+ res.end(JSON.stringify({ error: "Internal server error" }));
2901
+ }
2902
+ }
2903
+ }
2904
+ });
2905
+ await new Promise((resolve) => {
2906
+ httpServer.listen(config.httpPort, () => {
2907
+ console.error(`[vault-mcp] HTTP transport listening on port ${config.httpPort}`);
2908
+ resolve();
2909
+ });
2910
+ });
2911
+ return {
2912
+ shutdown: async () => {
2913
+ await transport.close();
2914
+ await new Promise((resolve) => {
2915
+ httpServer.close(() => resolve());
2916
+ });
2917
+ }
2918
+ };
2919
+ }
2920
+ function readRequestBody(req, maxSize) {
2921
+ return new Promise((resolve, reject) => {
2922
+ let data = "";
2923
+ let size = 0;
2924
+ req.on("data", (chunk) => {
2925
+ size += chunk.length;
2926
+ if (size > maxSize) {
2927
+ req.destroy();
2928
+ reject(new Error("Request body too large"));
2929
+ return;
2930
+ }
2931
+ data += chunk.toString();
2932
+ });
2933
+ req.on("end", () => {
2934
+ try {
2935
+ resolve(data ? JSON.parse(data) : void 0);
2936
+ } catch {
2937
+ reject(new Error("Invalid JSON body"));
2938
+ }
2939
+ });
2940
+ req.on("error", reject);
2941
+ });
2942
+ }
2943
+
2944
+ // src/index.ts
2945
+ var arg = process.argv[2];
2946
+ if (arg === "--help" || arg === "-h") {
2947
+ console.log(`vault-mcp-tools v1.0.0 \u2014 MCP server for Obsidian vault interaction
2948
+
2949
+ Usage:
2950
+ OBSIDIAN_VAULT_PATH="/path/to/vault" vault-mcp-tools
2951
+
2952
+ Environment variables:
2953
+ OBSIDIAN_VAULT_PATH (required) Absolute path to your Obsidian vault
2954
+ OBSIDIAN_REST_TOKEN Bearer token for Obsidian Local REST API plugin
2955
+ OBSIDIAN_REST_PORT REST API port (default: 27124)
2956
+ OBSIDIAN_TRANSPORT Transport mode: stdio (default) or http
2957
+ OBSIDIAN_HTTP_PORT HTTP server port (default: 3000)
2958
+ OBSIDIAN_HTTP_AUTH_TOKEN Bearer token for HTTP transport authentication
2959
+
2960
+ Documentation: https://github.com/afable702/obsidian-mcp-server`);
2961
+ process.exit(0);
2962
+ }
2963
+ if (arg === "--version" || arg === "-v") {
2964
+ console.log("1.0.0");
2965
+ process.exit(0);
2966
+ }
2967
+ async function main() {
2968
+ const config = loadConfig();
2969
+ config.restAvailable = await probeRestApi(config);
2970
+ const cache = new MetadataCache(config.vaultPath);
2971
+ await cache.initialize();
2972
+ cache.startWatching();
2973
+ const server = new McpServer({
2974
+ name: "vault-mcp",
2975
+ version: "1.0.0"
2976
+ });
2977
+ registerAllTools(server, cache, config);
2978
+ registerAllResources(server, cache, config);
2979
+ registerPhase2Resources(server, cache, config);
2980
+ registerAllPrompts(server, cache, config);
2981
+ const { shutdown: shutdownTransport } = await connectTransport(server, config);
2982
+ const restStatus = config.restAvailable ? "available" : "not available";
2983
+ const authStatus = config.httpAuthToken ? "auth: enabled" : "auth: none";
2984
+ console.error(`[vault-mcp] Server started. Vault: ${config.vaultPath} (${cache.notes.size} notes, REST: ${restStatus}, transport: ${config.transport}, ${authStatus})`);
2985
+ const shutdown = async () => {
2986
+ console.error("[vault-mcp] Shutting down...");
2987
+ await cache.stopWatching();
2988
+ await shutdownTransport();
2989
+ await server.close();
2990
+ process.exit(0);
2991
+ };
2992
+ process.on("SIGINT", shutdown);
2993
+ process.on("SIGTERM", shutdown);
2994
+ }
2995
+ main().catch((err) => {
2996
+ if (err && err.code === "CONFIG_MISSING") {
2997
+ console.error(`[vault-mcp] ${err.message}`);
2998
+ console.error("[vault-mcp] Run 'vault-mcp-tools --help' for usage information.");
2999
+ } else if (err && err.code === "CONFIG_INVALID") {
3000
+ console.error(`[vault-mcp] ${err.message}`);
3001
+ } else {
3002
+ console.error("[vault-mcp] Fatal error:", err);
3003
+ }
3004
+ process.exit(1);
3005
+ });