md-semantic-search 1.0.3 → 2.0.0

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