@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/CHANGELOG.md +35 -0
- package/README.md +34 -7
- package/dist/server.js +1114 -323
- package/package.json +6 -5
- package/src/amplify.ts +32 -41
- package/src/evidence_metadata.ts +191 -0
- package/src/guidance.ts +57 -0
- package/src/ingest/html.ts +129 -19
- package/src/profile.ts +15 -0
- package/src/public_safety.ts +110 -0
- package/src/response.ts +73 -0
- package/src/server.ts +304 -108
- package/src/tool_policy.ts +58 -0
- package/src/types.ts +4 -3
- package/src/vault.ts +300 -75
- package/src/vault_get.ts +109 -0
- package/src/vault_write.ts +78 -112
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
465
|
+
const s = statSync2(filePath);
|
|
44
466
|
return {
|
|
45
|
-
title: title || normalizeId(
|
|
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(
|
|
472
|
+
return { title: normalizeId(basename2(filePath)), modified: "", size: 0 };
|
|
51
473
|
}
|
|
52
474
|
}
|
|
53
475
|
function scanKnowledge() {
|
|
54
476
|
const entries = [];
|
|
55
|
-
|
|
477
|
+
const knowledgeDir = getKnowledgeDir();
|
|
478
|
+
if (!existsSync(knowledgeDir))
|
|
56
479
|
return entries;
|
|
57
|
-
const categories = readdirSync(
|
|
480
|
+
const categories = readdirSync(knowledgeDir);
|
|
58
481
|
for (const cat of categories) {
|
|
59
482
|
if (cat.startsWith("_"))
|
|
60
483
|
continue;
|
|
61
|
-
const catPath = join(
|
|
62
|
-
if (!existsSync(catPath) || !
|
|
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 =
|
|
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
|
-
|
|
522
|
+
const rawDir = getRawDir();
|
|
523
|
+
if (!existsSync(rawDir))
|
|
100
524
|
return pending;
|
|
101
525
|
try {
|
|
102
|
-
const entries = readdirSync(
|
|
526
|
+
const entries = readdirSync(rawDir);
|
|
103
527
|
for (const entry of entries) {
|
|
104
528
|
if (entry === "_inbox") {
|
|
105
|
-
const inbox = join(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
const score = scoreMap
|
|
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(
|
|
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
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
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
|
|
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
|
|
216
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
|
258
|
-
import { join as join3, dirname, basename as
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
400
|
-
|
|
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 (
|
|
403
|
-
throw new Error(`
|
|
1008
|
+
if (/^fe[89ab][0-9a-f]?:/i.test(stripped)) {
|
|
1009
|
+
throw new Error(`IPv6 link-local blocked: ${originalHostname}`);
|
|
404
1010
|
}
|
|
405
|
-
const
|
|
406
|
-
if (
|
|
407
|
-
|
|
1011
|
+
const mappedV4 = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
1012
|
+
if (mappedV4) {
|
|
1013
|
+
validateIpv4(mappedV4[1], originalHostname);
|
|
408
1014
|
}
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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(
|
|
1114
|
+
resolved = pathResolve(resolvedRoot, target);
|
|
452
1115
|
}
|
|
453
|
-
const rootNorm =
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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(
|
|
1145
|
+
return JSON.parse(readFileSync4(getChecksumsPath(), "utf-8"));
|
|
477
1146
|
} catch {
|
|
478
1147
|
return {};
|
|
479
1148
|
}
|
|
480
1149
|
}
|
|
481
1150
|
function saveChecksums(store) {
|
|
482
|
-
|
|
483
|
-
|
|
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 =
|
|
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
|
|
530
|
-
ensureDir(
|
|
531
|
-
const
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1250
|
+
filePath = safePath(getVaultRoot2(), input.path);
|
|
596
1251
|
} else if (input.id) {
|
|
597
|
-
const entry =
|
|
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(
|
|
1260
|
+
const id = normalizeId2(basename3(filePath));
|
|
606
1261
|
const scores = loadDecayScores2();
|
|
607
|
-
|
|
608
|
-
saveDecayScores(
|
|
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 "
|
|
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 ?? "
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
`
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1810
|
+
const heartbeat = setInterval(() => {
|
|
1811
|
+
try {
|
|
1812
|
+
controller.enqueue(encoder.encode(`: heartbeat
|
|
1057
1813
|
|
|
1058
1814
|
`));
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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 (
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
+
};
|