@syndash/research-vault-mcp 1.1.2 → 1.1.3

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/server.js CHANGED
@@ -2,34 +2,456 @@
2
2
  var __require = import.meta.require;
3
3
 
4
4
  // src/vault.ts
5
- import { readFileSync, readdirSync, existsSync, statSync } from "fs";
6
- import { join, basename } from "path";
5
+ import { readFileSync as readFileSync2, readdirSync, existsSync, statSync as statSync2 } from "fs";
6
+ import { join, basename as basename2 } from "path";
7
7
  import { homedir } from "os";
8
- var VAULT_ROOT = process.env.VAULT_ROOT ?? `${homedir()}/Documents/Evensong/research-vault`;
9
- var KNOWLEDGE_DIR = join(VAULT_ROOT, "knowledge");
10
- var RAW_DIR = join(VAULT_ROOT, "raw");
11
- var DECAY_PATH = join(VAULT_ROOT, ".meta", "decay-scores.json");
12
- var TAXONOMY_PATH = join(VAULT_ROOT, "knowledge", "_taxonomy.md");
8
+
9
+ // src/vault_get.ts
10
+ import { readFileSync, statSync } from "fs";
11
+ import { basename } from "path";
12
+
13
+ // src/guidance.ts
14
+ function passGuidance(reason, next_step, recommended_tool) {
15
+ return {
16
+ verdict: "PASS",
17
+ reason,
18
+ next_step,
19
+ recommended_tool,
20
+ retryable: false
21
+ };
22
+ }
23
+ function flagGuidance(reason, next_step, recommended_tool) {
24
+ return {
25
+ verdict: "FLAG",
26
+ reason,
27
+ next_step,
28
+ recommended_tool,
29
+ retryable: true
30
+ };
31
+ }
32
+ function blockGuidance(reason, next_step, recommended_tool) {
33
+ return {
34
+ verdict: "BLOCK",
35
+ reason,
36
+ next_step,
37
+ recommended_tool,
38
+ retryable: false
39
+ };
40
+ }
41
+ function readonlyBlockedGuidance(toolName, profile) {
42
+ return blockGuidance(`${toolName} is unavailable while Research Vault MCP is running in ${profile} profile.`, "Use vault_search for readonly evidence, or switch to MCP_PROFILE=full for operator-approved non-destructive mutation in a private operator session.", "vault_search");
43
+ }
44
+ function adminBlockedGuidance(toolName, profile) {
45
+ return blockGuidance(`${toolName} is admin-only and unavailable while Research Vault MCP is running in ${profile} profile.`, "Use vault_search for readonly evidence, or start a private admin operator session with MCP_PROFILE=admin; readonly/full profiles are insufficient for destructive/admin tools.", "vault_search");
46
+ }
47
+
48
+ // src/profile.ts
49
+ function getActiveProfile(env = process.env) {
50
+ const raw = String(env.MCP_PROFILE || env.RESEARCH_VAULT_MCP_PROFILE || "readonly").toLowerCase();
51
+ if (raw === "full" || raw === "admin" || raw === "readonly")
52
+ return raw;
53
+ return "readonly";
54
+ }
55
+ function profileAllowsMutation(profile) {
56
+ return profile === "full" || profile === "admin";
57
+ }
58
+
59
+ // src/public_safety.ts
60
+ var REDACTIONS = [
61
+ {
62
+ reason: "local_home_path",
63
+ marker: "[REDACTED_LOCAL_PATH]",
64
+ pattern: /\/Users\/[^/\s]+\/[^"'`]+?(?=["'`])/g
65
+ },
66
+ {
67
+ reason: "local_home_path",
68
+ marker: "[REDACTED_LOCAL_PATH]",
69
+ pattern: /\/Users\/[^/\s]+\/(?:[^/\s"'`),;]+(?: [^/\s"'`),;]+)*\/)*[^/\s"'`),;]+/g
70
+ },
71
+ {
72
+ reason: "operator_command",
73
+ marker: "[REDACTED_OPERATOR_COMMAND]",
74
+ pattern: /(^|[\s;|&])(?:ssh|scp|rsync)\s+[^\n"'`]*/g,
75
+ keepPrefix: true
76
+ },
77
+ {
78
+ reason: "token",
79
+ marker: "[REDACTED_TOKEN]",
80
+ pattern: /\b(?:sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9_]{12,}|xox[A-Za-z0-9-]{12,})\b/g
81
+ },
82
+ {
83
+ reason: "ipv4",
84
+ marker: "[REDACTED_IPV4]",
85
+ pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
86
+ }
87
+ ];
88
+ function isPlainObject(value) {
89
+ if (!value || typeof value !== "object")
90
+ return false;
91
+ const prototype = Object.getPrototypeOf(value);
92
+ return prototype === Object.prototype || prototype === null;
93
+ }
94
+ function hasEnumerableEntries(value) {
95
+ return !!value && typeof value === "object" && Object.keys(value).length > 0;
96
+ }
97
+ function redactStringWithReasons(input) {
98
+ const reasons = new Set;
99
+ let redacted = input;
100
+ for (const rule of REDACTIONS) {
101
+ rule.pattern.lastIndex = 0;
102
+ redacted = redacted.replace(rule.pattern, (...match) => {
103
+ reasons.add(rule.reason);
104
+ if ("keepPrefix" in rule && rule.keepPrefix) {
105
+ const prefix = String(match[1] || "");
106
+ return `${prefix}${rule.marker}`;
107
+ }
108
+ return rule.marker;
109
+ });
110
+ rule.pattern.lastIndex = 0;
111
+ }
112
+ return { redacted, reasons: [...reasons] };
113
+ }
114
+ function redactUnsafeText(input) {
115
+ return redactStringWithReasons(input).redacted;
116
+ }
117
+ function sanitizePublicData(value) {
118
+ if (typeof value === "string")
119
+ return redactUnsafeText(value);
120
+ if (Array.isArray(value))
121
+ return value.map((item) => sanitizePublicData(item));
122
+ if (!isPlainObject(value) && !hasEnumerableEntries(value))
123
+ return value;
124
+ const entries = Object.entries(value).map(([key, entry]) => [
125
+ redactUnsafeText(key),
126
+ sanitizePublicData(entry)
127
+ ]);
128
+ return Object.fromEntries(entries);
129
+ }
130
+ function collectReasons(value, reasons) {
131
+ if (typeof value === "string") {
132
+ for (const reason of redactStringWithReasons(value).reasons)
133
+ reasons.add(reason);
134
+ return;
135
+ }
136
+ if (Array.isArray(value)) {
137
+ for (const item of value)
138
+ collectReasons(item, reasons);
139
+ return;
140
+ }
141
+ if (!isPlainObject(value) && !hasEnumerableEntries(value))
142
+ return;
143
+ for (const [key, entry] of Object.entries(value)) {
144
+ collectReasons(key, reasons);
145
+ collectReasons(entry, reasons);
146
+ }
147
+ }
148
+ function scanPublicSafety(value) {
149
+ const reasons = new Set;
150
+ collectReasons(value, reasons);
151
+ return {
152
+ public_safe: reasons.size === 0,
153
+ redacted: sanitizePublicData(value),
154
+ reasons: [...reasons]
155
+ };
156
+ }
157
+
158
+ // src/response.ts
159
+ function buildEvidence(evidence = {}, public_safe = true, safety_reasons) {
160
+ return {
161
+ ...evidence,
162
+ as_of: evidence.as_of || new Date().toISOString(),
163
+ profile: evidence.profile || getActiveProfile(),
164
+ public_safe,
165
+ safety_reasons: safety_reasons || evidence.safety_reasons
166
+ };
167
+ }
168
+ function okEnvelope(data, guidance = passGuidance("Request completed.", "Use the returned evidence."), evidence = {}) {
169
+ const scan = scanPublicSafety(data);
170
+ const sanitized = sanitizePublicData(data);
171
+ if (!scan.public_safe) {
172
+ return {
173
+ ok: false,
174
+ data: sanitized,
175
+ agent_guidance: blockGuidance("Public-surface leak candidates were redacted from the tool response.", "Use a private diagnostic profile or sanitize the source data before sharing this result."),
176
+ evidence: buildEvidence(evidence, false, scan.reasons)
177
+ };
178
+ }
179
+ return {
180
+ ok: true,
181
+ data: sanitized,
182
+ agent_guidance: guidance,
183
+ evidence: buildEvidence(evidence, true)
184
+ };
185
+ }
186
+ function errorEnvelope(reason, next_step, evidence = {}) {
187
+ return {
188
+ ok: false,
189
+ data: null,
190
+ agent_guidance: blockGuidance(reason, next_step),
191
+ evidence: buildEvidence(evidence, evidence.public_safe ?? true, evidence.safety_reasons)
192
+ };
193
+ }
194
+
195
+ // src/vault_get.ts
196
+ var DEFAULT_EXCERPT_CHARS = 1200;
197
+ var HARD_MAX_CHARS = 12000;
198
+ function fileStem(entry) {
199
+ return basename(entry.path).replace(/\.md$/, "");
200
+ }
201
+ function sourceRef(entry) {
202
+ return `vault://knowledge/${entry.category}/${entry.id}`;
203
+ }
204
+ function requestedLimit(input) {
205
+ const fallback = input.include_content ? HARD_MAX_CHARS : DEFAULT_EXCERPT_CHARS;
206
+ const ceiling = input.include_content ? HARD_MAX_CHARS : DEFAULT_EXCERPT_CHARS;
207
+ const requested = input.max_chars === undefined ? fallback : Number.isFinite(input.max_chars) ? Math.max(0, Math.floor(input.max_chars)) : fallback;
208
+ return Math.min(requested, ceiling);
209
+ }
210
+ function resolveEntry(id, entries) {
211
+ const exact = entries.filter((entry) => entry.id === id);
212
+ if (exact.length === 1)
213
+ return { entry: exact[0] };
214
+ if (exact.length > 1)
215
+ return { reason: "Multiple Research Vault notes matched the requested id." };
216
+ const stemMatches = entries.filter((entry) => fileStem(entry) === id);
217
+ if (stemMatches.length === 1)
218
+ return { entry: stemMatches[0] };
219
+ if (stemMatches.length > 1)
220
+ return { reason: "Multiple Research Vault notes matched the requested file stem." };
221
+ return { reason: "No Research Vault note matched the requested id." };
222
+ }
223
+ function getVaultEntry(input, entries) {
224
+ const id = input.id?.trim();
225
+ if (!id) {
226
+ return {
227
+ envelope: errorEnvelope("vault_get requires a non-empty id.", "Call vault_search first, then retry vault_get with an exact id from the search results.", {}),
228
+ isError: true
229
+ };
230
+ }
231
+ const resolved = resolveEntry(id, entries);
232
+ if (!resolved.entry) {
233
+ return {
234
+ envelope: errorEnvelope(resolved.reason ?? "No Research Vault note matched the requested id.", "Call vault_search first, then retry vault_get with an exact id from the search results.", {}),
235
+ isError: true
236
+ };
237
+ }
238
+ const entry = resolved.entry;
239
+ const content = readFileSync(entry.path, "utf-8");
240
+ const stat = statSync(entry.path);
241
+ const limit = requestedLimit(input);
242
+ const body = content.slice(0, limit);
243
+ const truncated = content.length > body.length;
244
+ const contentKind = input.include_content ? "full" : "excerpt";
245
+ return {
246
+ envelope: okEnvelope({
247
+ id: entry.id,
248
+ title: entry.title,
249
+ category: entry.category,
250
+ source_ref: sourceRef(entry),
251
+ content: body,
252
+ content_kind: contentKind,
253
+ truncated,
254
+ chars_returned: body.length,
255
+ total_chars: content.length,
256
+ max_chars: limit,
257
+ modified: stat.mtime.toISOString(),
258
+ size: stat.size
259
+ }, passGuidance(input.include_content ? "Returned bounded note content from the Research Vault read surface." : "Returned a bounded excerpt from the Research Vault read surface.", truncated ? "Use include_content with a larger max_chars only if this excerpt is insufficient." : "Use the returned public-safe note reference for follow-up.", truncated ? "vault_get" : undefined), {}),
260
+ isError: false
261
+ };
262
+ }
263
+
264
+ // src/evidence_metadata.ts
265
+ var STALE_AFTER_DAYS = 7;
266
+ var DAY_MS = 24 * 60 * 60 * 1000;
267
+ function clamp(value, min, max) {
268
+ return Math.min(max, Math.max(min, value));
269
+ }
270
+ function lower(value) {
271
+ return (value ?? "").toLowerCase();
272
+ }
273
+ function queryTerms(query) {
274
+ return lower(query).split(/\s+/).map((term) => term.trim()).filter(Boolean);
275
+ }
276
+ function includesQuery(value, query) {
277
+ const terms = queryTerms(query);
278
+ if (terms.length === 0)
279
+ return false;
280
+ const haystack = lower(value);
281
+ return terms.some((term) => haystack.includes(term));
282
+ }
283
+ function matchedFields(entry, query) {
284
+ if (!query?.trim())
285
+ return [];
286
+ const candidates = [
287
+ ["title", entry.title],
288
+ ["content", entry.content],
289
+ ["id", entry.id],
290
+ ["category", entry.category]
291
+ ];
292
+ return candidates.filter(([, value]) => includesQuery(value, query)).map(([field]) => field);
293
+ }
294
+ function whyMatched(entry, query, fields) {
295
+ if (!query?.trim())
296
+ return "No query provided; result is included by category or default listing.";
297
+ if (fields.length === 0)
298
+ return "Result is included after filters; no direct field match was detected.";
299
+ const labels = fields.map((field) => {
300
+ if (field === "title")
301
+ return "title";
302
+ if (field === "content")
303
+ return "note content";
304
+ if (field === "category")
305
+ return "category";
306
+ return field;
307
+ });
308
+ return `Matched query "${query}" in ${labels.join(", ")}.`;
309
+ }
310
+ function snippetFromContent(content, query, maxChars = 240) {
311
+ const limit = Math.max(0, Math.floor(maxChars));
312
+ if (limit === 0)
313
+ return "";
314
+ const normalized = content.replace(/\s+/g, " ").trim();
315
+ if (normalized.length <= limit)
316
+ return normalized;
317
+ const terms = queryTerms(query);
318
+ const lowerContent = lower(normalized);
319
+ const hitIndex = terms.map((term) => lowerContent.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
320
+ if (hitIndex === undefined)
321
+ return normalized.slice(0, limit).trimEnd();
322
+ const halfWindow = Math.floor(limit / 2);
323
+ const start = Math.max(0, Math.min(hitIndex - halfWindow, normalized.length - limit));
324
+ const end = Math.min(normalized.length, start + limit);
325
+ const prefix = start > 0 ? "..." : "";
326
+ const suffix = end < normalized.length ? "..." : "";
327
+ const available = Math.max(0, limit - prefix.length - suffix.length);
328
+ return `${prefix}${normalized.slice(start, start + available).trim()}${suffix}`;
329
+ }
330
+ function staleVerdict(lastAnalyzedAt) {
331
+ if (!lastAnalyzedAt) {
332
+ return { verdict: "FLAG", reason: "No analysis timestamp was provided." };
333
+ }
334
+ const timestamp = Date.parse(lastAnalyzedAt);
335
+ if (Number.isNaN(timestamp)) {
336
+ return { verdict: "FLAG", reason: "Analysis timestamp could not be parsed." };
337
+ }
338
+ const ageDays = Math.floor((Date.now() - timestamp) / DAY_MS);
339
+ if (ageDays > STALE_AFTER_DAYS) {
340
+ return { verdict: "FLAG", reason: `Analysis is ${ageDays} days old.` };
341
+ }
342
+ return { verdict: "PASS", reason: "Analysis is fresh enough for the read surface." };
343
+ }
344
+ function itemFreshness(entry) {
345
+ const lastAnalyzedAt = entry.score?.lastAnalyzedAt ?? null;
346
+ const verdict = staleVerdict(lastAnalyzedAt);
347
+ return {
348
+ last_analyzed_at: lastAnalyzedAt,
349
+ source_mtime: entry.modified || null,
350
+ freshness_verdict: verdict.verdict,
351
+ freshness_reason: verdict.reason
352
+ };
353
+ }
354
+ function queueFreshness(queueItems) {
355
+ const timestamps = queueItems.map((item) => item.source_mtime ? Date.parse(item.source_mtime) : NaN).filter((timestamp) => !Number.isNaN(timestamp));
356
+ if (timestamps.length === 0) {
357
+ return {
358
+ oldest_pending_age: null,
359
+ oldest_pending_at: null
360
+ };
361
+ }
362
+ const oldest = Math.min(...timestamps);
363
+ return {
364
+ oldest_pending_age: Math.max(0, Math.floor((Date.now() - oldest) / DAY_MS)),
365
+ oldest_pending_at: new Date(oldest).toISOString()
366
+ };
367
+ }
368
+ function coverageMetadata(statusData) {
369
+ const analyzedCoverage = statusData.total === 0 ? 0 : Number(clamp(statusData.analyzed / statusData.total, 0, 1).toFixed(4));
370
+ const analyzedAt = (statusData.scores ?? []).map((score) => score.lastAnalyzedAt).filter((value) => Boolean(value)).sort().at(-1) ?? null;
371
+ const recentThroughput = (statusData.scores ?? []).filter((score) => {
372
+ if (!score.lastAnalyzedAt)
373
+ return false;
374
+ const timestamp = Date.parse(score.lastAnalyzedAt);
375
+ return !Number.isNaN(timestamp) && Date.now() - timestamp <= STALE_AFTER_DAYS * DAY_MS;
376
+ }).length;
377
+ return {
378
+ as_of: new Date().toISOString(),
379
+ last_analyzed_at: analyzedAt,
380
+ analyzed_coverage: analyzedCoverage,
381
+ oldest_pending_age: queueFreshness(statusData.queueItems ?? []).oldest_pending_age,
382
+ recent_throughput: recentThroughput
383
+ };
384
+ }
385
+ function releaseMetadata(env, packageJson) {
386
+ const npmLatestVersion = env.RESEARCH_VAULT_NPM_LATEST_VERSION ?? null;
387
+ const npmModifiedAt = env.RESEARCH_VAULT_NPM_MODIFIED_AT ?? null;
388
+ const publicRepo = env.RESEARCH_VAULT_PUBLIC_REPO_URL ?? null;
389
+ const modifiedVerdict = staleVerdict(npmModifiedAt);
390
+ const provided = Boolean(npmLatestVersion && npmModifiedAt && publicRepo);
391
+ return {
392
+ package_name: packageJson.name ?? null,
393
+ local_version: packageJson.version ?? null,
394
+ npm_latest_version: npmLatestVersion,
395
+ npm_modified_at: npmModifiedAt,
396
+ days_since_npm_update: npmModifiedAt && !Number.isNaN(Date.parse(npmModifiedAt)) ? Math.max(0, Math.floor((Date.now() - Date.parse(npmModifiedAt)) / DAY_MS)) : null,
397
+ public_repo: publicRepo,
398
+ freshness_verdict: provided ? modifiedVerdict.verdict : "FLAG",
399
+ freshness_reason: provided ? modifiedVerdict.reason : "Release freshness was not provided by the runtime environment."
400
+ };
401
+ }
402
+
403
+ // src/vault.ts
404
+ var DEFAULT_VAULT_ROOT = `${homedir()}/Documents/Evensong/research-vault`;
405
+ function getVaultRoot() {
406
+ return process.env.VAULT_ROOT ?? DEFAULT_VAULT_ROOT;
407
+ }
408
+ function getKnowledgeDir() {
409
+ return join(getVaultRoot(), "knowledge");
410
+ }
411
+ function getRawDir() {
412
+ return join(getVaultRoot(), "raw");
413
+ }
414
+ function getDecayPath() {
415
+ return join(getVaultRoot(), ".meta", "decay-scores.json");
416
+ }
417
+ function getTaxonomyPath() {
418
+ return join(getVaultRoot(), "knowledge", "_taxonomy.md");
419
+ }
420
+ function getPackageJson() {
421
+ try {
422
+ return JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8"));
423
+ } catch {
424
+ return {};
425
+ }
426
+ }
13
427
  function normalizeId(raw) {
14
428
  return raw.replace(/^\d{8}--?\d{4}-/, "").replace(/^(\d{10,})--?/, "").replace(/\.md$/, "");
15
429
  }
430
+ function normalizeDecayScoresStore(parsed) {
431
+ if (Array.isArray(parsed))
432
+ return parsed;
433
+ if (parsed && typeof parsed === "object")
434
+ return Object.values(parsed);
435
+ return [];
436
+ }
16
437
  function loadDecayScores() {
17
438
  try {
18
- return JSON.parse(readFileSync(DECAY_PATH, "utf-8"));
439
+ const parsed = JSON.parse(readFileSync2(getDecayPath(), "utf-8"));
440
+ return normalizeDecayScoresStore(parsed);
19
441
  } catch {
20
442
  return [];
21
443
  }
22
444
  }
23
445
  function loadTaxonomy() {
24
446
  try {
25
- return readFileSync(TAXONOMY_PATH, "utf-8");
447
+ return readFileSync2(getTaxonomyPath(), "utf-8");
26
448
  } catch {
27
449
  return "";
28
450
  }
29
451
  }
30
452
  function loadFileMeta(filePath) {
31
453
  try {
32
- const content = readFileSync(filePath, "utf-8");
454
+ const content = readFileSync2(filePath, "utf-8");
33
455
  const lines = content.split(`
34
456
  `);
35
457
  let title = "";
@@ -40,31 +462,32 @@ function loadFileMeta(filePath) {
40
462
  break;
41
463
  }
42
464
  }
43
- const s = statSync(filePath);
465
+ const s = statSync2(filePath);
44
466
  return {
45
- title: title || normalizeId(basename(filePath)),
467
+ title: title || normalizeId(basename2(filePath)),
46
468
  modified: s.mtime.toISOString(),
47
469
  size: s.size
48
470
  };
49
471
  } catch {
50
- return { title: normalizeId(basename(filePath)), modified: "", size: 0 };
472
+ return { title: normalizeId(basename2(filePath)), modified: "", size: 0 };
51
473
  }
52
474
  }
53
475
  function scanKnowledge() {
54
476
  const entries = [];
55
- if (!existsSync(KNOWLEDGE_DIR))
477
+ const knowledgeDir = getKnowledgeDir();
478
+ if (!existsSync(knowledgeDir))
56
479
  return entries;
57
- const categories = readdirSync(KNOWLEDGE_DIR);
480
+ const categories = readdirSync(knowledgeDir);
58
481
  for (const cat of categories) {
59
482
  if (cat.startsWith("_"))
60
483
  continue;
61
- const catPath = join(KNOWLEDGE_DIR, cat);
62
- if (!existsSync(catPath) || !statSync(catPath).isDirectory())
484
+ const catPath = join(knowledgeDir, cat);
485
+ if (!existsSync(catPath) || !statSync2(catPath).isDirectory())
63
486
  continue;
64
487
  const subEntries = readdirSync(catPath);
65
488
  for (const sub of subEntries) {
66
489
  const subPath = join(catPath, sub);
67
- const subStat = statSync(subPath);
490
+ const subStat = statSync2(subPath);
68
491
  if (subStat.isDirectory()) {
69
492
  const files = readdirSync(subPath).filter((f) => f.endsWith(".md"));
70
493
  for (const file of files) {
@@ -96,18 +519,19 @@ function scanKnowledge() {
96
519
  }
97
520
  function scanRaw() {
98
521
  const pending = [];
99
- if (!existsSync(RAW_DIR))
522
+ const rawDir = getRawDir();
523
+ if (!existsSync(rawDir))
100
524
  return pending;
101
525
  try {
102
- const entries = readdirSync(RAW_DIR);
526
+ const entries = readdirSync(rawDir);
103
527
  for (const entry of entries) {
104
528
  if (entry === "_inbox") {
105
- const inbox = join(RAW_DIR, entry);
529
+ const inbox = join(rawDir, entry);
106
530
  if (existsSync(inbox)) {
107
531
  pending.push(...readdirSync(inbox).filter((f) => /\.(md|pdf|txt)$/.test(f)));
108
532
  }
109
533
  } else if (/^\d{4}-\d{2}$/.test(entry)) {
110
- const monthDir = join(RAW_DIR, entry);
534
+ const monthDir = join(rawDir, entry);
111
535
  if (existsSync(monthDir)) {
112
536
  pending.push(...readdirSync(monthDir).filter((f) => /\.(md|pdf|txt)$/.test(f)).map((f) => `${entry}/${f}`));
113
537
  }
@@ -116,14 +540,98 @@ function scanRaw() {
116
540
  } catch {}
117
541
  return pending;
118
542
  }
543
+ function scanRawQueueItems() {
544
+ const rawDir = getRawDir();
545
+ return scanRaw().map((item) => {
546
+ let filePath = join(rawDir, item);
547
+ if (!existsSync(filePath))
548
+ filePath = join(rawDir, "_inbox", item);
549
+ let sourceMtime = null;
550
+ try {
551
+ sourceMtime = statSync2(filePath).mtime.toISOString();
552
+ } catch {}
553
+ const title = normalizeId(basename2(item)).replace(/\.[^.]+$/, "");
554
+ return {
555
+ id: normalizeId(basename2(item)).replace(/\.[^.]+$/, ""),
556
+ title,
557
+ source_mtime: sourceMtime
558
+ };
559
+ });
560
+ }
561
+ function entryScoreAliases(entry) {
562
+ const stem = basename2(entry.path).replace(/\.md$/, "");
563
+ const aliases = new Set([
564
+ entry.id,
565
+ stem,
566
+ normalizeId(entry.id),
567
+ normalizeId(stem),
568
+ entry.id.replace(/--/g, "-"),
569
+ stem.replace(/--/g, "-")
570
+ ]);
571
+ const datePrefixed = stem.match(/^\d{8}--(.+)$/);
572
+ if (datePrefixed)
573
+ aliases.add(datePrefixed[1]);
574
+ return aliases;
575
+ }
576
+ function scoreAliases(score) {
577
+ return new Set([
578
+ score.itemId,
579
+ normalizeId(score.itemId),
580
+ score.itemId.replace(/--/g, "-")
581
+ ]);
582
+ }
583
+ function scoreMatchesEntry(score, entry) {
584
+ const entryAliases = entryScoreAliases(entry);
585
+ return [...scoreAliases(score)].some((alias) => entryAliases.has(alias));
586
+ }
587
+ function matchedScoresForEntries(scores, entries) {
588
+ return scores.filter((score) => entries.some((entry) => scoreMatchesEntry(score, entry)));
589
+ }
590
+ function pendingRawQueueItems(entries) {
591
+ const analyzedIds = new Set(entries.flatMap((entry) => [...entryScoreAliases(entry)]));
592
+ return scanRawQueueItems().filter((item) => !analyzedIds.has(normalizeId(item.id)));
593
+ }
594
+ function sourceRef2(entry) {
595
+ return `vault://knowledge/${entry.category}/${entry.id}`;
596
+ }
597
+ function readEntryContent(entry) {
598
+ try {
599
+ return readFileSync2(entry.path, "utf-8");
600
+ } catch {
601
+ return "";
602
+ }
603
+ }
604
+ function scoreForEntry(scoreMap, item) {
605
+ return [...entryScoreAliases(item)].map((alias) => scoreMap.get(alias)).find(Boolean);
606
+ }
119
607
  var vaultTools = [
608
+ {
609
+ name: "vault_get",
610
+ description: "Read a bounded public-safe excerpt or capped content from a Research Vault note by id.",
611
+ inputSchema: {
612
+ type: "object",
613
+ properties: {
614
+ id: { type: "string", description: "Research Vault note id returned by vault_search" },
615
+ include_content: { type: "boolean", description: "Return capped content instead of the default excerpt" },
616
+ max_chars: { type: "number", description: "Maximum returned content characters, capped at 12000" }
617
+ },
618
+ required: ["id"]
619
+ },
620
+ call: async (args) => {
621
+ const result = getVaultEntry(args, scanKnowledge());
622
+ return {
623
+ content: [{ type: "text", text: JSON.stringify(result.envelope, null, 2) }],
624
+ ...result.isError ? { isError: true } : {}
625
+ };
626
+ }
627
+ },
120
628
  {
121
629
  name: "vault_search",
122
630
  description: "Search the Research Vault knowledge base. Returns analyzed papers with retention scores.",
123
631
  inputSchema: {
124
632
  type: "object",
125
633
  properties: {
126
- query: { type: "string", description: "Search query (matches title, category)" },
634
+ query: { type: "string", description: "Search query (matches title, content, id, and category)" },
127
635
  category: { type: "string", description: 'Filter by category (e.g., "ai-agents/benchmarking")' },
128
636
  limit: { type: "number", description: "Max results (default 10)" }
129
637
  }
@@ -131,17 +639,21 @@ var vaultTools = [
131
639
  call: async ({ query, category, limit = 10 }) => {
132
640
  let items = scanKnowledge();
133
641
  const scores = loadDecayScores();
134
- const scoreMap = new Map(scores.map((s) => [normalizeId(s.itemId), s]));
642
+ const scoreMap = new Map(scores.flatMap((s) => [...scoreAliases(s)].map((alias) => [alias, s])));
135
643
  if (category) {
136
644
  items = items.filter((item) => item.category === category || item.category.startsWith(category + "/"));
137
645
  }
138
646
  if (query) {
139
- const q = query.toLowerCase();
140
- items = items.filter((item) => item.title.toLowerCase().includes(q) || item.id.toLowerCase().includes(q) || item.category.toLowerCase().includes(q));
647
+ items = items.filter((item) => {
648
+ const content = readEntryContent(item);
649
+ return matchedFields({ ...item, content }, query).length > 0;
650
+ });
141
651
  }
142
652
  const results = items.slice(0, limit).map((item) => {
143
- const sid = item.id.replace(/--/g, "-");
144
- const score = scoreMap.get(item.id) || scoreMap.get(sid);
653
+ const content = readEntryContent(item);
654
+ const score = scoreForEntry(scoreMap, item);
655
+ const fields = matchedFields({ ...item, content }, query);
656
+ const freshness = itemFreshness({ ...item, score });
145
657
  return {
146
658
  id: item.id,
147
659
  title: item.title,
@@ -150,13 +662,25 @@ var vaultTools = [
150
662
  summaryLevel: score?.summaryLevel ?? null,
151
663
  nextReview: score?.nextReviewAt ?? null,
152
664
  accessCount: score?.accessCount ?? 0,
153
- modified: item.modified
665
+ modified: item.modified,
666
+ matched_fields: fields,
667
+ why_matched: whyMatched({ ...item, content }, query, fields),
668
+ snippet: snippetFromContent(content, query, 280),
669
+ source_ref: sourceRef2(item),
670
+ section_anchor: undefined,
671
+ canonical_group: undefined,
672
+ ...freshness
154
673
  };
155
674
  });
675
+ const hasStale = results.some((result) => result.freshness_verdict === "FLAG");
676
+ const envelope = okEnvelope({ query, category, results, total: results.length }, hasStale ? flagGuidance("Search completed, but one or more results lack fresh analysis metadata.", "Use source_ref for readonly follow-up and refresh analysis metadata in the operator lane if needed.", "vault_get") : passGuidance("Search completed with provenance and freshness metadata.", "Use source_ref or vault_get for bounded follow-up evidence.", "vault_get"), {
677
+ freshness: hasStale ? "Some search results are missing or stale analysis timestamps." : "Search result analysis metadata is fresh.",
678
+ provenance: "vault_search result source_ref values are vault:// references without local paths."
679
+ });
156
680
  return {
157
681
  content: [{
158
682
  type: "text",
159
- text: JSON.stringify({ query, category, results, total: results.length }, null, 2)
683
+ text: JSON.stringify(envelope, null, 2)
160
684
  }]
161
685
  };
162
686
  }
@@ -168,10 +692,19 @@ var vaultTools = [
168
692
  call: async () => {
169
693
  const scores = loadDecayScores();
170
694
  const entries = scanKnowledge();
171
- const deep = scores.filter((s) => s.summaryLevel === "deep");
172
- const shallow = scores.filter((s) => s.summaryLevel === "shallow");
173
- const none = scores.filter((s) => s.summaryLevel === "none");
174
- const sorted = [...scores].sort((a, b) => b.score - a.score);
695
+ const matchedScores = matchedScoresForEntries(scores, entries);
696
+ const deep = matchedScores.filter((s) => s.summaryLevel === "deep");
697
+ const shallow = matchedScores.filter((s) => s.summaryLevel === "shallow");
698
+ const none = matchedScores.filter((s) => s.summaryLevel === "none");
699
+ const sorted = [...matchedScores].sort((a, b) => b.score - a.score);
700
+ const queueItems = pendingRawQueueItems(entries);
701
+ const coverage = coverageMetadata({
702
+ total: entries.length,
703
+ analyzed: matchedScores.length,
704
+ scores: matchedScores,
705
+ queueItems
706
+ });
707
+ const release = releaseMetadata(process.env, getPackageJson());
175
708
  const top5 = sorted.slice(0, 5).map((s) => {
176
709
  const sid = s.itemId.replace(/--/g, "-");
177
710
  const entry = entries.find((e) => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid));
@@ -182,20 +715,29 @@ var vaultTools = [
182
715
  const entry = entries.find((e) => normalizeId(e.id) === normalizeId(s.itemId) || normalizeId(e.id) === normalizeId(sid));
183
716
  return { itemId: s.itemId, score: s.score, lastAccess: s.lastAccess.slice(0, 10), title: entry?.title || s.itemId };
184
717
  });
185
- const pending = scanRaw();
718
+ const statusData = {
719
+ total: entries.length,
720
+ analyzed: matchedScores.length,
721
+ deep: deep.length,
722
+ shallow: shallow.length,
723
+ dormant: none.length,
724
+ pending_raw: queueItems.length,
725
+ top5,
726
+ bottom5,
727
+ ...coverage,
728
+ release
729
+ };
730
+ const releaseFlag = release.freshness_verdict === "FLAG";
731
+ const analysisFlag = staleVerdict(coverage.last_analyzed_at).verdict === "FLAG";
732
+ const envelope = okEnvelope(statusData, releaseFlag || analysisFlag ? flagGuidance(releaseFlag ? "Vault status is available, but release freshness was not fully provided by the runtime environment." : "Vault status is available, but analysis freshness is stale or missing.", releaseFlag ? "Set RESEARCH_VAULT_NPM_LATEST_VERSION, RESEARCH_VAULT_NPM_MODIFIED_AT, and RESEARCH_VAULT_PUBLIC_REPO_URL in the runtime environment." : "Run the operator analysis lane before relying on freshness-sensitive claims.") : passGuidance("Vault status includes coverage, queue freshness, and release metadata.", "Use pending_raw and oldest_pending_age to decide whether operator analysis is needed."), {
733
+ as_of: coverage.as_of,
734
+ freshness: analysisFlag ? "Analysis freshness is stale or missing." : "Analysis freshness is within the accepted window.",
735
+ release: release.freshness_verdict
736
+ });
186
737
  return {
187
738
  content: [{
188
739
  type: "text",
189
- text: JSON.stringify({
190
- total: entries.length,
191
- analyzed: scores.length,
192
- deep: deep.length,
193
- shallow: shallow.length,
194
- dormant: none.length,
195
- pending_raw: pending.length,
196
- top5,
197
- bottom5
198
- }, null, 2)
740
+ text: JSON.stringify(envelope, null, 2)
199
741
  }]
200
742
  };
201
743
  }
@@ -210,25 +752,38 @@ var vaultTools = [
210
752
  }
211
753
  },
212
754
  call: async ({ count = 5 } = {}) => {
213
- const pending = scanRaw();
214
755
  const entries = scanKnowledge();
215
- const analyzedIds = new Set(entries.map((e) => normalizeId(e.id)));
216
- const unanalyzed = pending.filter((p) => {
217
- const id = normalizeId(p);
218
- return !analyzedIds.has(id);
219
- });
756
+ const unanalyzed = pendingRawQueueItems(entries);
757
+ const freshness = queueFreshness(unanalyzed);
220
758
  if (unanalyzed.length === 0) {
221
- return { content: [{ type: "text", text: JSON.stringify({ message: "Queue empty \u2014 all papers analyzed", analyzed: entries.length }) }] };
759
+ const envelope2 = okEnvelope({
760
+ message: "Queue empty; all visible raw papers are analyzed.",
761
+ analyzed: entries.length,
762
+ pending: 0,
763
+ preview: [],
764
+ oldest_pending_age: null,
765
+ next_action: "none"
766
+ }, passGuidance("Batch analysis queue is empty.", "No operator batch analysis action is needed."));
767
+ return { content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }] };
222
768
  }
769
+ const envelope = okEnvelope({
770
+ message: `${unanalyzed.length} papers pending analysis`,
771
+ pending: unanalyzed.length,
772
+ preview: unanalyzed.slice(0, count).map((item) => ({
773
+ id: item.id,
774
+ title: item.title
775
+ })),
776
+ oldest_pending_age: freshness.oldest_pending_age,
777
+ oldest_pending_at: freshness.oldest_pending_at,
778
+ next_action: "operator_run_batch_analysis"
779
+ }, flagGuidance("Raw queue has pending items that require operator-side batch analysis.", "Run the private operator batch analysis lane; this public response intentionally omits shell commands and local paths."), {
780
+ freshness: freshness.oldest_pending_age === null ? "Pending queue age could not be calculated." : `Oldest pending item is ${freshness.oldest_pending_age} days old.`,
781
+ provenance: "Queue preview exposes ids and titles only."
782
+ });
223
783
  return {
224
784
  content: [{
225
785
  type: "text",
226
- text: JSON.stringify({
227
- message: `${unanalyzed.length} papers pending analysis`,
228
- pending: unanalyzed.length,
229
- preview: unanalyzed.slice(0, count),
230
- hint: "cd ~/Desktop/research-vault && bun run scripts/batch-analyze.ts --count N"
231
- }, null, 2)
786
+ text: JSON.stringify(envelope, null, 2)
232
787
  }]
233
788
  };
234
789
  }
@@ -243,10 +798,13 @@ var vaultTools = [
243
798
  const catCounts = {};
244
799
  for (const e of entries)
245
800
  catCounts[e.category] = (catCounts[e.category] || 0) + 1;
801
+ const envelope = okEnvelope({ taxonomy, categories: catCounts }, passGuidance("Taxonomy loaded with public-safe evidence metadata.", "Use category counts for readonly routing and vault_search for bounded follow-up evidence.", "vault_search"), {
802
+ provenance: "Taxonomy response is sanitized through the public-safe envelope before serialization."
803
+ });
246
804
  return {
247
805
  content: [{
248
806
  type: "text",
249
- text: JSON.stringify({ taxonomy, categories: catCounts }, null, 2)
807
+ text: JSON.stringify(envelope, null, 2)
250
808
  }]
251
809
  };
252
810
  }
@@ -254,12 +812,12 @@ var vaultTools = [
254
812
  ];
255
813
 
256
814
  // src/vault_write.ts
257
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3, statSync as statSync2, mkdirSync as mkdirSync2, unlinkSync, realpathSync, readdirSync as readdirSync2 } from "fs";
258
- import { join as join3, dirname, basename as basename2, resolve as pathResolve } from "path";
815
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, unlinkSync, realpathSync } from "fs";
816
+ import { join as join3, dirname, basename as basename3, resolve as pathResolve } from "path";
259
817
  import { homedir as homedir2 } from "os";
260
818
 
261
819
  // src/vault_jobs.ts
262
- import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
820
+ import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
263
821
  import { join as join2 } from "path";
264
822
  import { createHash, randomUUID } from "crypto";
265
823
  var JOBS_FILE = "ingest-jobs.json";
@@ -282,7 +840,7 @@ class IngestJobStore {
282
840
  }
283
841
  loadJobs() {
284
842
  try {
285
- return JSON.parse(readFileSync2(this.jobsPath(), "utf-8"));
843
+ return JSON.parse(readFileSync3(this.jobsPath(), "utf-8"));
286
844
  } catch {
287
845
  return {};
288
846
  }
@@ -292,7 +850,7 @@ class IngestJobStore {
292
850
  }
293
851
  loadChecksums() {
294
852
  try {
295
- return JSON.parse(readFileSync2(this.checksumsPath(), "utf-8"));
853
+ return JSON.parse(readFileSync3(this.checksumsPath(), "utf-8"));
296
854
  } catch {
297
855
  return {};
298
856
  }
@@ -384,6 +942,11 @@ function parseArxivXml(xml) {
384
942
  }
385
943
 
386
944
  // src/ingest/html.ts
945
+ async function defaultLookup(hostname) {
946
+ const { lookup } = await import("dns/promises");
947
+ return lookup(hostname, { all: true });
948
+ }
949
+ var dnsLookup = defaultLookup;
387
950
  function validateUrl(url) {
388
951
  let parsed;
389
952
  try {
@@ -395,24 +958,107 @@ function validateUrl(url) {
395
958
  if (scheme !== "http:" && scheme !== "https:") {
396
959
  throw new Error(`URL scheme not allowed: ${scheme}. Only http/https permitted.`);
397
960
  }
398
- const hostname = parsed.hostname.toLowerCase();
399
- if (hostname === "169.254.169.254" || hostname === "metadata.google.internal") {
400
- throw new Error(`Cloud metadata endpoint blocked: ${hostname}`);
961
+ const hostname = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
962
+ validateHostnameLiteralPolicy(hostname);
963
+ if (hostname.includes(":")) {
964
+ validateIpv6(hostname, hostname);
965
+ return;
966
+ }
967
+ const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
968
+ if (ipMatch) {
969
+ validateIpv4(hostname, hostname);
970
+ return;
971
+ }
972
+ }
973
+ function validateHostnameLiteralPolicy(hostname) {
974
+ if (hostname === "localhost" || hostname === "metadata.google.internal") {
975
+ throw new Error(`Hostname not permitted: ${hostname}`);
976
+ }
977
+ }
978
+ function validateIpv4(ip, originalHostname) {
979
+ const parts = ip.split(".").map((p) => parseInt(p, 10));
980
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
981
+ throw new Error(`Invalid IPv4 address: ${originalHostname}`);
982
+ }
983
+ const [a, b] = parts;
984
+ if (a === 0)
985
+ throw new Error(`Reserved IP blocked: ${originalHostname}`);
986
+ if (a === 10)
987
+ throw new Error(`Private IP blocked: ${originalHostname}`);
988
+ if (a === 127)
989
+ throw new Error(`Loopback IP blocked: ${originalHostname}`);
990
+ if (a === 169 && b === 254 && parts[2] === 169 && parts[3] === 254) {
991
+ throw new Error(`Cloud metadata endpoint blocked: ${originalHostname}`);
992
+ }
993
+ if (a === 169 && b === 254)
994
+ throw new Error(`Link-local IP blocked: ${originalHostname}`);
995
+ if (a === 172 && b >= 16 && b <= 31)
996
+ throw new Error(`Private IP blocked: ${originalHostname}`);
997
+ if (a === 192 && b === 168)
998
+ throw new Error(`Private IP blocked: ${originalHostname}`);
999
+ }
1000
+ function validateIpv6(ip, originalHostname) {
1001
+ const stripped = ip.toLowerCase().split("%")[0];
1002
+ if (stripped === "::1" || stripped === "::") {
1003
+ throw new Error(`IPv6 loopback/unspecified blocked: ${originalHostname}`);
1004
+ }
1005
+ if (/^(fc|fd)[0-9a-f]{0,2}:/i.test(stripped)) {
1006
+ throw new Error(`IPv6 unique-local blocked: ${originalHostname}`);
401
1007
  }
402
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
403
- throw new Error(`Localhost not permitted: ${hostname}`);
1008
+ if (/^fe[89ab][0-9a-f]?:/i.test(stripped)) {
1009
+ throw new Error(`IPv6 link-local blocked: ${originalHostname}`);
404
1010
  }
405
- const ip = hostname;
406
- if (/^(10\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+)$/.test(ip)) {
407
- throw new Error(`Private IP not permitted: ${ip}`);
1011
+ const mappedV4 = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
1012
+ if (mappedV4) {
1013
+ validateIpv4(mappedV4[1], originalHostname);
408
1014
  }
409
- if (hostname.startsWith("169.254.")) {
410
- throw new Error(`Link-local IP blocked: ${hostname}`);
1015
+ }
1016
+ async function validateHostDns(hostname) {
1017
+ const stripped = hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
1018
+ validateHostnameLiteralPolicy(stripped);
1019
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(stripped))
1020
+ return;
1021
+ if (stripped.includes(":"))
1022
+ return;
1023
+ let resolved;
1024
+ try {
1025
+ resolved = await dnsLookup(stripped);
1026
+ } catch (e) {
1027
+ const msg = e instanceof Error ? e.message : String(e);
1028
+ throw new Error(`DNS lookup failed for ${hostname}: ${msg}`);
1029
+ }
1030
+ if (!resolved || resolved.length === 0) {
1031
+ throw new Error(`DNS lookup returned no records for ${hostname}`);
1032
+ }
1033
+ for (const { address, family } of resolved) {
1034
+ if (family === 4)
1035
+ validateIpv4(address, hostname);
1036
+ else if (family === 6)
1037
+ validateIpv6(address, hostname);
411
1038
  }
412
1039
  }
1040
+ var MAX_REDIRECTS = 5;
1041
+ async function safeFetch(url, init = {}) {
1042
+ let currentUrl = url;
1043
+ for (let hop = 0;hop <= MAX_REDIRECTS; hop++) {
1044
+ validateUrl(currentUrl);
1045
+ const parsed = new URL(currentUrl);
1046
+ const hostname = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
1047
+ await validateHostDns(hostname);
1048
+ const res = await fetch(currentUrl, { ...init, redirect: "manual" });
1049
+ if (res.status < 300 || res.status >= 400) {
1050
+ return res;
1051
+ }
1052
+ const location = res.headers.get("location");
1053
+ if (!location) {
1054
+ return res;
1055
+ }
1056
+ currentUrl = new URL(location, currentUrl).toString();
1057
+ }
1058
+ throw new Error(`Too many redirects (>${MAX_REDIRECTS}) starting from ${url}`);
1059
+ }
413
1060
  async function fetchHtml(url) {
414
- validateUrl(url);
415
- const res = await fetch(url, {
1061
+ const res = await safeFetch(url, {
416
1062
  headers: {
417
1063
  "User-Agent": "Mozilla/5.0 research-vault-mcp/1.1.0",
418
1064
  Accept: "text/html"
@@ -433,24 +1079,41 @@ async function fetchHtml(url) {
433
1079
  }
434
1080
 
435
1081
  // src/vault_write.ts
436
- var VAULT_ROOT2 = process.env.VAULT_ROOT ?? `${homedir2()}/Documents/Evensong/research-vault`;
437
- var KNOWLEDGE_DIR2 = join3(VAULT_ROOT2, "knowledge");
438
- var RAW_DIR2 = join3(VAULT_ROOT2, "raw");
439
- var DECAY_PATH2 = join3(VAULT_ROOT2, ".meta", "decay-scores.json");
440
- var CHECKSUMS_PATH = join3(VAULT_ROOT2, ".meta", "checksums.json");
1082
+ var DEFAULT_VAULT_ROOT2 = `${homedir2()}/Documents/Evensong/research-vault`;
1083
+ function getVaultRoot2() {
1084
+ return process.env.VAULT_ROOT ?? DEFAULT_VAULT_ROOT2;
1085
+ }
1086
+ function getKnowledgeDir2() {
1087
+ return join3(getVaultRoot2(), "knowledge");
1088
+ }
1089
+ function getRawDir2() {
1090
+ return join3(getVaultRoot2(), "raw");
1091
+ }
1092
+ function getDecayPath2() {
1093
+ return join3(getVaultRoot2(), ".meta", "decay-scores.json");
1094
+ }
1095
+ function getChecksumsPath() {
1096
+ return join3(getVaultRoot2(), ".meta", "checksums.json");
1097
+ }
441
1098
  function ensureDir(p) {
442
1099
  if (!existsSync3(p))
443
1100
  mkdirSync2(p, { recursive: true });
444
1101
  }
445
1102
  function safePath(root, target) {
446
1103
  const joined = join3(root, target);
1104
+ let resolvedRoot;
1105
+ try {
1106
+ resolvedRoot = realpathSync(root);
1107
+ } catch {
1108
+ resolvedRoot = pathResolve(root);
1109
+ }
447
1110
  let resolved;
448
1111
  try {
449
1112
  resolved = realpathSync(joined);
450
1113
  } catch {
451
- resolved = pathResolve(joined);
1114
+ resolved = pathResolve(resolvedRoot, target);
452
1115
  }
453
- const rootNorm = root.replace(/\\/g, "/").replace(/\/$/, "");
1116
+ const rootNorm = resolvedRoot.replace(/\\/g, "/").replace(/\/$/, "");
454
1117
  const resolvedNorm = resolved.replace(/\\/g, "/").replace(/\/$/, "");
455
1118
  if (!resolvedNorm.startsWith(rootNorm + "/") && resolvedNorm !== rootNorm) {
456
1119
  throw new Error("Path traversal detected: target outside vault root");
@@ -462,36 +1125,46 @@ function normalizeId2(raw) {
462
1125
  }
463
1126
  function loadDecayScores2() {
464
1127
  try {
465
- return JSON.parse(readFileSync3(DECAY_PATH2, "utf-8"));
1128
+ const data = JSON.parse(readFileSync4(getDecayPath2(), "utf-8"));
1129
+ if (Array.isArray(data))
1130
+ return data;
1131
+ if (data && typeof data === "object")
1132
+ return Object.values(data);
1133
+ return [];
466
1134
  } catch {
467
- return {};
1135
+ return [];
468
1136
  }
469
1137
  }
470
1138
  function saveDecayScores(scores) {
471
- ensureDir(dirname(DECAY_PATH2));
472
- writeFileSync2(DECAY_PATH2, JSON.stringify(scores, null, 2), "utf-8");
1139
+ const decayPath = getDecayPath2();
1140
+ ensureDir(dirname(decayPath));
1141
+ writeFileSync2(decayPath, JSON.stringify(scores, null, 2), "utf-8");
473
1142
  }
474
1143
  function loadChecksums() {
475
1144
  try {
476
- return JSON.parse(readFileSync3(CHECKSUMS_PATH, "utf-8"));
1145
+ return JSON.parse(readFileSync4(getChecksumsPath(), "utf-8"));
477
1146
  } catch {
478
1147
  return {};
479
1148
  }
480
1149
  }
481
1150
  function saveChecksums(store) {
482
- ensureDir(dirname(CHECKSUMS_PATH));
483
- writeFileSync2(CHECKSUMS_PATH, JSON.stringify(store, null, 2), "utf-8");
1151
+ const checksumsPath = getChecksumsPath();
1152
+ ensureDir(dirname(checksumsPath));
1153
+ writeFileSync2(checksumsPath, JSON.stringify(store, null, 2), "utf-8");
1154
+ }
1155
+ function getJobStore() {
1156
+ return new IngestJobStore(getVaultRoot2());
484
1157
  }
485
- var jobStore = new IngestJobStore(VAULT_ROOT2);
486
1158
  async function ingestArxiv(value, category) {
487
1159
  const id = parseArxivId(value);
488
1160
  if (!id)
489
1161
  throw new Error(`Invalid ArXiv ID: ${value}`);
1162
+ const jobStore = getJobStore();
1163
+ const metaPath = safePath(getRawDir2(), join3(category, `arxiv-${id}.meta.json`));
490
1164
  const job = await jobStore.createJob({ source: "arxiv", value: id, category });
491
1165
  await jobStore.updateJob(job.jobId, { status: "fetching" });
492
1166
  const metadata = await fetchArxivMetadata(id);
493
1167
  metadata.arxivId = id;
494
- const metaPath = join3(RAW_DIR2, category, `arxiv-${id}.meta.json`);
495
1168
  ensureDir(dirname(metaPath));
496
1169
  writeFileSync2(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
497
1170
  const hash = await computeChecksum(metaPath);
@@ -502,13 +1175,16 @@ async function ingestArxiv(value, category) {
502
1175
  return job;
503
1176
  }
504
1177
  async function ingestUrl(value, category) {
1178
+ const rawDir = getRawDir2();
1179
+ safePath(rawDir, category);
1180
+ const jobStore = getJobStore();
505
1181
  const job = await jobStore.createJob({ source: "url", value, category });
506
1182
  await jobStore.updateJob(job.jobId, { status: "fetching" });
507
1183
  (async () => {
508
1184
  try {
509
1185
  const text = await fetchHtml(value);
510
1186
  const safeName = value.replace(/[^a-z0-9]/gi, "_").slice(0, 64);
511
- const rawPath = join3(RAW_DIR2, category, `${Date.now()}--${safeName}.html`);
1187
+ const rawPath = safePath(rawDir, join3(category, `${Date.now()}--${safeName}.md`));
512
1188
  ensureDir(dirname(rawPath));
513
1189
  writeFileSync2(rawPath, text, "utf-8");
514
1190
  const hash = await computeChecksum(rawPath);
@@ -525,11 +1201,13 @@ async function ingestUrl(value, category) {
525
1201
  async function ingestFile(value, category) {
526
1202
  if (!existsSync3(value))
527
1203
  throw new Error(`File not found: ${value}`);
1204
+ const rawDir = getRawDir2();
1205
+ safePath(rawDir, category);
1206
+ const jobStore = getJobStore();
528
1207
  const job = await jobStore.createJob({ source: "file", value, category });
529
- const destDir = join3(RAW_DIR2, category);
530
- ensureDir(destDir);
531
- const destPath = join3(destDir, `${Date.now()}--${basename2(value)}`);
532
- const content = readFileSync3(value);
1208
+ const destPath = safePath(rawDir, join3(category, `${Date.now()}--${basename3(value)}`));
1209
+ ensureDir(dirname(destPath));
1210
+ const content = readFileSync4(value);
533
1211
  writeFileSync2(destPath, content);
534
1212
  const hash = await computeChecksum(destPath);
535
1213
  const checksums = loadChecksums();
@@ -541,7 +1219,7 @@ async function ingestFile(value, category) {
541
1219
  async function saveNote(input) {
542
1220
  const safeTitle = input.title.replace(/[^a-z0-9]/gi, "-").slice(0, 32);
543
1221
  const id = `${Date.now()}--${safeTitle}`;
544
- const filePath = safePath(KNOWLEDGE_DIR2, join3(input.category, `${id}.md`));
1222
+ const filePath = safePath(getKnowledgeDir2(), join3(input.category, `${id}.md`));
545
1223
  ensureDir(dirname(filePath));
546
1224
  const content = `# ${input.title}
547
1225
 
@@ -549,7 +1227,8 @@ ${input.content}
549
1227
  `;
550
1228
  writeFileSync2(filePath, content, "utf-8");
551
1229
  const scores = loadDecayScores2();
552
- scores[id] = {
1230
+ const filtered = scores.filter((s) => normalizeId2(s.itemId) !== normalizeId2(id));
1231
+ filtered.push({
553
1232
  itemId: id,
554
1233
  score: 0.5,
555
1234
  lastAccess: new Date().toISOString(),
@@ -557,44 +1236,20 @@ ${input.content}
557
1236
  summaryLevel: input.summaryLevel ?? "none",
558
1237
  nextReviewAt: new Date().toISOString(),
559
1238
  difficulty: 0.5
560
- };
561
- saveDecayScores(scores);
1239
+ });
1240
+ saveDecayScores(filtered);
562
1241
  const hash = await computeChecksum(filePath);
563
1242
  const checksums = loadChecksums();
564
1243
  checksums[filePath] = { sha256: hash, writtenAt: new Date().toISOString() };
565
1244
  saveChecksums(checksums);
566
1245
  return { id, path: filePath, writtenAt: new Date().toISOString() };
567
1246
  }
568
- function getEntry(input) {
569
- let filePath;
570
- if (input.path) {
571
- filePath = safePath(VAULT_ROOT2, input.path);
572
- } else if (input.id) {
573
- const entry = scanKnowledge2().find((e) => normalizeId2(e.id) === normalizeId2(input.id));
574
- if (!entry)
575
- throw new Error(`Entry not found: ${input.id}`);
576
- filePath = entry.path;
577
- } else {
578
- throw new Error("id or path required");
579
- }
580
- const content = readFileSync3(filePath, "utf-8");
581
- const s = statSync2(filePath);
582
- const relPath = filePath.replace(VAULT_ROOT2 + "/", "");
583
- return {
584
- id: normalizeId2(basename2(filePath)),
585
- title: content.match(/^#\s+(.+)/m)?.[1] ?? normalizeId2(basename2(filePath)),
586
- category: relPath.includes("/") ? relPath.split("/").slice(0, -1).join("/") : "",
587
- content,
588
- modified: s.mtime.toISOString(),
589
- size: s.size
590
- };
591
- }
592
1247
  function deleteEntry(input) {
593
1248
  let filePath;
594
1249
  if (input.path) {
595
- filePath = safePath(VAULT_ROOT2, input.path);
1250
+ filePath = safePath(getVaultRoot2(), input.path);
596
1251
  } else if (input.id) {
597
- const entry = scanKnowledge2().find((e) => normalizeId2(e.id) === normalizeId2(input.id));
1252
+ const entry = scanKnowledge().find((e) => normalizeId2(e.id) === normalizeId2(input.id));
598
1253
  if (!entry)
599
1254
  throw new Error(`Entry not found: ${input.id}`);
600
1255
  filePath = entry.path;
@@ -602,46 +1257,15 @@ function deleteEntry(input) {
602
1257
  throw new Error("id or path required");
603
1258
  }
604
1259
  unlinkSync(filePath);
605
- const id = normalizeId2(basename2(filePath));
1260
+ const id = normalizeId2(basename3(filePath));
606
1261
  const scores = loadDecayScores2();
607
- delete scores[id];
608
- saveDecayScores(scores);
1262
+ const filtered = scores.filter((s) => normalizeId2(s.itemId) !== normalizeId2(id));
1263
+ saveDecayScores(filtered);
609
1264
  const checksums = loadChecksums();
610
1265
  delete checksums[filePath];
611
1266
  saveChecksums(checksums);
612
1267
  return { deleted: true, path: filePath };
613
1268
  }
614
- function scanKnowledge2() {
615
- const entries = [];
616
- if (!existsSync3(KNOWLEDGE_DIR2))
617
- return entries;
618
- try {
619
- const categories = readdirSync2(KNOWLEDGE_DIR2);
620
- for (const cat of categories) {
621
- if (cat.startsWith("_"))
622
- continue;
623
- const catPath = join3(KNOWLEDGE_DIR2, cat);
624
- if (!existsSync3(catPath) || !statSync2(catPath).isDirectory())
625
- continue;
626
- try {
627
- const files = readdirSync2(catPath).filter((f) => f.endsWith(".md"));
628
- for (const file of files) {
629
- const fp = join3(catPath, file);
630
- const s = statSync2(fp);
631
- entries.push({
632
- id: normalizeId2(file),
633
- title: normalizeId2(file),
634
- category: cat,
635
- path: fp,
636
- modified: s.mtime.toISOString(),
637
- size: s.size
638
- });
639
- }
640
- } catch {}
641
- }
642
- } catch {}
643
- return entries;
644
- }
645
1269
  var vaultWriteTools = [
646
1270
  {
647
1271
  name: "vault_raw_ingest",
@@ -651,7 +1275,7 @@ var vaultWriteTools = [
651
1275
  properties: {
652
1276
  source: { type: "string", enum: ["url", "file", "arxiv"] },
653
1277
  value: { type: "string", description: "URL / absolute file path / ArXiv ID or URL" },
654
- category: { type: "string", description: 'raw/ subdirectory, default "inbox"' },
1278
+ category: { type: "string", description: 'raw/ subdirectory, default "_inbox"' },
655
1279
  priority: { type: "string", enum: ["high", "low"], default: "low" },
656
1280
  arxivMetadata: { type: "boolean", description: "ArXiv: fetch metadata before storing, default true" }
657
1281
  },
@@ -659,7 +1283,7 @@ var vaultWriteTools = [
659
1283
  },
660
1284
  call: async (args) => {
661
1285
  try {
662
- const category = args.category ?? "inbox";
1286
+ const category = args.category ?? "_inbox";
663
1287
  let job;
664
1288
  if (args.source === "arxiv") {
665
1289
  job = await ingestArxiv(args.value, category);
@@ -697,25 +1321,6 @@ var vaultWriteTools = [
697
1321
  }
698
1322
  }
699
1323
  },
700
- {
701
- name: "vault_get",
702
- description: "Read full content of a vault entry by id or path.",
703
- inputSchema: {
704
- type: "object",
705
- properties: {
706
- id: { type: "string" },
707
- path: { type: "string" }
708
- }
709
- },
710
- call: async (args) => {
711
- try {
712
- const result = getEntry(args);
713
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
714
- } catch (e) {
715
- return { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true };
716
- }
717
- }
718
- },
719
1324
  {
720
1325
  name: "vault_delete",
721
1326
  description: "Delete a vault entry (raw or knowledge).",
@@ -815,57 +1420,52 @@ var amplifyTools = [
815
1420
  const reader = res.body?.getReader();
816
1421
  if (!reader)
817
1422
  throw new Error("No response body");
818
- let fullText = "";
819
1423
  const decoder = new TextDecoder;
1424
+ let buffer = "";
1425
+ let fullText = "";
1426
+ const processEventBlock = (block) => {
1427
+ for (const line of block.split(`
1428
+ `)) {
1429
+ if (!line.startsWith("data: "))
1430
+ continue;
1431
+ let parsed;
1432
+ try {
1433
+ parsed = JSON.parse(line.slice(6));
1434
+ } catch {
1435
+ continue;
1436
+ }
1437
+ let textChunk = "";
1438
+ if (parsed?.data?.content) {
1439
+ textChunk = parsed.data.content;
1440
+ } else if (parsed?.data) {
1441
+ textChunk = typeof parsed.data === "string" ? parsed.data : JSON.stringify(parsed.data);
1442
+ }
1443
+ if (textChunk) {
1444
+ fullText += textChunk;
1445
+ if (stream && onProgress) {
1446
+ onProgress({ type: "chunk", text: textChunk });
1447
+ }
1448
+ }
1449
+ }
1450
+ };
820
1451
  while (true) {
821
1452
  const { done, value } = await reader.read();
822
1453
  if (done)
823
1454
  break;
824
- const chunk = decoder.decode(value, { stream: true });
825
- for (const line of chunk.split(`
826
- `)) {
827
- if (line.startsWith("data: ")) {
828
- try {
829
- const parsed = JSON.parse(line.slice(6));
830
- if (parsed.data?.content)
831
- fullText += parsed.data.content;
832
- else if (parsed.data)
833
- fullText += typeof parsed.data === "string" ? parsed.data : JSON.stringify(parsed.data);
834
- } catch {}
835
- }
1455
+ buffer += decoder.decode(value, { stream: true });
1456
+ let sep;
1457
+ while ((sep = buffer.indexOf(`
1458
+
1459
+ `)) !== -1) {
1460
+ const eventBlock = buffer.slice(0, sep);
1461
+ buffer = buffer.slice(sep + 2);
1462
+ processEventBlock(eventBlock);
836
1463
  }
837
1464
  }
838
- if (stream && onProgress) {
839
- const res2 = await fetch(`${AMPLIFY_BASE}/chat`, {
840
- method: "POST",
841
- headers: getHeaders(),
842
- body: JSON.stringify(body)
843
- });
844
- if (!res2.ok)
845
- throw new Error(`HTTP ${res2.status}`);
846
- const reader2 = res2.body?.getReader();
847
- if (!reader2)
848
- throw new Error("No response body");
849
- const decoder2 = new TextDecoder;
850
- let buffer2 = "";
851
- while (true) {
852
- const { done, value } = await reader2.read();
853
- if (done)
854
- break;
855
- buffer2 += decoder2.decode(value, { stream: true });
856
- for (const line of buffer2.split(`
857
- `)) {
858
- if (line.startsWith("data: ")) {
859
- try {
860
- const parsed = JSON.parse(line.slice(6));
861
- if (parsed.data?.content) {
862
- onProgress({ type: "chunk", text: parsed.data.content });
863
- }
864
- } catch {}
865
- }
866
- }
867
- }
868
- return { content: [{ type: "text", text: "(streamed)" }] };
1465
+ buffer += decoder.decode();
1466
+ if (buffer.length > 0) {
1467
+ processEventBlock(buffer);
1468
+ buffer = "";
869
1469
  }
870
1470
  return {
871
1471
  content: [{ type: "text", text: fullText || "(no response)" }]
@@ -946,10 +1546,71 @@ var amplifyTools = [
946
1546
  }
947
1547
  ];
948
1548
 
1549
+ // src/tool_policy.ts
1550
+ var READONLY_TOOL_NAMES = new Set([
1551
+ "vault_status",
1552
+ "vault_taxonomy",
1553
+ "vault_search",
1554
+ "vault_get",
1555
+ "vault_batch_analyze"
1556
+ ]);
1557
+ var MUTATION_TOOL_NAMES = new Set([
1558
+ "vault_raw_ingest",
1559
+ "vault_note_save",
1560
+ "vault_delete"
1561
+ ]);
1562
+ function isDeleteOrAdminTool(name) {
1563
+ return name === "vault_delete" || name.includes("_delete") || name.includes("_admin") || name.startsWith("admin_");
1564
+ }
1565
+ function visibleToolsForProfile(tools, profile = getActiveProfile()) {
1566
+ return tools.filter((tool) => isToolAllowed(tool.name, profile));
1567
+ }
1568
+ function isToolAllowed(name, profile = getActiveProfile()) {
1569
+ if (profile === "admin")
1570
+ return true;
1571
+ if (profile === "readonly")
1572
+ return READONLY_TOOL_NAMES.has(name);
1573
+ return !isDeleteOrAdminTool(name);
1574
+ }
1575
+ function blockedToolResponse(name, profile = getActiveProfile()) {
1576
+ const guidance = isDeleteOrAdminTool(name) ? adminBlockedGuidance(name, profile) : readonlyBlockedGuidance(name, profile);
1577
+ return {
1578
+ content: [{
1579
+ type: "text",
1580
+ text: JSON.stringify({
1581
+ ...errorEnvelope(guidance.reason, guidance.next_step, { profile }),
1582
+ agent_guidance: guidance
1583
+ })
1584
+ }],
1585
+ isError: true
1586
+ };
1587
+ }
1588
+ function configureAllowed(profile = getActiveProfile()) {
1589
+ return profileAllowsMutation(profile);
1590
+ }
1591
+
949
1592
  // src/server.ts
1593
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
1594
+ function loadAmplifyFromEnv() {
1595
+ if (process.env.AMPLIFY_API_KEY) {
1596
+ configureAmplify(process.env.AMPLIFY_API_KEY);
1597
+ console.error("[MCP] Loaded Amplify API key from AMPLIFY_API_KEY env var");
1598
+ return true;
1599
+ }
1600
+ return false;
1601
+ }
1602
+ loadAmplifyFromEnv();
950
1603
  var HOST = "0.0.0.0";
951
1604
  var TRANSPORT = process.env.MCP_TRANSPORT ?? "stdio";
952
1605
  var PORT = parseInt(process.env.MCP_PORT ?? "8765");
1606
+ var SUPPORTED_PROTOCOL_VERSIONS = [
1607
+ "2025-11-25",
1608
+ "2025-06-18",
1609
+ "2025-03-26",
1610
+ "2024-11-05",
1611
+ "2024-10-07"
1612
+ ];
1613
+ var DEFAULT_STREAMABLE_PROTOCOL_VERSION = "2025-03-26";
953
1614
  var allTools = [
954
1615
  ...vaultTools,
955
1616
  ...vaultWriteTools,
@@ -957,12 +1618,40 @@ var allTools = [
957
1618
  ];
958
1619
  var toolMap = new Map(allTools.map((t) => [t.name, t]));
959
1620
  var sessions = new Map;
1621
+ var streamableSessions = new Set;
960
1622
  function makeResponse(id, result, error) {
961
1623
  return { jsonrpc: "2.0", id, result, error };
962
1624
  }
963
1625
  function generateSessionId() {
964
1626
  return crypto.randomUUID();
965
1627
  }
1628
+ function timingSafeStringEqual(a, b) {
1629
+ const ah = createHash2("sha256").update(a).digest();
1630
+ const bh = createHash2("sha256").update(b).digest();
1631
+ return timingSafeEqual(ah, bh);
1632
+ }
1633
+ function negotiateProtocolVersion(requested) {
1634
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
1635
+ return requested;
1636
+ }
1637
+ return DEFAULT_STREAMABLE_PROTOCOL_VERSION;
1638
+ }
1639
+ function mcpResponseHeaders(sessionId) {
1640
+ const headers = new Headers;
1641
+ if (sessionId)
1642
+ headers.set("mcp-session-id", sessionId);
1643
+ return headers;
1644
+ }
1645
+ function makeMcpJsonError(status, code, message, sessionId) {
1646
+ return Response.json({
1647
+ jsonrpc: "2.0",
1648
+ error: { code, message },
1649
+ id: null
1650
+ }, {
1651
+ status,
1652
+ headers: mcpResponseHeaders(sessionId)
1653
+ });
1654
+ }
966
1655
  async function handleRequest(req) {
967
1656
  const { method, id, params } = req;
968
1657
  if (method === "notifications/initialized" || method === "notifications/cancelled") {
@@ -970,7 +1659,7 @@ async function handleRequest(req) {
970
1659
  }
971
1660
  if (method === "initialize") {
972
1661
  return makeResponse(id, {
973
- protocolVersion: "2024-11-05",
1662
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
974
1663
  capabilities: {
975
1664
  tools: { listChanged: false }
976
1665
  },
@@ -981,8 +1670,9 @@ async function handleRequest(req) {
981
1670
  });
982
1671
  }
983
1672
  if (method === "tools/list") {
1673
+ const visibleTools = visibleToolsForProfile(allTools, getActiveProfile());
984
1674
  return makeResponse(id, {
985
- tools: allTools.map((t) => ({
1675
+ tools: visibleTools.map((t) => ({
986
1676
  name: t.name,
987
1677
  description: t.description,
988
1678
  inputSchema: t.inputSchema
@@ -992,6 +1682,9 @@ async function handleRequest(req) {
992
1682
  if (method === "tools/call") {
993
1683
  const { name, arguments: args } = params;
994
1684
  console.error("[DEBUG] tools/call:", name, JSON.stringify(args));
1685
+ if (!isToolAllowed(name, getActiveProfile())) {
1686
+ return makeResponse(id, blockedToolResponse(name, getActiveProfile()));
1687
+ }
995
1688
  const tool = toolMap.get(name);
996
1689
  if (!tool) {
997
1690
  return makeResponse(id, undefined, { code: -32602, message: `Unknown tool: ${name}` });
@@ -1031,109 +1724,201 @@ async function handleStdioTransport() {
1031
1724
  }
1032
1725
  }
1033
1726
  var server;
1034
- if (TRANSPORT !== "stdio") {
1035
- server = Bun.serve({
1036
- port: PORT,
1037
- hostname: HOST,
1038
- async fetch(req) {
1039
- const url = new URL(req.url);
1040
- if (url.pathname === "/sse" && req.method === "GET") {
1041
- const sessionId = generateSessionId();
1042
- const stream = new ReadableStream({
1043
- start(controller) {
1044
- const encoder = new TextEncoder;
1045
- const send = (data) => {
1046
- try {
1047
- controller.enqueue(encoder.encode(data));
1048
- } catch {}
1049
- };
1050
- send(`event: endpoint
1727
+ async function httpHandler(req) {
1728
+ const url = new URL(req.url);
1729
+ if (url.pathname === "/mcp" && req.method === "POST") {
1730
+ let body;
1731
+ try {
1732
+ body = await req.json();
1733
+ } catch (e) {
1734
+ return makeMcpJsonError(400, -32700, `Parse error: ${e.message}`);
1735
+ }
1736
+ const messages = Array.isArray(body) ? body : [body];
1737
+ if (messages.length === 0) {
1738
+ return makeMcpJsonError(400, -32600, "Invalid Request: empty batch");
1739
+ }
1740
+ const hasInitialize = messages.some((message) => message?.method === "initialize");
1741
+ let sessionId = req.headers.get("mcp-session-id") ?? undefined;
1742
+ if (hasInitialize) {
1743
+ if (messages.length > 1) {
1744
+ return makeMcpJsonError(400, -32600, "Invalid Request: initialize must be sent alone");
1745
+ }
1746
+ sessionId = generateSessionId();
1747
+ streamableSessions.add(sessionId);
1748
+ } else {
1749
+ if (!sessionId) {
1750
+ return makeMcpJsonError(400, -32000, "Bad Request: Mcp-Session-Id header is required");
1751
+ }
1752
+ if (!streamableSessions.has(sessionId)) {
1753
+ return makeMcpJsonError(404, -32001, "Session not found", sessionId);
1754
+ }
1755
+ }
1756
+ const responses = [];
1757
+ for (const message of messages) {
1758
+ const result = await handleRequest(message);
1759
+ if (result)
1760
+ responses.push(result);
1761
+ }
1762
+ if (responses.length === 0) {
1763
+ return new Response(null, {
1764
+ status: 202,
1765
+ headers: mcpResponseHeaders(sessionId)
1766
+ });
1767
+ }
1768
+ return Response.json(Array.isArray(body) ? responses : responses[0], {
1769
+ status: 200,
1770
+ headers: mcpResponseHeaders(sessionId)
1771
+ });
1772
+ }
1773
+ if (url.pathname === "/mcp" && req.method === "GET") {
1774
+ return Response.json({
1775
+ jsonrpc: "2.0",
1776
+ error: { code: -32000, message: "Method Not Allowed: /mcp supports POST JSON responses" },
1777
+ id: null
1778
+ }, {
1779
+ status: 405,
1780
+ headers: {
1781
+ Allow: "POST, DELETE"
1782
+ }
1783
+ });
1784
+ }
1785
+ if (url.pathname === "/mcp" && req.method === "DELETE") {
1786
+ const sessionId = req.headers.get("mcp-session-id") ?? undefined;
1787
+ if (!sessionId) {
1788
+ return makeMcpJsonError(400, -32000, "Bad Request: Mcp-Session-Id header is required");
1789
+ }
1790
+ if (!streamableSessions.has(sessionId)) {
1791
+ return makeMcpJsonError(404, -32001, "Session not found", sessionId);
1792
+ }
1793
+ streamableSessions.delete(sessionId);
1794
+ return new Response(null, { status: 204 });
1795
+ }
1796
+ if (url.pathname === "/sse" && req.method === "GET") {
1797
+ const sessionId = generateSessionId();
1798
+ const stream = new ReadableStream({
1799
+ start(controller) {
1800
+ const encoder = new TextEncoder;
1801
+ const send = (data) => {
1802
+ try {
1803
+ controller.enqueue(encoder.encode(data));
1804
+ } catch {}
1805
+ };
1806
+ send(`event: endpoint
1051
1807
  data: /messages?sessionId=${sessionId}
1052
1808
 
1053
1809
  `);
1054
- const heartbeat = setInterval(() => {
1055
- try {
1056
- controller.enqueue(encoder.encode(`: heartbeat
1810
+ const heartbeat = setInterval(() => {
1811
+ try {
1812
+ controller.enqueue(encoder.encode(`: heartbeat
1057
1813
 
1058
1814
  `));
1059
- } catch {
1060
- clearInterval(heartbeat);
1061
- sessions.delete(sessionId);
1062
- }
1063
- }, 15000);
1064
- sessions.set(sessionId, { send, heartbeat });
1065
- console.error(`[SSE] Session ${sessionId} connected`);
1066
- req.signal.addEventListener("abort", () => {
1067
- clearInterval(heartbeat);
1068
- sessions.delete(sessionId);
1069
- console.error(`[SSE] Session ${sessionId} disconnected`);
1070
- });
1071
- }
1072
- });
1073
- return new Response(stream, {
1074
- status: 200,
1075
- headers: {
1076
- "Content-Type": "text/event-stream",
1077
- "Cache-Control": "no-cache",
1078
- Connection: "keep-alive",
1079
- "X-Accel-Buffering": "no"
1815
+ } catch {
1816
+ clearInterval(heartbeat);
1817
+ sessions.delete(sessionId);
1080
1818
  }
1819
+ }, 15000);
1820
+ sessions.set(sessionId, { send, heartbeat });
1821
+ console.error(`[SSE] Session ${sessionId} connected`);
1822
+ req.signal.addEventListener("abort", () => {
1823
+ clearInterval(heartbeat);
1824
+ sessions.delete(sessionId);
1825
+ console.error(`[SSE] Session ${sessionId} disconnected`);
1081
1826
  });
1082
1827
  }
1083
- if (url.pathname === "/messages" && req.method === "POST") {
1084
- const sessionId = url.searchParams.get("sessionId");
1085
- if (!sessionId || !sessions.has(sessionId)) {
1086
- return Response.json({ error: "Invalid or missing sessionId" }, { status: 400 });
1087
- }
1088
- const session = sessions.get(sessionId);
1089
- try {
1090
- const body = await req.json();
1091
- const result = await handleRequest(body);
1092
- if (result) {
1093
- session.send(`event: message
1828
+ });
1829
+ return new Response(stream, {
1830
+ status: 200,
1831
+ headers: {
1832
+ "Content-Type": "text/event-stream",
1833
+ "Cache-Control": "no-cache",
1834
+ Connection: "keep-alive",
1835
+ "X-Accel-Buffering": "no"
1836
+ }
1837
+ });
1838
+ }
1839
+ if (url.pathname === "/messages" && req.method === "POST") {
1840
+ const sessionId = url.searchParams.get("sessionId");
1841
+ if (!sessionId || !sessions.has(sessionId)) {
1842
+ return Response.json({ error: "Invalid or missing sessionId" }, { status: 400 });
1843
+ }
1844
+ const session = sessions.get(sessionId);
1845
+ try {
1846
+ const body = await req.json();
1847
+ const result = await handleRequest(body);
1848
+ if (result) {
1849
+ session.send(`event: message
1094
1850
  data: ${JSON.stringify(result)}
1095
1851
 
1096
1852
  `);
1097
- }
1098
- return new Response(null, { status: 202 });
1099
- } catch (e) {
1100
- return Response.json({ jsonrpc: "2.0", error: { code: -32700, message: `Parse error: ${e.message}` } }, { status: 400 });
1101
- }
1102
1853
  }
1103
- if (url.pathname === "/health" && req.method === "GET") {
1104
- return Response.json({
1105
- status: "ok",
1106
- tools: allTools.length,
1107
- vault_tools: vaultTools.length,
1108
- amplify_tools: amplifyTools.length,
1109
- sse_sessions: sessions.size,
1110
- uptime: process.uptime()
1111
- });
1112
- }
1113
- if (url.pathname === "/configure" && req.method === "POST") {
1114
- try {
1115
- const { apiKey } = await req.json();
1116
- if (!apiKey)
1117
- throw new Error("apiKey required");
1118
- configureAmplify(apiKey);
1119
- return Response.json({ status: "configured" });
1120
- } catch (e) {
1121
- return Response.json({ error: e.message }, { status: 400 });
1122
- }
1854
+ return new Response(null, { status: 202 });
1855
+ } catch (e) {
1856
+ return Response.json({ jsonrpc: "2.0", error: { code: -32700, message: `Parse error: ${e.message}` } }, { status: 400 });
1857
+ }
1858
+ }
1859
+ if (url.pathname === "/health" && req.method === "GET") {
1860
+ const profile = getActiveProfile();
1861
+ const visibleTools = visibleToolsForProfile(allTools, profile);
1862
+ return Response.json({
1863
+ status: "ok",
1864
+ profile,
1865
+ public_safe_default: true,
1866
+ tools: visibleTools.length,
1867
+ total_registered_tools: allTools.length,
1868
+ visible_tools: visibleTools.map((tool) => tool.name),
1869
+ vault_tools: vaultTools.length,
1870
+ amplify_tools: amplifyTools.length,
1871
+ sse_sessions: sessions.size,
1872
+ streamable_sessions: streamableSessions.size,
1873
+ uptime: process.uptime()
1874
+ });
1875
+ }
1876
+ if (url.pathname === "/configure" && req.method === "POST") {
1877
+ const profile = getActiveProfile();
1878
+ if (!configureAllowed(profile)) {
1879
+ return Response.json(errorEnvelope(`/configure is unavailable while Research Vault MCP is running in ${profile} profile.`, "Set MCP_PROFILE=full or MCP_PROFILE=admin in a private operator session before configuring mutation-capable tools.", { profile }), { status: 403 });
1880
+ }
1881
+ const requiredSecret = process.env.MCP_CONFIGURE_SECRET;
1882
+ if (requiredSecret) {
1883
+ const providedSecret = req.headers.get("x-configure-secret") ?? "";
1884
+ if (!timingSafeStringEqual(providedSecret, requiredSecret)) {
1885
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1123
1886
  }
1124
- return Response.json({ error: "Not found" }, { status: 404 });
1125
1887
  }
1888
+ try {
1889
+ const { apiKey } = await req.json();
1890
+ if (!apiKey)
1891
+ throw new Error("apiKey required");
1892
+ configureAmplify(apiKey);
1893
+ return Response.json({ status: "configured" });
1894
+ } catch (e) {
1895
+ return Response.json({ error: e.message }, { status: 400 });
1896
+ }
1897
+ }
1898
+ return Response.json({ error: "Not found" }, { status: 404 });
1899
+ }
1900
+ function startHttpServer() {
1901
+ server = Bun.serve({
1902
+ port: PORT,
1903
+ hostname: HOST,
1904
+ fetch: httpHandler
1126
1905
  });
1127
1906
  }
1128
- if (TRANSPORT === "stdio") {
1129
- console.error("[MCP] Running in stdio mode (stdin/stdout JSON-RPC)");
1130
- await handleStdioTransport();
1131
- process.exit(0);
1132
- } else {
1133
- console.log(`
1907
+ if (import.meta.main) {
1908
+ if (TRANSPORT === "stdio") {
1909
+ console.error("[MCP] Running in stdio mode (stdin/stdout JSON-RPC)");
1910
+ await handleStdioTransport();
1911
+ process.exit(0);
1912
+ } else {
1913
+ if (!process.env.MCP_CONFIGURE_SECRET) {
1914
+ console.error("[MCP] WARNING: /configure endpoint is unauthenticated. Set MCP_CONFIGURE_SECRET to require X-Configure-Secret header. Use AMPLIFY_API_KEY env var to skip /configure entirely.");
1915
+ }
1916
+ startHttpServer();
1917
+ console.log(`
1134
1918
  \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
1135
- \u2551 Research Vault MCP Server \u2014 MCP SSE Transport \u2551
1919
+ \u2551 Research Vault MCP Server \u2014 MCP HTTP Transport \u2551
1136
1920
  \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
1921
+ \u2551 MCP: http://${HOST}:${PORT}/mcp \u2551
1137
1922
  \u2551 SSE: http://${HOST}:${PORT}/sse \u2551
1138
1923
  \u2551 Messages: http://${HOST}:${PORT}/messages \u2551
1139
1924
  \u2551 Health: http://${HOST}:${PORT}/health \u2551
@@ -1141,14 +1926,20 @@ if (TRANSPORT === "stdio") {
1141
1926
  \u2551 Tools: ${String(allTools.length).padEnd(3)} (${vaultTools.length} vault, ${amplifyTools.length} amplify) \u2551
1142
1927
  \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
1143
1928
  `);
1144
- }
1145
- process.on("SIGINT", () => {
1146
- console.log(`
1147
- Shutting down...`);
1148
- for (const [id, session] of sessions) {
1149
- clearInterval(session.heartbeat);
1150
1929
  }
1151
- sessions.clear();
1152
- server?.stop();
1153
- process.exit(0);
1154
- });
1930
+ process.on("SIGINT", () => {
1931
+ console.log(`
1932
+ Shutting down...`);
1933
+ for (const [id, session] of sessions) {
1934
+ clearInterval(session.heartbeat);
1935
+ }
1936
+ sessions.clear();
1937
+ streamableSessions.clear();
1938
+ server?.stop();
1939
+ process.exit(0);
1940
+ });
1941
+ }
1942
+ export {
1943
+ loadAmplifyFromEnv,
1944
+ httpHandler
1945
+ };