smoking-mirror 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/index.js +1196 -0
  4. package/package.json +61 -0
package/dist/index.js ADDED
@@ -0,0 +1,1196 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/core/vault.ts
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
11
+ ".obsidian",
12
+ ".trash",
13
+ ".git",
14
+ "node_modules"
15
+ ]);
16
+ async function scanVault(vaultPath2) {
17
+ const files = [];
18
+ async function scan(dir, relativePath = "") {
19
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
20
+ for (const entry of entries) {
21
+ const fullPath = path.join(dir, entry.name);
22
+ const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
23
+ if (entry.isDirectory()) {
24
+ if (EXCLUDED_DIRS.has(entry.name)) {
25
+ continue;
26
+ }
27
+ await scan(fullPath, relPath);
28
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
29
+ try {
30
+ const stats = await fs.promises.stat(fullPath);
31
+ files.push({
32
+ path: relPath.replace(/\\/g, "/"),
33
+ // Normalize to forward slashes
34
+ absolutePath: fullPath,
35
+ modified: stats.mtime,
36
+ created: stats.birthtime
37
+ });
38
+ } catch (err) {
39
+ console.error(`Warning: Could not stat ${fullPath}:`, err);
40
+ }
41
+ }
42
+ }
43
+ }
44
+ await scan(vaultPath2);
45
+ return files;
46
+ }
47
+
48
+ // src/core/parser.ts
49
+ import * as fs2 from "fs";
50
+ import matter from "gray-matter";
51
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
52
+ function isBinaryContent(content) {
53
+ const nullBytes = (content.match(/\x00/g) || []).length;
54
+ if (nullBytes > 0) return true;
55
+ const sample = content.slice(0, 1e3);
56
+ const nonPrintable = sample.replace(/[\x20-\x7E\t\n\r]/g, "").length;
57
+ return nonPrintable / sample.length > 0.1;
58
+ }
59
+ var WIKILINK_REGEX = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;
60
+ var TAG_REGEX = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
61
+ var CODE_BLOCK_REGEX = /```[\s\S]*?```|`[^`\n]+`/g;
62
+ function extractWikilinks(content) {
63
+ const links = [];
64
+ const contentWithoutCode = content.replace(CODE_BLOCK_REGEX, (match) => " ".repeat(match.length));
65
+ const lines = contentWithoutCode.split("\n");
66
+ let charIndex = 0;
67
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
68
+ const line = lines[lineNum];
69
+ let match;
70
+ WIKILINK_REGEX.lastIndex = 0;
71
+ while ((match = WIKILINK_REGEX.exec(line)) !== null) {
72
+ const target = match[1].trim();
73
+ const alias = match[2]?.trim();
74
+ if (target) {
75
+ links.push({
76
+ target,
77
+ alias,
78
+ line: lineNum + 1
79
+ // 1-indexed
80
+ });
81
+ }
82
+ }
83
+ charIndex += line.length + 1;
84
+ }
85
+ return links;
86
+ }
87
+ function extractTags(content, frontmatter) {
88
+ const tags = /* @__PURE__ */ new Set();
89
+ const fmTags = frontmatter.tags;
90
+ if (Array.isArray(fmTags)) {
91
+ for (const tag of fmTags) {
92
+ if (typeof tag === "string") {
93
+ tags.add(tag.replace(/^#/, ""));
94
+ }
95
+ }
96
+ } else if (typeof fmTags === "string") {
97
+ tags.add(fmTags.replace(/^#/, ""));
98
+ }
99
+ const contentWithoutCode = content.replace(CODE_BLOCK_REGEX, "");
100
+ let match;
101
+ TAG_REGEX.lastIndex = 0;
102
+ while ((match = TAG_REGEX.exec(contentWithoutCode)) !== null) {
103
+ tags.add(match[1]);
104
+ }
105
+ return Array.from(tags);
106
+ }
107
+ function extractAliases(frontmatter) {
108
+ const aliases = frontmatter.aliases;
109
+ if (Array.isArray(aliases)) {
110
+ return aliases.filter((a) => typeof a === "string");
111
+ }
112
+ if (typeof aliases === "string") {
113
+ return [aliases];
114
+ }
115
+ return [];
116
+ }
117
+ async function parseNote(file) {
118
+ const result = await parseNoteWithWarnings(file);
119
+ if (result.skipped) {
120
+ throw new Error(result.skipReason || "File skipped");
121
+ }
122
+ if (result.warnings.length > 0) {
123
+ for (const warning of result.warnings) {
124
+ console.error(`Warning [${file.path}]: ${warning}`);
125
+ }
126
+ }
127
+ return result.note;
128
+ }
129
+ async function parseNoteWithWarnings(file) {
130
+ const warnings = [];
131
+ try {
132
+ const stats = await fs2.promises.stat(file.absolutePath);
133
+ if (stats.size > MAX_FILE_SIZE) {
134
+ return {
135
+ note: createEmptyNote(file),
136
+ warnings: [],
137
+ skipped: true,
138
+ skipReason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`
139
+ };
140
+ }
141
+ } catch {
142
+ }
143
+ let content;
144
+ try {
145
+ content = await fs2.promises.readFile(file.absolutePath, "utf-8");
146
+ } catch (err) {
147
+ return {
148
+ note: createEmptyNote(file),
149
+ warnings: [],
150
+ skipped: true,
151
+ skipReason: `Could not read file: ${err instanceof Error ? err.message : String(err)}`
152
+ };
153
+ }
154
+ if (content.trim().length === 0) {
155
+ return {
156
+ note: createEmptyNote(file),
157
+ warnings: ["Empty file"],
158
+ skipped: false
159
+ };
160
+ }
161
+ if (isBinaryContent(content)) {
162
+ return {
163
+ note: createEmptyNote(file),
164
+ warnings: [],
165
+ skipped: true,
166
+ skipReason: "Binary content detected"
167
+ };
168
+ }
169
+ let frontmatter = {};
170
+ let markdown = content;
171
+ try {
172
+ const parsed = matter(content);
173
+ frontmatter = parsed.data;
174
+ markdown = parsed.content;
175
+ } catch (err) {
176
+ warnings.push(`Malformed frontmatter: ${err instanceof Error ? err.message : String(err)}`);
177
+ }
178
+ const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
179
+ return {
180
+ note: {
181
+ path: file.path,
182
+ title,
183
+ aliases: extractAliases(frontmatter),
184
+ frontmatter,
185
+ outlinks: extractWikilinks(markdown),
186
+ tags: extractTags(markdown, frontmatter),
187
+ modified: file.modified,
188
+ created: file.created
189
+ },
190
+ warnings,
191
+ skipped: false
192
+ };
193
+ }
194
+ function createEmptyNote(file) {
195
+ const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
196
+ return {
197
+ path: file.path,
198
+ title,
199
+ aliases: [],
200
+ frontmatter: {},
201
+ outlinks: [],
202
+ tags: [],
203
+ modified: file.modified,
204
+ created: file.created
205
+ };
206
+ }
207
+
208
+ // src/core/graph.ts
209
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
210
+ var PARSE_CONCURRENCY = 50;
211
+ var PROGRESS_INTERVAL = 100;
212
+ function normalizeTarget(target) {
213
+ return target.toLowerCase().replace(/\.md$/, "");
214
+ }
215
+ function normalizeNotePath(path3) {
216
+ return path3.toLowerCase().replace(/\.md$/, "");
217
+ }
218
+ async function buildVaultIndex(vaultPath2, options = {}) {
219
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
220
+ console.error(`Scanning vault: ${vaultPath2}`);
221
+ const startTime = Date.now();
222
+ const timeoutPromise = new Promise((_, reject) => {
223
+ setTimeout(() => {
224
+ reject(new Error(`Vault indexing timed out after ${timeoutMs / 1e3}s`));
225
+ }, timeoutMs);
226
+ });
227
+ return Promise.race([
228
+ buildVaultIndexInternal(vaultPath2, startTime, onProgress),
229
+ timeoutPromise
230
+ ]);
231
+ }
232
+ async function buildVaultIndexInternal(vaultPath2, startTime, onProgress) {
233
+ const files = await scanVault(vaultPath2);
234
+ console.error(`Found ${files.length} markdown files`);
235
+ const notes = /* @__PURE__ */ new Map();
236
+ const parseErrors = [];
237
+ let parsedCount = 0;
238
+ for (let i = 0; i < files.length; i += PARSE_CONCURRENCY) {
239
+ const batch = files.slice(i, i + PARSE_CONCURRENCY);
240
+ const results = await Promise.allSettled(
241
+ batch.map(async (file) => {
242
+ const note = await parseNote(file);
243
+ return { file, note };
244
+ })
245
+ );
246
+ for (const result of results) {
247
+ if (result.status === "fulfilled") {
248
+ notes.set(result.value.note.path, result.value.note);
249
+ } else {
250
+ const batchIndex = results.indexOf(result);
251
+ if (batchIndex >= 0 && batch[batchIndex]) {
252
+ parseErrors.push(batch[batchIndex].path);
253
+ }
254
+ }
255
+ parsedCount++;
256
+ }
257
+ if (parsedCount % PROGRESS_INTERVAL === 0 || parsedCount === files.length) {
258
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
259
+ console.error(`Parsed ${parsedCount}/${files.length} files (${elapsed}s)`);
260
+ onProgress?.(parsedCount, files.length);
261
+ }
262
+ }
263
+ if (parseErrors.length > 0) {
264
+ console.error(`Failed to parse ${parseErrors.length} files`);
265
+ }
266
+ const entities = /* @__PURE__ */ new Map();
267
+ for (const note of notes.values()) {
268
+ const normalizedTitle = normalizeTarget(note.title);
269
+ if (!entities.has(normalizedTitle)) {
270
+ entities.set(normalizedTitle, note.path);
271
+ }
272
+ const normalizedPath = normalizeNotePath(note.path);
273
+ entities.set(normalizedPath, note.path);
274
+ for (const alias of note.aliases) {
275
+ const normalizedAlias = normalizeTarget(alias);
276
+ if (!entities.has(normalizedAlias)) {
277
+ entities.set(normalizedAlias, note.path);
278
+ }
279
+ }
280
+ }
281
+ const backlinks = /* @__PURE__ */ new Map();
282
+ for (const note of notes.values()) {
283
+ for (const link of note.outlinks) {
284
+ const normalizedTarget = normalizeTarget(link.target);
285
+ const targetPath = entities.get(normalizedTarget);
286
+ const key = targetPath ? normalizeNotePath(targetPath) : normalizedTarget;
287
+ if (!backlinks.has(key)) {
288
+ backlinks.set(key, []);
289
+ }
290
+ backlinks.get(key).push({
291
+ source: note.path,
292
+ line: link.line
293
+ // Context will be loaded on-demand to save memory
294
+ });
295
+ }
296
+ }
297
+ const tags = /* @__PURE__ */ new Map();
298
+ for (const note of notes.values()) {
299
+ for (const tag of note.tags) {
300
+ if (!tags.has(tag)) {
301
+ tags.set(tag, /* @__PURE__ */ new Set());
302
+ }
303
+ tags.get(tag).add(note.path);
304
+ }
305
+ }
306
+ console.error(`Index built: ${notes.size} notes, ${entities.size} entities, ${backlinks.size} link targets, ${tags.size} tags`);
307
+ return {
308
+ notes,
309
+ backlinks,
310
+ entities,
311
+ tags
312
+ };
313
+ }
314
+ function resolveTarget(index, target) {
315
+ const normalized = normalizeTarget(target);
316
+ return index.entities.get(normalized);
317
+ }
318
+ function getBacklinksForNote(index, notePath) {
319
+ const normalized = normalizeNotePath(notePath);
320
+ return index.backlinks.get(normalized) || [];
321
+ }
322
+ function getForwardLinksForNote(index, notePath) {
323
+ const note = index.notes.get(notePath);
324
+ if (!note) return [];
325
+ return note.outlinks.map((link) => {
326
+ const resolvedPath = resolveTarget(index, link.target);
327
+ return {
328
+ target: link.target,
329
+ alias: link.alias,
330
+ line: link.line,
331
+ resolvedPath,
332
+ exists: resolvedPath !== void 0
333
+ };
334
+ });
335
+ }
336
+ function findOrphanNotes(index, folder) {
337
+ const orphans = [];
338
+ for (const note of index.notes.values()) {
339
+ if (folder && !note.path.startsWith(folder)) {
340
+ continue;
341
+ }
342
+ const backlinks = getBacklinksForNote(index, note.path);
343
+ if (backlinks.length === 0) {
344
+ orphans.push({
345
+ path: note.path,
346
+ title: note.title,
347
+ modified: note.modified
348
+ });
349
+ }
350
+ }
351
+ return orphans.sort((a, b) => b.modified.getTime() - a.modified.getTime());
352
+ }
353
+ function findHubNotes(index, minLinks = 5) {
354
+ const hubs = [];
355
+ for (const note of index.notes.values()) {
356
+ const backlinkCount = getBacklinksForNote(index, note.path).length;
357
+ const forwardLinkCount = note.outlinks.length;
358
+ const totalConnections = backlinkCount + forwardLinkCount;
359
+ if (totalConnections >= minLinks) {
360
+ hubs.push({
361
+ path: note.path,
362
+ title: note.title,
363
+ backlink_count: backlinkCount,
364
+ forward_link_count: forwardLinkCount,
365
+ total_connections: totalConnections
366
+ });
367
+ }
368
+ }
369
+ return hubs.sort((a, b) => b.total_connections - a.total_connections);
370
+ }
371
+
372
+ // src/tools/graph.ts
373
+ import * as fs3 from "fs";
374
+ import * as path2 from "path";
375
+ import { z } from "zod";
376
+ async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
377
+ try {
378
+ const fullPath = path2.join(vaultPath2, sourcePath);
379
+ const content = await fs3.promises.readFile(fullPath, "utf-8");
380
+ const lines = content.split("\n");
381
+ const startLine = Math.max(0, line - 1 - contextLines);
382
+ const endLine = Math.min(lines.length, line + contextLines);
383
+ return lines.slice(startLine, endLine).join("\n").trim();
384
+ } catch {
385
+ return "";
386
+ }
387
+ }
388
+ var BacklinkItemSchema = z.object({
389
+ source: z.string().describe("Path of the note containing the link"),
390
+ line: z.number().describe("Line number where the link appears"),
391
+ context: z.string().optional().describe("Surrounding text for context")
392
+ });
393
+ var GetBacklinksOutputSchema = {
394
+ note: z.string().describe("The resolved note path"),
395
+ backlink_count: z.number().describe("Total number of backlinks found"),
396
+ backlinks: z.array(BacklinkItemSchema).describe("List of backlinks")
397
+ };
398
+ function registerGraphTools(server2, getIndex, getVaultPath) {
399
+ server2.registerTool(
400
+ "get_backlinks",
401
+ {
402
+ title: "Get Backlinks",
403
+ description: "Get all notes that link TO the specified note. Returns the source file paths and line numbers where links appear.",
404
+ inputSchema: {
405
+ path: z.string().describe('Path to the note (e.g., "daily/2024-01-15.md" or just "My Note")'),
406
+ include_context: z.boolean().default(true).describe("Include surrounding text for context")
407
+ },
408
+ outputSchema: GetBacklinksOutputSchema
409
+ },
410
+ async ({ path: notePath, include_context }) => {
411
+ const index = getIndex();
412
+ const vaultPath2 = getVaultPath();
413
+ let resolvedPath = notePath;
414
+ if (!notePath.endsWith(".md")) {
415
+ const resolved = resolveTarget(index, notePath);
416
+ if (resolved) {
417
+ resolvedPath = resolved;
418
+ } else {
419
+ resolvedPath = notePath + ".md";
420
+ }
421
+ }
422
+ const backlinks = getBacklinksForNote(index, resolvedPath);
423
+ const results = await Promise.all(
424
+ backlinks.map(async (bl) => {
425
+ const result = {
426
+ source: bl.source,
427
+ line: bl.line
428
+ };
429
+ if (include_context) {
430
+ result.context = await getContext(vaultPath2, bl.source, bl.line);
431
+ }
432
+ return result;
433
+ })
434
+ );
435
+ const output = {
436
+ note: resolvedPath,
437
+ backlink_count: results.length,
438
+ backlinks: results
439
+ };
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: JSON.stringify(output, null, 2)
445
+ }
446
+ ],
447
+ structuredContent: output
448
+ };
449
+ }
450
+ );
451
+ const ForwardLinkItemSchema = z.object({
452
+ target: z.string().describe("The link target as written"),
453
+ alias: z.string().optional().describe("Display text if using [[target|alias]] syntax"),
454
+ line: z.number().describe("Line number where the link appears"),
455
+ resolved_path: z.string().optional().describe("Resolved path if target exists"),
456
+ exists: z.boolean().describe("Whether the target note exists")
457
+ });
458
+ const GetForwardLinksOutputSchema = {
459
+ note: z.string().describe("The source note path"),
460
+ forward_link_count: z.number().describe("Total number of forward links"),
461
+ forward_links: z.array(ForwardLinkItemSchema).describe("List of forward links")
462
+ };
463
+ server2.registerTool(
464
+ "get_forward_links",
465
+ {
466
+ title: "Get Forward Links",
467
+ description: "Get all notes that this note links TO. Returns the target paths and whether they exist.",
468
+ inputSchema: {
469
+ path: z.string().describe('Path to the note (e.g., "daily/2024-01-15.md" or just "My Note")')
470
+ },
471
+ outputSchema: GetForwardLinksOutputSchema
472
+ },
473
+ async ({ path: notePath }) => {
474
+ const index = getIndex();
475
+ let resolvedPath = notePath;
476
+ if (!notePath.endsWith(".md")) {
477
+ const resolved = resolveTarget(index, notePath);
478
+ if (resolved) {
479
+ resolvedPath = resolved;
480
+ } else {
481
+ resolvedPath = notePath + ".md";
482
+ }
483
+ }
484
+ const forwardLinks = getForwardLinksForNote(index, resolvedPath);
485
+ const output = {
486
+ note: resolvedPath,
487
+ forward_link_count: forwardLinks.length,
488
+ forward_links: forwardLinks.map((link) => ({
489
+ target: link.target,
490
+ alias: link.alias,
491
+ line: link.line,
492
+ resolved_path: link.resolvedPath,
493
+ exists: link.exists
494
+ }))
495
+ };
496
+ return {
497
+ content: [
498
+ {
499
+ type: "text",
500
+ text: JSON.stringify(output, null, 2)
501
+ }
502
+ ],
503
+ structuredContent: output
504
+ };
505
+ }
506
+ );
507
+ const OrphanNoteSchema = z.object({
508
+ path: z.string().describe("Path to the orphan note"),
509
+ title: z.string().describe("Title of the note"),
510
+ modified: z.string().describe("Last modified date (ISO format)")
511
+ });
512
+ const FindOrphansOutputSchema = {
513
+ orphan_count: z.number().describe("Total number of orphan notes found"),
514
+ folder: z.string().optional().describe("Folder filter if specified"),
515
+ orphans: z.array(OrphanNoteSchema).describe("List of orphan notes")
516
+ };
517
+ server2.registerTool(
518
+ "find_orphan_notes",
519
+ {
520
+ title: "Find Orphan Notes",
521
+ description: "Find notes that have no backlinks (no other notes link to them). Useful for finding disconnected content.",
522
+ inputSchema: {
523
+ folder: z.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")')
524
+ },
525
+ outputSchema: FindOrphansOutputSchema
526
+ },
527
+ async ({ folder }) => {
528
+ const index = getIndex();
529
+ const orphans = findOrphanNotes(index, folder);
530
+ const output = {
531
+ orphan_count: orphans.length,
532
+ folder,
533
+ orphans: orphans.map((o) => ({
534
+ path: o.path,
535
+ title: o.title,
536
+ modified: o.modified.toISOString()
537
+ }))
538
+ };
539
+ return {
540
+ content: [
541
+ {
542
+ type: "text",
543
+ text: JSON.stringify(output, null, 2)
544
+ }
545
+ ],
546
+ structuredContent: output
547
+ };
548
+ }
549
+ );
550
+ const HubNoteSchema = z.object({
551
+ path: z.string().describe("Path to the hub note"),
552
+ title: z.string().describe("Title of the note"),
553
+ backlink_count: z.number().describe("Number of notes linking TO this note"),
554
+ forward_link_count: z.number().describe("Number of notes this note links TO"),
555
+ total_connections: z.number().describe("Total connections (backlinks + forward links)")
556
+ });
557
+ const FindHubsOutputSchema = {
558
+ hub_count: z.number().describe("Total number of hub notes found"),
559
+ min_links: z.number().describe("Minimum connection threshold used"),
560
+ hubs: z.array(HubNoteSchema).describe("List of hub notes, sorted by total connections")
561
+ };
562
+ server2.registerTool(
563
+ "find_hub_notes",
564
+ {
565
+ title: "Find Hub Notes",
566
+ description: "Find highly connected notes (hubs) that have many links to/from other notes. Useful for identifying key concepts.",
567
+ inputSchema: {
568
+ min_links: z.number().default(5).describe("Minimum total connections (backlinks + forward links) to qualify as a hub")
569
+ },
570
+ outputSchema: FindHubsOutputSchema
571
+ },
572
+ async ({ min_links }) => {
573
+ const index = getIndex();
574
+ const hubs = findHubNotes(index, min_links);
575
+ const output = {
576
+ hub_count: hubs.length,
577
+ min_links,
578
+ hubs: hubs.map((h) => ({
579
+ path: h.path,
580
+ title: h.title,
581
+ backlink_count: h.backlink_count,
582
+ forward_link_count: h.forward_link_count,
583
+ total_connections: h.total_connections
584
+ }))
585
+ };
586
+ return {
587
+ content: [
588
+ {
589
+ type: "text",
590
+ text: JSON.stringify(output, null, 2)
591
+ }
592
+ ],
593
+ structuredContent: output
594
+ };
595
+ }
596
+ );
597
+ }
598
+
599
+ // src/tools/wikilinks.ts
600
+ import { z as z2 } from "zod";
601
+ function findEntityMatches(text, entities) {
602
+ const matches = [];
603
+ const sortedEntities = Array.from(entities.entries()).filter(([name]) => name.length >= 2).sort((a, b) => b[0].length - a[0].length);
604
+ const skipRegions = [];
605
+ const wikilinkRegex = /\[\[[^\]]+\]\]/g;
606
+ let match;
607
+ while ((match = wikilinkRegex.exec(text)) !== null) {
608
+ skipRegions.push({ start: match.index, end: match.index + match[0].length });
609
+ }
610
+ const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g;
611
+ while ((match = codeBlockRegex.exec(text)) !== null) {
612
+ skipRegions.push({ start: match.index, end: match.index + match[0].length });
613
+ }
614
+ const urlRegex = /https?:\/\/[^\s)>\]]+/g;
615
+ while ((match = urlRegex.exec(text)) !== null) {
616
+ skipRegions.push({ start: match.index, end: match.index + match[0].length });
617
+ }
618
+ const matchedPositions = /* @__PURE__ */ new Set();
619
+ function shouldSkip(start, end) {
620
+ for (const region of skipRegions) {
621
+ if (start < region.end && end > region.start) {
622
+ return true;
623
+ }
624
+ }
625
+ for (let i = start; i < end; i++) {
626
+ if (matchedPositions.has(i)) {
627
+ return true;
628
+ }
629
+ }
630
+ return false;
631
+ }
632
+ function markMatched(start, end) {
633
+ for (let i = start; i < end; i++) {
634
+ matchedPositions.add(i);
635
+ }
636
+ }
637
+ const textLower = text.toLowerCase();
638
+ for (const [entityName, targetPath] of sortedEntities) {
639
+ const entityLower = entityName.toLowerCase();
640
+ let searchStart = 0;
641
+ while (searchStart < textLower.length) {
642
+ const pos = textLower.indexOf(entityLower, searchStart);
643
+ if (pos === -1) break;
644
+ const end = pos + entityName.length;
645
+ const charBefore = pos > 0 ? text[pos - 1] : " ";
646
+ const charAfter = end < text.length ? text[end] : " ";
647
+ const isWordBoundaryBefore = /[\s\n\r.,;:!?()[\]{}'"<>-]/.test(charBefore);
648
+ const isWordBoundaryAfter = /[\s\n\r.,;:!?()[\]{}'"<>-]/.test(charAfter);
649
+ if (isWordBoundaryBefore && isWordBoundaryAfter && !shouldSkip(pos, end)) {
650
+ const originalText = text.substring(pos, end);
651
+ matches.push({
652
+ entity: originalText,
653
+ start: pos,
654
+ end,
655
+ target: targetPath
656
+ });
657
+ markMatched(pos, end);
658
+ }
659
+ searchStart = pos + 1;
660
+ }
661
+ }
662
+ return matches.sort((a, b) => a.start - b.start);
663
+ }
664
+ function registerWikilinkTools(server2, getIndex, getVaultPath) {
665
+ const SuggestionSchema = z2.object({
666
+ entity: z2.string().describe("The matched text in the input"),
667
+ start: z2.number().describe("Start position in text (0-indexed)"),
668
+ end: z2.number().describe("End position in text (0-indexed)"),
669
+ target: z2.string().describe("Path to the target note")
670
+ });
671
+ const SuggestWikilinksOutputSchema = {
672
+ input_length: z2.number().describe("Length of the input text"),
673
+ suggestion_count: z2.number().describe("Number of suggestions found"),
674
+ suggestions: z2.array(SuggestionSchema).describe("List of wikilink suggestions")
675
+ };
676
+ server2.registerTool(
677
+ "suggest_wikilinks",
678
+ {
679
+ title: "Suggest Wikilinks",
680
+ description: "Analyze text and suggest where wikilinks could be added. Finds mentions of existing note titles and aliases.",
681
+ inputSchema: {
682
+ text: z2.string().describe("The text to analyze for potential wikilinks")
683
+ },
684
+ outputSchema: SuggestWikilinksOutputSchema
685
+ },
686
+ async ({ text }) => {
687
+ const index = getIndex();
688
+ const matches = findEntityMatches(text, index.entities);
689
+ const output = {
690
+ input_length: text.length,
691
+ suggestion_count: matches.length,
692
+ suggestions: matches
693
+ };
694
+ return {
695
+ content: [
696
+ {
697
+ type: "text",
698
+ text: JSON.stringify(output, null, 2)
699
+ }
700
+ ],
701
+ structuredContent: output
702
+ };
703
+ }
704
+ );
705
+ const BrokenLinkSchema = z2.object({
706
+ source: z2.string().describe("Path to the note containing the broken link"),
707
+ target: z2.string().describe("The broken link target"),
708
+ line: z2.number().describe("Line number where the link appears"),
709
+ suggestion: z2.string().optional().describe("Suggested fix if a similar note exists")
710
+ });
711
+ const ValidateLinksOutputSchema = {
712
+ scope: z2.string().describe('What was validated (note path or "all")'),
713
+ total_links: z2.number().describe("Total number of links checked"),
714
+ valid_links: z2.number().describe("Number of valid links"),
715
+ broken_links: z2.number().describe("Number of broken links"),
716
+ broken: z2.array(BrokenLinkSchema).describe("List of broken links")
717
+ };
718
+ function findSimilarEntity(target, entities) {
719
+ const targetLower = target.toLowerCase();
720
+ for (const [name, path3] of entities) {
721
+ if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
722
+ return path3;
723
+ }
724
+ }
725
+ for (const [name, path3] of entities) {
726
+ if (name.includes(targetLower) || targetLower.includes(name)) {
727
+ return path3;
728
+ }
729
+ }
730
+ return void 0;
731
+ }
732
+ server2.registerTool(
733
+ "validate_links",
734
+ {
735
+ title: "Validate Links",
736
+ description: "Check wikilinks in a note (or all notes) and report broken links. Optionally suggests fixes.",
737
+ inputSchema: {
738
+ path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes.")
739
+ },
740
+ outputSchema: ValidateLinksOutputSchema
741
+ },
742
+ async ({ path: notePath }) => {
743
+ const index = getIndex();
744
+ const broken = [];
745
+ let totalLinks = 0;
746
+ let validLinks = 0;
747
+ let notesToCheck;
748
+ if (notePath) {
749
+ let resolvedPath = notePath;
750
+ if (!notePath.endsWith(".md")) {
751
+ const resolved = resolveTarget(index, notePath);
752
+ if (resolved) {
753
+ resolvedPath = resolved;
754
+ } else {
755
+ resolvedPath = notePath + ".md";
756
+ }
757
+ }
758
+ notesToCheck = [resolvedPath];
759
+ } else {
760
+ notesToCheck = Array.from(index.notes.keys());
761
+ }
762
+ for (const sourcePath of notesToCheck) {
763
+ const note = index.notes.get(sourcePath);
764
+ if (!note) continue;
765
+ for (const link of note.outlinks) {
766
+ totalLinks++;
767
+ const resolved = resolveTarget(index, link.target);
768
+ if (resolved) {
769
+ validLinks++;
770
+ } else {
771
+ const suggestion = findSimilarEntity(link.target, index.entities);
772
+ broken.push({
773
+ source: sourcePath,
774
+ target: link.target,
775
+ line: link.line,
776
+ suggestion
777
+ });
778
+ }
779
+ }
780
+ }
781
+ const output = {
782
+ scope: notePath || "all",
783
+ total_links: totalLinks,
784
+ valid_links: validLinks,
785
+ broken_links: broken.length,
786
+ broken
787
+ };
788
+ return {
789
+ content: [
790
+ {
791
+ type: "text",
792
+ text: JSON.stringify(output, null, 2)
793
+ }
794
+ ],
795
+ structuredContent: output
796
+ };
797
+ }
798
+ );
799
+ }
800
+
801
+ // src/tools/health.ts
802
+ import { z as z3 } from "zod";
803
+ function registerHealthTools(server2, getIndex, getVaultPath) {
804
+ const BrokenLinkSchema = z3.object({
805
+ source: z3.string().describe("Path to the note containing the broken link"),
806
+ target: z3.string().describe("The broken link target"),
807
+ line: z3.number().describe("Line number where the link appears")
808
+ });
809
+ const FindBrokenLinksOutputSchema = {
810
+ scope: z3.string().describe('Folder searched, or "all" for entire vault'),
811
+ broken_count: z3.number().describe("Number of broken links found"),
812
+ affected_notes: z3.number().describe("Number of notes with broken links"),
813
+ broken_links: z3.array(BrokenLinkSchema).describe("List of broken links")
814
+ };
815
+ server2.registerTool(
816
+ "find_broken_links",
817
+ {
818
+ title: "Find Broken Links",
819
+ description: "Find all wikilinks that point to non-existent notes. Useful for vault maintenance.",
820
+ inputSchema: {
821
+ folder: z3.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")')
822
+ },
823
+ outputSchema: FindBrokenLinksOutputSchema
824
+ },
825
+ async ({ folder }) => {
826
+ const index = getIndex();
827
+ const brokenLinks = [];
828
+ const affectedNotes = /* @__PURE__ */ new Set();
829
+ for (const note of index.notes.values()) {
830
+ if (folder && !note.path.startsWith(folder)) {
831
+ continue;
832
+ }
833
+ for (const link of note.outlinks) {
834
+ const resolved = resolveTarget(index, link.target);
835
+ if (!resolved) {
836
+ brokenLinks.push({
837
+ source: note.path,
838
+ target: link.target,
839
+ line: link.line
840
+ });
841
+ affectedNotes.add(note.path);
842
+ }
843
+ }
844
+ }
845
+ brokenLinks.sort((a, b) => {
846
+ const pathCompare = a.source.localeCompare(b.source);
847
+ if (pathCompare !== 0) return pathCompare;
848
+ return a.line - b.line;
849
+ });
850
+ const output = {
851
+ scope: folder || "all",
852
+ broken_count: brokenLinks.length,
853
+ affected_notes: affectedNotes.size,
854
+ broken_links: brokenLinks
855
+ };
856
+ return {
857
+ content: [
858
+ {
859
+ type: "text",
860
+ text: JSON.stringify(output, null, 2)
861
+ }
862
+ ],
863
+ structuredContent: output
864
+ };
865
+ }
866
+ );
867
+ const TagStatSchema = z3.object({
868
+ tag: z3.string().describe("The tag name"),
869
+ count: z3.number().describe("Number of notes with this tag")
870
+ });
871
+ const FolderStatSchema = z3.object({
872
+ folder: z3.string().describe("Folder path"),
873
+ note_count: z3.number().describe("Number of notes in this folder")
874
+ });
875
+ const GetVaultStatsOutputSchema = {
876
+ total_notes: z3.number().describe("Total number of notes in the vault"),
877
+ total_links: z3.number().describe("Total number of wikilinks"),
878
+ total_tags: z3.number().describe("Total number of unique tags"),
879
+ orphan_notes: z3.number().describe("Notes with no backlinks"),
880
+ broken_links: z3.number().describe("Links pointing to non-existent notes"),
881
+ average_links_per_note: z3.number().describe("Average outgoing links per note"),
882
+ most_linked_notes: z3.array(
883
+ z3.object({
884
+ path: z3.string(),
885
+ backlinks: z3.number()
886
+ })
887
+ ).describe("Top 10 most linked-to notes"),
888
+ top_tags: z3.array(TagStatSchema).describe("Top 20 most used tags"),
889
+ folders: z3.array(FolderStatSchema).describe("Note counts by top-level folder")
890
+ };
891
+ server2.registerTool(
892
+ "get_vault_stats",
893
+ {
894
+ title: "Get Vault Statistics",
895
+ description: "Get comprehensive statistics about the vault: note counts, link metrics, tag usage, and folder distribution.",
896
+ inputSchema: {},
897
+ outputSchema: GetVaultStatsOutputSchema
898
+ },
899
+ async () => {
900
+ const index = getIndex();
901
+ const totalNotes = index.notes.size;
902
+ let totalLinks = 0;
903
+ let brokenLinks = 0;
904
+ let orphanNotes = 0;
905
+ for (const note of index.notes.values()) {
906
+ totalLinks += note.outlinks.length;
907
+ for (const link of note.outlinks) {
908
+ if (!resolveTarget(index, link.target)) {
909
+ brokenLinks++;
910
+ }
911
+ }
912
+ }
913
+ for (const note of index.notes.values()) {
914
+ const backlinks = getBacklinksForNote(index, note.path);
915
+ if (backlinks.length === 0) {
916
+ orphanNotes++;
917
+ }
918
+ }
919
+ const linkCounts = [];
920
+ for (const note of index.notes.values()) {
921
+ const backlinks = getBacklinksForNote(index, note.path);
922
+ if (backlinks.length > 0) {
923
+ linkCounts.push({ path: note.path, backlinks: backlinks.length });
924
+ }
925
+ }
926
+ linkCounts.sort((a, b) => b.backlinks - a.backlinks);
927
+ const mostLinkedNotes = linkCounts.slice(0, 10);
928
+ const tagStats = [];
929
+ for (const [tag, notes] of index.tags) {
930
+ tagStats.push({ tag, count: notes.size });
931
+ }
932
+ tagStats.sort((a, b) => b.count - a.count);
933
+ const topTags = tagStats.slice(0, 20);
934
+ const folderCounts = /* @__PURE__ */ new Map();
935
+ for (const note of index.notes.values()) {
936
+ const parts = note.path.split("/");
937
+ const folder = parts.length > 1 ? parts[0] : "(root)";
938
+ folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1);
939
+ }
940
+ const folders = Array.from(folderCounts.entries()).map(([folder, count]) => ({ folder, note_count: count })).sort((a, b) => b.note_count - a.note_count);
941
+ const output = {
942
+ total_notes: totalNotes,
943
+ total_links: totalLinks,
944
+ total_tags: index.tags.size,
945
+ orphan_notes: orphanNotes,
946
+ broken_links: brokenLinks,
947
+ average_links_per_note: totalNotes > 0 ? Math.round(totalLinks / totalNotes * 100) / 100 : 0,
948
+ most_linked_notes: mostLinkedNotes,
949
+ top_tags: topTags,
950
+ folders
951
+ };
952
+ return {
953
+ content: [
954
+ {
955
+ type: "text",
956
+ text: JSON.stringify(output, null, 2)
957
+ }
958
+ ],
959
+ structuredContent: output
960
+ };
961
+ }
962
+ );
963
+ }
964
+
965
+ // src/tools/query.ts
966
+ import { z as z4 } from "zod";
967
+ function matchesFrontmatter(note, where) {
968
+ for (const [key, value] of Object.entries(where)) {
969
+ const noteValue = note.frontmatter[key];
970
+ if (value === null || value === void 0) {
971
+ if (noteValue !== null && noteValue !== void 0) {
972
+ return false;
973
+ }
974
+ continue;
975
+ }
976
+ if (Array.isArray(noteValue)) {
977
+ if (!noteValue.some((v) => String(v).toLowerCase() === String(value).toLowerCase())) {
978
+ return false;
979
+ }
980
+ continue;
981
+ }
982
+ if (typeof value === "string" && typeof noteValue === "string") {
983
+ if (noteValue.toLowerCase() !== value.toLowerCase()) {
984
+ return false;
985
+ }
986
+ continue;
987
+ }
988
+ if (noteValue !== value) {
989
+ return false;
990
+ }
991
+ }
992
+ return true;
993
+ }
994
+ function hasTag(note, tag) {
995
+ const normalizedTag = tag.replace(/^#/, "").toLowerCase();
996
+ return note.tags.some((t) => t.toLowerCase() === normalizedTag);
997
+ }
998
+ function hasAnyTag(note, tags) {
999
+ return tags.some((tag) => hasTag(note, tag));
1000
+ }
1001
+ function hasAllTags(note, tags) {
1002
+ return tags.every((tag) => hasTag(note, tag));
1003
+ }
1004
+ function inFolder(note, folder) {
1005
+ const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
1006
+ return note.path.startsWith(normalizedFolder) || note.path.split("/")[0] === folder.replace("/", "");
1007
+ }
1008
+ function sortNotes(notes, sortBy, order) {
1009
+ const sorted = [...notes];
1010
+ sorted.sort((a, b) => {
1011
+ let comparison = 0;
1012
+ switch (sortBy) {
1013
+ case "modified":
1014
+ comparison = a.modified.getTime() - b.modified.getTime();
1015
+ break;
1016
+ case "created":
1017
+ const aCreated = a.created || a.modified;
1018
+ const bCreated = b.created || b.modified;
1019
+ comparison = aCreated.getTime() - bCreated.getTime();
1020
+ break;
1021
+ case "title":
1022
+ comparison = a.title.localeCompare(b.title);
1023
+ break;
1024
+ }
1025
+ return order === "desc" ? -comparison : comparison;
1026
+ });
1027
+ return sorted;
1028
+ }
1029
+ function registerQueryTools(server2, getIndex, getVaultPath) {
1030
+ const NoteResultSchema = z4.object({
1031
+ path: z4.string().describe("Path to the note"),
1032
+ title: z4.string().describe("Note title"),
1033
+ modified: z4.string().describe("Last modified date (ISO format)"),
1034
+ created: z4.string().optional().describe("Creation date if available (ISO format)"),
1035
+ tags: z4.array(z4.string()).describe("Tags on this note"),
1036
+ frontmatter: z4.record(z4.unknown()).describe("Frontmatter fields")
1037
+ });
1038
+ const SearchNotesOutputSchema = {
1039
+ query: z4.object({
1040
+ where: z4.record(z4.unknown()).optional(),
1041
+ has_tag: z4.string().optional(),
1042
+ has_any_tag: z4.array(z4.string()).optional(),
1043
+ has_all_tags: z4.array(z4.string()).optional(),
1044
+ folder: z4.string().optional(),
1045
+ title_contains: z4.string().optional(),
1046
+ sort_by: z4.string().optional(),
1047
+ order: z4.string().optional(),
1048
+ limit: z4.number().optional()
1049
+ }).describe("The search query that was executed"),
1050
+ total_matches: z4.number().describe("Total number of matching notes"),
1051
+ returned: z4.number().describe("Number of notes returned (may be limited)"),
1052
+ notes: z4.array(NoteResultSchema).describe("Matching notes")
1053
+ };
1054
+ server2.registerTool(
1055
+ "search_notes",
1056
+ {
1057
+ title: "Search Notes",
1058
+ description: "Search notes by frontmatter fields, tags, folders, or title. Covers ~80% of Dataview use cases.",
1059
+ inputSchema: {
1060
+ where: z4.record(z4.unknown()).optional().describe('Frontmatter filters as key-value pairs. Example: { "type": "project", "status": "active" }'),
1061
+ has_tag: z4.string().optional().describe('Filter to notes with this tag. Example: "work"'),
1062
+ has_any_tag: z4.array(z4.string()).optional().describe('Filter to notes with any of these tags. Example: ["work", "personal"]'),
1063
+ has_all_tags: z4.array(z4.string()).optional().describe('Filter to notes with all of these tags. Example: ["project", "active"]'),
1064
+ folder: z4.string().optional().describe('Limit to notes in this folder. Example: "daily-notes"'),
1065
+ title_contains: z4.string().optional().describe("Filter to notes whose title contains this text (case-insensitive)"),
1066
+ sort_by: z4.enum(["modified", "created", "title"]).default("modified").describe("Field to sort by"),
1067
+ order: z4.enum(["asc", "desc"]).default("desc").describe("Sort order"),
1068
+ limit: z4.number().default(50).describe("Maximum number of results to return")
1069
+ },
1070
+ outputSchema: SearchNotesOutputSchema
1071
+ },
1072
+ async ({
1073
+ where,
1074
+ has_tag,
1075
+ has_any_tag,
1076
+ has_all_tags,
1077
+ folder,
1078
+ title_contains,
1079
+ sort_by = "modified",
1080
+ order = "desc",
1081
+ limit = 50
1082
+ }) => {
1083
+ const index = getIndex();
1084
+ let matchingNotes = Array.from(index.notes.values());
1085
+ if (where && Object.keys(where).length > 0) {
1086
+ matchingNotes = matchingNotes.filter((note) => matchesFrontmatter(note, where));
1087
+ }
1088
+ if (has_tag) {
1089
+ matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag));
1090
+ }
1091
+ if (has_any_tag && has_any_tag.length > 0) {
1092
+ matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag));
1093
+ }
1094
+ if (has_all_tags && has_all_tags.length > 0) {
1095
+ matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags));
1096
+ }
1097
+ if (folder) {
1098
+ matchingNotes = matchingNotes.filter((note) => inFolder(note, folder));
1099
+ }
1100
+ if (title_contains) {
1101
+ const searchTerm = title_contains.toLowerCase();
1102
+ matchingNotes = matchingNotes.filter(
1103
+ (note) => note.title.toLowerCase().includes(searchTerm)
1104
+ );
1105
+ }
1106
+ matchingNotes = sortNotes(matchingNotes, sort_by, order);
1107
+ const totalMatches = matchingNotes.length;
1108
+ const limitedNotes = matchingNotes.slice(0, limit);
1109
+ const notes = limitedNotes.map((note) => ({
1110
+ path: note.path,
1111
+ title: note.title,
1112
+ modified: note.modified.toISOString(),
1113
+ created: note.created?.toISOString(),
1114
+ tags: note.tags,
1115
+ frontmatter: note.frontmatter
1116
+ }));
1117
+ const output = {
1118
+ query: {
1119
+ where,
1120
+ has_tag,
1121
+ has_any_tag,
1122
+ has_all_tags,
1123
+ folder,
1124
+ title_contains,
1125
+ sort_by,
1126
+ order,
1127
+ limit
1128
+ },
1129
+ total_matches: totalMatches,
1130
+ returned: notes.length,
1131
+ notes
1132
+ };
1133
+ return {
1134
+ content: [
1135
+ {
1136
+ type: "text",
1137
+ text: JSON.stringify(output, null, 2)
1138
+ }
1139
+ ],
1140
+ structuredContent: output
1141
+ };
1142
+ }
1143
+ );
1144
+ }
1145
+
1146
+ // src/index.ts
1147
+ var VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH;
1148
+ if (!VAULT_PATH) {
1149
+ console.error("Error: OBSIDIAN_VAULT_PATH environment variable is required");
1150
+ process.exit(1);
1151
+ }
1152
+ var vaultPath = VAULT_PATH;
1153
+ var vaultIndex;
1154
+ var server = new McpServer({
1155
+ name: "smoking-mirror",
1156
+ version: "1.0.0"
1157
+ });
1158
+ registerGraphTools(
1159
+ server,
1160
+ () => vaultIndex,
1161
+ () => vaultPath
1162
+ );
1163
+ registerWikilinkTools(
1164
+ server,
1165
+ () => vaultIndex,
1166
+ () => vaultPath
1167
+ );
1168
+ registerHealthTools(
1169
+ server,
1170
+ () => vaultIndex,
1171
+ () => vaultPath
1172
+ );
1173
+ registerQueryTools(
1174
+ server,
1175
+ () => vaultIndex,
1176
+ () => vaultPath
1177
+ );
1178
+ async function main() {
1179
+ console.error("Building vault index...");
1180
+ const startTime = Date.now();
1181
+ try {
1182
+ vaultIndex = await buildVaultIndex(vaultPath);
1183
+ const duration = Date.now() - startTime;
1184
+ console.error(`Vault index built in ${duration}ms`);
1185
+ } catch (err) {
1186
+ console.error("Failed to build vault index:", err);
1187
+ process.exit(1);
1188
+ }
1189
+ const transport = new StdioServerTransport();
1190
+ await server.connect(transport);
1191
+ console.error("smoking-mirror MCP server running on stdio");
1192
+ }
1193
+ main().catch((error) => {
1194
+ console.error("Fatal error:", error);
1195
+ process.exit(1);
1196
+ });