md-semantic-search 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/dist/serve.js CHANGED
@@ -10,10 +10,12 @@ import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { buildIndex } from './indexer.js';
12
12
  import { loadIndex, searchIndex } from './search.js';
13
- import { walkMarkdown, assertSafePath } from './core.js';
13
+ import { synthesizeAnswer, chatTurn, loadChatSession, assertSafeSessionId } from './rag.js';
14
+ import { walkMarkdown, assertSafePath, getDocLines } from './core.js';
14
15
  import { createFileWatcher, classifyFsError, readWithRetry } from './watcher.js';
15
16
  import { WEBUI_HTML, WEBUI_JS, WEBUI_CSS } from './webui.js';
16
- import { handleMcpRequest } from './mcp.js';
17
+ import { handleMcpRequest, createClientChannel } from './mcp.js';
18
+ import { findRelatedNotes } from './wikilinks.js';
17
19
  const DEFAULT_PORT = 8747;
18
20
  const DEFAULT_HOST = '127.0.0.1';
19
21
  const WATCH_INTERVAL_MS = 3000;
@@ -178,7 +180,7 @@ export class ServeLimiter {
178
180
  }
179
181
  }
180
182
  export async function createServe(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;
183
+ 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, maxChunkTokens, llmEndpoint, llmModel, llmProvider, systemPrompt, embedFn, rerankFn, log = () => { }, } = opts;
182
184
  if (indexDir)
183
185
  assertSafePath(indexDir);
184
186
  if (db)
@@ -188,7 +190,7 @@ export async function createServe(opts) {
188
190
  if (!db)
189
191
  throw new Error('serve: no index found and no --db given to build one');
190
192
  log(`No index at ${indexDir} — building it from ${db}…`);
191
- await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
193
+ await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn, ...(maxChunkTokens !== undefined ? { maxChunkTokens } : {}) });
192
194
  }
