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