md-semantic-search 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/dist/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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
+
rawResult = files;
|
|
174
760
|
}
|
|
175
761
|
else if (name === 'index_status') {
|
|
176
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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: {
|
|
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
|
|
244
|
-
output: process.stdout,
|
|
881
|
+
input,
|
|
245
882
|
terminal: false,
|
|
246
883
|
});
|
|
247
|
-
|
|
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 (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
}
|