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/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
- const hashAt = (f, m) => {
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
- const md5 = fileMd5(f);
83
- md5Cache.set(f, { mtime: m, md5 });
84
- return md5;
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
- const treeContentFingerprint = (tree) => {
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, { m, rel }] of tree) {
89
- let md5;
90
- try {
91
- md5 = hashAt(f, m);
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: fs.statSync(f).mtimeMs, rel: relOf(f) });
326
+ tree.set(f, { m: statFile(f).mtimeMs, rel: relOf(f) });
115
327
  }
116
- catch {
117
- /* vanished */
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, { m, rel }] of tree) {
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
- lastFingerprint = treeContentFingerprint(scanTree());
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 tree = scanTree();
169
- const fingerprint = treeContentFingerprint(tree);
170
- const changed = contentChangedPaths(tree, indexedHashes);
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
- for (const [f, { m, rel }] of scanTree()) {
183
- if (!md5Cache.has(f) && rel in indexedHashes) {
184
- md5Cache.set(f, { mtime: m, md5: indexedHashes[rel] });
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, { 'content-type': 'application/json; charset=utf-8' });
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 };