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.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
400
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
590
|
+
const currentState = stateOf(tree, fileHashes);
|
|
501
591
|
const changed = contentChangedPaths(tree, fileHashes, indexedHashes);
|
|
502
|
-
if (
|
|
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
|
-
|
|
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
|
|
516
|
-
for (const [f, { m,
|
|
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
|
-
|
|
610
|
+
lastState = stateOf(fresh.tree, freshHashes);
|
|
521
611
|
}
|
|
522
|
-
} else if (!pending && changed.length > 0 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
952
|
-
|
|
953
|
-
|
|
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',
|