@openparachute/vault 0.1.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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,672 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Store, Note } from "./types.js";
3
+ import * as noteOps from "./notes.js";
4
+ import { filterMetadata } from "./notes.js";
5
+ import * as linkOps from "./links.js";
6
+ import * as tagSchemaOps from "./tag-schemas.js";
7
+
8
+ export interface McpToolDef {
9
+ name: string;
10
+ description: string;
11
+ inputSchema: Record<string, unknown>;
12
+ execute: (params: Record<string, unknown>) => unknown;
13
+ }
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Resolve a note identifier — tries ID first, then case-insensitive path match.
21
+ * Works everywhere a note reference is accepted.
22
+ */
23
+ function resolveNote(db: Database, idOrPath: string): Note | null {
24
+ // Try ID match first (fast, indexed)
25
+ const byId = noteOps.getNote(db, idOrPath);
26
+ if (byId) return byId;
27
+ // Fallback to path match
28
+ return noteOps.getNoteByPath(db, idOrPath);
29
+ }
30
+
31
+ function requireNote(db: Database, idOrPath: string): Note {
32
+ const note = resolveNote(db, idOrPath);
33
+ if (!note) throw new Error(`Note not found: "${idOrPath}"`);
34
+ return note;
35
+ }
36
+
37
+ /**
38
+ * Remove [[wikilink]] brackets from note content for a specific target.
39
+ * Handles [[Target]], [[Target|alias]], [[Target#section]].
40
+ */
41
+ function removeWikilinkBrackets(content: string, targetPath: string): string {
42
+ // Match [[TargetPath...]] with optional alias/anchor, replace with display text
43
+ const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ // [[Target|alias]] → alias
45
+ content = content.replace(
46
+ new RegExp(`\\[\\[${escaped}\\|([^\\]]+)\\]\\]`, "gi"),
47
+ "$1",
48
+ );
49
+ // [[Target#section]] → Target#section (just remove brackets)
50
+ content = content.replace(
51
+ new RegExp(`\\[\\[${escaped}(#[^\\]]+)?\\]\\]`, "gi"),
52
+ `${targetPath}$1`,
53
+ );
54
+ return content;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tool generation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Generate the 9 consolidated MCP tools for a vault.
63
+ */
64
+ export function generateMcpTools(store: Store): McpToolDef[] {
65
+ const db: Database = (store as any).db;
66
+
67
+ return [
68
+
69
+ // =====================================================================
70
+ // 1. query-notes — the universal read tool
71
+ // =====================================================================
72
+ {
73
+ name: "query-notes",
74
+ description: `Query notes. Returns notes matching the given filters.
75
+
76
+ - **Single note**: pass \`id\` (accepts note ID or path, e.g., "Projects/README")
77
+ - **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
78
+ - **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
79
+ - **No filters**: returns all notes (paginated)
80
+
81
+ Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".`,
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ id: { type: "string", description: "Get one note by ID or path" },
86
+ tag: {
87
+ oneOf: [
88
+ { type: "string" },
89
+ { type: "array", items: { type: "string" } },
90
+ ],
91
+ description: "Filter by tag(s)",
92
+ },
93
+ tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
94
+ exclude_tags: { type: "array", items: { type: "string" }, description: "Exclude notes with these tags" },
95
+ path: { type: "string", description: "Exact path match (case-insensitive)" },
96
+ path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
97
+ search: { type: "string", description: "Full-text search query" },
98
+ metadata: { type: "object", description: "Filter by metadata values (exact match per key)" },
99
+ date_from: { type: "string", description: "Start date (ISO, inclusive)" },
100
+ date_to: { type: "string", description: "End date (ISO, exclusive)" },
101
+ near: {
102
+ type: "object",
103
+ properties: {
104
+ note_id: { type: "string", description: "Anchor note ID or path" },
105
+ depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
106
+ relationship: { type: "string", description: "Only follow links with this relationship" },
107
+ },
108
+ required: ["note_id"],
109
+ description: "Scope results to notes within N hops of an anchor note",
110
+ },
111
+ sort: { type: "string", enum: ["asc", "desc"], description: "Sort by created_at" },
112
+ limit: { type: "number", description: "Max results (default 50)" },
113
+ offset: { type: "number", description: "Pagination offset (default 0)" },
114
+ include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
115
+ include_metadata: {
116
+ oneOf: [
117
+ { type: "boolean" },
118
+ { type: "array", items: { type: "string" } },
119
+ ],
120
+ description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
121
+ },
122
+ include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
123
+ include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
124
+ },
125
+ },
126
+ execute: (params) => {
127
+ // --- Single note by ID/path ---
128
+ if (params.id) {
129
+ const note = resolveNote(db, params.id as string);
130
+ if (!note) return { error: "Note not found", id: params.id };
131
+ const includeContent = params.include_content !== false; // default true for single
132
+ let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
133
+ result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
134
+ if (params.include_links) {
135
+ result.links = linkOps.getLinksHydrated(db, note.id);
136
+ }
137
+ if (params.include_attachments) {
138
+ result.attachments = store.getAttachments(note.id);
139
+ }
140
+ return result;
141
+ }
142
+
143
+ // --- Build near-scope (graph-filtered set of allowed IDs) ---
144
+ let nearScope: Set<string> | null = null;
145
+ if (params.near) {
146
+ const near = params.near as { note_id: string; depth?: number; relationship?: string };
147
+ const anchor = resolveNote(db, near.note_id);
148
+ if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
149
+ const depth = Math.min(near.depth ?? 2, 5);
150
+ const traversed = linkOps.traverseLinks(db, anchor.id, {
151
+ max_depth: depth,
152
+ relationship: near.relationship,
153
+ });
154
+ nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
155
+ }
156
+
157
+ // --- Full-text search ---
158
+ let results: Note[];
159
+ if (params.search) {
160
+ // Normalize tag param
161
+ const tags = normalizeTags(params.tag);
162
+ results = noteOps.searchNotes(db, params.search as string, {
163
+ tags,
164
+ limit: (params.limit as number) ?? 50,
165
+ });
166
+ } else {
167
+ // --- Structured query ---
168
+ const tags = normalizeTags(params.tag);
169
+ results = noteOps.queryNotes(db, {
170
+ tags,
171
+ tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
172
+ excludeTags: params.exclude_tags as string[] | undefined,
173
+ path: params.path as string | undefined,
174
+ pathPrefix: params.path_prefix as string | undefined,
175
+ metadata: params.metadata as Record<string, unknown> | undefined,
176
+ dateFrom: params.date_from as string | undefined,
177
+ dateTo: params.date_to as string | undefined,
178
+ sort: params.sort as "asc" | "desc" | undefined,
179
+ limit: (params.limit as number) ?? 50,
180
+ offset: params.offset as number | undefined,
181
+ });
182
+ }
183
+
184
+ // --- Apply near-scope filter ---
185
+ if (nearScope) {
186
+ results = results.filter((n) => nearScope!.has(n.id));
187
+ }
188
+
189
+ // --- Format output ---
190
+ const includeContent = params.include_content === true; // default false for list
191
+ const includeMetadata = params.include_metadata as boolean | string[] | undefined;
192
+ let output = includeContent ? results : results.map(noteOps.toNoteIndex);
193
+
194
+ // --- Apply metadata filtering ---
195
+ if (includeMetadata !== undefined && includeMetadata !== true) {
196
+ output = output.map((n: any) => filterMetadata(n, includeMetadata));
197
+ }
198
+
199
+ // --- Hydrate links/attachments per note if requested ---
200
+ if (params.include_links || params.include_attachments) {
201
+ return output.map((n: any) => {
202
+ const enriched = { ...n };
203
+ if (params.include_links) enriched.links = linkOps.getLinksHydrated(db, n.id);
204
+ if (params.include_attachments) enriched.attachments = store.getAttachments(n.id);
205
+ return enriched;
206
+ });
207
+ }
208
+
209
+ return output;
210
+ },
211
+ },
212
+
213
+ // =====================================================================
214
+ // 2. create-note — single or batch
215
+ // =====================================================================
216
+ {
217
+ name: "create-note",
218
+ description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ // Single note fields
223
+ content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
224
+ path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
225
+ metadata: { type: "object", description: "Metadata fields" },
226
+ tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
227
+ links: {
228
+ type: "array",
229
+ items: {
230
+ type: "object",
231
+ properties: {
232
+ target: { type: "string", description: "Target note ID or path" },
233
+ relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
234
+ },
235
+ required: ["target", "relationship"],
236
+ },
237
+ description: "Links to create from this note",
238
+ },
239
+ created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
240
+ // Batch
241
+ notes: {
242
+ type: "array",
243
+ items: {
244
+ type: "object",
245
+ properties: {
246
+ content: { type: "string" },
247
+ path: { type: "string" },
248
+ metadata: { type: "object" },
249
+ tags: { type: "array", items: { type: "string" } },
250
+ links: { type: "array" },
251
+ created_at: { type: "string" },
252
+ },
253
+ required: ["content"],
254
+ },
255
+ description: "Array of notes for batch creation",
256
+ },
257
+ },
258
+ },
259
+ execute: (params) => {
260
+ const batch = params.notes as any[] | undefined;
261
+ const items = batch ?? [params];
262
+
263
+ const created: Note[] = [];
264
+ for (const item of items) {
265
+ const note = store.createNote(item.content as string ?? "", {
266
+ path: item.path as string | undefined,
267
+ tags: item.tags as string[] | undefined,
268
+ metadata: item.metadata as Record<string, unknown> | undefined,
269
+ created_at: item.created_at as string | undefined,
270
+ });
271
+
272
+ // Create explicit links (not wikilinks — those are automatic)
273
+ if (item.links) {
274
+ for (const link of item.links as { target: string; relationship: string }[]) {
275
+ const target = resolveNote(db, link.target);
276
+ if (target) {
277
+ store.createLink(note.id, target.id, link.relationship);
278
+ }
279
+ }
280
+ }
281
+
282
+ created.push(noteOps.getNote(db, note.id) ?? note);
283
+ }
284
+
285
+ // Apply tag schema effects
286
+ for (const note of created) {
287
+ if (note.tags && note.tags.length > 0) {
288
+ applySchemaDefaults(store, db, [note.id], note.tags);
289
+ }
290
+ }
291
+
292
+ return batch ? created : created[0];
293
+ },
294
+ },
295
+
296
+ // =====================================================================
297
+ // 3. update-note — single or batch, absorbs tag/untag + link add/remove
298
+ // =====================================================================
299
+ {
300
+ name: "update-note",
301
+ description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
302
+
303
+ - \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
304
+ - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
305
+ - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
306
+ - For batch: pass a \`notes\` array, each with an \`id\` field.`,
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ id: { type: "string", description: "Note ID or path" },
311
+ content: { type: "string", description: "New content" },
312
+ path: { type: "string", description: "New path" },
313
+ metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
314
+ created_at: { type: "string", description: "New created_at timestamp" },
315
+ tags: {
316
+ type: "object",
317
+ properties: {
318
+ add: { type: "array", items: { type: "string" } },
319
+ remove: { type: "array", items: { type: "string" } },
320
+ },
321
+ description: "Tags to add/remove",
322
+ },
323
+ links: {
324
+ type: "object",
325
+ properties: {
326
+ add: {
327
+ type: "array",
328
+ items: {
329
+ type: "object",
330
+ properties: {
331
+ target: { type: "string", description: "Target note ID or path" },
332
+ relationship: { type: "string" },
333
+ },
334
+ required: ["target", "relationship"],
335
+ },
336
+ },
337
+ remove: {
338
+ type: "array",
339
+ items: {
340
+ type: "object",
341
+ properties: {
342
+ target: { type: "string", description: "Target note ID or path" },
343
+ relationship: { type: "string" },
344
+ },
345
+ required: ["target", "relationship"],
346
+ },
347
+ },
348
+ },
349
+ description: "Links to add/remove",
350
+ },
351
+ // Batch
352
+ notes: {
353
+ type: "array",
354
+ items: {
355
+ type: "object",
356
+ properties: {
357
+ id: { type: "string" },
358
+ content: { type: "string" },
359
+ path: { type: "string" },
360
+ metadata: { type: "object" },
361
+ created_at: { type: "string" },
362
+ tags: { type: "object" },
363
+ links: { type: "object" },
364
+ },
365
+ required: ["id"],
366
+ },
367
+ description: "Array of note updates for batch",
368
+ },
369
+ },
370
+ },
371
+ execute: (params) => {
372
+ const batch = params.notes as any[] | undefined;
373
+ const items = batch ?? [params];
374
+
375
+ const updated: Note[] = [];
376
+ for (const item of items) {
377
+ const note = requireNote(db, item.id as string);
378
+ let contentOverride = item.content as string | undefined;
379
+
380
+ // --- Remove links (before content update, so bracket removal applies) ---
381
+ const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
382
+ if (linksRemove) {
383
+ for (const link of linksRemove) {
384
+ const target = resolveNote(db, link.target);
385
+ if (target) {
386
+ store.deleteLink(note.id, target.id, link.relationship);
387
+ // Remove [[brackets]] from content if this was a wikilink
388
+ if (link.relationship === "wikilink" && target.path) {
389
+ const currentContent = contentOverride ?? note.content;
390
+ const cleaned = removeWikilinkBrackets(currentContent, target.path);
391
+ if (cleaned !== currentContent) {
392
+ contentOverride = cleaned;
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ // --- Core update (content, path, metadata, created_at) ---
400
+ const updates: any = {};
401
+ if (contentOverride !== undefined) updates.content = contentOverride;
402
+ if (item.path !== undefined) updates.path = item.path;
403
+ if (item.metadata !== undefined) {
404
+ // Merge metadata (don't replace wholesale)
405
+ const existing = (note.metadata as Record<string, unknown>) ?? {};
406
+ updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
407
+ }
408
+ if (item.created_at !== undefined) updates.created_at = item.created_at;
409
+
410
+ let result: Note;
411
+ if (Object.keys(updates).length > 0) {
412
+ result = store.updateNote(note.id, updates);
413
+ } else {
414
+ result = note;
415
+ }
416
+
417
+ // --- Tags ---
418
+ const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
419
+ if (tagsOp?.add?.length) {
420
+ store.tagNote(note.id, tagsOp.add);
421
+ applySchemaDefaults(store, db, [note.id], tagsOp.add);
422
+ }
423
+ if (tagsOp?.remove?.length) {
424
+ store.untagNote(note.id, tagsOp.remove);
425
+ }
426
+
427
+ // --- Add links ---
428
+ const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
429
+ if (linksAdd) {
430
+ for (const link of linksAdd) {
431
+ const target = resolveNote(db, link.target);
432
+ if (target) {
433
+ store.createLink(note.id, target.id, link.relationship, link.metadata);
434
+ }
435
+ }
436
+ }
437
+
438
+ // Re-read for final state
439
+ updated.push(noteOps.getNote(db, note.id) ?? result);
440
+ }
441
+
442
+ return batch ? updated : updated[0];
443
+ },
444
+ },
445
+
446
+ // =====================================================================
447
+ // 4. delete-note
448
+ // =====================================================================
449
+ {
450
+ name: "delete-note",
451
+ description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
452
+ inputSchema: {
453
+ type: "object",
454
+ properties: {
455
+ id: { type: "string", description: "Note ID or path" },
456
+ },
457
+ required: ["id"],
458
+ },
459
+ execute: (params) => {
460
+ const note = requireNote(db, params.id as string);
461
+ store.deleteNote(note.id);
462
+ return { deleted: true, id: note.id };
463
+ },
464
+ },
465
+
466
+ // =====================================================================
467
+ // 5. list-tags — with optional single-tag detail + schema
468
+ // =====================================================================
469
+ {
470
+ name: "list-tags",
471
+ description: `List tags with usage counts. Pass \`tag\` to get a single tag's details including its schema (description + fields). Pass \`include_schema: true\` to include schemas for all tags.`,
472
+ inputSchema: {
473
+ type: "object",
474
+ properties: {
475
+ tag: { type: "string", description: "Get details for a single tag" },
476
+ include_schema: { type: "boolean", description: "Include schema (description + fields) for each tag (default: false)" },
477
+ },
478
+ },
479
+ execute: (params) => {
480
+ const singleTag = params.tag as string | undefined;
481
+
482
+ if (singleTag) {
483
+ // Single tag detail
484
+ const allTags = noteOps.listTags(db);
485
+ const found = allTags.find((t) => t.name === singleTag);
486
+ const schema = tagSchemaOps.getTagSchema(db, singleTag);
487
+ return {
488
+ name: singleTag,
489
+ count: found?.count ?? 0,
490
+ description: schema?.description ?? null,
491
+ fields: schema?.fields ?? null,
492
+ };
493
+ }
494
+
495
+ // All tags
496
+ const tags = noteOps.listTags(db);
497
+ if (params.include_schema) {
498
+ const schemas = tagSchemaOps.getTagSchemaMap(db);
499
+ return tags.map((t) => ({
500
+ ...t,
501
+ description: schemas[t.name]?.description ?? null,
502
+ fields: schemas[t.name]?.fields ?? null,
503
+ }));
504
+ }
505
+ return tags;
506
+ },
507
+ },
508
+
509
+ // =====================================================================
510
+ // 6. update-tag — create/update tag description + schema fields
511
+ // =====================================================================
512
+ {
513
+ name: "update-tag",
514
+ description: "Create or update a tag's description and schema fields. If the tag doesn't exist, it's created. Fields are merged — new keys are added, existing keys are replaced.",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: {
518
+ tag: { type: "string", description: "Tag name" },
519
+ description: { type: "string", description: "Human-readable description of what this tag means" },
520
+ fields: {
521
+ type: "object",
522
+ description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"] } }',
523
+ additionalProperties: {
524
+ type: "object",
525
+ properties: {
526
+ type: { type: "string", description: "Field type: string, boolean, integer" },
527
+ description: { type: "string" },
528
+ enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
529
+ },
530
+ required: ["type"],
531
+ },
532
+ },
533
+ },
534
+ required: ["tag"],
535
+ },
536
+ execute: (params) => {
537
+ const tag = params.tag as string;
538
+ const existing = tagSchemaOps.getTagSchema(db, tag);
539
+ const mergedFields = { ...existing?.fields, ...(params.fields as any) };
540
+ return tagSchemaOps.upsertTagSchema(db, tag, {
541
+ description: (params.description as string | undefined) ?? existing?.description,
542
+ fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
543
+ });
544
+ },
545
+ },
546
+
547
+ // =====================================================================
548
+ // 7. delete-tag — delete tag + schema from all notes
549
+ // =====================================================================
550
+ {
551
+ name: "delete-tag",
552
+ description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
553
+ inputSchema: {
554
+ type: "object",
555
+ properties: {
556
+ tag: { type: "string", description: "Tag name to delete" },
557
+ },
558
+ required: ["tag"],
559
+ },
560
+ execute: (params) => {
561
+ const tag = params.tag as string;
562
+ // Delete schema first (FK cascade would handle it, but be explicit)
563
+ tagSchemaOps.deleteTagSchema(db, tag);
564
+ return store.deleteTag(tag);
565
+ },
566
+ },
567
+
568
+ // =====================================================================
569
+ // 8. find-path — BFS between two notes
570
+ // =====================================================================
571
+ {
572
+ name: "find-path",
573
+ description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
574
+ inputSchema: {
575
+ type: "object",
576
+ properties: {
577
+ source: { type: "string", description: "Starting note ID or path" },
578
+ target: { type: "string", description: "Destination note ID or path" },
579
+ max_depth: { type: "number", description: "Max path length (default 5)" },
580
+ },
581
+ required: ["source", "target"],
582
+ },
583
+ execute: (params) => {
584
+ const source = requireNote(db, params.source as string);
585
+ const target = requireNote(db, params.target as string);
586
+ return linkOps.findPath(db, source.id, target.id, {
587
+ max_depth: Math.min((params.max_depth as number) ?? 5, 10),
588
+ });
589
+ },
590
+ },
591
+
592
+ // =====================================================================
593
+ // 9. vault-info — get/update vault description + stats
594
+ // =====================================================================
595
+ {
596
+ name: "vault-info",
597
+ description: "Get vault description and optionally stats (note/tag/link counts, distribution). Pass `description` to update the vault description (changes how AI agents behave in future sessions).",
598
+ inputSchema: {
599
+ type: "object",
600
+ properties: {
601
+ include_stats: { type: "boolean", description: "Include note count, tag count, distribution by month (default: false)" },
602
+ description: { type: "string", description: "If provided, updates the vault description" },
603
+ },
604
+ },
605
+ // execute is overridden in mcp-tools.ts where vault config is available
606
+ execute: () => {
607
+ // This is a placeholder — vault-info needs access to vault config,
608
+ // which is only available in the server layer (mcp-tools.ts).
609
+ return { error: "vault-info must be configured by the server layer" };
610
+ },
611
+ },
612
+
613
+ ];
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Tag schema effects — auto-populate defaults when tags are applied
618
+ // ---------------------------------------------------------------------------
619
+
620
+ function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): void {
621
+ const schemas = tagSchemaOps.getTagSchemaMap(db);
622
+ if (Object.keys(schemas).length === 0) return;
623
+
624
+ const defaults: Record<string, unknown> = {};
625
+ for (const tag of tags) {
626
+ const schema = schemas[tag];
627
+ if (!schema?.fields) continue;
628
+ for (const [field, fieldSchema] of Object.entries(schema.fields)) {
629
+ if (!(field in defaults)) {
630
+ defaults[field] = defaultForField(fieldSchema);
631
+ }
632
+ }
633
+ }
634
+ if (Object.keys(defaults).length === 0) return;
635
+
636
+ for (const noteId of noteIds) {
637
+ const note = noteOps.getNote(db, noteId);
638
+ if (!note) continue;
639
+ const existing = (note.metadata as Record<string, unknown>) ?? {};
640
+ const missing: Record<string, unknown> = {};
641
+ for (const [field, value] of Object.entries(defaults)) {
642
+ if (!(field in existing)) {
643
+ missing[field] = value;
644
+ }
645
+ }
646
+ if (Object.keys(missing).length === 0) continue;
647
+ store.updateNote(noteId, {
648
+ metadata: { ...existing, ...missing },
649
+ skipUpdatedAt: true,
650
+ });
651
+ }
652
+ }
653
+
654
+ function defaultForField(field: { type: string; enum?: string[] }): unknown {
655
+ if (field.enum && field.enum.length > 0) return field.enum[0];
656
+ switch (field.type) {
657
+ case "boolean": return false;
658
+ case "integer": return 0;
659
+ default: return "";
660
+ }
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Helpers
665
+ // ---------------------------------------------------------------------------
666
+
667
+ function normalizeTags(tag: unknown): string[] | undefined {
668
+ if (!tag) return undefined;
669
+ if (Array.isArray(tag)) return tag;
670
+ return [tag as string];
671
+ }
672
+