md-semantic-search 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/dist/mcp.js CHANGED
@@ -9,17 +9,119 @@ import path from 'node:path';
9
9
  import { buildIndex } from './indexer.js';
10
10
  import { loadIndex, searchIndex } from './search.js';
11
11
  import { searchFederated } from './federation.js';
12
- import { askQuestion } from './rag.js';
13
- import { globToRegExp } from './core.js';
12
+ import { askQuestion, chatTurn, loadChatSession } from './rag.js';
13
+ import { assertSafePath, globToRegExp, getDocLines } from './core.js';
14
+ import { findRelatedNotes } from './wikilinks.js';
14
15
  /**
15
16
  * Package version reported via MCP initialize → serverInfo.
16
17
  * Single source of truth: package.json (resolved relative to dist/mcp.js).
17
18
  */
18
19
  const MCP_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
20
+ /**
21
+ * Protocol versions this server can speak (issue #197), newest first.
22
+ * `initialize` echoes the client's requested version when we support it,
23
+ * otherwise answers with MCP_SUPPORTED_VERSIONS[0].
24
+ */
25
+ export const MCP_SUPPORTED_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
26
+ /**
27
+ * Resolves the protocol version to answer an `initialize` with: the client's
28
+ * exact request when supported, else the newest version we speak (the spec's
29
+ * "respond with another protocol version you support" branch).
30
+ */
31
+ export function negotiateProtocolVersion(requested) {
32
+ const v = typeof requested === 'string' ? requested : '';
33
+ return MCP_SUPPORTED_VERSIONS.includes(v) ? v : MCP_SUPPORTED_VERSIONS[0];
34
+ }
35
+ /** Server→client requests that outlive this wait degrade, not hang (issue #197). */
36
+ export const DEFAULT_SAMPLING_TIMEOUT_MS = 60_000;
37
+ /** `sampling/createMessage` requires maxTokens — a grounded citation answer fits comfortably. */
38
+ export const DEFAULT_SAMPLING_MAX_TOKENS = 1024;
39
+ export function samplingTimeoutMs(envValue) {
40
+ const raw = Number(envValue ?? process.env.MDSS_SAMPLING_TIMEOUT_MS);
41
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SAMPLING_TIMEOUT_MS;
42
+ }
43
+ /**
44
+ * Builds a client channel over a raw sink. Ids are prefixed `mdss-srv-` so
45
+ * they can never collide with the client's own request ids in either
46
+ * direction's logs or pending maps.
47
+ */
48
+ export function createClientChannel(send) {
49
+ const pending = new Map();
50
+ let nextId = 0;
51
+ return {
52
+ request(method, params = {}, opts = {}) {
53
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_SAMPLING_TIMEOUT_MS;
54
+ const id = `mdss-srv-${++nextId}`;
55
+ return new Promise((resolve, reject) => {
56
+ const timer = setTimeout(() => {
57
+ pending.delete(id);
58
+ reject(new Error(`${method}: client did not respond within ${timeoutMs}ms`));
59
+ }, timeoutMs);
60
+ timer.unref?.();
61
+ pending.set(id, { resolve, reject, timer });
62
+ try {
63
+ send({ jsonrpc: '2.0', id, method, params });
64
+ }
65
+ catch (e) {
66
+ clearTimeout(timer);
67
+ pending.delete(id);
68
+ reject(new Error(`${method}: ${e?.message || String(e)}`));
69
+ }
70
+ });
71
+ },
72
+ handleResponse(msg) {
73
+ if (!msg || typeof msg !== 'object' || msg.method !== undefined)
74
+ return false;
75
+ const entry = pending.get(msg.id);
76
+ if (!entry)
77
+ return false;
78
+ clearTimeout(entry.timer);
79
+ pending.delete(msg.id);
80
+ if (msg.error) {
81
+ entry.reject(new Error(msg.error.message || `client error ${msg.error.code ?? ''}`));
82
+ }
83
+ else {
84
+ entry.resolve(msg.result);
85
+ }
86
+ return true;
87
+ },
88
+ };
89
+ }
90
+ /**
91
+ * A sampling-backed synthesis hook for the RAG layer (issue #197): returns
92
+ * null unless the client both declared the `sampling` capability at
93
+ * initialize AND a reverse channel exists — otherwise ask_knowledge_base goes
94
+ * straight to the local extractive answer. Any in-flight failure (client
95
+ * refusal, timeout, non-text content) throws and degrades the same way.
96
+ */
97
+ export function createSamplingSynthesizer(state) {
98
+ if (!state.channel || !state.clientCapabilities?.sampling)
99
+ return null;
100
+ let used = false;
101
+ const channel = state.channel;
102
+ return {
103
+ used: () => used,
104
+ fn: async ({ system, user }) => {
105
+ const result = await channel.request('sampling/createMessage', {
106
+ messages: [{ role: 'user', content: { type: 'text', text: user } }],
107
+ systemPrompt: system,
108
+ includeContext: 'thisServer',
109
+ maxTokens: DEFAULT_SAMPLING_MAX_TOKENS,
110
+ }, { timeoutMs: samplingTimeoutMs() });
111
+ const text = result?.content?.text;
112
+ if (typeof text !== 'string' || !text.trim()) {
113
+ throw new Error('sampling/createMessage returned no text content');
114
+ }
115
+ used = true;
116
+ return text;
117
+ },
118
+ };
119
+ }
19
120
  export const MCP_TOOLS = [
20
121
  {
21
122
  name: 'search_markdown',
22
123
  description: 'Search local Markdown knowledge base by meaning and hybrid BM25 lexical ranking',
124
+ annotations: { readOnly: true, idempotent: true },
23
125
  inputSchema: {
24
126
  type: 'object',
25
127
  properties: {
@@ -37,6 +139,7 @@ export const MCP_TOOLS = [
37
139
  {
38
140
  name: 'get_chunk',
39
141
  description: 'Retrieve a specific chunk or section by file path and optional heading label',
142
+ annotations: { readOnly: true, idempotent: true },
40
143
  inputSchema: {
41
144
  type: 'object',
42
145
  properties: {
@@ -46,9 +149,39 @@ export const MCP_TOOLS = [
46
149
  required: ['file'],
47
150
  },
48
151
  },
152
+ {
153
+ name: 'get_lines',
154
+ description: 'Retrieve an exact range of lines from an indexed Markdown document by file path and line span',
155
+ annotations: { readOnly: true, idempotent: true },
156
+ inputSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ file: { type: 'string', description: 'Relative file path (e.g. "guides/api.md")' },
160
+ fromLine: { type: 'number', description: '1-based starting line number (default: 1)' },
161
+ maxLines: { type: 'number', description: 'Optional maximum number of lines to return' },
162
+ },
163
+ required: ['file'],
164
+ },
165
+ },
166
+ {
167
+ name: 'related_notes',
168
+ description: 'Find notes related to a specific document via backlinks, outgoing links, 2-hop graph neighborhood, and semantic similarity',
169
+ annotations: { readOnly: true, idempotent: true },
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ file: { type: 'string', description: 'File path, title, or alias of the target note' },
174
+ k: { type: 'number', description: 'Number of related notes to retrieve (default: 6)' },
175
+ direction: { type: 'string', enum: ['both', 'outgoing', 'backlinks'], description: 'Graph link direction (default: "both")' },
176
+ semantic: { type: 'boolean', description: 'Include dense semantic similarity alongside graph links (default: true)' },
177
+ },
178
+ required: ['file'],
179
+ },
180
+ },
49
181
  {
50
182
  name: 'list_files',
51
183
  description: 'List all indexed Markdown files and their chunk counts',
184
+ annotations: { readOnly: true, idempotent: true },
52
185
  inputSchema: {
53
186
  type: 'object',
54
187
  properties: {
@@ -59,6 +192,7 @@ export const MCP_TOOLS = [
59
192
  {
60
193
  name: 'index_status',
61
194
  description: 'Get current index status, model metadata, chunk count, and build timestamp',
195
+ annotations: { readOnly: true, idempotent: true },
62
196
  inputSchema: {
63
197
  type: 'object',
64
198
  properties: {},
@@ -67,16 +201,106 @@ export const MCP_TOOLS = [
67
201
  {
68
202
  name: 'ask_knowledge_base',
69
203
  description: 'Synthesize a grounded direct answer with citations from the local Markdown knowledge base',
204
+ annotations: { readOnly: true, idempotent: true },
70
205
  inputSchema: {
71
206
  type: 'object',
72
207
  properties: {
73
208
  query: { type: 'string', description: 'Question to ask the knowledge base' },
74
209
  k: { type: 'number', description: 'Number of candidate passages to retrieve (default: 5)' },
210
+ sessionId: { type: 'string', description: 'Optional conversation session ID for persistent multi-turn context' },
75
211
  },
76
212
  required: ['query'],
77
213
  },
78
214
  },
79
215
  ];
216
+ export const MCP_RESOURCE_TEMPLATES = [
217
+ {
218
+ uriTemplate: 'mdss://note/{path}',
219
+ name: 'note',
220
+ description: 'Read the full content of an indexed Markdown note',
221
+ mimeType: 'text/markdown',
222
+ },
223
+ {
224
+ uriTemplate: 'mdss://note/{path}{?fromLine,maxLines}',
225
+ name: 'note_lines',
226
+ description: 'Read a specific line range from an indexed Markdown note',
227
+ mimeType: 'text/markdown',
228
+ },
229
+ {
230
+ uriTemplate: 'mdss://vault/{vault}',
231
+ name: 'vault',
232
+ description: 'List all indexed notes in the specified vault',
233
+ mimeType: 'application/json',
234
+ },
235
+ {
236
+ uriTemplate: 'mdss://status',
237
+ name: 'status',
238
+ description: 'Retrieve current index status, model metadata, and chunk statistics',
239
+ mimeType: 'application/json',
240
+ },
241
+ ];
242
+ export const MCP_PROMPTS = [
243
+ {
244
+ name: 'search-and-cite',
245
+ description: 'Search the local knowledge base and synthesize a grounded answer with resource citations',
246
+ arguments: [
247
+ {
248
+ name: 'query',
249
+ description: 'Natural language question or search topic',
250
+ required: true,
251
+ },
252
+ ],
253
+ },
254
+ {
255
+ name: 'summarize-note',
256
+ description: 'Read an indexed note and produce a concise summary of its key takeaways and actionable points',
257
+ arguments: [
258
+ {
259
+ name: 'note',
260
+ description: 'Relative path, title, or alias of the note to summarize',
261
+ required: true,
262
+ },
263
+ ],
264
+ },
265
+ {
266
+ name: 'compare-notes',
267
+ description: 'Compare and contrast architectural decisions, concepts, or details across two notes',
268
+ arguments: [
269
+ {
270
+ name: 'note1',
271
+ description: 'First note relative path or title',
272
+ required: true,
273
+ },
274
+ {
275
+ name: 'note2',
276
+ description: 'Second note relative path or title',
277
+ required: true,
278
+ },
279
+ ],
280
+ },
281
+ {
282
+ name: 'find-contradictions',
283
+ description: 'Analyze knowledge base notes for conflicting or contradicting statements on a given topic',
284
+ arguments: [
285
+ {
286
+ name: 'topic',
287
+ description: 'Topic, claim, or architectural decision to audit for contradictions',
288
+ required: true,
289
+ },
290
+ ],
291
+ },
292
+ {
293
+ name: 'timeline',
294
+ description: 'Extract a chronological timeline of events, ADRs, releases, or milestones from notes',
295
+ arguments: [
296
+ {
297
+ name: 'topic',
298
+ description: 'Project name, feature, or domain to build a timeline for',
299
+ required: true,
300
+ },
301
+ ],
302
+ },
303
+ ];
80
304
  export async function handleMcpRequest(req, state) {
81
305
  if (!req || typeof req !== 'object')
82
306
  return null;
@@ -84,14 +308,32 @@ export async function handleMcpRequest(req, state) {
84
308
  if (id === undefined || id === null) {
85
309
  return null;
86
310
  }
311
+ // A JSON-RPC *response* to one of our server→client requests carries an id
312
+ // but no method — the transport's channel correlation already consumed any
313
+ // that matched; replying to the leftovers would be nonsense.
314
+ if (typeof method !== 'string') {
315
+ return null;
316
+ }
87
317
  if (method === 'initialize') {
318
+ // issue #197: remember what the client can do — `sampling` opens the
319
+ // ask_knowledge_base LLM path — and answer with the negotiated version.
320
+ state.clientCapabilities = params?.capabilities ?? {};
88
321
  return {
89
322
  jsonrpc: '2.0',
90
323
  id,
91
324
  result: {
92
- protocolVersion: '2024-11-05',
325
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
93
326
  capabilities: {
94
- tools: {},
327
+ tools: {
328
+ listChanged: false,
329
+ },
330
+ resources: {
331
+ subscribe: false,
332
+ listChanged: false,
333
+ },
334
+ prompts: {
335
+ listChanged: false,
336
+ },
95
337
  },
96
338
  serverInfo: {
97
339
  name: 'md-semantic-search',
@@ -100,6 +342,15 @@ export async function handleMcpRequest(req, state) {
100
342
  },
101
343
  };
102
344
  }
345
+ // issue #197: liveness probe per the 2025-11-25 spec — answer promptly with
346
+ // an empty result, regardless of initialization state.
347
+ if (method === 'ping') {
348
+ return {
349
+ jsonrpc: '2.0',
350
+ id,
351
+ result: {},
352
+ };
353
+ }
103
354
  if (method === 'tools/list') {
104
355
  return {
105
356
  jsonrpc: '2.0',
@@ -109,18 +360,321 @@ export async function handleMcpRequest(req, state) {
109
360
  },
110
361
  };
111
362
  }
363
+ if (method === 'resources/list') {
364
+ const fileTitles = new Map();
365
+ for (const c of state.loaded.index.chunks) {
366
+ if (!fileTitles.has(c.file)) {
367
+ fileTitles.set(c.file, c.title || c.file);
368
+ }
369
+ }
370
+ const resources = [
371
+ ...[...fileTitles.entries()].map(([file, title]) => ({
372
+ uri: `mdss://note/${encodeURI(file)}`,
373
+ name: file,
374
+ description: title !== file ? `${title} (${file})` : file,
375
+ mimeType: 'text/markdown',
376
+ })),
377
+ {
378
+ uri: 'mdss://status',
379
+ name: 'status',
380
+ description: 'Index status and statistics',
381
+ mimeType: 'application/json',
382
+ },
383
+ ];
384
+ return {
385
+ jsonrpc: '2.0',
386
+ id,
387
+ result: {
388
+ resources,
389
+ },
390
+ };
391
+ }
392
+ if (method === 'resources/templates/list') {
393
+ return {
394
+ jsonrpc: '2.0',
395
+ id,
396
+ result: {
397
+ resourceTemplates: MCP_RESOURCE_TEMPLATES,
398
+ },
399
+ };
400
+ }
401
+ if (method === 'resources/read') {
402
+ const uri = params?.uri;
403
+ if (!uri || typeof uri !== 'string') {
404
+ return {
405
+ jsonrpc: '2.0',
406
+ id,
407
+ error: { code: -32602, message: 'Invalid params: missing "uri"' },
408
+ };
409
+ }
410
+ if (uri === 'mdss://status') {
411
+ const info = {
412
+ ok: true,
413
+ chunks: state.loaded.index.chunks.length,
414
+ model: state.loaded.model.id,
415
+ dim: state.loaded.index.dim || state.loaded.model.dim || 0,
416
+ built: state.loaded.index.built || null,
417
+ db: state.loaded.index.db || null,
418
+ };
419
+ return {
420
+ jsonrpc: '2.0',
421
+ id,
422
+ result: {
423
+ contents: [
424
+ {
425
+ uri,
426
+ mimeType: 'application/json',
427
+ text: JSON.stringify(info, null, 2),
428
+ },
429
+ ],
430
+ },
431
+ };
432
+ }
433
+ if (uri.startsWith('mdss://vault')) {
434
+ const fileCounts = new Map();
435
+ for (const c of state.loaded.index.chunks) {
436
+ fileCounts.set(c.file, (fileCounts.get(c.file) || 0) + 1);
437
+ }
438
+ const files = [...fileCounts.entries()].map(([file, count]) => ({ file, chunksCount: count }));
439
+ return {
440
+ jsonrpc: '2.0',
441
+ id,
442
+ result: {
443
+ contents: [
444
+ {
445
+ uri,
446
+ mimeType: 'application/json',
447
+ text: JSON.stringify(files, null, 2),
448
+ },
449
+ ],
450
+ },
451
+ };
452
+ }
453
+ if (uri.startsWith('mdss://note/')) {
454
+ const rawTarget = uri.slice('mdss://note/'.length);
455
+ const [pathPart, queryPart] = rawTarget.split('?');
456
+ const file = decodeURIComponent(pathPart);
457
+ let fromLine;
458
+ let maxLines;
459
+ if (queryPart) {
460
+ const searchParams = new URLSearchParams(queryPart);
461
+ const fl = searchParams.get('fromLine');
462
+ if (fl)
463
+ fromLine = parseInt(fl, 10);
464
+ const ml = searchParams.get('maxLines') || searchParams.get('lines');
465
+ if (ml)
466
+ maxLines = parseInt(ml, 10);
467
+ }
468
+ try {
469
+ let text;
470
+ if (fromLine !== undefined || maxLines !== undefined) {
471
+ const linesRes = getDocLines({
472
+ file,
473
+ fromLine: fromLine && !isNaN(fromLine) ? fromLine : 1,
474
+ maxLines: maxLines && !isNaN(maxLines) ? maxLines : undefined,
475
+ dbDir: state.loaded.index.db,
476
+ chunks: state.loaded.index.chunks,
477
+ });
478
+ text = linesRes.text;
479
+ }
480
+ else {
481
+ // Read entire document
482
+ let fullText = null;
483
+ if (state.loaded.index.db) {
484
+ const absPath = assertSafePath(path.resolve(state.loaded.index.db, file), [state.loaded.index.db]);
485
+ try {
486
+ if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) {
487
+ fullText = fs.readFileSync(absPath, 'utf8');
488
+ }
489
+ }
490
+ catch { }
491
+ }
492
+ if (fullText === null) {
493
+ const linesRes = getDocLines({
494
+ file,
495
+ fromLine: 1,
496
+ maxLines: undefined,
497
+ dbDir: state.loaded.index.db,
498
+ chunks: state.loaded.index.chunks,
499
+ });
500
+ fullText = linesRes.text;
501
+ }
502
+ text = fullText;
503
+ }
504
+ return {
505
+ jsonrpc: '2.0',
506
+ id,
507
+ result: {
508
+ contents: [
509
+ {
510
+ uri,
511
+ mimeType: 'text/markdown',
512
+ text,
513
+ },
514
+ ],
515
+ },
516
+ };
517
+ }
518
+ catch (e) {
519
+ if (/path traversal guard|Forbidden path/i.test(e.message || '')) {
520
+ return {
521
+ jsonrpc: '2.0',
522
+ id,
523
+ error: { code: -32602, message: e.message },
524
+ };
525
+ }
526
+ return {
527
+ jsonrpc: '2.0',
528
+ id,
529
+ error: {
530
+ code: e.message?.includes('not found') ? -32602 : -32603,
531
+ message: `Error reading resource: ${e.message}`,
532
+ },
533
+ };
534
+ }
535
+ }
536
+ return {
537
+ jsonrpc: '2.0',
538
+ id,
539
+ error: { code: -32602, message: `Unknown resource URI: ${uri}` },
540
+ };
541
+ }
542
+ if (method === 'prompts/list') {
543
+ return {
544
+ jsonrpc: '2.0',
545
+ id,
546
+ result: {
547
+ prompts: MCP_PROMPTS,
548
+ },
549
+ };
550
+ }
551
+ if (method === 'prompts/get') {
552
+ const name = params?.name;
553
+ const args = params?.arguments || {};
554
+ if (name === 'search-and-cite') {
555
+ const query = args.query || '';
556
+ return {
557
+ jsonrpc: '2.0',
558
+ id,
559
+ result: {
560
+ description: 'Search the local knowledge base and synthesize a grounded answer with resource citations',
561
+ messages: [
562
+ {
563
+ role: 'user',
564
+ content: {
565
+ type: 'text',
566
+ text: `Search the knowledge base for "${query}" using the \`search_markdown\` tool. Synthesize a comprehensive, grounded answer citing specific notes with \`mdss://note/{path}\` resource URIs.`,
567
+ },
568
+ },
569
+ ],
570
+ },
571
+ };
572
+ }
573
+ if (name === 'summarize-note') {
574
+ const note = args.note || '';
575
+ return {
576
+ jsonrpc: '2.0',
577
+ id,
578
+ result: {
579
+ description: 'Read an indexed note and produce a concise summary of its key takeaways and actionable points',
580
+ messages: [
581
+ {
582
+ role: 'user',
583
+ content: {
584
+ type: 'text',
585
+ text: `Read note \`mdss://note/${encodeURI(note)}\` and summarize its core concepts, key takeaways, and relevant linked documents.`,
586
+ },
587
+ },
588
+ ],
589
+ },
590
+ };
591
+ }
592
+ if (name === 'compare-notes') {
593
+ const note1 = args.note1 || '';
594
+ const note2 = args.note2 || '';
595
+ return {
596
+ jsonrpc: '2.0',
597
+ id,
598
+ result: {
599
+ description: 'Compare and contrast architectural decisions, concepts, or details across two notes',
600
+ messages: [
601
+ {
602
+ role: 'user',
603
+ content: {
604
+ type: 'text',
605
+ text: `Read notes \`mdss://note/${encodeURI(note1)}\` and \`mdss://note/${encodeURI(note2)}\`. Compare and contrast their topics, identifying agreements, divergences, and complementary insights.`,
606
+ },
607
+ },
608
+ ],
609
+ },
610
+ };
611
+ }
612
+ if (name === 'find-contradictions') {
613
+ const topic = args.topic || '';
614
+ return {
615
+ jsonrpc: '2.0',
616
+ id,
617
+ result: {
618
+ description: 'Analyze knowledge base notes for conflicting or contradicting statements on a given topic',
619
+ messages: [
620
+ {
621
+ role: 'user',
622
+ content: {
623
+ type: 'text',
624
+ text: `Search the knowledge base for notes related to "${topic}". Identify any contradictory claims, conflicting dates, or conflicting architectural directives across documents, citing sources with \`mdss://note/{path}\`.`,
625
+ },
626
+ },
627
+ ],
628
+ },
629
+ };
630
+ }
631
+ if (name === 'timeline') {
632
+ const topic = args.topic || '';
633
+ return {
634
+ jsonrpc: '2.0',
635
+ id,
636
+ result: {
637
+ description: 'Extract a chronological timeline of events, ADRs, releases, or milestones from notes',
638
+ messages: [
639
+ {
640
+ role: 'user',
641
+ content: {
642
+ type: 'text',
643
+ text: `Analyze notes related to "${topic}" and construct a chronological timeline of key events, releases, ADRs, or milestone decisions with citations to \`mdss://note/{path}\`.`,
644
+ },
645
+ },
646
+ ],
647
+ },
648
+ };
649
+ }
650
+ return {
651
+ jsonrpc: '2.0',
652
+ id,
653
+ error: { code: -32601, message: `Unknown prompt: ${name}` },
654
+ };
655
+ }
112
656
  if (method === 'tools/call') {
113
657
  const name = params?.name;
114
658
  const args = params?.arguments || {};
115
659
  try {
116
- let content = [];
660
+ let rawResult;
117
661
  if (name === 'search_markdown') {
118
662
  const query = typeof args.query === 'string' ? args.query : '';
119
663
  const k = Number.isInteger(args.k) && args.k > 0 ? args.k : 6;
120
- let results;
121
664
  if (Array.isArray(args.vaults) && args.vaults.length > 0) {
122
- results = await searchFederated({
665
+ // issue #179: the vaults argument must never name an arbitrary
666
+ // directory (silent index read + query-cache write outside the
667
+ // vault). Pin federated roots to the daemon's own trust domain —
668
+ // the indexed vault root, widened only via MDSS_ROOT_GUARD.
669
+ const vaultRoots = [
670
+ state.loaded.index.db,
671
+ ...(process.env.MDSS_ROOT_GUARD
672
+ ? process.env.MDSS_ROOT_GUARD.split(path.delimiter).map((p) => p.trim()).filter(Boolean)
673
+ : []),
674
+ ].filter(Boolean);
675
+ const fed = await searchFederated({
123
676
  vaults: args.vaults,
677
+ allowedRoots: vaultRoots,
124
678
  cacheDir: state.cacheDir,
125
679
  query,
126
680
  k,
@@ -131,9 +685,12 @@ export async function handleMcpRequest(req, state) {
131
685
  offline: state.offline,
132
686
  embedFn: state.embedFn,
133
687
  });
688
+ // issue #189: broken vaults surface in the tool payload instead of
689
+ // masquerading as "no results"
690
+ rawResult = { results: fed.hits, vaultErrors: fed.vaultErrors };
134
691
  }
135
692
  else {
136
- results = await searchIndex({
693
+ rawResult = await searchIndex({
137
694
  loaded: state.loaded,
138
695
  cacheDir: state.cacheDir,
139
696
  query,
@@ -146,19 +703,48 @@ export async function handleMcpRequest(req, state) {
146
703
  embedFn: state.embedFn,
147
704
  });
148
705
  }
149
- content = [{ type: 'text', text: JSON.stringify(results, null, 2) }];
150
706
  }
151
707
  else if (name === 'get_chunk') {
152
708
  const file = args.file;
153
709
  const heading = args.heading;
154
- const chunks = state.loaded.index.chunks.filter((c) => {
710
+ rawResult = state.loaded.index.chunks.filter((c) => {
155
711
  if (c.file !== file)
156
712
  return false;
157
713
  if (heading && c.heading.toLowerCase() !== heading.toLowerCase())
158
714
  return false;
159
715
  return true;
160
716
  });
161
- content = [{ type: 'text', text: JSON.stringify(chunks, null, 2) }];
717
+ }
718
+ else if (name === 'get_lines') {
719
+ const file = args.file;
720
+ if (!file || typeof file !== 'string') {
721
+ throw new Error('missing "file" argument');
722
+ }
723
+ const fromLine = typeof args.fromLine === 'number' ? args.fromLine : 1;
724
+ const maxLines = typeof args.maxLines === 'number' ? args.maxLines : undefined;
725
+ rawResult = getDocLines({
726
+ file,
727
+ fromLine,
728
+ maxLines,
729
+ dbDir: state.loaded.index.db,
730
+ chunks: state.loaded.index.chunks,
731
+ });
732
+ }
733
+ else if (name === 'related_notes' || name === 'related_documents') {
734
+ const file = args.file || args.note || args.target;
735
+ if (!file || typeof file !== 'string') {
736
+ throw new Error('missing "file" argument');
737
+ }
738
+ const k = typeof args.k === 'number' ? args.k : 6;
739
+ const direction = args.direction || 'both';
740
+ const semantic = args.semantic !== false;
741
+ rawResult = findRelatedNotes({
742
+ loaded: state.loaded,
743
+ target: file,
744
+ k,
745
+ direction,
746
+ semantic,
747
+ });
162
748
  }
163
749
  else if (name === 'list_files') {
164
750
  const fileCounts = new Map();
@@ -170,29 +756,62 @@ export async function handleMcpRequest(req, state) {
170
756
  const re = globToRegExp(args.path);
171
757
  files = files.filter((f) => re.test(f.file));
172
758
  }
173
- content = [{ type: 'text', text: JSON.stringify(files, null, 2) }];
759
+ rawResult = files;
174
760
  }
175
761
  else if (name === 'index_status') {
176
- const info = {
762
+ rawResult = {
177
763
  ok: true,
178
764
  chunks: state.loaded.index.chunks.length,
179
765
  model: state.loaded.model.id,
180
766
  dim: state.loaded.index.dim || state.loaded.model.dim || 0,
181
767
  built: state.loaded.index.built || null,
182
768
  };
183
- content = [{ type: 'text', text: JSON.stringify(info, null, 2) }];
184
769
  }
185
770
  else if (name === 'ask_knowledge_base') {
186
771
  const query = args.query;
187
772
  const k = typeof args.k === 'number' ? args.k : 5;
188
- const answerResult = await askQuestion({
189
- loaded: state.loaded,
190
- cacheDir: state.cacheDir,
191
- query,
192
- k,
193
- embedFn: state.embedFn,
194
- });
195
- content = [{ type: 'text', text: JSON.stringify(answerResult, null, 2) }];
773
+ const sessionId = args.sessionId || args.session_id;
774
+ // issue #197: with a sampling-capable client, hand the synthesis to
775
+ // the client's own LLM via sampling/createMessage — no local model,
776
+ // no user-side LLM config. Any failure (capability absent, client
777
+ // refusal, timeout) degrades to the extractive answer as before.
778
+ const sampling = createSamplingSynthesizer(state);
779
+ if (sessionId && typeof sessionId === 'string') {
780
+ const indexDir = state.loaded.indexDir || (state.loaded.index?.db ? path.join(state.loaded.index.db, '.mdss') : state.cacheDir);
781
+ const session = loadChatSession(indexDir, sessionId);
782
+ const turnResult = await chatTurn({
783
+ session,
784
+ query,
785
+ loaded: state.loaded,
786
+ indexDir,
787
+ cacheDir: state.cacheDir,
788
+ k,
789
+ embedFn: state.embedFn,
790
+ llmRequestFn: sampling?.fn,
791
+ });
792
+ rawResult = {
793
+ answer: turnResult.answer,
794
+ citations: turnResult.citations,
795
+ manifest: turnResult.manifest,
796
+ sessionId: turnResult.session.id,
797
+ turnsCount: turnResult.session.turns.length,
798
+ answerMode: sampling?.used() ? 'sampling' : 'extractive',
799
+ };
800
+ }
801
+ else {
802
+ const synth = await askQuestion({
803
+ loaded: state.loaded,
804
+ cacheDir: state.cacheDir,
805
+ query,
806
+ k,
807
+ embedFn: state.embedFn,
808
+ llmRequestFn: sampling?.fn,
809
+ });
810
+ rawResult = {
811
+ ...synth,
812
+ answerMode: sampling?.used() ? 'sampling' : 'extractive',
813
+ };
814
+ }
196
815
  }
197
816
  else {
198
817
  return {
@@ -201,10 +820,23 @@ export async function handleMcpRequest(req, state) {
201
820
  error: { code: -32601, message: `Unknown tool: ${name}` },
202
821
  };
203
822
  }
823
+ // issue #205: the MCP spec requires structuredContent to be an OBJECT
824
+ // when present — strict clients reject bare arrays outright. Text content
825
+ // keeps the raw result verbatim for clients reading content[0].text.
826
+ const structured = Array.isArray(rawResult)
827
+ ? name === 'list_files'
828
+ ? { files: rawResult }
829
+ : name === 'get_chunk'
830
+ ? { chunks: rawResult }
831
+ : { results: rawResult }
832
+ : rawResult;
204
833
  return {
205
834
  jsonrpc: '2.0',
206
835
  id,
207
- result: { content },
836
+ result: {
837
+ content: [{ type: 'text', text: JSON.stringify(rawResult, null, 2) }],
838
+ structuredContent: structured,
839
+ },
208
840
  };
209
841
  }
210
842
  catch (e) {
@@ -225,7 +857,7 @@ export async function handleMcpRequest(req, state) {
225
857
  };
226
858
  }
227
859
  export async function startMcpServer(opts) {
228
- const { indexDir, cacheDir, db, modelName = 'e5-base', ignore = [], offline = false, embedFn, log = () => { }, } = opts;
860
+ const { indexDir, cacheDir, db, modelName = 'e5-base', ignore = [], offline = false, embedFn, input = process.stdin, output = process.stdout, log = () => { }, } = opts;
229
861
  fs.mkdirSync(indexDir, { recursive: true });
230
862
  if (!fs.existsSync(path.join(indexDir, 'vectors.json'))) {
231
863
  if (!db)
@@ -238,32 +870,99 @@ export async function startMcpServer(opts) {
238
870
  cacheDir,
239
871
  offline,
240
872
  embedFn,
873
+ output,
241
874
  };
875
+ // issue #197: over stdio the reverse channel is the transport itself —
876
+ // server→client requests (sampling) go out as JSON-RPC lines on stdout and
877
+ // the client answers with plain response lines on stdin.
878
+ const channel = createClientChannel((msg) => output.write(JSON.stringify(msg) + '\n'));
879
+ state.channel = channel;
242
880
  const rl = readline.createInterface({
243
- input: process.stdin,
244
- output: process.stdout,
881
+ input,
245
882
  terminal: false,
246
883
  });
247
- rl.on('line', async (line) => {
884
+ // issue #185: an async 'line' handler drops its promise — any rejection
885
+ // outside the per-tool try/catch (e.g. a primitive `params` reaching
886
+ // prompts/get) would crash the whole process (unhandled rejection, Node ≥15),
887
+ // and handling lines in parallel can reorder responses. Route every line
888
+ // through one guarded, serialized handler.
889
+ let tail = Promise.resolve();
890
+ rl.on('line', (line) => {
891
+ // issue #197: a response line resolves a pending server→client request
892
+ // immediately — the serialized chain below is blocked awaiting exactly
893
+ // that request (sampling), so queueing the answer behind itself would
894
+ // deadlock. Only client requests/notifications join the chain.
248
895
  const trimmed = line.trim();
249
- if (!trimmed)
250
- return;
251
- let req;
252
- try {
253
- req = JSON.parse(trimmed);
254
- }
255
- catch {
256
- process.stdout.write(JSON.stringify({
257
- jsonrpc: '2.0',
258
- id: null,
259
- error: { code: -32700, message: 'Parse error: invalid JSON' },
260
- }) + '\n');
261
- return;
262
- }
263
- const res = await handleMcpRequest(req, state);
264
- if (res) {
265
- process.stdout.write(JSON.stringify(res) + '\n');
896
+ if (trimmed) {
897
+ try {
898
+ const msg = JSON.parse(trimmed);
899
+ if (msg &&
900
+ typeof msg === 'object' &&
901
+ msg.method === undefined &&
902
+ (msg.result !== undefined || msg.error !== undefined) &&
903
+ channel.handleResponse(msg)) {
904
+ return;
905
+ }
906
+ }
907
+ catch {
908
+ /* not JSON — handleStdioLine turns it into -32700 */
909
+ }
266
910
  }
911
+ tail = tail
912
+ .then(() => handleStdioLine(line, state))
913
+ .catch(() => {
914
+ /* handleStdioLine guards everything — keep the chain alive regardless */
915
+ });
267
916
  });
268
917
  return { state, rl };
269
918
  }
919
+ /**
920
+ * One guarded, in-order stdio JSON-RPC exchange (issues #180/#185): caps the
921
+ * line length, turns JSON parse failures into `-32700`, and converts ANY
922
+ * handler escape into a `-32603` response instead of an unhandled rejection
923
+ * that would kill the daemon.
924
+ */
925
+ export const MAX_STDIO_LINE_CHARS = 1024 * 1024;
926
+ export async function handleStdioLine(line, state) {
927
+ const out = state.output ?? process.stdout;
928
+ if (line.length > MAX_STDIO_LINE_CHARS) {
929
+ out.write(JSON.stringify({
930
+ jsonrpc: '2.0',
931
+ id: null,
932
+ error: {
933
+ code: -32600,
934
+ message: `Request too large (max ${MAX_STDIO_LINE_CHARS} chars per line)`,
935
+ },
936
+ }) + '\n');
937
+ return;
938
+ }
939
+ const trimmed = line.trim();
940
+ if (!trimmed)
941
+ return;
942
+ let req;
943
+ try {
944
+ req = JSON.parse(trimmed);
945
+ }
946
+ catch {
947
+ out.write(JSON.stringify({
948
+ jsonrpc: '2.0',
949
+ id: null,
950
+ error: { code: -32700, message: 'Parse error: invalid JSON' },
951
+ }) + '\n');
952
+ return;
953
+ }
954
+ let res;
955
+ try {
956
+ res = await handleMcpRequest(req, state);
957
+ }
958
+ catch (e) {
959
+ res = {
960
+ jsonrpc: '2.0',
961
+ id: req && typeof req === 'object' && 'id' in req ? req.id : null,
962
+ error: { code: -32603, message: `Internal error: ${e?.message || String(e)}` },
963
+ };
964
+ }
965
+ if (res) {
966
+ out.write(JSON.stringify(res) + '\n');
967
+ }
968
+ }