md-semantic-search 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/src/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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
821
|
+
rawResult = files;
|
|
177
822
|
} else if (name === 'index_status') {
|
|
178
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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: {
|
|
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:
|
|
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
|
|
268
|
-
output: process.stdout,
|
|
972
|
+
input,
|
|
269
973
|
terminal: false,
|
|
270
974
|
});
|
|
271
975
|
|
|
272
|
-
|
|
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 (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|