193
195
  const state = {
194
196
  loaded: loadIndex(indexDir),
@@ -202,14 +204,48 @@ export async function createServe(opts) {
202
204
  healthPublic,
203
205
  ui: ui !== false,
204
206
  mcp,
207
+ ...(llmEndpoint ? { llmEndpoint } : {}),
208
+ ...(llmModel ? { llmModel } : {}),
209
+ ...(llmProvider ? { llmProvider } : {}),
210
+ ...(systemPrompt ? { systemPrompt } : {}),
205
211
  allowedHosts,
206
212
  corsOrigins,
207
213
  limiter: new ServeLimiter(rateLimit, DEFAULT_RATE_BURST, maxConcurrency),
214
+ sseConnections: 0,
208
215
  };
216
+ // issue #197: server→client requests (sampling) ride the GET /mcp SSE
217
+ // stream as `event: message` frames. With no stream open the send throws
218
+ // synchronously, the request rejects, and ask_knowledge_base degrades to
219
+ // its extractive answer — sampling stays strictly best-effort.
220
+ state.mcpChannel = createClientChannel((msg) => {
221
+ const sse = state.mcpSseRes;
222
+ if (!sse) {
223
+ throw new Error('sampling unavailable: client has no open GET /mcp stream');
224
+ }
225
+ sse.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
226
+ });
209
227
  const sockets = new Set();
210
228
  const server = http.createServer((req, res) => {
211
229
  handleRequest(req, res, state).catch((e) => {
212
- json(res, 500, { error: e.message });
230
+ // issue #182: raw exception text can carry absolute filesystem paths —
231
+ // reply generically, log the details server-side
232
+ process.stderr.write(`serve: unhandled request error: ${e?.stack || e}\n`);
233
+ try {
234
+ if (res.headersSent) {
235
+ res.destroy();
236
+ }
237
+ else {
238
+ json(res, 500, { error: 'internal server error' });
239
+ }
240
+ }
241
+ catch {
242
+ try {
243
+ res.destroy();
244
+ }
245
+ catch {
246
+ /* socket already gone */
247
+ }
248
+ }
213
249
  });
214
250
  });
215
251
  server.on('connection', (socket) => {
@@ -224,7 +260,7 @@ export async function createServe(opts) {
224
260
  return false;
225
261
  log('Change detected — re-indexing…');
226
262
  try {
227
- await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
263
+ await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn, ...(maxChunkTokens !== undefined ? { maxChunkTokens } : {}) });
228
264
  }
229
265
  catch (e) {
230
266
  if (/(being written by pid|being written|lock)/i.test(String(e?.message || ''))) {
@@ -239,12 +275,19 @@ export async function createServe(opts) {
239
275
  return true;
240
276
  };
241
277
  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 };
278
+ const statFile = (f) => {
279
+ if (_testFs?.stat)
280
+ return _testFs.stat(f);
281
+ const st = fs.statSync(f);
282
+ return { mtimeMs: st.mtimeMs, size: st.size };
283
+ };
243
284
  const relOf = (f) => path.relative(db, f).split(path.sep).join('/');
244
285
  const dbg = (msg) => {
245
286
  if (watchDebug)
246
287
  log(`watch-debug: ${msg}`);
247
288
  };
289
+ // issue #195: cache identity is size+mtimeMs — a file restored from a
290
+ // backup with an OLD mtime but new content no longer masks its change
248
291
  const md5Cache = new Map();
249
292
  // issue #116: files whose hash currently fails (AV/sync contention) — they
250
293
  // are EXCLUDED from fingerprints instead of becoming stable 'unreadable'
@@ -257,9 +300,9 @@ export async function createServe(opts) {
257
300
  * Returns null when the file must be excluded this cycle: vanished (deleted),
258
301
  * or still unreadable after retries (changed-pending, warned rate-limited).
259
302
  */
260
- const hashWithRetry = async (f, m) => {
303
+ const hashWithRetry = async (f, st) => {
261
304
  const hit = md5Cache.get(f);
262
- if (hit && hit.mtime === m) {
305
+ if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
263
306
  unreadable.delete(f);
264
307
  return hit.md5;
265
308
  }
@@ -269,7 +312,7 @@ export async function createServe(opts) {
269
312
  onRetry: (attempt, err) => dbg(`retry ${attempt} ${path.basename(f)}: ${err?.code ?? err?.message}`),
270
313
  });
271
314
  if (r.ok) {
272
- md5Cache.set(f, { mtime: m, md5: r.value });
315
+ md5Cache.set(f, { mtime: st.mtimeMs, size: st.size, md5: r.value });
273
316
  unreadable.delete(f);
274
317
  dbg(`hashed ${path.basename(f)}`);
275
318
  return r.value;
@@ -293,19 +336,42 @@ export async function createServe(opts) {
293
336
  */
294
337
  const probeTree = async (tree) => {
295
338
  const hashes = new Map();
296
- for (const [f, { m }] of tree) {
297
- hashes.set(f, await hashWithRetry(f, m));
339
+ for (const [f, { m, s }] of tree) {
340
+ hashes.set(f, await hashWithRetry(f, { mtimeMs: m, size: s }));
298
341
  }
299
342
  return hashes;
300
343
  };
301
- const fingerprintOf = (tree, fileHashes) => {
302
- const parts = [];
344
+ /**
345
+ * issue #195: tree state as a rel→hash map — compared map-to-map with no
346
+ * O(n log n) string build + sort per poll. null means "unknown" (initial
347
+ * load or a native event forced a re-probe).
348
+ */
349
+ const stateOf = (tree, fileHashes) => {
350
+ const out = new Map();
303
351
  for (const [f, { rel }] of tree) {
304
352
  const h = fileHashes.get(f);
305
353
  if (h !== null && h !== undefined)
306
- parts.push(`${rel}:${h}`);
354
+ out.set(rel, h);
355
+ }
356
+ return out;
357
+ };
358
+ const statesEqual = (a, b) => {
359
+ if (!a || a.size !== b.size)
360
+ return false;
361
+ for (const [rel, hash] of b) {
362
+ if (a.get(rel) !== hash)
363
+ return false;
364
+ }
365
+ return true;
366
+ };
367
+ /** issue #195: evict cache entries for files that left the tree. */
368
+ const evictStaleCacheEntries = (tree) => {
369
+ if (md5Cache.size <= tree.size)
370
+ return;
371
+ for (const f of md5Cache.keys()) {
372
+ if (!tree.has(f))
373
+ md5Cache.delete(f);
307
374
  }
308
- return parts.sort().join('|');
309
375
  };
310
376
  const readIndexedHashes = () => {
311
377
  try {
@@ -323,7 +389,8 @@ export async function createServe(opts) {
323
389
  return { tree, problems };
324
390
  for (const f of walkMarkdown(db, ignore)) {
325
391
  try {
326
- tree.set(f, { m: statFile(f).mtimeMs, rel: relOf(f) });
392
+ const st = statFile(f);
393
+ tree.set(f, { m: st.mtimeMs, s: st.size, rel: relOf(f) });
327
394
  }
328
395
  catch (e) {
329
396
  const cls = classifyFsError(e);
@@ -355,7 +422,9 @@ export async function createServe(opts) {
355
422
  }
356
423
  return out;
357
424
  };
358
- let lastFingerprint = '';
425
+ // issue #195: null = "state unknown" (startup or a native event forced a
426
+ // re-probe); otherwise the rel→hash map of the last observed tree
427
+ let lastState = null;
359
428
  /** issue #116: one combined warning per cooldown, ≤5 files named + "+k more". */
360
429
  const warnUnreadableRateLimited = (now) => {
361
430
  if (unreadable.size === 0 || now - lastWarnAt < WARN_COOLDOWN_MS)
@@ -372,7 +441,7 @@ export async function createServe(opts) {
372
441
  {
373
442
  const { tree } = scanTree();
374
443
  const hashes = await probeTree(tree);
375
- lastFingerprint = fingerprintOf(tree, hashes);
444
+ lastState = stateOf(tree, hashes);
376
445
  }
377
446
  let lastChangeAt = 0;
378
447
  let pending = false;
@@ -394,16 +463,17 @@ export async function createServe(opts) {
394
463
  if (!unreadable.has(f))
395
464
  unreadable.set(f, { cls, code: `stat:${cls}`, firstAt: t0 });
396
465
  }
466
+ evictStaleCacheEntries(tree);
397
467
  dbg(`poll: ${tree.size} file(s), ${unreadable.size} pending-unreadable`);
398
468
  const fileHashes = await probeTree(tree);
399
469
  warnUnreadableRateLimited(Date.now());
400
- const fingerprint = fingerprintOf(tree, fileHashes);
470
+ const currentState = stateOf(tree, fileHashes);
401
471
  const changed = contentChangedPaths(tree, fileHashes, indexedHashes);
402
- if (fingerprint !== lastFingerprint) {
472
+ if (!statesEqual(lastState, currentState)) {
403
473
  if (!pending)
404
474
  log(`watch: change in ${changed.length} file(s) — waiting ${watchDelay} ms for the tree to settle…`);
405
475
  pending = true;
406
- lastFingerprint = fingerprint;
476
+ lastState = currentState;
407
477
  lastChangeAt = Date.now();
408
478
  }
409
479
  else if (pending && changed.length > 0 && Date.now() - lastChangeAt >= watchDelay) {
@@ -414,16 +484,16 @@ export async function createServe(opts) {
414
484
  const fresh = scanTree();
415
485
  const freshHashes = await probeTree(fresh.tree);
416
486
  // 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) {
487
+ // next poll is IO-free for unchanged files
488
+ for (const [f, { m, s }] of fresh.tree) {
419
489
  const h = freshHashes.get(f);
420
490
  if (h !== null && h !== undefined && !md5Cache.has(f))
421
- md5Cache.set(f, { mtime: m, md5: h });
491
+ md5Cache.set(f, { mtime: m, size: s, md5: h });
422
492
  }
423
- lastFingerprint = fingerprintOf(fresh.tree, freshHashes);
493
+ lastState = stateOf(fresh.tree, freshHashes);
424
494
  }
425
495
  }
426
- else if (!pending && changed.length > 0 && fingerprint === lastFingerprint) {
496
+ else if (!pending && changed.length > 0 && statesEqual(lastState, currentState)) {
427
497
  pending = true;
428
498
  lastChangeAt = Date.now();
429
499
  log(`watch: tree differs from index (no motion) — settling then re-index…`);
@@ -435,7 +505,7 @@ export async function createServe(opts) {
435
505
  // settled but nothing actually differs (e.g. a locked file became
436
506
  // readable again with unchanged content) — resync the baseline
437
507
  pending = false;
438
- lastFingerprint = fingerprint;
508
+ lastState = currentState;
439
509
  }
440
510
  }
441
511
  };
@@ -444,14 +514,35 @@ export async function createServe(opts) {
444
514
  if (!db)
445
515
  throw new Error('serve --watch requires --db');
446
516
  nativeWatcher = createFileWatcher(db, () => {
447
- // Prompt watchLoop settle trigger on native event
448
- lastFingerprint = '';
517
+ // Prompt watchLoop settle trigger on native event: state becomes
518
+ // "unknown", so the next poll re-probes and enters the settle path
519
+ lastState = null;
449
520
  }, {
450
521
  debounceMs: watchDelay,
451
522
  ignore,
452
523
  log,
453
524
  });
454
- watchLoop();
525
+ // issue #186: watchLoop runs unawaited — an early rejection used to be an
526
+ // unhandled one and killed the daemon. Restart with backoff, give up
527
+ // after 5 crashes (search keeps serving the last good index either way).
528
+ void (async () => {
529
+ for (let attempt = 0;; attempt++) {
530
+ try {
531
+ await watchLoop();
532
+ return; // resolved → stopped normally
533
+ }
534
+ catch (e) {
535
+ if (stopped || attempt >= 4) {
536
+ log(`watch loop failed permanently (search still serves the last index): ${e.message}`);
537
+ return;
538
+ }
539
+ log(`watch loop crashed (attempt ${attempt + 1}/5): ${e.message} — restarting in 5s…`);
540
+ await new Promise((r) => setTimeout(r, 5_000));
541
+ if (stopped)
542
+ return;
543
+ }
544
+ }
545
+ })();
455
546
  }
456
547
  const close = async () => {
457
548
  stopped = true;
@@ -559,6 +650,52 @@ export function splitHostHeader(hostHeader) {
559
650
  // a single colon separates host:port; several colons mean a bare IPv6 literal
560
651
  return first !== -1 && first === last ? s.slice(0, first) : s;
561
652
  }
653
+ /**
654
+ * Cross-site POST guard (issue #178). Browsers fire "simple" POSTs without
655
+ * preflight (Content-Type: text/plain, form encodings) from any open page;
656
+ * the loopback Host check alone does not stop them. A POST counts as
657
+ * same-site when:
658
+ * - the client sent no Origin/Sec-Fetch headers (curl, MCP and other
659
+ * non-browser clients), or
660
+ * - Sec-Fetch-Site is not cross-site AND the Origin's host matches the
661
+ * request Host, a loopback allowlist entry, or an explicit --cors-origin.
662
+ * `Origin: null` (sandboxed iframe, file:// page) is always rejected.
663
+ */
664
+ export function isSameSitePost(req, allowedHosts, corsOrigins) {
665
+ const secFetchSite = req.headers['sec-fetch-site'];
666
+ if (typeof secFetchSite === 'string' && secFetchSite === 'cross-site')
667
+ return false;
668
+ const origin = req.headers.origin;
669
+ if (typeof origin !== 'string' || origin.length === 0)
670
+ return true; // non-browser client
671
+ if (origin === 'null')
672
+ return false;
673
+ if (corsOrigins && corsOrigins.includes(origin))
674
+ return true;
675
+ const hostHeader = typeof req.headers.host === 'string' ? req.headers.host.toLowerCase() : '';
676
+ if (!hostHeader)
677
+ return false;
678
+ let originUrl;
679
+ try {
680
+ originUrl = new URL(origin);
681
+ }
682
+ catch {
683
+ return false; // malformed Origin — fail closed
684
+ }
685
+ if (originUrl.host === hostHeader)
686
+ return true; // exact same-origin (host:port)
687
+ // loopback aliases pass only on the SAME port — a page on localhost:3000 is
688
+ // a different origin even though the bare hostname is allowlisted
689
+ const portOf = (h, proto) => {
690
+ const m = h.match(/:(\d+)$/);
691
+ if (m)
692
+ return m[1];
693
+ return proto === 'https:' || proto === 'wss:' ? '443' : '80';
694
+ };
695
+ if (portOf(originUrl.host, originUrl.protocol) !== portOf(hostHeader, 'http:'))
696
+ return false;
697
+ return allowedHosts.has(splitHostHeader(originUrl.host));
698
+ }
562
699
  /**
563
700
  * Reflect CORS headers ONLY for an exact allowlisted Origin (issue #120).
564
701
  * Off entirely by default; `Vary: Origin` is set whenever CORS is configured so
@@ -604,17 +741,37 @@ async function handleRequest(req, res, state) {
604
741
  }
605
742
  return;
606
743
  }
607
- if (state.apiKey) {
608
- const isPublicHealth = url.pathname === '/health' && req.method === 'GET' && state.healthPublic;
609
- if (!isPublicHealth) {
610
- const authHeader = req.headers['authorization'];
611
- const match = typeof authHeader === 'string' ? authHeader.match(/^Bearer\s+(.+)$/i) : null;
612
- const token = match ? match[1].trim() : null;
613
- if (!token || !isAuthorizedToken(token, state.apiKey)) {
614
- json(res, 401, { error: 'unauthorized' });
744
+ // CSRF / body-safety gate for every body-parsing POST (issue #178): a "simple"
745
+ // cross-site browser POST (text/plain, form) sails past the Host allowlist on
746
+ // loopback, so reject foreign origins and non-JSON media types up front.
747
+ if (req.method === 'POST') {
748
+ const ct = req.headers['content-type'];
749
+ if (typeof ct === 'string') {
750
+ const media = ct.split(';')[0].trim().toLowerCase();
751
+ if (media !== 'application/json') {
752
+ json(res, 415, { error: `unsupported content-type "${media}" — POST bodies must be application/json` });
615
753
  return;
616
754
  }
617
755
  }
756
+ if (!isSameSitePost(req, allowedHosts, state.corsOrigins)) {
757
+ json(res, 403, { error: 'cross-site request rejected' });
758
+ return;
759
+ }
760
+ }
761
+ // issue #181: /health path disclosure depends on whether THIS request is
762
+ // authenticated — an unauthenticated daemon or public health hides indexDir
763
+ let requestAuthenticated = false;
764
+ if (state.apiKey) {
765
+ const authHeader = req.headers['authorization'];
766
+ const match = typeof authHeader === 'string' ? authHeader.match(/^Bearer\s+(.+)$/i) : null;
767
+ const token = match ? match[1].trim() : null;
768
+ const valid = !!token && isAuthorizedToken(token, state.apiKey);
769
+ const isPublicHealth = url.pathname === '/health' && req.method === 'GET' && state.healthPublic;
770
+ if (!valid && !isPublicHealth) {
771
+ json(res, 401, { error: 'unauthorized' });
772
+ return;
773
+ }
774
+ requestAuthenticated = valid;
618
775
  }
619
776
  // Streamable HTTP MCP transport (issue #123): same auth/host/rate-limit
620
777
  // gates as /search, reusing handleMcpRequest untouched as the tool brain.
@@ -689,6 +846,11 @@ async function handleRequest(req, res, state) {
689
846
  offline: state.offline,
690
847
  embedFn: state.embedFn,
691
848
  rerankFn: state.rerankFn,
849
+ // issue #203: multi-query expansion (LLM bridge = the daemon's own
850
+ // --llm-endpoint, NEVER a payload-supplied URL — that would let any
851
+ // client exfiltrate note content to an arbitrary host) + reranker alias
852
+ ...(payload.multiQuery && state.llmEndpoint ? { multiQuery: payload.multiQuery, llmEndpoint: state.llmEndpoint, ...(state.llmModel ? { llmModel: state.llmModel } : {}) } : {}),
853
+ ...(payload.rerankModel ? { rerankModel: payload.rerankModel } : {}),
692
854
  tag: payload.tag,
693
855
  project: payload.project,
694
856
  type: payload.type,
@@ -706,7 +868,215 @@ async function handleRequest(req, res, state) {
706
868
  json(res, 200, { query, k, count: results.length, results });
707
869
  }
708
870
  catch (e) {
709
- json(res, 500, { error: e.message });
871
+ // issue #182: search errors can name absolute paths — keep the reply
872
+ // generic, log the details server-side
873
+ process.stderr.write(`serve: /search failed: ${e?.stack || e}\n`);
874
+ json(res, 500, { error: 'internal server error' });
875
+ }
876
+ finally {
877
+ release();
878
+ }
879
+ return;
880
+ }
881
+ // issue #199: grounded QA over SSE — a `sources` frame as soon as retrieval
882
+ // settles, streamed `delta` frames while the LLM answers, one `done` frame
883
+ // with the assembled result. Same fairness gates as /search (an LLM call
884
+ // holds a concurrency slot for its whole generation). Requires --llm-endpoint:
885
+ // over HTTP a missing chat backend is a 400, never a silent extractive degrade.
886
+ if (req.method === 'POST' && url.pathname === '/ask') {
887
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
888
+ if (limited) {
889
+ res.setHeader('retry-after', String(limited.retryAfterSec));
890
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
891
+ return;
892
+ }
893
+ if (!state.llmEndpoint) {
894
+ json(res, 400, { error: 'POST /ask requires an LLM chat endpoint — start mdss serve with --llm-endpoint' });
895
+ return;
896
+ }
897
+ const declared = req.headers['content-length'];
898
+ if (declared !== undefined) {
899
+ const n = Number(declared);
900
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BODY_BYTES) {
901
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
902
+ return;
903
+ }
904
+ }
905
+ let body = '';
906
+ let tooLarge = false;
907
+ for await (const chunk of req) {
908
+ body += chunk;
909
+ if (body.length > MAX_BODY_BYTES) {
910
+ tooLarge = true;
911
+ break;
912
+ }
913
+ }
914
+ if (tooLarge) {
915
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
916
+ req.resume();
917
+ return;
918
+ }
919
+ let payload;
920
+ try {
921
+ payload = body ? JSON.parse(body) : {};
922
+ }
923
+ catch (e) {
924
+ json(res, 400, { error: `invalid JSON body: ${e.message}` });
925
+ return;
926
+ }
927
+ const query = typeof payload.query === 'string' ? payload.query.trim() : '';
928
+ if (!query) {
929
+ json(res, 400, { error: 'missing "query" string in JSON body' });
930
+ return;
931
+ }
932
+ if (query.length > 2048) {
933
+ json(res, 400, { error: 'query exceeds maximum length of 2048 characters' });
934
+ return;
935
+ }
936
+ const k = Number.isInteger(payload.k) && payload.k > 0 ? payload.k : 5;
937
+ // issue #199 (follow-up): chat mode. `{"sessionId": "..."}` continues a
938
+ // persistent session (same store as `mdss chat`); `{"chat": true}` starts
939
+ // a fresh one whose id comes back in the `session`/`done` frames for the
940
+ // next request. One SSE protocol for both.
941
+ const rawSessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
942
+ const chatMode = !!rawSessionId || payload.chat === true;
943
+ if (rawSessionId) {
944
+ // issue #149 discipline: validate BEFORE any filesystem use; a bad id
945
+ // gets a plain 400 here rather than a thrown error mid-stream
946
+ try {
947
+ assertSafeSessionId(rawSessionId);
948
+ }
949
+ catch (e) {
950
+ json(res, 400, { error: `invalid sessionId: ${e.message}` });
951
+ return;
952
+ }
953
+ }
954
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
955
+ if (!release) {
956
+ res.setHeader('retry-after', '1');
957
+ json(res, 503, { error: 'server busy — search concurrency cap reached, queue full' });
958
+ return;
959
+ }
960
+ // Client disconnect must tear down the in-flight LLM call, not just stop
961
+ // writing frames — the slot releases in `finally` either way.
962
+ const clientAbort = new AbortController();
963
+ res.on('close', () => clientAbort.abort());
964
+ res.writeHead(200, {
965
+ 'content-type': 'text/event-stream',
966
+ 'cache-control': 'no-store',
967
+ 'connection': 'keep-alive',
968
+ });
969
+ const sse = (event, data) => {
970
+ if (!res.writableEnded)
971
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
972
+ };
973
+ const llmOpts = {
974
+ llmEndpoint: state.llmEndpoint,
975
+ ...(state.llmModel ? { llmModel: state.llmModel } : {}),
976
+ ...(state.llmProvider ? { llmProvider: state.llmProvider } : {}),
977
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
978
+ onDelta: (d) => sse('delta', { text: d }),
979
+ signal: clientAbort.signal,
980
+ };
981
+ try {
982
+ if (chatMode) {
983
+ const session = loadChatSession(state.indexDir, rawSessionId || crypto.randomUUID().slice(0, 8));
984
+ sse('session', { id: session.id, turns: session.turns.length });
985
+ const turn = await chatTurn({
986
+ session,
987
+ query,
988
+ loaded: state.loaded,
989
+ indexDir: state.indexDir,
990
+ cacheDir: state.cacheDir,
991
+ k,
992
+ offline: state.offline,
993
+ embedFn: state.embedFn,
994
+ ...llmOpts,
995
+ });
996
+ sse('done', {
997
+ answer: turn.answer,
998
+ mode: turn.mode,
999
+ citations: turn.citations,
1000
+ citationsUsed: turn.citationsUsed,
1001
+ manifest: turn.manifest,
1002
+ sessionId: session.id,
1003
+ turnsCount: session.turns.length,
1004
+ });
1005
+ }
1006
+ else {
1007
+ const passages = await searchIndex({
1008
+ loaded: state.loaded,
1009
+ cacheDir: state.cacheDir,
1010
+ query,
1011
+ k,
1012
+ offline: state.offline,
1013
+ embedFn: state.embedFn,
1014
+ });
1015
+ sse('sources', {
1016
+ citations: passages.map((p) => ({ file: p.file, heading: p.heading, score: p.score })),
1017
+ });
1018
+ const result = await synthesizeAnswer(query, passages, llmOpts);
1019
+ sse('done', {
1020
+ answer: result.answer,
1021
+ mode: result.mode,
1022
+ citations: result.citations,
1023
+ citationsUsed: result.citationsUsed,
1024
+ });
1025
+ }
1026
+ }
1027
+ catch (e) {
1028
+ // issue #182 discipline: raw errors can carry absolute paths — the
1029
+ // frame stays generic, details go to the server log
1030
+ process.stderr.write(`serve: /ask failed: ${e?.stack || e}\n`);
1031
+ sse('error', { error: 'answer synthesis failed' });
1032
+ }
1033
+ finally {
1034
+ release();
1035
+ res.end();
1036
+ }
1037
+ return;
1038
+ }
1039
+ if (req.method === 'GET' && url.pathname === '/doc') {
1040
+ const file = url.searchParams.get('file');
1041
+ if (!file) {
1042
+ json(res, 400, { error: 'missing "file" query parameter' });
1043
+ return;
1044
+ }
1045
+ // issue #181: /doc does synchronous disk IO per request — it goes through
1046
+ // the same fairness gates as /search so a local storm cannot starve it
1047
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
1048
+ if (limited) {
1049
+ res.setHeader('retry-after', String(limited.retryAfterSec));
1050
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
1051
+ return;
1052
+ }
1053
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
1054
+ if (!release) {
1055
+ res.setHeader('retry-after', '1');
1056
+ json(res, 503, { error: 'server busy — concurrency cap reached, queue full' });
1057
+ return;
1058
+ }
1059
+ const fromLineParam = url.searchParams.get('fromLine') || url.searchParams.get('from');
1060
+ const maxLinesParam = url.searchParams.get('maxLines') || url.searchParams.get('lines') || url.searchParams.get('limit');
1061
+ const fromLine = fromLineParam ? parseInt(fromLineParam, 10) : 1;
1062
+ const maxLines = maxLinesParam ? parseInt(maxLinesParam, 10) : undefined;
1063
+ try {
1064
+ const docResult = getDocLines({
1065
+ file,
1066
+ fromLine: isNaN(fromLine) ? 1 : fromLine,
1067
+ maxLines: maxLines && !isNaN(maxLines) ? maxLines : undefined,
1068
+ dbDir: state.loaded.index.db,
1069
+ chunks: state.loaded.index.chunks,
1070
+ });
1071
+ json(res, 200, docResult);
1072
+ }
1073
+ catch (e) {
1074
+ if (e.message?.includes('not found')) {
1075
+ json(res, 404, { error: e.message });
1076
+ }
1077
+ else {
1078
+ json(res, 400, { error: e.message });
1079
+ }
710
1080
  }
711
1081
  finally {
712
1082
  release();
@@ -714,6 +1084,10 @@ async function handleRequest(req, res, state) {
714
1084
  return;
715
1085
  }
716
1086
  if (req.method === 'GET' && url.pathname === '/health') {
1087
+ // issue #181: an unauthenticated /health must not disclose filesystem
1088
+ // locations — the path comes back only to an authenticated caller on a
1089
+ // daemon that also requires auth for /health
1090
+ const hideIndexDir = !state.apiKey || !requestAuthenticated;
717
1091
  json(res, 200, {
718
1092
  ok: true,
719
1093
  chunks: state.loaded.index.chunks.length,
@@ -721,7 +1095,7 @@ async function handleRequest(req, res, state) {
721
1095
  dim: state.loaded.index.dim || state.loaded.model.dim || 0,
722
1096
  built: state.loaded.index.built || null,
723
1097
  watching: state.watching,
724
- indexDir: state.indexDir,
1098
+ indexDir: hideIndexDir ? undefined : state.indexDir,
725
1099
  // fairness counters (issue #119)
726
1100
  in_flight: state.limiter?.inFlight ?? 0,
727
1101
  queued: state.limiter?.queued ?? 0,
@@ -744,11 +1118,103 @@ async function handleRequest(req, res, state) {
744
1118
  sendAsset(res, 'text/css; charset=utf-8', WEBUI_CSS);
745
1119
  return;
746
1120
  }
1121
+ if (url.pathname === '/related' && (req.method === 'POST' || req.method === 'GET')) {
1122
+ // issue #181: /related runs the (semantic) relatedness sweep — same rate
1123
+ // gate as /search, before any body parsing
1124
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
1125
+ if (limited) {
1126
+ res.setHeader('retry-after', String(limited.retryAfterSec));
1127
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
1128
+ return;
1129
+ }
1130
+ let target = '';
1131
+ let k = 10;
1132
+ let direction = 'both';
1133
+ let semantic = true;
1134
+ if (req.method === 'GET') {
1135
+ target = url.searchParams.get('file') || url.searchParams.get('target') || url.searchParams.get('note') || '';
1136
+ const kParam = url.searchParams.get('k');
1137
+ if (kParam)
1138
+ k = parseInt(kParam, 10) || 10;
1139
+ const dirParam = url.searchParams.get('direction');
1140
+ if (dirParam === 'outgoing' || dirParam === 'backlinks' || dirParam === 'both')
1141
+ direction = dirParam;
1142
+ if (url.searchParams.has('semantic'))
1143
+ semantic = url.searchParams.get('semantic') !== 'false';
1144
+ }
1145
+ else {
1146
+ // issue #181: an overflowing body is a 413, not a silently truncated parse
1147
+ let body = '';
1148
+ let tooLarge = false;
1149
+ for await (const chunk of req) {
1150
+ body += chunk;
1151
+ if (body.length > MAX_BODY_BYTES) {
1152
+ tooLarge = true;
1153
+ break;
1154
+ }
1155
+ }
1156
+ if (tooLarge) {
1157
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
1158
+ req.resume();
1159
+ return;
1160
+ }
1161
+ try {
1162
+ const payload = body ? JSON.parse(body) : {};
1163
+ target = payload.file || payload.target || payload.note || '';
1164
+ if (typeof payload.k === 'number' && payload.k > 0)
1165
+ k = payload.k;
1166
+ if (payload.direction === 'outgoing' || payload.direction === 'backlinks' || payload.direction === 'both') {
1167
+ direction = payload.direction;
1168
+ }
1169
+ if (payload.semantic !== undefined)
1170
+ semantic = Boolean(payload.semantic);
1171
+ }
1172
+ catch (e) {
1173
+ json(res, 400, { error: `invalid JSON body: ${e.message}` });
1174
+ return;
1175
+ }
1176
+ }
1177
+ if (!target) {
1178
+ json(res, 400, { error: 'missing "file" or "target" parameter' });
1179
+ return;
1180
+ }
1181
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
1182
+ if (!release) {
1183
+ res.setHeader('retry-after', '1');
1184
+ json(res, 503, { error: 'server busy — concurrency cap reached, queue full' });
1185
+ return;
1186
+ }
1187
+ try {
1188
+ const relatedResult = findRelatedNotes({
1189
+ loaded: state.loaded,
1190
+ target,
1191
+ k,
1192
+ direction,
1193
+ semantic,
1194
+ });
1195
+ json(res, 200, relatedResult);
1196
+ }
1197
+ catch (e) {
1198
+ if (e.message?.includes('not found')) {
1199
+ json(res, 404, { error: e.message });
1200
+ }
1201
+ else {
1202
+ json(res, 400, { error: e.message });
1203
+ }
1204
+ }
1205
+ finally {
1206
+ release();
1207
+ }
1208
+ return;
1209
+ }
747
1210
  if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/help')) {
748
1211
  json(res, 200, {
749
1212
  name: 'mdss serve',
750
1213
  endpoints: [
751
1214
  { method: 'POST', path: '/search', body: '{ "query": "...", "k": 6, "semanticOnly": false, "rerank": false }' },
1215
+ { method: 'POST', path: '/ask', body: '{ "query": "...", "k": 5 } — SSE: sources/delta/done frames (needs --llm-endpoint, issue #199)' },
1216
+ { method: 'GET', path: '/doc?file=...&fromLine=1&maxLines=50' },
1217
+ { method: 'POST', path: '/related', body: '{ "file": "...", "k": 10, "semantic": true }' },
752
1218
  { method: 'GET', path: '/health' },
753
1219
  ],
754
1220
  });
@@ -772,12 +1238,23 @@ async function handleMcpHttp(req, res, state) {
772
1238
  if (req.method === 'GET') {
773
1239
  // Server-initiated channel: we have nothing to push today, so keep the
774
1240
  // stream alive with comments until the client goes away.
1241
+ // issue #181: each stream holds a socket + a heartbeat interval — cap the
1242
+ // number of concurrent SSE connections at the fairness concurrency limit.
1243
+ const sseCap = state.limiter?.concurrencyEnabled ? state.limiter.maxConcurrency : DEFAULT_MAX_CONCURRENCY;
1244
+ if ((state.sseConnections ?? 0) >= sseCap) {
1245
+ json(res, 503, { error: 'server busy — SSE connection cap reached' });
1246
+ return;
1247
+ }
1248
+ state.sseConnections = (state.sseConnections ?? 0) + 1;
775
1249
  res.writeHead(200, {
776
1250
  'content-type': 'text/event-stream',
777
1251
  'cache-control': 'no-store',
778
1252
  'connection': 'keep-alive',
779
1253
  });
780
1254
  res.write(':ok\n\n');
1255
+ // issue #197: this stream doubles as the server→client downlink for
1256
+ // sampling/createMessage. One client realistically; newest stream wins.
1257
+ state.mcpSseRes = res;
781
1258
  const hb = setInterval(() => {
782
1259
  try {
783
1260
  res.write(':hb\n\n');
@@ -787,14 +1264,34 @@ async function handleMcpHttp(req, res, state) {
787
1264
  }
788
1265
  }, 15_000);
789
1266
  hb.unref?.();
790
- req.on('close', () => clearInterval(hb));
1267
+ // res 'close' fires on client abort mid-stream too — req may never close
1268
+ // while the response stream is still open
1269
+ res.on('close', () => {
1270
+ clearInterval(hb);
1271
+ if (state.mcpSseRes === res)
1272
+ state.mcpSseRes = undefined;
1273
+ state.sseConnections = Math.max(0, (state.sseConnections ?? 1) - 1);
1274
+ });
791
1275
  return;
792
1276
  }
793
1277
  if (req.method !== 'POST') {
794
1278
  json(res, 405, { error: 'method not allowed' });
795
1279
  return;
796
1280
  }
1281
+ // issue #182: session binding before any work — the server mints its own id
1282
+ // and rejects mismatching client ids with 404 instead of adopting them
1283
+ // (session fixation) or feeding them to res.setHeader (500 on bad headers)
1284
+ const requested = req.headers['mcp-session-id'];
1285
+ if (typeof requested === 'string' &&
1286
+ requested &&
1287
+ state.mcpSessionId &&
1288
+ requested !== state.mcpSessionId) {
1289
+ json(res, 404, { error: 'session not found — re-initialize' });
1290
+ return;
1291
+ }
797
1292
  // Same fairness gate as /search — tools/call can trigger embedding work.
1293
+ // (The gates run AFTER body parse below: responses to server→client
1294
+ // requests must bypass them, see issue #197.)
798
1295
  const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
799
1296
  if (limited) {
800
1297
  res.setHeader('retry-after', String(limited.retryAfterSec));
@@ -826,37 +1323,60 @@ async function handleMcpHttp(req, res, state) {
826
1323
  }
827
1324
  const batch = Array.isArray(messages);
828
1325
  const items = batch ? messages : [messages];
1326
+ // issue #197: answers to server→client requests (sampling) arrive as
1327
+ // plain POSTs. Resolve them BEFORE the fairness gates — the tools/call
1328
+ // waiting on this response may hold the only concurrency slot, so
1329
+ // queueing it behind itself would self-deadlock at max_concurrency 1.
1330
+ let sawResponseOnly = items.length > 0;
1331
+ for (const msg of items) {
1332
+ if (!msg || typeof msg !== 'object') {
1333
+ continue;
1334
+ }
1335
+ const isResponse = msg.method === undefined && (msg.result !== undefined || msg.error !== undefined);
1336
+ if (isResponse) {
1337
+ state.mcpChannel?.handleResponse(msg);
1338
+ }
1339
+ else {
1340
+ sawResponseOnly = false;
1341
+ }
1342
+ }
1343
+ if (sawResponseOnly) {
1344
+ res.writeHead(202);
1345
+ res.end();
1346
+ return;
1347
+ }
829
1348
  const responses = [];
830
1349
  for (const msg of items) {
831
1350
  if (!msg || typeof msg !== 'object')
832
1351
  continue;
1352
+ if (msg.method === undefined)
1353
+ continue; // response — correlated above
833
1354
  const isNotification = msg.id === undefined || msg.id === null;
834
1355
  const result = isNotification ? null : await handleMcpRequest(msg, {
835
1356
  loaded: state.loaded,
836
1357
  cacheDir: state.cacheDir,
837
1358
  offline: state.offline,
838
1359
  embedFn: state.embedFn,
1360
+ channel: state.mcpChannel,
1361
+ clientCapabilities: state.mcpClientCapabilities,
839
1362
  });
840
1363
  if (result)
841
1364
  responses.push(result);
842
1365
  else if (!isNotification && msg.method === undefined)
843
1366
  continue;
1367
+ // issue #197: remember the client's declared capabilities (sampling
1368
+ // gates the ask_knowledge_base LLM path on later tools/call).
1369
+ if (msg.method === 'initialize') {
1370
+ state.mcpClientCapabilities = msg.params?.capabilities ?? {};
1371
+ }
844
1372
  }
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;
1373
+ // session id: server-minted only (issue #182 — see the 404 gate above);
1374
+ // mint on first contact, echo ours afterwards. Protocol negotiation
1375
+ // itself moved into the shared brain (issue #197) so stdio and HTTP
1376
+ // speak identical version logic.
849
1377
  if (!state.mcpSessionId)
850
1378
  state.mcpSessionId = crypto.randomUUID();
851
1379
  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
1380
  if (responses.length === 0) {
861
1381
  res.writeHead(202);
862
1382
  res.end();
@@ -868,10 +1388,6 @@ async function handleMcpHttp(req, res, state) {
868
1388
  release();
869
1389
  }
870
1390
  }
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
- }
875
1391
  function json(res, status, data) {
876
1392
  res.writeHead(status, {
877
1393
  'content-type': 'application/json; charset=utf-8',