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/src/serve.ts CHANGED
@@ -10,10 +10,13 @@ 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, ModelDescriptor } from './core.js';
13
+ import { synthesizeAnswer, chatTurn, loadChatSession, assertSafeSessionId } from './rag.js';
14
+ import { walkMarkdown, assertSafePath, getDocLines, ModelDescriptor } from './core.js';
14
15
  import { createFileWatcher, FileWatcher, 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';
19
+ import { ChatProviderId } from './providers.js';
17
20
 
18
21
  const DEFAULT_PORT = 8747;
19
22
  const DEFAULT_HOST = '127.0.0.1';
@@ -40,11 +43,24 @@ export interface ServeState {
40
43
  reindexCount?: number;
41
44
  apiKey?: string;
42
45
  healthPublic?: boolean;
46
+ /** issue #181: live count of open SSE /mcp streams (capped by maxConcurrency). */
47
+ sseConnections?: number;
43
48
  /** issue #111: serve the built-in web UI at `/` (default on). */
44
49
  ui?: boolean;
50
+ /** issue #199: chat endpoint backing POST /ask — absent → /ask returns 400. */
51
+ llmEndpoint?: string;
52
+ llmModel?: string;
53
+ llmProvider?: ChatProviderId;
54
+ systemPrompt?: string;
45
55
  /** issue #123: mount the Streamable HTTP MCP transport at /mcp (default off). */
46
56
  mcp?: boolean;
47
57
  mcpSessionId?: string;
58
+ /** issue #197: reverse channel for server→client requests (MCP sampling). */
59
+ mcpChannel?: ReturnType<typeof createClientChannel>;
60
+ /** issue #197: capabilities the client declared at initialize. */
61
+ mcpClientCapabilities?: any;
62
+ /** issue #197: the open GET /mcp SSE stream doubling as the downlink (newest wins). */
63
+ mcpSseRes?: http.ServerResponse;
48
64
  /** Extra Host-header values accepted beyond the loopback defaults (issue #120). */
49
65
  allowedHosts?: string[];
50
66
  /** Exact origins reflected as `Access-Control-Allow-Origin`; absent → CORS off (issue #120). */
@@ -74,11 +90,18 @@ export interface CreateServeOptions {
74
90
  /** issue #116 test seam: override hashing/stat so fault-injection needs no real locks. */
75
91
  _testFs?: {
76
92
  hash?: (absPath: string) => string;
77
- stat?: (absPath: string) => { mtimeMs: number };
93
+ stat?: (absPath: string) => { mtimeMs: number; size?: number };
78
94
  };
79
95
  ui?: boolean;
80
96
  /** issue #123: mount Streamable HTTP MCP at /mcp (default off; stdio stays default). */
81
97
  mcp?: boolean;
98
+ /** issue #202: chunk budget in tokens; default min(adapter.maxTokens, 512), 0 = legacy chars. */
99
+ maxChunkTokens?: number;
100
+ /** issue #199: chat endpoint for POST /ask SSE answer synthesis. */
101
+ llmEndpoint?: string;
102
+ llmModel?: string;
103
+ llmProvider?: ChatProviderId;
104
+ systemPrompt?: string;
82
105
  embedFn?: any;
83
106
  rerankFn?: any;
84
107
  log?: (msg: string) => void;
@@ -266,6 +289,11 @@ export async function createServe(opts: CreateServeOptions): Promise<{
266
289
  _testFs,
267
290
  mcp = false,
268
291
  ui = true,
292
+ maxChunkTokens,
293
+ llmEndpoint,
294
+ llmModel,
295
+ llmProvider,
296
+ systemPrompt,
269
297
  embedFn,
270
298
  rerankFn,
271
299
  log = () => {},
@@ -278,7 +306,7 @@ export async function createServe(opts: CreateServeOptions): Promise<{
278
306
  if (!fs.existsSync(path.join(indexDir, 'vectors.json'))) {
279
307
  if (!db) throw new Error('serve: no index found and no --db given to build one');
280
308
  log(`No index at ${indexDir} — building it from ${db}…`);
281
- await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
309
+ await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn, ...(maxChunkTokens !== undefined ? { maxChunkTokens } : {}) });
282
310
  }
283
311
 
284
312
  const state: ServeState = {
@@ -293,15 +321,47 @@ export async function createServe(opts: CreateServeOptions): Promise<{
293
321
  healthPublic,
294
322
  ui: ui !== false,
295
323
  mcp,
324
+ ...(llmEndpoint ? { llmEndpoint } : {}),
325
+ ...(llmModel ? { llmModel } : {}),
326
+ ...(llmProvider ? { llmProvider } : {}),
327
+ ...(systemPrompt ? { systemPrompt } : {}),
296
328
  allowedHosts,
297
329
  corsOrigins,
298
330
  limiter: new ServeLimiter(rateLimit, DEFAULT_RATE_BURST, maxConcurrency),
331
+ sseConnections: 0,
299
332
  };
300
333
 
334
+ // issue #197: server→client requests (sampling) ride the GET /mcp SSE
335
+ // stream as `event: message` frames. With no stream open the send throws
336
+ // synchronously, the request rejects, and ask_knowledge_base degrades to
337
+ // its extractive answer — sampling stays strictly best-effort.
338
+ state.mcpChannel = createClientChannel((msg) => {
339
+ const sse = state.mcpSseRes;
340
+ if (!sse) {
341
+ throw new Error('sampling unavailable: client has no open GET /mcp stream');
342
+ }
343
+ sse.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
344
+ });
345
+
301
346
  const sockets = new Set<import('node:net').Socket>();
302
347
  const server = http.createServer((req, res) => {
303
348
  handleRequest(req, res, state).catch((e) => {
304
- json(res, 500, { error: e.message });
349
+ // issue #182: raw exception text can carry absolute filesystem paths —
350
+ // reply generically, log the details server-side
351
+ process.stderr.write(`serve: unhandled request error: ${e?.stack || e}\n`);
352
+ try {
353
+ if (res.headersSent) {
354
+ res.destroy();
355
+ } else {
356
+ json(res, 500, { error: 'internal server error' });
357
+ }
358
+ } catch {
359
+ try {
360
+ res.destroy();
361
+ } catch {
362
+ /* socket already gone */
363
+ }
364
+ }
305
365
  });
306
366
  });
307
367
  server.on('connection', (socket) => {
@@ -317,7 +377,7 @@ export async function createServe(opts: CreateServeOptions): Promise<{
317
377
  if (!db) return false;
318
378
  log('Change detected — re-indexing…');
319
379
  try {
320
- await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
380
+ await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn, ...(maxChunkTokens !== undefined ? { maxChunkTokens } : {}) });
321
381
  } catch (e: any) {
322
382
  if (/(being written by pid|being written|lock)/i.test(String(e?.message || ''))) {
323
383
  log(`re-index deferred: ${e.message}`);
@@ -333,14 +393,19 @@ export async function createServe(opts: CreateServeOptions): Promise<{
333
393
 
334
394
  const fileMd5 = (f: string): string =>
335
395
  _testFs?.hash ? _testFs.hash(f) : crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
336
- const statFile = (f: string): { mtimeMs: number } =>
337
- _testFs?.stat ? _testFs.stat(f) : { mtimeMs: fs.statSync(f).mtimeMs };
396
+ const statFile = (f: string): { mtimeMs: number; size?: number } => {
397
+ if (_testFs?.stat) return _testFs.stat(f);
398
+ const st = fs.statSync(f);
399
+ return { mtimeMs: st.mtimeMs, size: st.size };
400
+ };
338
401
  const relOf = (f: string) => path.relative(db!, f).split(path.sep).join('/');
339
402
  const dbg = (msg: string) => {
340
403
  if (watchDebug) log(`watch-debug: ${msg}`);
341
404
  };
342
405
 
343
- const md5Cache = new Map<string, { mtime: number; md5: string }>();
406
+ // issue #195: cache identity is size+mtimeMs — a file restored from a
407
+ // backup with an OLD mtime but new content no longer masks its change
408
+ const md5Cache = new Map<string, { mtime: number; size?: number; md5: string }>();
344
409
  // issue #116: files whose hash currently fails (AV/sync contention) — they
345
410
  // are EXCLUDED from fingerprints instead of becoming stable 'unreadable'
346
411
  // ones, so a transiently locked file can never mask a real change.
@@ -353,9 +418,9 @@ export async function createServe(opts: CreateServeOptions): Promise<{
353
418
  * Returns null when the file must be excluded this cycle: vanished (deleted),
354
419
  * or still unreadable after retries (changed-pending, warned rate-limited).
355
420
  */
356
- const hashWithRetry = async (f: string, m: number): Promise<string | null> => {
421
+ const hashWithRetry = async (f: string, st: { mtimeMs: number; size?: number }): Promise<string | null> => {
357
422
  const hit = md5Cache.get(f);
358
- if (hit && hit.mtime === m) {
423
+ if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
359
424
  unreadable.delete(f);
360
425
  return hit.md5;
361
426
  }
@@ -365,7 +430,7 @@ export async function createServe(opts: CreateServeOptions): Promise<{
365
430
  onRetry: (attempt, err) => dbg(`retry ${attempt} ${path.basename(f)}: ${(err as any)?.code ?? (err as any)?.message}`),
366
431
  });
367
432
  if (r.ok) {
368
- md5Cache.set(f, { mtime: m, md5: r.value });
433
+ md5Cache.set(f, { mtime: st.mtimeMs, size: st.size, md5: r.value });
369
434
  unreadable.delete(f);
370
435
  dbg(`hashed ${path.basename(f)}`);
371
436
  return r.value;
@@ -388,24 +453,45 @@ export async function createServe(opts: CreateServeOptions): Promise<{
388
453
  * One probe pass: hash every tree file (with retry). Returns per-file hashes
389
454
  * where null = vanished-or-unreadable this cycle (excluded from comparison).
390
455
  */
391
- const probeTree = async (tree: Map<string, { m: number; rel: string }>) => {
456
+ const probeTree = async (tree: Map<string, { m: number; s?: number; rel: string }>) => {
392
457
  const hashes = new Map<string, string | null>();
393
- for (const [f, { m }] of tree) {
394
- hashes.set(f, await hashWithRetry(f, m));
458
+ for (const [f, { m, s }] of tree) {
459
+ hashes.set(f, await hashWithRetry(f, { mtimeMs: m, size: s }));
395
460
  }
396
461
  return hashes;
397
462
  };
398
463
 
399
- const fingerprintOf = (
400
- tree: Map<string, { m: number; rel: string }>,
464
+ /**
465
+ * issue #195: tree state as a rel→hash map — compared map-to-map with no
466
+ * O(n log n) string build + sort per poll. null means "unknown" (initial
467
+ * load or a native event forced a re-probe).
468
+ */
469
+ const stateOf = (
470
+ tree: Map<string, { m: number; s?: number; rel: string }>,
401
471
  fileHashes: Map<string, string | null>,
402
- ): string => {
403
- const parts: string[] = [];
472
+ ): Map<string, string> => {
473
+ const out = new Map<string, string>();
404
474
  for (const [f, { rel }] of tree) {
405
475
  const h = fileHashes.get(f);
406
- if (h !== null && h !== undefined) parts.push(`${rel}:${h}`);
476
+ if (h !== null && h !== undefined) out.set(rel, h);
477
+ }
478
+ return out;
479
+ };
480
+
481
+ const statesEqual = (a: Map<string, string> | null, b: Map<string, string>): boolean => {
482
+ if (!a || a.size !== b.size) return false;
483
+ for (const [rel, hash] of b) {
484
+ if (a.get(rel) !== hash) return false;
485
+ }
486
+ return true;
487
+ };
488
+
489
+ /** issue #195: evict cache entries for files that left the tree. */
490
+ const evictStaleCacheEntries = (tree: Map<string, { m: number; s?: number; rel: string }>): void => {
491
+ if (md5Cache.size <= tree.size) return;
492
+ for (const f of md5Cache.keys()) {
493
+ if (!tree.has(f)) md5Cache.delete(f);
407
494
  }
408
- return parts.sort().join('|');
409
495
  };
410
496
 
411
497
  const readIndexedHashes = (): Record<string, string> => {
@@ -417,13 +503,14 @@ export async function createServe(opts: CreateServeOptions): Promise<{
417
503
  };
418
504
 
419
505
  /** issue #116: stat failures are classified — only ENOENT vanishes silently. */
420
- const scanTree = (): { tree: Map<string, { m: number; rel: string }>; problems: Map<string, 'transient' | 'permanent'> } => {
421
- const tree = new Map<string, { m: number; rel: string }>();
506
+ const scanTree = (): { tree: Map<string, { m: number; s?: number; rel: string }>; problems: Map<string, 'transient' | 'permanent'> } => {
507
+ const tree = new Map<string, { m: number; s?: number; rel: string }>();
422
508
  const problems = new Map<string, 'transient' | 'permanent'>();
423
509
  if (!db) return { tree, problems };
424
510
  for (const f of walkMarkdown(db, ignore)) {
425
511
  try {
426
- tree.set(f, { m: statFile(f).mtimeMs, rel: relOf(f) });
512
+ const st = statFile(f);
513
+ tree.set(f, { m: st.mtimeMs, s: st.size, rel: relOf(f) });
427
514
  } catch (e) {
428
515
  const cls = classifyFsError(e);
429
516
  if (cls !== 'vanished') problems.set(f, cls as 'transient' | 'permanent');
@@ -456,7 +543,9 @@ export async function createServe(opts: CreateServeOptions): Promise<{
456
543
  return out;
457
544
  };
458
545
 
459
- let lastFingerprint = '';
546
+ // issue #195: null = "state unknown" (startup or a native event forced a
547
+ // re-probe); otherwise the rel→hash map of the last observed tree
548
+ let lastState: Map<string, string> | null = null;
460
549
 
461
550
  /** issue #116: one combined warning per cooldown, ≤5 files named + "+k more". */
462
551
  const warnUnreadableRateLimited = (now: number): void => {
@@ -474,7 +563,7 @@ export async function createServe(opts: CreateServeOptions): Promise<{
474
563
  {
475
564
  const { tree } = scanTree();
476
565
  const hashes = await probeTree(tree);
477
- lastFingerprint = fingerprintOf(tree, hashes);
566
+ lastState = stateOf(tree, hashes);
478
567
  }
479
568
  let lastChangeAt = 0;
480
569
  let pending = false;
@@ -494,15 +583,16 @@ export async function createServe(opts: CreateServeOptions): Promise<{
494
583
  for (const [f, cls] of problems) {
495
584
  if (!unreadable.has(f)) unreadable.set(f, { cls, code: `stat:${cls}`, firstAt: t0 });
496
585
  }
586
+ evictStaleCacheEntries(tree);
497
587
  dbg(`poll: ${tree.size} file(s), ${unreadable.size} pending-unreadable`);
498
588
  const fileHashes = await probeTree(tree);
499
589
  warnUnreadableRateLimited(Date.now());
500
- const fingerprint = fingerprintOf(tree, fileHashes);
590
+ const currentState = stateOf(tree, fileHashes);
501
591
  const changed = contentChangedPaths(tree, fileHashes, indexedHashes);
502
- if (fingerprint !== lastFingerprint) {
592
+ if (!statesEqual(lastState, currentState)) {
503
593
  if (!pending) log(`watch: change in ${changed.length} file(s) — waiting ${watchDelay} ms for the tree to settle…`);
504
594
  pending = true;
505
- lastFingerprint = fingerprint;
595
+ lastState = currentState;
506
596
  lastChangeAt = Date.now();
507
597
  } else if (pending && changed.length > 0 && Date.now() - lastChangeAt >= watchDelay) {
508
598
  pending = false;
@@ -512,14 +602,14 @@ export async function createServe(opts: CreateServeOptions): Promise<{
512
602
  const fresh = scanTree();
513
603
  const freshHashes = await probeTree(fresh.tree);
514
604
  // seed the md5 cache from the authoritative post-build state so the
515
- // next fingerprint is IO-free for unchanged files
516
- for (const [f, { m, rel }] of fresh.tree) {
605
+ // next poll is IO-free for unchanged files
606
+ for (const [f, { m, s }] of fresh.tree) {
517
607
  const h = freshHashes.get(f);
518
- if (h !== null && h !== undefined && !md5Cache.has(f)) md5Cache.set(f, { mtime: m, md5: h });
608
+ if (h !== null && h !== undefined && !md5Cache.has(f)) md5Cache.set(f, { mtime: m, size: s, md5: h });
519
609
  }
520
- lastFingerprint = fingerprintOf(fresh.tree, freshHashes);
610
+ lastState = stateOf(fresh.tree, freshHashes);
521
611
  }
522
- } else if (!pending && changed.length > 0 && fingerprint === lastFingerprint) {
612
+ } else if (!pending && changed.length > 0 && statesEqual(lastState, currentState)) {
523
613
  pending = true;
524
614
  lastChangeAt = Date.now();
525
615
  log(`watch: tree differs from index (no motion) — settling then re-index…`);
@@ -529,7 +619,7 @@ export async function createServe(opts: CreateServeOptions): Promise<{
529
619
  // settled but nothing actually differs (e.g. a locked file became
530
620
  // readable again with unchanged content) — resync the baseline
531
621
  pending = false;
532
- lastFingerprint = fingerprint;
622
+ lastState = currentState;
533
623
  }
534
624
  }
535
625
  };
@@ -539,14 +629,33 @@ export async function createServe(opts: CreateServeOptions): Promise<{
539
629
  if (watch) {
540
630
  if (!db) throw new Error('serve --watch requires --db');
541
631
  nativeWatcher = createFileWatcher(db, () => {
542
- // Prompt watchLoop settle trigger on native event
543
- lastFingerprint = '';
632
+ // Prompt watchLoop settle trigger on native event: state becomes
633
+ // "unknown", so the next poll re-probes and enters the settle path
634
+ lastState = null;
544
635
  }, {
545
636
  debounceMs: watchDelay,
546
637
  ignore,
547
638
  log,
548
639
  });
549
- watchLoop();
640
+ // issue #186: watchLoop runs unawaited — an early rejection used to be an
641
+ // unhandled one and killed the daemon. Restart with backoff, give up
642
+ // after 5 crashes (search keeps serving the last good index either way).
643
+ void (async () => {
644
+ for (let attempt = 0; ; attempt++) {
645
+ try {
646
+ await watchLoop();
647
+ return; // resolved → stopped normally
648
+ } catch (e: any) {
649
+ if (stopped || attempt >= 4) {
650
+ log(`watch loop failed permanently (search still serves the last index): ${e.message}`);
651
+ return;
652
+ }
653
+ log(`watch loop crashed (attempt ${attempt + 1}/5): ${e.message} — restarting in 5s…`);
654
+ await new Promise((r) => setTimeout(r, 5_000));
655
+ if (stopped) return;
656
+ }
657
+ }
658
+ })();
550
659
  }
551
660
 
552
661
  const close = async (): Promise<void> => {
@@ -655,6 +764,48 @@ export function splitHostHeader(hostHeader: string): string {
655
764
  return first !== -1 && first === last ? s.slice(0, first) : s;
656
765
  }
657
766
 
767
+ /**
768
+ * Cross-site POST guard (issue #178). Browsers fire "simple" POSTs without
769
+ * preflight (Content-Type: text/plain, form encodings) from any open page;
770
+ * the loopback Host check alone does not stop them. A POST counts as
771
+ * same-site when:
772
+ * - the client sent no Origin/Sec-Fetch headers (curl, MCP and other
773
+ * non-browser clients), or
774
+ * - Sec-Fetch-Site is not cross-site AND the Origin's host matches the
775
+ * request Host, a loopback allowlist entry, or an explicit --cors-origin.
776
+ * `Origin: null` (sandboxed iframe, file:// page) is always rejected.
777
+ */
778
+ export function isSameSitePost(
779
+ req: http.IncomingMessage,
780
+ allowedHosts: Set<string>,
781
+ corsOrigins?: string[],
782
+ ): boolean {
783
+ const secFetchSite = req.headers['sec-fetch-site'];
784
+ if (typeof secFetchSite === 'string' && secFetchSite === 'cross-site') return false;
785
+ const origin = req.headers.origin;
786
+ if (typeof origin !== 'string' || origin.length === 0) return true; // non-browser client
787
+ if (origin === 'null') return false;
788
+ if (corsOrigins && corsOrigins.includes(origin)) return true;
789
+ const hostHeader = typeof req.headers.host === 'string' ? req.headers.host.toLowerCase() : '';
790
+ if (!hostHeader) return false;
791
+ let originUrl: URL;
792
+ try {
793
+ originUrl = new URL(origin);
794
+ } catch {
795
+ return false; // malformed Origin — fail closed
796
+ }
797
+ if (originUrl.host === hostHeader) return true; // exact same-origin (host:port)
798
+ // loopback aliases pass only on the SAME port — a page on localhost:3000 is
799
+ // a different origin even though the bare hostname is allowlisted
800
+ const portOf = (h: string, proto: string): string => {
801
+ const m = h.match(/:(\d+)$/);
802
+ if (m) return m[1];
803
+ return proto === 'https:' || proto === 'wss:' ? '443' : '80';
804
+ };
805
+ if (portOf(originUrl.host, originUrl.protocol) !== portOf(hostHeader, 'http:')) return false;
806
+ return allowedHosts.has(splitHostHeader(originUrl.host));
807
+ }
808
+
658
809
  /**
659
810
  * Reflect CORS headers ONLY for an exact allowlisted Origin (issue #120).
660
811
  * Off entirely by default; `Vary: Origin` is set whenever CORS is configured so
@@ -703,17 +854,38 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
703
854
  return;
704
855
  }
705
856
 
706
- if (state.apiKey) {
707
- const isPublicHealth = url.pathname === '/health' && req.method === 'GET' && state.healthPublic;
708
- if (!isPublicHealth) {
709
- const authHeader = req.headers['authorization'];
710
- const match = typeof authHeader === 'string' ? authHeader.match(/^Bearer\s+(.+)$/i) : null;
711
- const token = match ? match[1].trim() : null;
712
- if (!token || !isAuthorizedToken(token, state.apiKey)) {
713
- json(res, 401, { error: 'unauthorized' });
857
+ // CSRF / body-safety gate for every body-parsing POST (issue #178): a "simple"
858
+ // cross-site browser POST (text/plain, form) sails past the Host allowlist on
859
+ // loopback, so reject foreign origins and non-JSON media types up front.
860
+ if (req.method === 'POST') {
861
+ const ct = req.headers['content-type'];
862
+ if (typeof ct === 'string') {
863
+ const media = ct.split(';')[0].trim().toLowerCase();
864
+ if (media !== 'application/json') {
865
+ json(res, 415, { error: `unsupported content-type "${media}" — POST bodies must be application/json` });
714
866
  return;
715
867
  }
716
868
  }
869
+ if (!isSameSitePost(req, allowedHosts, state.corsOrigins)) {
870
+ json(res, 403, { error: 'cross-site request rejected' });
871
+ return;
872
+ }
873
+ }
874
+
875
+ // issue #181: /health path disclosure depends on whether THIS request is
876
+ // authenticated — an unauthenticated daemon or public health hides indexDir
877
+ let requestAuthenticated = false;
878
+ if (state.apiKey) {
879
+ const authHeader = req.headers['authorization'];
880
+ const match = typeof authHeader === 'string' ? authHeader.match(/^Bearer\s+(.+)$/i) : null;
881
+ const token = match ? match[1].trim() : null;
882
+ const valid = !!token && isAuthorizedToken(token, state.apiKey);
883
+ const isPublicHealth = url.pathname === '/health' && req.method === 'GET' && state.healthPublic;
884
+ if (!valid && !isPublicHealth) {
885
+ json(res, 401, { error: 'unauthorized' });
886
+ return;
887
+ }
888
+ requestAuthenticated = valid;
717
889
  }
718
890
 
719
891
  // Streamable HTTP MCP transport (issue #123): same auth/host/rate-limit
@@ -791,6 +963,11 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
791
963
  offline: state.offline,
792
964
  embedFn: state.embedFn,
793
965
  rerankFn: state.rerankFn,
966
+ // issue #203: multi-query expansion (LLM bridge = the daemon's own
967
+ // --llm-endpoint, NEVER a payload-supplied URL — that would let any
968
+ // client exfiltrate note content to an arbitrary host) + reranker alias
969
+ ...(payload.multiQuery && state.llmEndpoint ? { multiQuery: payload.multiQuery, llmEndpoint: state.llmEndpoint, ...(state.llmModel ? { llmModel: state.llmModel } : {}) } : {}),
970
+ ...(payload.rerankModel ? { rerankModel: payload.rerankModel } : {}),
794
971
  tag: payload.tag,
795
972
  project: payload.project,
796
973
  type: payload.type,
@@ -807,7 +984,213 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
807
984
  });
808
985
  json(res, 200, { query, k, count: results.length, results });
809
986
  } catch (e: any) {
810
- json(res, 500, { error: e.message });
987
+ // issue #182: search errors can name absolute paths — keep the reply
988
+ // generic, log the details server-side
989
+ process.stderr.write(`serve: /search failed: ${e?.stack || e}\n`);
990
+ json(res, 500, { error: 'internal server error' });
991
+ } finally {
992
+ release();
993
+ }
994
+ return;
995
+ }
996
+
997
+ // issue #199: grounded QA over SSE — a `sources` frame as soon as retrieval
998
+ // settles, streamed `delta` frames while the LLM answers, one `done` frame
999
+ // with the assembled result. Same fairness gates as /search (an LLM call
1000
+ // holds a concurrency slot for its whole generation). Requires --llm-endpoint:
1001
+ // over HTTP a missing chat backend is a 400, never a silent extractive degrade.
1002
+ if (req.method === 'POST' && url.pathname === '/ask') {
1003
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
1004
+ if (limited) {
1005
+ res.setHeader('retry-after', String(limited.retryAfterSec));
1006
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
1007
+ return;
1008
+ }
1009
+ if (!state.llmEndpoint) {
1010
+ json(res, 400, { error: 'POST /ask requires an LLM chat endpoint — start mdss serve with --llm-endpoint' });
1011
+ return;
1012
+ }
1013
+
1014
+ const declared = req.headers['content-length'];
1015
+ if (declared !== undefined) {
1016
+ const n = Number(declared);
1017
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BODY_BYTES) {
1018
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
1019
+ return;
1020
+ }
1021
+ }
1022
+ let body = '';
1023
+ let tooLarge = false;
1024
+ for await (const chunk of req) {
1025
+ body += chunk;
1026
+ if (body.length > MAX_BODY_BYTES) {
1027
+ tooLarge = true;
1028
+ break;
1029
+ }
1030
+ }
1031
+ if (tooLarge) {
1032
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
1033
+ req.resume();
1034
+ return;
1035
+ }
1036
+ let payload: Record<string, any>;
1037
+ try {
1038
+ payload = body ? JSON.parse(body) : {};
1039
+ } catch (e: any) {
1040
+ json(res, 400, { error: `invalid JSON body: ${e.message}` });
1041
+ return;
1042
+ }
1043
+ const query = typeof payload.query === 'string' ? payload.query.trim() : '';
1044
+ if (!query) {
1045
+ json(res, 400, { error: 'missing "query" string in JSON body' });
1046
+ return;
1047
+ }
1048
+ if (query.length > 2048) {
1049
+ json(res, 400, { error: 'query exceeds maximum length of 2048 characters' });
1050
+ return;
1051
+ }
1052
+ const k = Number.isInteger(payload.k) && payload.k > 0 ? payload.k : 5;
1053
+
1054
+ // issue #199 (follow-up): chat mode. `{"sessionId": "..."}` continues a
1055
+ // persistent session (same store as `mdss chat`); `{"chat": true}` starts
1056
+ // a fresh one whose id comes back in the `session`/`done` frames for the
1057
+ // next request. One SSE protocol for both.
1058
+ const rawSessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
1059
+ const chatMode = !!rawSessionId || payload.chat === true;
1060
+ if (rawSessionId) {
1061
+ // issue #149 discipline: validate BEFORE any filesystem use; a bad id
1062
+ // gets a plain 400 here rather than a thrown error mid-stream
1063
+ try {
1064
+ assertSafeSessionId(rawSessionId);
1065
+ } catch (e: any) {
1066
+ json(res, 400, { error: `invalid sessionId: ${e.message}` });
1067
+ return;
1068
+ }
1069
+ }
1070
+
1071
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
1072
+ if (!release) {
1073
+ res.setHeader('retry-after', '1');
1074
+ json(res, 503, { error: 'server busy — search concurrency cap reached, queue full' });
1075
+ return;
1076
+ }
1077
+
1078
+ // Client disconnect must tear down the in-flight LLM call, not just stop
1079
+ // writing frames — the slot releases in `finally` either way.
1080
+ const clientAbort = new AbortController();
1081
+ res.on('close', () => clientAbort.abort());
1082
+ res.writeHead(200, {
1083
+ 'content-type': 'text/event-stream',
1084
+ 'cache-control': 'no-store',
1085
+ 'connection': 'keep-alive',
1086
+ });
1087
+ const sse = (event: string, data: unknown) => {
1088
+ if (!res.writableEnded) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1089
+ };
1090
+ const llmOpts = {
1091
+ llmEndpoint: state.llmEndpoint!,
1092
+ ...(state.llmModel ? { llmModel: state.llmModel } : {}),
1093
+ ...(state.llmProvider ? { llmProvider: state.llmProvider } : {}),
1094
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
1095
+ onDelta: (d: string) => sse('delta', { text: d }),
1096
+ signal: clientAbort.signal,
1097
+ };
1098
+ try {
1099
+ if (chatMode) {
1100
+ const session = loadChatSession(state.indexDir, rawSessionId || crypto.randomUUID().slice(0, 8));
1101
+ sse('session', { id: session.id, turns: session.turns.length });
1102
+ const turn = await chatTurn({
1103
+ session,
1104
+ query,
1105
+ loaded: state.loaded,
1106
+ indexDir: state.indexDir,
1107
+ cacheDir: state.cacheDir,
1108
+ k,
1109
+ offline: state.offline,
1110
+ embedFn: state.embedFn,
1111
+ ...llmOpts,
1112
+ });
1113
+ sse('done', {
1114
+ answer: turn.answer,
1115
+ mode: turn.mode,
1116
+ citations: turn.citations,
1117
+ citationsUsed: turn.citationsUsed,
1118
+ manifest: turn.manifest,
1119
+ sessionId: session.id,
1120
+ turnsCount: session.turns.length,
1121
+ });
1122
+ } else {
1123
+ const passages = await searchIndex({
1124
+ loaded: state.loaded,
1125
+ cacheDir: state.cacheDir,
1126
+ query,
1127
+ k,
1128
+ offline: state.offline,
1129
+ embedFn: state.embedFn,
1130
+ });
1131
+ sse('sources', {
1132
+ citations: passages.map((p) => ({ file: p.file, heading: p.heading, score: p.score })),
1133
+ });
1134
+ const result = await synthesizeAnswer(query, passages, llmOpts);
1135
+ sse('done', {
1136
+ answer: result.answer,
1137
+ mode: result.mode,
1138
+ citations: result.citations,
1139
+ citationsUsed: result.citationsUsed,
1140
+ });
1141
+ }
1142
+ } catch (e: any) {
1143
+ // issue #182 discipline: raw errors can carry absolute paths — the
1144
+ // frame stays generic, details go to the server log
1145
+ process.stderr.write(`serve: /ask failed: ${e?.stack || e}\n`);
1146
+ sse('error', { error: 'answer synthesis failed' });
1147
+ } finally {
1148
+ release();
1149
+ res.end();
1150
+ }
1151
+ return;
1152
+ }
1153
+
1154
+ if (req.method === 'GET' && url.pathname === '/doc') {
1155
+ const file = url.searchParams.get('file');
1156
+ if (!file) {
1157
+ json(res, 400, { error: 'missing "file" query parameter' });
1158
+ return;
1159
+ }
1160
+ // issue #181: /doc does synchronous disk IO per request — it goes through
1161
+ // the same fairness gates as /search so a local storm cannot starve it
1162
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
1163
+ if (limited) {
1164
+ res.setHeader('retry-after', String(limited.retryAfterSec));
1165
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
1166
+ return;
1167
+ }
1168
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
1169
+ if (!release) {
1170
+ res.setHeader('retry-after', '1');
1171
+ json(res, 503, { error: 'server busy — concurrency cap reached, queue full' });
1172
+ return;
1173
+ }
1174
+ const fromLineParam = url.searchParams.get('fromLine') || url.searchParams.get('from');
1175
+ const maxLinesParam = url.searchParams.get('maxLines') || url.searchParams.get('lines') || url.searchParams.get('limit');
1176
+ const fromLine = fromLineParam ? parseInt(fromLineParam, 10) : 1;
1177
+ const maxLines = maxLinesParam ? parseInt(maxLinesParam, 10) : undefined;
1178
+
1179
+ try {
1180
+ const docResult = getDocLines({
1181
+ file,
1182
+ fromLine: isNaN(fromLine) ? 1 : fromLine,
1183
+ maxLines: maxLines && !isNaN(maxLines) ? maxLines : undefined,
1184
+ dbDir: state.loaded.index.db,
1185
+ chunks: state.loaded.index.chunks,
1186
+ });
1187
+ json(res, 200, docResult);
1188
+ } catch (e: any) {
1189
+ if (e.message?.includes('not found')) {
1190
+ json(res, 404, { error: e.message });
1191
+ } else {
1192
+ json(res, 400, { error: e.message });
1193
+ }
811
1194
  } finally {
812
1195
  release();
813
1196
  }
@@ -815,6 +1198,10 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
815
1198
  }
816
1199
 
817
1200
  if (req.method === 'GET' && url.pathname === '/health') {
1201
+ // issue #181: an unauthenticated /health must not disclose filesystem
1202
+ // locations — the path comes back only to an authenticated caller on a
1203
+ // daemon that also requires auth for /health
1204
+ const hideIndexDir = !state.apiKey || !requestAuthenticated;
818
1205
  json(res, 200, {
819
1206
  ok: true,
820
1207
  chunks: state.loaded.index.chunks.length,
@@ -822,7 +1209,7 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
822
1209
  dim: state.loaded.index.dim || state.loaded.model.dim || 0,
823
1210
  built: state.loaded.index.built || null,
824
1211
  watching: state.watching,
825
- indexDir: state.indexDir,
1212
+ indexDir: hideIndexDir ? undefined : state.indexDir,
826
1213
  // fairness counters (issue #119)
827
1214
  in_flight: state.limiter?.inFlight ?? 0,
828
1215
  queued: state.limiter?.queued ?? 0,
@@ -847,11 +1234,97 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
847
1234
  return;
848
1235
  }
849
1236
 
1237
+ if (url.pathname === '/related' && (req.method === 'POST' || req.method === 'GET')) {
1238
+ // issue #181: /related runs the (semantic) relatedness sweep — same rate
1239
+ // gate as /search, before any body parsing
1240
+ const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
1241
+ if (limited) {
1242
+ res.setHeader('retry-after', String(limited.retryAfterSec));
1243
+ json(res, 429, { error: 'rate limit exceeded — slow down', retryAfterSec: limited.retryAfterSec });
1244
+ return;
1245
+ }
1246
+ let target = '';
1247
+ let k = 10;
1248
+ let direction: 'both' | 'outgoing' | 'backlinks' = 'both';
1249
+ let semantic = true;
1250
+
1251
+ if (req.method === 'GET') {
1252
+ target = url.searchParams.get('file') || url.searchParams.get('target') || url.searchParams.get('note') || '';
1253
+ const kParam = url.searchParams.get('k');
1254
+ if (kParam) k = parseInt(kParam, 10) || 10;
1255
+ const dirParam = url.searchParams.get('direction');
1256
+ if (dirParam === 'outgoing' || dirParam === 'backlinks' || dirParam === 'both') direction = dirParam;
1257
+ if (url.searchParams.has('semantic')) semantic = url.searchParams.get('semantic') !== 'false';
1258
+ } else {
1259
+ // issue #181: an overflowing body is a 413, not a silently truncated parse
1260
+ let body = '';
1261
+ let tooLarge = false;
1262
+ for await (const chunk of req) {
1263
+ body += chunk;
1264
+ if (body.length > MAX_BODY_BYTES) {
1265
+ tooLarge = true;
1266
+ break;
1267
+ }
1268
+ }
1269
+ if (tooLarge) {
1270
+ json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
1271
+ req.resume();
1272
+ return;
1273
+ }
1274
+ try {
1275
+ const payload = body ? JSON.parse(body) : {};
1276
+ target = payload.file || payload.target || payload.note || '';
1277
+ if (typeof payload.k === 'number' && payload.k > 0) k = payload.k;
1278
+ if (payload.direction === 'outgoing' || payload.direction === 'backlinks' || payload.direction === 'both') {
1279
+ direction = payload.direction;
1280
+ }
1281
+ if (payload.semantic !== undefined) semantic = Boolean(payload.semantic);
1282
+ } catch (e: any) {
1283
+ json(res, 400, { error: `invalid JSON body: ${e.message}` });
1284
+ return;
1285
+ }
1286
+ }
1287
+
1288
+ if (!target) {
1289
+ json(res, 400, { error: 'missing "file" or "target" parameter' });
1290
+ return;
1291
+ }
1292
+
1293
+ const release = state.limiter ? await state.limiter.acquireSlot() : null;
1294
+ if (!release) {
1295
+ res.setHeader('retry-after', '1');
1296
+ json(res, 503, { error: 'server busy — concurrency cap reached, queue full' });
1297
+ return;
1298
+ }
1299
+ try {
1300
+ const relatedResult = findRelatedNotes({
1301
+ loaded: state.loaded,
1302
+ target,
1303
+ k,
1304
+ direction,
1305
+ semantic,
1306
+ });
1307
+ json(res, 200, relatedResult);
1308
+ } catch (e: any) {
1309
+ if (e.message?.includes('not found')) {
1310
+ json(res, 404, { error: e.message });
1311
+ } else {
1312
+ json(res, 400, { error: e.message });
1313
+ }
1314
+ } finally {
1315
+ release();
1316
+ }
1317
+ return;
1318
+ }
1319
+
850
1320
  if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/help')) {
851
1321
  json(res, 200, {
852
1322
  name: 'mdss serve',
853
1323
  endpoints: [
854
1324
  { method: 'POST', path: '/search', body: '{ "query": "...", "k": 6, "semanticOnly": false, "rerank": false }' },
1325
+ { method: 'POST', path: '/ask', body: '{ "query": "...", "k": 5 } — SSE: sources/delta/done frames (needs --llm-endpoint, issue #199)' },
1326
+ { method: 'GET', path: '/doc?file=...&fromLine=1&maxLines=50' },
1327
+ { method: 'POST', path: '/related', body: '{ "file": "...", "k": 10, "semantic": true }' },
855
1328
  { method: 'GET', path: '/health' },
856
1329
  ],
857
1330
  });
@@ -878,12 +1351,23 @@ async function handleMcpHttp(req: http.IncomingMessage, res: http.ServerResponse
878
1351
  if (req.method === 'GET') {
879
1352
  // Server-initiated channel: we have nothing to push today, so keep the
880
1353
  // stream alive with comments until the client goes away.
1354
+ // issue #181: each stream holds a socket + a heartbeat interval — cap the
1355
+ // number of concurrent SSE connections at the fairness concurrency limit.
1356
+ const sseCap = state.limiter?.concurrencyEnabled ? state.limiter.maxConcurrency : DEFAULT_MAX_CONCURRENCY;
1357
+ if ((state.sseConnections ?? 0) >= sseCap) {
1358
+ json(res, 503, { error: 'server busy — SSE connection cap reached' });
1359
+ return;
1360
+ }
1361
+ state.sseConnections = (state.sseConnections ?? 0) + 1;
881
1362
  res.writeHead(200, {
882
1363
  'content-type': 'text/event-stream',
883
1364
  'cache-control': 'no-store',
884
1365
  'connection': 'keep-alive',
885
1366
  });
886
1367
  res.write(':ok\n\n');
1368
+ // issue #197: this stream doubles as the server→client downlink for
1369
+ // sampling/createMessage. One client realistically; newest stream wins.
1370
+ state.mcpSseRes = res;
887
1371
  const hb = setInterval(() => {
888
1372
  try {
889
1373
  res.write(':hb\n\n');
@@ -892,7 +1376,13 @@ async function handleMcpHttp(req: http.IncomingMessage, res: http.ServerResponse
892
1376
  }
893
1377
  }, 15_000);
894
1378
  hb.unref?.();
895
- req.on('close', () => clearInterval(hb));
1379
+ // res 'close' fires on client abort mid-stream too — req may never close
1380
+ // while the response stream is still open
1381
+ res.on('close', () => {
1382
+ clearInterval(hb);
1383
+ if (state.mcpSseRes === res) state.mcpSseRes = undefined;
1384
+ state.sseConnections = Math.max(0, (state.sseConnections ?? 1) - 1);
1385
+ });
896
1386
  return;
897
1387
  }
898
1388
 
@@ -901,7 +1391,23 @@ async function handleMcpHttp(req: http.IncomingMessage, res: http.ServerResponse
901
1391
  return;
902
1392
  }
903
1393
 
1394
+ // issue #182: session binding before any work — the server mints its own id
1395
+ // and rejects mismatching client ids with 404 instead of adopting them
1396
+ // (session fixation) or feeding them to res.setHeader (500 on bad headers)
1397
+ const requested = req.headers['mcp-session-id'];
1398
+ if (
1399
+ typeof requested === 'string' &&
1400
+ requested &&
1401
+ state.mcpSessionId &&
1402
+ requested !== state.mcpSessionId
1403
+ ) {
1404
+ json(res, 404, { error: 'session not found — re-initialize' });
1405
+ return;
1406
+ }
1407
+
904
1408
  // Same fairness gate as /search — tools/call can trigger embedding work.
1409
+ // (The gates run AFTER body parse below: responses to server→client
1410
+ // requests must bypass them, see issue #197.)
905
1411
  const limited = state.limiter?.gate(req.socket.remoteAddress || 'unknown');
906
1412
  if (limited) {
907
1413
  res.setHeader('retry-after', String(limited.retryAfterSec));
@@ -934,35 +1440,57 @@ async function handleMcpHttp(req: http.IncomingMessage, res: http.ServerResponse
934
1440
  const batch = Array.isArray(messages);
935
1441
  const items = batch ? messages : [messages];
936
1442
 
1443
+ // issue #197: answers to server→client requests (sampling) arrive as
1444
+ // plain POSTs. Resolve them BEFORE the fairness gates — the tools/call
1445
+ // waiting on this response may hold the only concurrency slot, so
1446
+ // queueing it behind itself would self-deadlock at max_concurrency 1.
1447
+ let sawResponseOnly = items.length > 0;
1448
+ for (const msg of items) {
1449
+ if (!msg || typeof msg !== 'object') {
1450
+ continue;
1451
+ }
1452
+ const isResponse = msg.method === undefined && (msg.result !== undefined || msg.error !== undefined);
1453
+ if (isResponse) {
1454
+ state.mcpChannel?.handleResponse(msg);
1455
+ } else {
1456
+ sawResponseOnly = false;
1457
+ }
1458
+ }
1459
+ if (sawResponseOnly) {
1460
+ res.writeHead(202);
1461
+ res.end();
1462
+ return;
1463
+ }
1464
+
937
1465
  const responses: any[] = [];
938
1466
  for (const msg of items) {
939
1467
  if (!msg || typeof msg !== 'object') continue;
1468
+ if (msg.method === undefined) continue; // response — correlated above
940
1469
  const isNotification = msg.id === undefined || msg.id === null;
941
1470
  const result = isNotification ? null : await handleMcpRequest(msg, {
942
1471
  loaded: state.loaded,
943
1472
  cacheDir: state.cacheDir,
944
1473
  offline: state.offline,
945
1474
  embedFn: state.embedFn,
1475
+ channel: state.mcpChannel,
1476
+ clientCapabilities: state.mcpClientCapabilities,
946
1477
  });
947
1478
  if (result) responses.push(result);
948
1479
  else if (!isNotification && msg.method === undefined) continue;
1480
+ // issue #197: remember the client's declared capabilities (sampling
1481
+ // gates the ask_knowledge_base LLM path on later tools/call).
1482
+ if (msg.method === 'initialize') {
1483
+ state.mcpClientCapabilities = msg.params?.capabilities ?? {};
1484
+ }
949
1485
  }
950
1486
 
951
- // session id: mint on first contact, echo afterwards
952
- const requested = req.headers['mcp-session-id'];
953
- if (typeof requested === 'string' && requested) state.mcpSessionId = requested;
1487
+ // session id: server-minted only (issue #182 — see the 404 gate above);
1488
+ // mint on first contact, echo ours afterwards. Protocol negotiation
1489
+ // itself moved into the shared brain (issue #197) so stdio and HTTP
1490
+ // speak identical version logic.
954
1491
  if (!state.mcpSessionId) state.mcpSessionId = crypto.randomUUID();
955
1492
  res.setHeader('mcp-session-id', state.mcpSessionId);
956
1493
 
957
- // protocol negotiation: honour a supported requested version, else latest
958
- for (const resp of responses) {
959
- if (resp?.result?.protocolVersion && msg0(items)?.params?.protocolVersion) {
960
- const wanted = String(msg0(items).params.protocolVersion);
961
- const SUPPORTED = ['2025-03-26', '2024-11-05'];
962
- resp.result.protocolVersion = SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0];
963
- }
964
- }
965
-
966
1494
  if (responses.length === 0) {
967
1495
  res.writeHead(202);
968
1496
  res.end();
@@ -974,11 +1502,6 @@ async function handleMcpHttp(req: http.IncomingMessage, res: http.ServerResponse
974
1502
  }
975
1503
  }
976
1504
 
977
- /** First object of a (possibly single-message) payload — for init negotiation. */
978
- function msg0(items: any[]): any | null {
979
- return items.length > 0 ? items[0] : null;
980
- }
981
-
982
1505
  function json(res: http.ServerResponse, status: number, data: object): void {
983
1506
  res.writeHead(status, {
984
1507
  'content-type': 'application/json; charset=utf-8',