md-semantic-search 1.0.2 → 1.0.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/README.md +154 -6
- package/bin/cli.mjs +403 -22
- package/dist/completions.d.ts +25 -0
- package/dist/completions.js +233 -0
- package/dist/core.d.ts +5 -0
- package/dist/core.js +17 -0
- package/dist/download-progress.d.ts +52 -0
- package/dist/download-progress.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +6 -1
- package/dist/open.d.ts +43 -0
- package/dist/open.js +84 -0
- package/dist/providers.d.ts +21 -0
- package/dist/providers.js +104 -0
- package/dist/query-cache-disk.d.ts +14 -0
- package/dist/query-cache-disk.js +175 -0
- package/dist/repair.d.ts +36 -0
- package/dist/repair.js +208 -0
- package/dist/rerank.js +3 -2
- package/dist/search.d.ts +34 -0
- package/dist/search.js +172 -5
- package/dist/serve.d.ts +126 -0
- package/dist/serve.js +579 -45
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.js +41 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +231 -0
- package/package.json +1 -1
- package/src/completions.ts +250 -0
- package/src/core.ts +26 -0
- package/src/download-progress.ts +146 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +10 -3
- package/src/open.ts +119 -0
- package/src/providers.ts +134 -0
- package/src/query-cache-disk.ts +191 -0
- package/src/repair.ts +245 -0
- package/src/rerank.ts +3 -2
- package/src/search.ts +192 -6
- package/src/serve.ts +635 -42
- package/src/watcher.ts +46 -0
- package/src/webui.ts +234 -0
package/dist/serve.js
CHANGED
|
@@ -6,18 +6,179 @@
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { buildIndex } from './indexer.js';
|
|
11
12
|
import { loadIndex, searchIndex } from './search.js';
|
|
12
13
|
import { walkMarkdown, assertSafePath } from './core.js';
|
|
13
|
-
import { createFileWatcher } from './watcher.js';
|
|
14
|
+
import { createFileWatcher, classifyFsError, readWithRetry } from './watcher.js';
|
|
15
|
+
import { WEBUI_HTML, WEBUI_JS, WEBUI_CSS } from './webui.js';
|
|
16
|
+
import { handleMcpRequest } from './mcp.js';
|
|
14
17
|
const DEFAULT_PORT = 8747;
|
|
15
18
|
const DEFAULT_HOST = '127.0.0.1';
|
|
16
19
|
const WATCH_INTERVAL_MS = 3000;
|
|
17
20
|
const WATCH_DELAY_MS = 1000;
|
|
18
21
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
22
|
+
/** Hosts a browser is allowed to name in the Host header (issue #120). */
|
|
23
|
+
export const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1', '[::1]'];
|
|
24
|
+
/** Fairness defaults (issue #119): protective yet friction-free locally. */
|
|
25
|
+
export const DEFAULT_RATE_LIMIT_PER_MIN = 60;
|
|
26
|
+
export const DEFAULT_RATE_BURST = 10;
|
|
27
|
+
export const DEFAULT_MAX_CONCURRENCY = os.cpus().length || 1;
|
|
28
|
+
/**
|
|
29
|
+
* Token bucket for per-client request budgeting (issue #119): starts full
|
|
30
|
+
* (`burst`), refills continuously at `limitPerMinute`/60 tokens per second.
|
|
31
|
+
* `take` is injective in time so tests can time-travel via the `now` argument.
|
|
32
|
+
*/
|
|
33
|
+
export class TokenBucket {
|
|
34
|
+
limitPerMinute;
|
|
35
|
+
burst;
|
|
36
|
+
tokens;
|
|
37
|
+
/** Last refill timestamp — doubles as a staleness marker for map pruning. */
|
|
38
|
+
lastTouched;
|
|
39
|
+
constructor(limitPerMinute, burst) {
|
|
40
|
+
this.limitPerMinute = limitPerMinute;
|
|
41
|
+
this.burst = burst;
|
|
42
|
+
this.tokens = burst;
|
|
43
|
+
this.lastTouched = Date.now();
|
|
44
|
+
}
|
|
45
|
+
take(now = Date.now()) {
|
|
46
|
+
const elapsedMs = Math.max(0, now - this.lastTouched);
|
|
47
|
+
this.lastTouched = now;
|
|
48
|
+
this.tokens = Math.min(this.burst, this.tokens + (elapsedMs * this.limitPerMinute) / 60_000);
|
|
49
|
+
if (this.tokens >= 1) {
|
|
50
|
+
this.tokens -= 1;
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
const retryAfterSec = Math.max(1, Math.ceil(((1 - this.tokens) * 60_000) / this.limitPerMinute / 1000));
|
|
54
|
+
return { ok: false, retryAfterSec };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Counting semaphore with a bounded wait queue (issue #119): once `maxQueued`
|
|
59
|
+
* callers already wait, acquire() returns null instead of enqueueing, so an
|
|
60
|
+
* overloaded daemon sheds load with 503 rather than ballooning memory.
|
|
61
|
+
*/
|
|
62
|
+
export class BoundedSemaphore {
|
|
63
|
+
size;
|
|
64
|
+
maxQueued;
|
|
65
|
+
available;
|
|
66
|
+
waiters = [];
|
|
67
|
+
constructor(size, maxQueued) {
|
|
68
|
+
this.size = size;
|
|
69
|
+
this.maxQueued = maxQueued;
|
|
70
|
+
this.available = size;
|
|
71
|
+
}
|
|
72
|
+
get executing() {
|
|
73
|
+
return this.size - this.available;
|
|
74
|
+
}
|
|
75
|
+
get queued() {
|
|
76
|
+
return this.waiters.length;
|
|
77
|
+
}
|
|
78
|
+
async acquire() {
|
|
79
|
+
if (this.available > 0) {
|
|
80
|
+
this.available -= 1;
|
|
81
|
+
return makeRelease(() => this.releaseSlot());
|
|
82
|
+
}
|
|
83
|
+
if (this.waiters.length >= this.maxQueued)
|
|
84
|
+
return null;
|
|
85
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
86
|
+
return makeRelease(() => this.releaseSlot());
|
|
87
|
+
}
|
|
88
|
+
releaseSlot() {
|
|
89
|
+
const next = this.waiters.shift();
|
|
90
|
+
if (next)
|
|
91
|
+
next(); // hand the slot straight to a waiter — count stays consistent
|
|
92
|
+
else
|
|
93
|
+
this.available += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function makeRelease(release) {
|
|
97
|
+
let done = false;
|
|
98
|
+
return () => {
|
|
99
|
+
if (!done) {
|
|
100
|
+
done = true;
|
|
101
|
+
release();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Fairness gates for POST /search (issue #119):
|
|
107
|
+
* - per-client token bucket → 429 + Retry-After when a client exceeds its budget;
|
|
108
|
+
* - global concurrency cap around the embedding sweep; queue overflow → 503.
|
|
109
|
+
* Either gate disabled by passing `0`; counters are exposed via /health.
|
|
110
|
+
*/
|
|
111
|
+
export class ServeLimiter {
|
|
112
|
+
rateLimitPerMin;
|
|
113
|
+
rateBurst;
|
|
114
|
+
maxConcurrency;
|
|
115
|
+
buckets = new Map();
|
|
116
|
+
sem;
|
|
117
|
+
rejected429 = 0;
|
|
118
|
+
rejected503 = 0;
|
|
119
|
+
constructor(rateLimitPerMin, rateBurst, maxConcurrency) {
|
|
120
|
+
this.rateLimitPerMin = rateLimitPerMin;
|
|
121
|
+
this.rateBurst = rateBurst;
|
|
122
|
+
this.maxConcurrency = maxConcurrency;
|
|
123
|
+
// queue depth cap: 2× slots (min 4) — enough burst absorption, bounded memory
|
|
124
|
+
this.sem = maxConcurrency > 0 ? new BoundedSemaphore(maxConcurrency, Math.max(maxConcurrency * 2, 4)) : null;
|
|
125
|
+
}
|
|
126
|
+
/** Burst is capped by the per-minute budget itself (--rate-limit 5 → burst 5). */
|
|
127
|
+
effectiveBurst() {
|
|
128
|
+
return Math.min(this.rateBurst, Math.max(1, this.rateLimitPerMin));
|
|
129
|
+
}
|
|
130
|
+
get rateEnabled() {
|
|
131
|
+
return this.rateLimitPerMin > 0 && this.rateBurst > 0;
|
|
132
|
+
}
|
|
133
|
+
get concurrencyEnabled() {
|
|
134
|
+
return this.sem !== null;
|
|
135
|
+
}
|
|
136
|
+
get inFlight() {
|
|
137
|
+
return this.sem ? this.sem.executing : 0;
|
|
138
|
+
}
|
|
139
|
+
get queued() {
|
|
140
|
+
return this.sem ? this.sem.queued : 0;
|
|
141
|
+
}
|
|
142
|
+
get rejectedTotal() {
|
|
143
|
+
return this.rejected429 + this.rejected503;
|
|
144
|
+
}
|
|
145
|
+
/** Cheap synchronous rate check. Null → proceed; otherwise reject with 429. */
|
|
146
|
+
gate(clientKey, now = Date.now()) {
|
|
147
|
+
if (!this.rateEnabled)
|
|
148
|
+
return null;
|
|
149
|
+
let bucket = this.buckets.get(clientKey);
|
|
150
|
+
if (!bucket) {
|
|
151
|
+
if (this.buckets.size >= 4096) {
|
|
152
|
+
// opportunistic prune of idle clients (no takes within two windows)
|
|
153
|
+
for (const [key, b] of this.buckets) {
|
|
154
|
+
if (now - b.lastTouched > 2 * 60_000)
|
|
155
|
+
this.buckets.delete(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
bucket = new TokenBucket(this.rateLimitPerMin, this.effectiveBurst());
|
|
159
|
+
this.buckets.set(clientKey, bucket);
|
|
160
|
+
}
|
|
161
|
+
const r = bucket.take(now);
|
|
162
|
+
if (r.ok)
|
|
163
|
+
return null;
|
|
164
|
+
this.rejected429 += 1;
|
|
165
|
+
return { status: 429, retryAfterSec: r.retryAfterSec };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Acquire a global search slot. Resolves with a release handle, or null when
|
|
169
|
+
* the wait queue is saturated (caller replies 503).
|
|
170
|
+
*/
|
|
171
|
+
async acquireSlot() {
|
|
172
|
+
if (!this.sem)
|
|
173
|
+
return makeRelease(() => { });
|
|
174
|
+
const release = await this.sem.acquire();
|
|
175
|
+
if (!release)
|
|
176
|
+
this.rejected503 += 1;
|
|
177
|
+
return release;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
19
180
|
export async function createServe(opts) {
|
|
20
|
-
const { indexDir, cacheDir, db, modelName = 'e5-base', ignore = [], offline = false, watch = false, watchInterval = WATCH_INTERVAL_MS, watchDelay = WATCH_DELAY_MS, apiKey = process.env.MDSS_API_KEY, healthPublic = process.env.MDSS_HEALTH_PUBLIC === 'true', embedFn, rerankFn, log = () => { }, } = opts;
|
|
181
|
+
const { indexDir, cacheDir, db, modelName = 'e5-base', ignore = [], offline = false, watch = false, watchInterval = WATCH_INTERVAL_MS, watchDelay = WATCH_DELAY_MS, apiKey = process.env.MDSS_API_KEY, healthPublic = process.env.MDSS_HEALTH_PUBLIC === 'true', allowedHosts, corsOrigins, rateLimit = DEFAULT_RATE_LIMIT_PER_MIN, maxConcurrency = DEFAULT_MAX_CONCURRENCY, watchDebug = false, _testFs, mcp = false, ui = true, embedFn, rerankFn, log = () => { }, } = opts;
|
|
21
182
|
if (indexDir)
|
|
22
183
|
assertSafePath(indexDir);
|
|
23
184
|
if (db)
|
|
@@ -39,6 +200,11 @@ export async function createServe(opts) {
|
|
|
39
200
|
watching: watch,
|
|
40
201
|
apiKey,
|
|
41
202
|
healthPublic,
|
|
203
|
+
ui: ui !== false,
|
|
204
|
+
mcp,
|
|
205
|
+
allowedHosts,
|
|
206
|
+
corsOrigins,
|
|
207
|
+
limiter: new ServeLimiter(rateLimit, DEFAULT_RATE_BURST, maxConcurrency),
|
|
42
208
|
};
|
|
43
209
|
const sockets = new Set();
|
|
44
210
|
const server = http.createServer((req, res) => {
|
|
@@ -72,28 +238,72 @@ export async function createServe(opts) {
|
|
|
72
238
|
log(`Re-indexed; ${state.loaded.index.chunks.length} chunks in memory.`);
|
|
73
239
|
return true;
|
|
74
240
|
};
|
|
75
|
-
const fileMd5 = (f) => crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
|
|
241
|
+
const fileMd5 = (f) => _testFs?.hash ? _testFs.hash(f) : crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
|
|
242
|
+
const statFile = (f) => _testFs?.stat ? _testFs.stat(f) : { mtimeMs: fs.statSync(f).mtimeMs };
|
|
76
243
|
const relOf = (f) => path.relative(db, f).split(path.sep).join('/');
|
|
244
|
+
const dbg = (msg) => {
|
|
245
|
+
if (watchDebug)
|
|
246
|
+
log(`watch-debug: ${msg}`);
|
|
247
|
+
};
|
|
77
248
|
const md5Cache = new Map();
|
|
78
|
-
|
|
249
|
+
// issue #116: files whose hash currently fails (AV/sync contention) — they
|
|
250
|
+
// are EXCLUDED from fingerprints instead of becoming stable 'unreadable'
|
|
251
|
+
// ones, so a transiently locked file can never mask a real change.
|
|
252
|
+
const unreadable = new Map();
|
|
253
|
+
let lastWarnAt = 0;
|
|
254
|
+
const WARN_COOLDOWN_MS = 30_000;
|
|
255
|
+
/**
|
|
256
|
+
* Hash a file with bounded retry for TRANSIENT failures (issue #116).
|
|
257
|
+
* Returns null when the file must be excluded this cycle: vanished (deleted),
|
|
258
|
+
* or still unreadable after retries (changed-pending, warned rate-limited).
|
|
259
|
+
*/
|
|
260
|
+
const hashWithRetry = async (f, m) => {
|
|
79
261
|
const hit = md5Cache.get(f);
|
|
80
|
-
if (hit && hit.mtime === m)
|
|
262
|
+
if (hit && hit.mtime === m) {
|
|
263
|
+
unreadable.delete(f);
|
|
81
264
|
return hit.md5;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
265
|
+
}
|
|
266
|
+
const r = await readWithRetry(() => fileMd5(f), {
|
|
267
|
+
attempts: 3,
|
|
268
|
+
baseDelayMs: 200,
|
|
269
|
+
onRetry: (attempt, err) => dbg(`retry ${attempt} ${path.basename(f)}: ${err?.code ?? err?.message}`),
|
|
270
|
+
});
|
|
271
|
+
if (r.ok) {
|
|
272
|
+
md5Cache.set(f, { mtime: m, md5: r.value });
|
|
273
|
+
unreadable.delete(f);
|
|
274
|
+
dbg(`hashed ${path.basename(f)}`);
|
|
275
|
+
return r.value;
|
|
276
|
+
}
|
|
277
|
+
const cls = classifyFsError(r.err);
|
|
278
|
+
if (cls === 'vanished') {
|
|
279
|
+
md5Cache.delete(f);
|
|
280
|
+
return null; // deleted mid-cycle — handled via liveRels diff
|
|
281
|
+
}
|
|
282
|
+
const prev = unreadable.get(f);
|
|
283
|
+
unreadable.set(f, {
|
|
284
|
+
cls: cls,
|
|
285
|
+
code: r.err?.code || String(r.err?.message || 'error'),
|
|
286
|
+
firstAt: prev?.firstAt ?? Date.now(),
|
|
287
|
+
});
|
|
288
|
+
return null;
|
|
85
289
|
};
|
|
86
|
-
|
|
290
|
+
/**
|
|
291
|
+
* One probe pass: hash every tree file (with retry). Returns per-file hashes
|
|
292
|
+
* where null = vanished-or-unreadable this cycle (excluded from comparison).
|
|
293
|
+
*/
|
|
294
|
+
const probeTree = async (tree) => {
|
|
295
|
+
const hashes = new Map();
|
|
296
|
+
for (const [f, { m }] of tree) {
|
|
297
|
+
hashes.set(f, await hashWithRetry(f, m));
|
|
298
|
+
}
|
|
299
|
+
return hashes;
|
|
300
|
+
};
|
|
301
|
+
const fingerprintOf = (tree, fileHashes) => {
|
|
87
302
|
const parts = [];
|
|
88
|
-
for (const [f, {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
md5 = 'unreadable';
|
|
95
|
-
}
|
|
96
|
-
parts.push(`${rel}:${md5}`);
|
|
303
|
+
for (const [f, { rel }] of tree) {
|
|
304
|
+
const h = fileHashes.get(f);
|
|
305
|
+
if (h !== null && h !== undefined)
|
|
306
|
+
parts.push(`${rel}:${h}`);
|
|
97
307
|
}
|
|
98
308
|
return parts.sort().join('|');
|
|
99
309
|
};
|
|
@@ -105,39 +315,37 @@ export async function createServe(opts) {
|
|
|
105
315
|
return {};
|
|
106
316
|
}
|
|
107
317
|
};
|
|
318
|
+
/** issue #116: stat failures are classified — only ENOENT vanishes silently. */
|
|
108
319
|
const scanTree = () => {
|
|
109
320
|
const tree = new Map();
|
|
321
|
+
const problems = new Map();
|
|
110
322
|
if (!db)
|
|
111
|
-
return tree;
|
|
323
|
+
return { tree, problems };
|
|
112
324
|
for (const f of walkMarkdown(db, ignore)) {
|
|
113
325
|
try {
|
|
114
|
-
tree.set(f, { m:
|
|
326
|
+
tree.set(f, { m: statFile(f).mtimeMs, rel: relOf(f) });
|
|
115
327
|
}
|
|
116
|
-
catch {
|
|
117
|
-
|
|
328
|
+
catch (e) {
|
|
329
|
+
const cls = classifyFsError(e);
|
|
330
|
+
if (cls !== 'vanished')
|
|
331
|
+
problems.set(f, cls);
|
|
332
|
+
/* vanished files drop out via the liveRels diff */
|
|
118
333
|
}
|
|
119
334
|
}
|
|
120
|
-
return tree;
|
|
335
|
+
return { tree, problems };
|
|
121
336
|
};
|
|
122
|
-
const contentChangedPaths = (tree, indexedHashes) => {
|
|
337
|
+
const contentChangedPaths = (tree, fileHashes, indexedHashes) => {
|
|
123
338
|
const out = [];
|
|
124
339
|
const liveRels = new Set();
|
|
125
|
-
for (const [f, {
|
|
340
|
+
for (const [f, { rel }] of tree) {
|
|
126
341
|
liveRels.add(rel);
|
|
127
342
|
if (!(rel in indexedHashes)) {
|
|
128
|
-
out.push(rel);
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const cached = md5Cache.get(f);
|
|
132
|
-
if (cached && cached.mtime === m && cached.md5 === indexedHashes[rel])
|
|
133
|
-
continue;
|
|
134
|
-
let fresh;
|
|
135
|
-
try {
|
|
136
|
-
fresh = hashAt(f, m);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
343
|
+
out.push(rel); // new file
|
|
139
344
|
continue;
|
|
140
345
|
}
|
|
346
|
+
const fresh = fileHashes.get(f);
|
|
347
|
+
if (fresh === null || fresh === undefined)
|
|
348
|
+
continue; // unreadable → changed-pending
|
|
141
349
|
if (fresh !== indexedHashes[rel])
|
|
142
350
|
out.push(rel);
|
|
143
351
|
}
|
|
@@ -148,9 +356,24 @@ export async function createServe(opts) {
|
|
|
148
356
|
return out;
|
|
149
357
|
};
|
|
150
358
|
let lastFingerprint = '';
|
|
359
|
+
/** issue #116: one combined warning per cooldown, ≤5 files named + "+k more". */
|
|
360
|
+
const warnUnreadableRateLimited = (now) => {
|
|
361
|
+
if (unreadable.size === 0 || now - lastWarnAt < WARN_COOLDOWN_MS)
|
|
362
|
+
return;
|
|
363
|
+
lastWarnAt = now;
|
|
364
|
+
const names = [...unreadable.keys()].map((f) => path.basename(f));
|
|
365
|
+
const head = names.slice(0, 5).join(', ');
|
|
366
|
+
const more = names.length > 5 ? ` …+${names.length - 5} more` : '';
|
|
367
|
+
const codes = [...new Set([...unreadable.values()].map((u) => u.code))].join('/');
|
|
368
|
+
log(`watch: ${unreadable.size} file(s) unreadable (${codes}) — will retry: ${head}${more}`);
|
|
369
|
+
};
|
|
151
370
|
const watchLoop = async () => {
|
|
152
371
|
let indexedHashes = readIndexedHashes();
|
|
153
|
-
|
|
372
|
+
{
|
|
373
|
+
const { tree } = scanTree();
|
|
374
|
+
const hashes = await probeTree(tree);
|
|
375
|
+
lastFingerprint = fingerprintOf(tree, hashes);
|
|
376
|
+
}
|
|
154
377
|
let lastChangeAt = 0;
|
|
155
378
|
let pending = false;
|
|
156
379
|
while (!stopped) {
|
|
@@ -165,9 +388,17 @@ export async function createServe(opts) {
|
|
|
165
388
|
});
|
|
166
389
|
if (stopped)
|
|
167
390
|
return;
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
391
|
+
const t0 = Date.now();
|
|
392
|
+
const { tree, problems } = scanTree();
|
|
393
|
+
for (const [f, cls] of problems) {
|
|
394
|
+
if (!unreadable.has(f))
|
|
395
|
+
unreadable.set(f, { cls, code: `stat:${cls}`, firstAt: t0 });
|
|
396
|
+
}
|
|
397
|
+
dbg(`poll: ${tree.size} file(s), ${unreadable.size} pending-unreadable`);
|
|
398
|
+
const fileHashes = await probeTree(tree);
|
|
399
|
+
warnUnreadableRateLimited(Date.now());
|
|
400
|
+
const fingerprint = fingerprintOf(tree, fileHashes);
|
|
401
|
+
const changed = contentChangedPaths(tree, fileHashes, indexedHashes);
|
|
171
402
|
if (fingerprint !== lastFingerprint) {
|
|
172
403
|
if (!pending)
|
|
173
404
|
log(`watch: change in ${changed.length} file(s) — waiting ${watchDelay} ms for the tree to settle…`);
|
|
@@ -179,12 +410,18 @@ export async function createServe(opts) {
|
|
|
179
410
|
pending = false;
|
|
180
411
|
await reload().catch((e) => log(`re-index failed: ${e.message}`));
|
|
181
412
|
indexedHashes = readIndexedHashes();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
413
|
+
{
|
|
414
|
+
const fresh = scanTree();
|
|
415
|
+
const freshHashes = await probeTree(fresh.tree);
|
|
416
|
+
// seed the md5 cache from the authoritative post-build state so the
|
|
417
|
+
// next fingerprint is IO-free for unchanged files
|
|
418
|
+
for (const [f, { m, rel }] of fresh.tree) {
|
|
419
|
+
const h = freshHashes.get(f);
|
|
420
|
+
if (h !== null && h !== undefined && !md5Cache.has(f))
|
|
421
|
+
md5Cache.set(f, { mtime: m, md5: h });
|
|
185
422
|
}
|
|
423
|
+
lastFingerprint = fingerprintOf(fresh.tree, freshHashes);
|
|
186
424
|
}
|
|
187
|
-
lastFingerprint = treeContentFingerprint(scanTree());
|
|
188
425
|
}
|
|
189
426
|
else if (!pending && changed.length > 0 && fingerprint === lastFingerprint) {
|
|
190
427
|
pending = true;
|
|
@@ -194,6 +431,12 @@ export async function createServe(opts) {
|
|
|
194
431
|
else if (pending && changed.length === 0 && Date.now() - lastChangeAt < watchDelay) {
|
|
195
432
|
pending = false;
|
|
196
433
|
}
|
|
434
|
+
else if (pending && changed.length === 0) {
|
|
435
|
+
// settled but nothing actually differs (e.g. a locked file became
|
|
436
|
+
// readable again with unchanged content) — resync the baseline
|
|
437
|
+
pending = false;
|
|
438
|
+
lastFingerprint = fingerprint;
|
|
439
|
+
}
|
|
197
440
|
}
|
|
198
441
|
};
|
|
199
442
|
let nativeWatcher = null;
|
|
@@ -245,8 +488,122 @@ export function isAuthorizedToken(token, expectedKey) {
|
|
|
245
488
|
return false;
|
|
246
489
|
return crypto.timingSafeEqual(a, b);
|
|
247
490
|
}
|
|
491
|
+
/** True when `host` binds to loopback only (issue #121). Empty/undefined means the loopback default. */
|
|
492
|
+
export function isLoopbackHost(host) {
|
|
493
|
+
if (!host)
|
|
494
|
+
return true;
|
|
495
|
+
const h = String(host).trim().toLowerCase();
|
|
496
|
+
return (h === 'localhost' ||
|
|
497
|
+
h === '127.0.0.1' ||
|
|
498
|
+
h === '::1' ||
|
|
499
|
+
h === '[::1]' ||
|
|
500
|
+
h === '::ffff:127.0.0.1' ||
|
|
501
|
+
h === '[::ffff:127.0.0.1]');
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Refuse to start an unauthenticated server on a non-loopback interface (issue #121):
|
|
505
|
+
* `mdss serve --host 0.0.0.0` without an API key silently serves the whole knowledge
|
|
506
|
+
* base to the LAN. Explicit `--allow-unsecured` opts in (banner still printed by CLI).
|
|
507
|
+
*/
|
|
508
|
+
export function validateBindSecurity(opts) {
|
|
509
|
+
const { host, hasApiKey, allowUnsecured } = opts;
|
|
510
|
+
if (hasApiKey || allowUnsecured || isLoopbackHost(host))
|
|
511
|
+
return;
|
|
512
|
+
throw new Error(`serve: refusing to bind non-loopback host "${host}" without authentication.\n` +
|
|
513
|
+
' This would expose the entire knowledge base to the network.\n' +
|
|
514
|
+
' Fix one of:\n' +
|
|
515
|
+
' --api-key <key> require Bearer auth on every request\n' +
|
|
516
|
+
' --api-key-file <path> read the key from a file (or MDSS_API_KEY_FILE)\n' +
|
|
517
|
+
' --allow-unsecured explicit opt-in (e.g. behind a firewall/reverse proxy)');
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Read an API key from a file (issue #121): avoids shell-history / CI-log leaks of
|
|
521
|
+
* literal `--api-key` values. Trailing newlines are trimmed; on POSIX a group- or
|
|
522
|
+
* world-readable key file draws a loud warning.
|
|
523
|
+
*/
|
|
524
|
+
export function loadApiKeyFile(filePath, log) {
|
|
525
|
+
let raw;
|
|
526
|
+
try {
|
|
527
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
throw new Error(`api-key-file: cannot read ${filePath}: ${e.message}`);
|
|
531
|
+
}
|
|
532
|
+
if (process.platform !== 'win32') {
|
|
533
|
+
try {
|
|
534
|
+
const mode = fs.statSync(filePath).mode & 0o777;
|
|
535
|
+
if (mode & 0o077) {
|
|
536
|
+
log?.(`api-key-file: WARNING ${filePath} is readable by group/others (mode ${mode.toString(8)}) — chmod 600 recommended`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
/* stat raced after a successful read — not fatal */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const key = raw.replace(/[\r\n]+$/, '');
|
|
544
|
+
if (!key)
|
|
545
|
+
throw new Error(`api-key-file: ${filePath} contains no key`);
|
|
546
|
+
return key;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Strip the port from a Host header value, keeping IPv6 bracket forms intact:
|
|
550
|
+
* "localhost:8747" → "localhost", "[::1]:8747" → "[::1]", "::1" → "::1" (issue #120).
|
|
551
|
+
*/
|
|
552
|
+
export function splitHostHeader(hostHeader) {
|
|
553
|
+
const s = String(hostHeader).trim().toLowerCase();
|
|
554
|
+
const m = s.match(/^(\[[^\]]+\])(?::\d+)?$/);
|
|
555
|
+
if (m)
|
|
556
|
+
return m[1];
|
|
557
|
+
const first = s.indexOf(':');
|
|
558
|
+
const last = s.lastIndexOf(':');
|
|
559
|
+
// a single colon separates host:port; several colons mean a bare IPv6 literal
|
|
560
|
+
return first !== -1 && first === last ? s.slice(0, first) : s;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Reflect CORS headers ONLY for an exact allowlisted Origin (issue #120).
|
|
564
|
+
* Off entirely by default; `Vary: Origin` is set whenever CORS is configured so
|
|
565
|
+
* shared caches never hand a cross-origin response to another origin.
|
|
566
|
+
*/
|
|
567
|
+
function applyCors(req, res, origins) {
|
|
568
|
+
if (!origins || origins.length === 0)
|
|
569
|
+
return;
|
|
570
|
+
res.setHeader('Vary', 'Origin');
|
|
571
|
+
const origin = req.headers.origin;
|
|
572
|
+
if (typeof origin === 'string' && origin !== 'null' && origins.includes(origin)) {
|
|
573
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
248
576
|
async function handleRequest(req, res, state) {
|
|
249
577
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
578
|
+
// DNS-rebinding protection (issue #120): after a rebind attack the browser's
|
|
579
|
+
// Host header names the attacker's domain instead of ours — reject it with 403
|
|
580
|
+
// BEFORE anything else so a rebound page is never treated as same-origin.
|
|
581
|
+
const hostHeader = req.headers.host;
|
|
582
|
+
const hostName = typeof hostHeader === 'string' ? splitHostHeader(hostHeader) : '';
|
|
583
|
+
const allowedHosts = new Set([
|
|
584
|
+
...DEFAULT_ALLOWED_HOSTS,
|
|
585
|
+
...(state.allowedHosts || []).map(splitHostHeader),
|
|
586
|
+
]);
|
|
587
|
+
if (!hostName || !allowedHosts.has(hostName)) {
|
|
588
|
+
json(res, 403, { error: `untrusted Host header${hostHeader ? ` "${hostHeader}"` : ' (missing)'}` });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
applyCors(req, res, state.corsOrigins);
|
|
592
|
+
// CORS preflight — only meaningful when --cors-origin is configured.
|
|
593
|
+
if (req.method === 'OPTIONS') {
|
|
594
|
+
if (state.corsOrigins && state.corsOrigins.length > 0) {
|
|
595
|
+
res.writeHead(204, {
|
|
596
|
+
'access-control-allow-methods': 'GET, POST',
|
|
597
|
+
'access-control-allow-headers': 'content-type, authorization',
|
|
598
|
+
'access-control-max-age': '600',
|
|
599
|
+
});
|
|
600
|
+
res.end();
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
json(res, 405, { error: 'method not allowed' });
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
250
607
|
if (state.apiKey) {
|
|
251
608
|
const isPublicHealth = url.pathname === '/health' && req.method === 'GET' && state.healthPublic;
|
|
252
609
|
if (!isPublicHealth) {
|
|
@@ -259,7 +616,20 @@ async function handleRequest(req, res, state) {
|
|
|
259
616
|
}
|
|
260
617
|
}
|
|
261
618
|
}
|
|
619
|
+
// Streamable HTTP MCP transport (issue #123): same auth/host/rate-limit
|
|
620
|
+
// gates as /search, reusing handleMcpRequest untouched as the tool brain.
|
|
621
|
+
if (state.mcp && url.pathname === '/mcp') {
|
|
622
|
+
await handleMcpHttp(req, res, state);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
262
625
|
if (req.method === 'POST' && url.pathname === '/search') {
|
|
626
|
+
// Fairness gate 1 (issue #119): per-client token bucket → 429 + Retry-After.
|
|
627
|
+
const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
|
|
628
|
+
if (limited) {
|
|
629
|
+
res.setHeader('retry-after', String(limited.retryAfterSec));
|
|
630
|
+
json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
263
633
|
const declared = req.headers['content-length'];
|
|
264
634
|
if (declared !== undefined) {
|
|
265
635
|
const n = Number(declared);
|
|
@@ -300,6 +670,14 @@ async function handleRequest(req, res, state) {
|
|
|
300
670
|
return;
|
|
301
671
|
}
|
|
302
672
|
const k = Number.isInteger(payload.k) && payload.k > 0 ? payload.k : 6;
|
|
673
|
+
// Fairness gate 2 (issue #119): one global slot around the model sweep;
|
|
674
|
+
// a saturated wait queue sheds load with 503 instead of ballooning.
|
|
675
|
+
const release = state.limiter ? await state.limiter.acquireSlot() : null;
|
|
676
|
+
if (!release) {
|
|
677
|
+
res.setHeader('retry-after', '1');
|
|
678
|
+
json(res, 503, { error: 'server busy — search concurrency cap reached, queue full' });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
303
681
|
try {
|
|
304
682
|
const results = await searchIndex({
|
|
305
683
|
loaded: state.loaded,
|
|
@@ -323,12 +701,16 @@ async function handleRequest(req, res, state) {
|
|
|
323
701
|
nprobe: payload.nprobe,
|
|
324
702
|
graphBoost: payload.graphBoost,
|
|
325
703
|
filter: payload.filter,
|
|
704
|
+
recency: typeof payload.recency === 'number' && payload.recency > 0 ? payload.recency : undefined,
|
|
326
705
|
});
|
|
327
706
|
json(res, 200, { query, k, count: results.length, results });
|
|
328
707
|
}
|
|
329
708
|
catch (e) {
|
|
330
709
|
json(res, 500, { error: e.message });
|
|
331
710
|
}
|
|
711
|
+
finally {
|
|
712
|
+
release();
|
|
713
|
+
}
|
|
332
714
|
return;
|
|
333
715
|
}
|
|
334
716
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
@@ -340,9 +722,28 @@ async function handleRequest(req, res, state) {
|
|
|
340
722
|
built: state.loaded.index.built || null,
|
|
341
723
|
watching: state.watching,
|
|
342
724
|
indexDir: state.indexDir,
|
|
725
|
+
// fairness counters (issue #119)
|
|
726
|
+
in_flight: state.limiter?.inFlight ?? 0,
|
|
727
|
+
queued: state.limiter?.queued ?? 0,
|
|
728
|
+
rejected_total: state.limiter?.rejectedTotal ?? 0,
|
|
729
|
+
rate_limit_per_min: state.limiter?.rateEnabled ? state.limiter.rateLimitPerMin : null,
|
|
730
|
+
max_concurrency: state.limiter?.concurrencyEnabled ? state.limiter.maxConcurrency : null,
|
|
343
731
|
});
|
|
344
732
|
return;
|
|
345
733
|
}
|
|
734
|
+
if (req.method === 'GET' && url.pathname === '/' && state.ui) {
|
|
735
|
+
// Built-in web UI (issue #111): strict CSP, split assets, no inline code.
|
|
736
|
+
sendAsset(res, 'text/html; charset=utf-8', WEBUI_HTML);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (req.method === 'GET' && url.pathname === '/ui.js' && state.ui) {
|
|
740
|
+
sendAsset(res, 'text/javascript; charset=utf-8', WEBUI_JS);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (req.method === 'GET' && url.pathname === '/ui.css' && state.ui) {
|
|
744
|
+
sendAsset(res, 'text/css; charset=utf-8', WEBUI_CSS);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
346
747
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/help')) {
|
|
347
748
|
json(res, 200, {
|
|
348
749
|
name: 'mdss serve',
|
|
@@ -355,8 +756,141 @@ async function handleRequest(req, res, state) {
|
|
|
355
756
|
}
|
|
356
757
|
json(res, 404, { error: `not found: ${req.method} ${url.pathname}` });
|
|
357
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Streamable HTTP transport for MCP (issue #123): POST carries JSON-RPC
|
|
761
|
+
* (single message or batch), notifications answer 202, GET opens the SSE
|
|
762
|
+
* server-channel, DELETE terminates a session. Responses are plain JSON —
|
|
763
|
+
* the spec allows JSON instead of SSE when nothing is streamed. Session id is
|
|
764
|
+
* minted on first contact and echoed back on every reply.
|
|
765
|
+
*/
|
|
766
|
+
async function handleMcpHttp(req, res, state) {
|
|
767
|
+
if (req.method === 'DELETE') {
|
|
768
|
+
res.writeHead(204);
|
|
769
|
+
res.end();
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (req.method === 'GET') {
|
|
773
|
+
// Server-initiated channel: we have nothing to push today, so keep the
|
|
774
|
+
// stream alive with comments until the client goes away.
|
|
775
|
+
res.writeHead(200, {
|
|
776
|
+
'content-type': 'text/event-stream',
|
|
777
|
+
'cache-control': 'no-store',
|
|
778
|
+
'connection': 'keep-alive',
|
|
779
|
+
});
|
|
780
|
+
res.write(':ok\n\n');
|
|
781
|
+
const hb = setInterval(() => {
|
|
782
|
+
try {
|
|
783
|
+
res.write(':hb\n\n');
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
/* closed */
|
|
787
|
+
}
|
|
788
|
+
}, 15_000);
|
|
789
|
+
hb.unref?.();
|
|
790
|
+
req.on('close', () => clearInterval(hb));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (req.method !== 'POST') {
|
|
794
|
+
json(res, 405, { error: 'method not allowed' });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// Same fairness gate as /search — tools/call can trigger embedding work.
|
|
798
|
+
const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
|
|
799
|
+
if (limited) {
|
|
800
|
+
res.setHeader('retry-after', String(limited.retryAfterSec));
|
|
801
|
+
json(res, 429, { error: 'rate limit exceeded - slow down', retryAfterSec: limited.retryAfterSec });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const release = state.limiter ? await state.limiter.acquireSlot() : null;
|
|
805
|
+
if (!release) {
|
|
806
|
+
res.setHeader('retry-after', '1');
|
|
807
|
+
json(res, 503, { error: 'server busy' });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
let body = '';
|
|
812
|
+
for await (const chunk of req) {
|
|
813
|
+
body += chunk;
|
|
814
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
815
|
+
json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
let messages;
|
|
820
|
+
try {
|
|
821
|
+
messages = JSON.parse(body || '{}');
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
json(res, 400, { error: `invalid JSON: ${e.message}` });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const batch = Array.isArray(messages);
|
|
828
|
+
const items = batch ? messages : [messages];
|
|
829
|
+
const responses = [];
|
|
830
|
+
for (const msg of items) {
|
|
831
|
+
if (!msg || typeof msg !== 'object')
|
|
832
|
+
continue;
|
|
833
|
+
const isNotification = msg.id === undefined || msg.id === null;
|
|
834
|
+
const result = isNotification ? null : await handleMcpRequest(msg, {
|
|
835
|
+
loaded: state.loaded,
|
|
836
|
+
cacheDir: state.cacheDir,
|
|
837
|
+
offline: state.offline,
|
|
838
|
+
embedFn: state.embedFn,
|
|
839
|
+
});
|
|
840
|
+
if (result)
|
|
841
|
+
responses.push(result);
|
|
842
|
+
else if (!isNotification && msg.method === undefined)
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
// session id: mint on first contact, echo afterwards
|
|
846
|
+
const requested = req.headers['mcp-session-id'];
|
|
847
|
+
if (typeof requested === 'string' && requested)
|
|
848
|
+
state.mcpSessionId = requested;
|
|
849
|
+
if (!state.mcpSessionId)
|
|
850
|
+
state.mcpSessionId = crypto.randomUUID();
|
|
851
|
+
res.setHeader('mcp-session-id', state.mcpSessionId);
|
|
852
|
+
// protocol negotiation: honour a supported requested version, else latest
|
|
853
|
+
for (const resp of responses) {
|
|
854
|
+
if (resp?.result?.protocolVersion && msg0(items)?.params?.protocolVersion) {
|
|
855
|
+
const wanted = String(msg0(items).params.protocolVersion);
|
|
856
|
+
const SUPPORTED = ['2025-03-26', '2024-11-05'];
|
|
857
|
+
resp.result.protocolVersion = SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0];
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (responses.length === 0) {
|
|
861
|
+
res.writeHead(202);
|
|
862
|
+
res.end();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
json(res, 200, batch ? responses : responses[0]);
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
release();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/** First object of a (possibly single-message) payload — for init negotiation. */
|
|
872
|
+
function msg0(items) {
|
|
873
|
+
return items.length > 0 ? items[0] : null;
|
|
874
|
+
}
|
|
358
875
|
function json(res, status, data) {
|
|
359
|
-
res.writeHead(status, {
|
|
876
|
+
res.writeHead(status, {
|
|
877
|
+
'content-type': 'application/json; charset=utf-8',
|
|
878
|
+
// Security headers on EVERY response (issue #120)
|
|
879
|
+
'x-content-type-options': 'nosniff',
|
|
880
|
+
'cache-control': 'no-store',
|
|
881
|
+
});
|
|
360
882
|
res.end(JSON.stringify(data));
|
|
361
883
|
}
|
|
884
|
+
/** Serve a web-UI asset with the same security headers + a strict CSP (issue #111). */
|
|
885
|
+
function sendAsset(res, contentType, body) {
|
|
886
|
+
res.writeHead(200, {
|
|
887
|
+
'content-type': contentType,
|
|
888
|
+
'x-content-type-options': 'nosniff',
|
|
889
|
+
'cache-control': 'no-store',
|
|
890
|
+
// no inline script/style anywhere in the UI -> nothing needs unsafe-*
|
|
891
|
+
'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
892
|
+
'referrer-policy': 'no-referrer',
|
|
893
|
+
});
|
|
894
|
+
res.end(body);
|
|
895
|
+
}
|
|
362
896
|
export { DEFAULT_PORT, DEFAULT_HOST, MAX_BODY_BYTES, WATCH_INTERVAL_MS };
|