@ambicuity/kindx 0.1.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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +578 -0
- package/dist/catalogs.d.ts +137 -0
- package/dist/catalogs.js +349 -0
- package/dist/inference.d.ts +398 -0
- package/dist/inference.js +1131 -0
- package/dist/kindx.d.ts +1 -0
- package/dist/kindx.js +2621 -0
- package/dist/protocol.d.ts +21 -0
- package/dist/protocol.js +666 -0
- package/dist/renderer.d.ts +119 -0
- package/dist/renderer.js +350 -0
- package/dist/repository.d.ts +783 -0
- package/dist/repository.js +2787 -0
- package/dist/runtime.d.ts +33 -0
- package/dist/runtime.js +34 -0
- package/package.json +90 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KINDX MCP Server - Model Context Protocol server for QMD
|
|
3
|
+
*
|
|
4
|
+
* Exposes KINDX search and document retrieval as MCP tools and resources.
|
|
5
|
+
* Documents are accessible via kindx:// URIs.
|
|
6
|
+
*
|
|
7
|
+
* Follows MCP spec 2025-06-18 for proper response types.
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
15
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { createStore, extractSnippet, addLineNumbers, structuredSearch, DEFAULT_MULTI_GET_MAX_BYTES, } from "./repository.js";
|
|
18
|
+
import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./catalogs.js";
|
|
19
|
+
import { disposeDefaultLlamaCpp } from "./inference.js";
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Helper functions
|
|
22
|
+
// =============================================================================
|
|
23
|
+
/**
|
|
24
|
+
* Encode a path for use in kindx:// URIs.
|
|
25
|
+
* Encodes special characters but preserves forward slashes for readability.
|
|
26
|
+
*/
|
|
27
|
+
function encodeQmdPath(path) {
|
|
28
|
+
// Encode each path segment separately to preserve slashes
|
|
29
|
+
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Format search results as human-readable text summary
|
|
33
|
+
*/
|
|
34
|
+
function formatSearchSummary(results, query) {
|
|
35
|
+
if (results.length === 0) {
|
|
36
|
+
return `No results found for "${query}"`;
|
|
37
|
+
}
|
|
38
|
+
const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
|
|
39
|
+
for (const r of results) {
|
|
40
|
+
lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// MCP Server
|
|
46
|
+
// =============================================================================
|
|
47
|
+
/**
|
|
48
|
+
* Build dynamic server instructions from actual index state.
|
|
49
|
+
* Injected into the LLM's system prompt via MCP initialize response —
|
|
50
|
+
* gives the LLM immediate context about what's searchable without a tool call.
|
|
51
|
+
*/
|
|
52
|
+
function buildInstructions(store) {
|
|
53
|
+
const status = store.getStatus();
|
|
54
|
+
const lines = [];
|
|
55
|
+
// --- What is this? ---
|
|
56
|
+
const globalCtx = getGlobalContext();
|
|
57
|
+
lines.push(`KINDX is your local search engine over ${status.totalDocuments} markdown documents.`);
|
|
58
|
+
if (globalCtx)
|
|
59
|
+
lines.push(`Context: ${globalCtx}`);
|
|
60
|
+
// --- What's searchable? ---
|
|
61
|
+
if (status.collections.length > 0) {
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push("Collections (scope with `collection` parameter):");
|
|
64
|
+
for (const col of status.collections) {
|
|
65
|
+
const collConfig = getCollection(col.name);
|
|
66
|
+
const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"];
|
|
67
|
+
const desc = rootCtx ? ` — ${rootCtx}` : "";
|
|
68
|
+
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// --- Capability gaps ---
|
|
72
|
+
if (!status.hasVectorIndex) {
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push("Note: No vector embeddings yet. Run `kindx embed` to enable semantic search (vec/hyde).");
|
|
75
|
+
}
|
|
76
|
+
else if (status.needsEmbedding > 0) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`kindx embed\` to update.`);
|
|
79
|
+
}
|
|
80
|
+
// --- Search tool ---
|
|
81
|
+
lines.push("");
|
|
82
|
+
lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):");
|
|
83
|
+
lines.push(" - type:'lex' — BM25 keyword search (exact terms, fast)");
|
|
84
|
+
lines.push(" - type:'vec' — semantic vector search (meaning-based)");
|
|
85
|
+
lines.push(" - type:'hyde' — hypothetical document (write what the answer looks like)");
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push("Examples:");
|
|
88
|
+
lines.push(" Quick keyword lookup: [{type:'lex', query:'error handling'}]");
|
|
89
|
+
lines.push(" Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
|
|
90
|
+
lines.push(" Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
|
|
91
|
+
// --- Retrieval workflow ---
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push("Retrieval:");
|
|
94
|
+
lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`).");
|
|
95
|
+
lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list.");
|
|
96
|
+
// --- Non-obvious things that prevent mistakes ---
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push("Tips:");
|
|
99
|
+
lines.push(" - File paths in results are relative to their collection.");
|
|
100
|
+
lines.push(" - Use `minScore: 0.5` to filter low-confidence results.");
|
|
101
|
+
lines.push(" - Results include a `context` field describing the content type.");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create an MCP server with all KINDX tools, resources, and prompts registered.
|
|
106
|
+
* Shared by both stdio and HTTP transports.
|
|
107
|
+
*/
|
|
108
|
+
function createMcpServer(store) {
|
|
109
|
+
const server = new McpServer({ name: "kindx", version: "0.9.9" }, { instructions: buildInstructions(store) });
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Resource: kindx://{path} - read-only access to documents by path
|
|
112
|
+
// Note: No list() - documents are discovered via search tools
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
server.registerResource("document", new ResourceTemplate("kindx://{+path}", { list: undefined }), {
|
|
115
|
+
title: "KINDX Document",
|
|
116
|
+
description: "A markdown document from your KINDX knowledge base. Use search tools to discover documents.",
|
|
117
|
+
mimeType: "text/markdown",
|
|
118
|
+
}, async (uri, { path }) => {
|
|
119
|
+
// Decode URL-encoded path (MCP clients send encoded URIs)
|
|
120
|
+
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
|
121
|
+
const decodedPath = decodeURIComponent(pathStr);
|
|
122
|
+
// Parse virtual path: collection/relative/path
|
|
123
|
+
const parts = decodedPath.split('/');
|
|
124
|
+
const collection = parts[0] || '';
|
|
125
|
+
const relativePath = parts.slice(1).join('/');
|
|
126
|
+
// Find document by collection and path, join with content table
|
|
127
|
+
let doc = store.db.prepare(`
|
|
128
|
+
SELECT d.collection, d.path, d.title, c.doc as body
|
|
129
|
+
FROM documents d
|
|
130
|
+
JOIN content c ON c.hash = d.hash
|
|
131
|
+
WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
|
132
|
+
`).get(collection, relativePath);
|
|
133
|
+
// Try suffix match if exact match fails
|
|
134
|
+
if (!doc) {
|
|
135
|
+
doc = store.db.prepare(`
|
|
136
|
+
SELECT d.collection, d.path, d.title, c.doc as body
|
|
137
|
+
FROM documents d
|
|
138
|
+
JOIN content c ON c.hash = d.hash
|
|
139
|
+
WHERE d.path LIKE ? AND d.active = 1
|
|
140
|
+
LIMIT 1
|
|
141
|
+
`).get(`%${relativePath}`);
|
|
142
|
+
}
|
|
143
|
+
if (!doc) {
|
|
144
|
+
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
|
|
145
|
+
}
|
|
146
|
+
// Construct virtual path for context lookup
|
|
147
|
+
const virtualPath = `kindx://${doc.collection}/${doc.path}`;
|
|
148
|
+
const context = store.getContextForFile(virtualPath);
|
|
149
|
+
let text = addLineNumbers(doc.body); // Default to line numbers
|
|
150
|
+
if (context) {
|
|
151
|
+
text = `<!-- Context: ${context} -->\n\n` + text;
|
|
152
|
+
}
|
|
153
|
+
const displayName = `${doc.collection}/${doc.path}`;
|
|
154
|
+
return {
|
|
155
|
+
contents: [{
|
|
156
|
+
uri: uri.href,
|
|
157
|
+
name: displayName,
|
|
158
|
+
title: doc.title || doc.path,
|
|
159
|
+
mimeType: "text/markdown",
|
|
160
|
+
text,
|
|
161
|
+
}],
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Tool: query (Primary search tool)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
const subSearchSchema = z.object({
|
|
168
|
+
type: z.enum(['lex', 'vec', 'hyde']).describe("lex = BM25 keywords (supports \"phrase\" and -negation); " +
|
|
169
|
+
"vec = semantic question; hyde = hypothetical answer passage"),
|
|
170
|
+
query: z.string().describe("The query text. For lex: use keywords, \"quoted phrases\", and -negation. " +
|
|
171
|
+
"For vec: natural language question. For hyde: 50-100 word answer passage."),
|
|
172
|
+
});
|
|
173
|
+
server.registerTool("query", {
|
|
174
|
+
title: "Query",
|
|
175
|
+
description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.
|
|
176
|
+
|
|
177
|
+
## Query Types
|
|
178
|
+
|
|
179
|
+
**lex** — BM25 keyword search. Fast, exact, no LLM needed.
|
|
180
|
+
Full lex syntax:
|
|
181
|
+
- \`term\` — prefix match ("perf" matches "performance")
|
|
182
|
+
- \`"exact phrase"\` — phrase must appear verbatim
|
|
183
|
+
- \`-term\` or \`-"phrase"\` — exclude documents containing this
|
|
184
|
+
|
|
185
|
+
Good lex examples:
|
|
186
|
+
- \`"connection pool" timeout -redis\`
|
|
187
|
+
- \`"machine learning" -sports -athlete\`
|
|
188
|
+
- \`handleError async typescript\`
|
|
189
|
+
|
|
190
|
+
**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.
|
|
191
|
+
- \`how does the rate limiter handle burst traffic?\`
|
|
192
|
+
- \`what is the tradeoff between consistency and availability?\`
|
|
193
|
+
|
|
194
|
+
**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.
|
|
195
|
+
- \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\`
|
|
196
|
+
|
|
197
|
+
## Strategy
|
|
198
|
+
|
|
199
|
+
Combine types for best results. First sub-query gets 2× weight — put your strongest signal first.
|
|
200
|
+
|
|
201
|
+
| Goal | Approach |
|
|
202
|
+
|------|----------|
|
|
203
|
+
| Know exact term/name | \`lex\` only |
|
|
204
|
+
| Concept search | \`vec\` only |
|
|
205
|
+
| Best recall | \`lex\` + \`vec\` |
|
|
206
|
+
| Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` |
|
|
207
|
+
| Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |
|
|
208
|
+
|
|
209
|
+
## Examples
|
|
210
|
+
|
|
211
|
+
Simple lookup:
|
|
212
|
+
\`\`\`json
|
|
213
|
+
[{ "type": "lex", "query": "CAP theorem" }]
|
|
214
|
+
\`\`\`
|
|
215
|
+
|
|
216
|
+
Best recall on a technical topic:
|
|
217
|
+
\`\`\`json
|
|
218
|
+
[
|
|
219
|
+
{ "type": "lex", "query": "\\"connection pool\\" timeout -redis" },
|
|
220
|
+
{ "type": "vec", "query": "why do database connections time out under load" },
|
|
221
|
+
{ "type": "hyde", "query": "Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected." }
|
|
222
|
+
]
|
|
223
|
+
\`\`\`
|
|
224
|
+
|
|
225
|
+
Intent-aware lex (C++ performance, not sports):
|
|
226
|
+
\`\`\`json
|
|
227
|
+
[
|
|
228
|
+
{ "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" },
|
|
229
|
+
{ "type": "vec", "query": "how to optimize C++ program performance" }
|
|
230
|
+
]
|
|
231
|
+
\`\`\``,
|
|
232
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
233
|
+
inputSchema: {
|
|
234
|
+
searches: z.array(subSearchSchema).min(1).max(10).describe("Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."),
|
|
235
|
+
limit: z.number().optional().default(10).describe("Max results (default: 10)"),
|
|
236
|
+
minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"),
|
|
237
|
+
candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"),
|
|
238
|
+
collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
|
|
239
|
+
},
|
|
240
|
+
}, async ({ searches, limit, minScore, candidateLimit, collections }) => {
|
|
241
|
+
// Map to internal format
|
|
242
|
+
const subSearches = searches.map((s) => ({
|
|
243
|
+
type: s.type,
|
|
244
|
+
query: s.query,
|
|
245
|
+
}));
|
|
246
|
+
// Use default collections if none specified
|
|
247
|
+
const effectiveCollections = collections ?? getDefaultCollectionNames();
|
|
248
|
+
const results = await structuredSearch(store, subSearches, {
|
|
249
|
+
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
250
|
+
limit,
|
|
251
|
+
minScore,
|
|
252
|
+
candidateLimit,
|
|
253
|
+
});
|
|
254
|
+
// Use first lex or vec query for snippet extraction
|
|
255
|
+
const primaryQuery = searches.find((s) => s.type === 'lex')?.query
|
|
256
|
+
|| searches.find((s) => s.type === 'vec')?.query
|
|
257
|
+
|| searches[0]?.query || "";
|
|
258
|
+
const filtered = results.map(r => {
|
|
259
|
+
const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
|
|
260
|
+
return {
|
|
261
|
+
docid: `#${r.docid}`,
|
|
262
|
+
file: r.displayPath,
|
|
263
|
+
title: r.title,
|
|
264
|
+
score: Math.round(r.score * 100) / 100,
|
|
265
|
+
context: r.context,
|
|
266
|
+
snippet: addLineNumbers(snippet, line),
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }],
|
|
271
|
+
structuredContent: { results: filtered },
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Tool: qmd_get (Retrieve document)
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
server.registerTool("get", {
|
|
278
|
+
title: "Get Document",
|
|
279
|
+
description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
|
|
280
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
281
|
+
inputSchema: {
|
|
282
|
+
file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"),
|
|
283
|
+
fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
|
|
284
|
+
maxLines: z.number().optional().describe("Maximum number of lines to return"),
|
|
285
|
+
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
|
286
|
+
},
|
|
287
|
+
}, async ({ file, fromLine, maxLines, lineNumbers }) => {
|
|
288
|
+
// Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
|
|
289
|
+
let parsedFromLine = fromLine;
|
|
290
|
+
let lookup = file;
|
|
291
|
+
const colonMatch = lookup.match(/:(\d+)$/);
|
|
292
|
+
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
|
|
293
|
+
parsedFromLine = parseInt(colonMatch[1], 10);
|
|
294
|
+
lookup = lookup.slice(0, -colonMatch[0].length);
|
|
295
|
+
}
|
|
296
|
+
const result = store.findDocument(lookup, { includeBody: false });
|
|
297
|
+
if ("error" in result) {
|
|
298
|
+
let msg = `Document not found: ${file}`;
|
|
299
|
+
if (result.similarFiles.length > 0) {
|
|
300
|
+
msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: msg }],
|
|
304
|
+
isError: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
|
|
308
|
+
let text = body;
|
|
309
|
+
if (lineNumbers) {
|
|
310
|
+
const startLine = parsedFromLine || 1;
|
|
311
|
+
text = addLineNumbers(text, startLine);
|
|
312
|
+
}
|
|
313
|
+
if (result.context) {
|
|
314
|
+
text = `<!-- Context: ${result.context} -->\n\n` + text;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
content: [{
|
|
318
|
+
type: "resource",
|
|
319
|
+
resource: {
|
|
320
|
+
uri: `kindx://${encodeQmdPath(result.displayPath)}`,
|
|
321
|
+
name: result.displayPath,
|
|
322
|
+
title: result.title,
|
|
323
|
+
mimeType: "text/markdown",
|
|
324
|
+
text,
|
|
325
|
+
},
|
|
326
|
+
}],
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Tool: qmd_multi_get (Retrieve multiple documents)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
server.registerTool("multi_get", {
|
|
333
|
+
title: "Multi-Get Documents",
|
|
334
|
+
description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
|
|
335
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
336
|
+
inputSchema: {
|
|
337
|
+
pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
|
|
338
|
+
maxLines: z.number().optional().describe("Maximum lines per file"),
|
|
339
|
+
maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
|
|
340
|
+
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
|
341
|
+
},
|
|
342
|
+
}, async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
|
343
|
+
const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
|
344
|
+
if (docs.length === 0 && errors.length === 0) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
|
|
347
|
+
isError: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const content = [];
|
|
351
|
+
if (errors.length > 0) {
|
|
352
|
+
content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
|
|
353
|
+
}
|
|
354
|
+
for (const result of docs) {
|
|
355
|
+
if (result.skipped) {
|
|
356
|
+
content.push({
|
|
357
|
+
type: "text",
|
|
358
|
+
text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
let text = result.doc.body || "";
|
|
363
|
+
if (maxLines !== undefined) {
|
|
364
|
+
const lines = text.split("\n");
|
|
365
|
+
text = lines.slice(0, maxLines).join("\n");
|
|
366
|
+
if (lines.length > maxLines) {
|
|
367
|
+
text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (lineNumbers) {
|
|
371
|
+
text = addLineNumbers(text);
|
|
372
|
+
}
|
|
373
|
+
if (result.doc.context) {
|
|
374
|
+
text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
|
|
375
|
+
}
|
|
376
|
+
content.push({
|
|
377
|
+
type: "resource",
|
|
378
|
+
resource: {
|
|
379
|
+
uri: `kindx://${encodeQmdPath(result.doc.displayPath)}`,
|
|
380
|
+
name: result.doc.displayPath,
|
|
381
|
+
title: result.doc.title,
|
|
382
|
+
mimeType: "text/markdown",
|
|
383
|
+
text,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return { content };
|
|
388
|
+
});
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Tool: qmd_status (Index status)
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
server.registerTool("status", {
|
|
393
|
+
title: "Index Status",
|
|
394
|
+
description: "Show the status of the KINDX index: collections, document counts, and health information.",
|
|
395
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
396
|
+
inputSchema: {},
|
|
397
|
+
}, async () => {
|
|
398
|
+
const status = store.getStatus();
|
|
399
|
+
const summary = [
|
|
400
|
+
`KINDX Index Status:`,
|
|
401
|
+
` Total documents: ${status.totalDocuments}`,
|
|
402
|
+
` Needs embedding: ${status.needsEmbedding}`,
|
|
403
|
+
` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
|
|
404
|
+
` Collections: ${status.collections.length}`,
|
|
405
|
+
];
|
|
406
|
+
for (const col of status.collections) {
|
|
407
|
+
summary.push(` - ${col.path} (${col.documents} docs)`);
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
content: [{ type: "text", text: summary.join('\n') }],
|
|
411
|
+
structuredContent: status,
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
return server;
|
|
415
|
+
}
|
|
416
|
+
// =============================================================================
|
|
417
|
+
// Transport: stdio (default)
|
|
418
|
+
// =============================================================================
|
|
419
|
+
export async function startMcpServer() {
|
|
420
|
+
const store = createStore();
|
|
421
|
+
const server = createMcpServer(store);
|
|
422
|
+
const transport = new StdioServerTransport();
|
|
423
|
+
await server.connect(transport);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Start MCP server over Streamable HTTP (JSON responses, no SSE).
|
|
427
|
+
* Binds to localhost only. Returns a handle for shutdown and port discovery.
|
|
428
|
+
*/
|
|
429
|
+
export async function startMcpHttpServer(port, options) {
|
|
430
|
+
const store = createStore();
|
|
431
|
+
// Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
|
|
432
|
+
// The store is shared — it's stateless SQLite, safe for concurrent access.
|
|
433
|
+
const sessions = new Map();
|
|
434
|
+
async function createSession() {
|
|
435
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
436
|
+
sessionIdGenerator: () => randomUUID(),
|
|
437
|
+
enableJsonResponse: true,
|
|
438
|
+
onsessioninitialized: (sessionId) => {
|
|
439
|
+
sessions.set(sessionId, transport);
|
|
440
|
+
log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
const server = createMcpServer(store);
|
|
444
|
+
await server.connect(transport);
|
|
445
|
+
transport.onclose = () => {
|
|
446
|
+
if (transport.sessionId) {
|
|
447
|
+
sessions.delete(transport.sessionId);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
return transport;
|
|
451
|
+
}
|
|
452
|
+
const startTime = Date.now();
|
|
453
|
+
const quiet = options?.quiet ?? false;
|
|
454
|
+
/** Format timestamp for request logging */
|
|
455
|
+
function ts() {
|
|
456
|
+
return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
|
|
457
|
+
}
|
|
458
|
+
/** Extract a human-readable label from a JSON-RPC body */
|
|
459
|
+
function describeRequest(body) {
|
|
460
|
+
const method = body?.method ?? "unknown";
|
|
461
|
+
if (method === "tools/call") {
|
|
462
|
+
const tool = body.params?.name ?? "?";
|
|
463
|
+
const args = body.params?.arguments;
|
|
464
|
+
// Show query string if present, truncated
|
|
465
|
+
if (args?.query) {
|
|
466
|
+
const q = String(args.query).slice(0, 80);
|
|
467
|
+
return `tools/call ${tool} "${q}"`;
|
|
468
|
+
}
|
|
469
|
+
if (args?.path)
|
|
470
|
+
return `tools/call ${tool} ${args.path}`;
|
|
471
|
+
if (args?.pattern)
|
|
472
|
+
return `tools/call ${tool} ${args.pattern}`;
|
|
473
|
+
return `tools/call ${tool}`;
|
|
474
|
+
}
|
|
475
|
+
return method;
|
|
476
|
+
}
|
|
477
|
+
function log(msg) {
|
|
478
|
+
if (!quiet)
|
|
479
|
+
console.error(msg);
|
|
480
|
+
}
|
|
481
|
+
// Helper to collect request body
|
|
482
|
+
async function collectBody(req) {
|
|
483
|
+
const chunks = [];
|
|
484
|
+
for await (const chunk of req)
|
|
485
|
+
chunks.push(chunk);
|
|
486
|
+
return Buffer.concat(chunks).toString();
|
|
487
|
+
}
|
|
488
|
+
const httpServer = createServer(async (nodeReq, nodeRes) => {
|
|
489
|
+
const reqStart = Date.now();
|
|
490
|
+
const pathname = nodeReq.url || "/";
|
|
491
|
+
try {
|
|
492
|
+
if (pathname === "/health" && nodeReq.method === "GET") {
|
|
493
|
+
const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) });
|
|
494
|
+
nodeRes.writeHead(200, { "Content-Type": "application/json" });
|
|
495
|
+
nodeRes.end(body);
|
|
496
|
+
log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// REST endpoint: POST /search — structured search without MCP protocol
|
|
500
|
+
// REST endpoint: POST /query (alias: /search) — structured search without MCP protocol
|
|
501
|
+
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
|
|
502
|
+
const rawBody = await collectBody(nodeReq);
|
|
503
|
+
const params = JSON.parse(rawBody);
|
|
504
|
+
// Validate required fields
|
|
505
|
+
if (!params.searches || !Array.isArray(params.searches)) {
|
|
506
|
+
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
507
|
+
nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" }));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Map to internal format
|
|
511
|
+
const subSearches = params.searches.map((s) => ({
|
|
512
|
+
type: s.type,
|
|
513
|
+
query: String(s.query || ""),
|
|
514
|
+
}));
|
|
515
|
+
// Use default collections if none specified
|
|
516
|
+
const effectiveCollections = params.collections ?? getDefaultCollectionNames();
|
|
517
|
+
const results = await structuredSearch(store, subSearches, {
|
|
518
|
+
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
519
|
+
limit: params.limit ?? 10,
|
|
520
|
+
minScore: params.minScore ?? 0,
|
|
521
|
+
candidateLimit: params.candidateLimit,
|
|
522
|
+
});
|
|
523
|
+
// Use first lex or vec query for snippet extraction
|
|
524
|
+
const primaryQuery = params.searches.find((s) => s.type === 'lex')?.query
|
|
525
|
+
|| params.searches.find((s) => s.type === 'vec')?.query
|
|
526
|
+
|| params.searches[0]?.query || "";
|
|
527
|
+
const formatted = results.map(r => {
|
|
528
|
+
const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
|
|
529
|
+
return {
|
|
530
|
+
docid: `#${r.docid}`,
|
|
531
|
+
file: r.displayPath,
|
|
532
|
+
title: r.title,
|
|
533
|
+
score: Math.round(r.score * 100) / 100,
|
|
534
|
+
context: r.context,
|
|
535
|
+
snippet: addLineNumbers(snippet, line),
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
nodeRes.writeHead(200, { "Content-Type": "application/json" });
|
|
539
|
+
nodeRes.end(JSON.stringify({ results: formatted }));
|
|
540
|
+
log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (pathname === "/mcp" && nodeReq.method === "POST") {
|
|
544
|
+
const rawBody = await collectBody(nodeReq);
|
|
545
|
+
const body = JSON.parse(rawBody);
|
|
546
|
+
const label = describeRequest(body);
|
|
547
|
+
const url = `http://localhost:${port}${pathname}`;
|
|
548
|
+
const headers = {};
|
|
549
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
|
550
|
+
if (typeof v === "string")
|
|
551
|
+
headers[k] = v;
|
|
552
|
+
}
|
|
553
|
+
// Route to existing session or create new one on initialize
|
|
554
|
+
const sessionId = headers["mcp-session-id"];
|
|
555
|
+
let transport;
|
|
556
|
+
if (sessionId) {
|
|
557
|
+
const existing = sessions.get(sessionId);
|
|
558
|
+
if (!existing) {
|
|
559
|
+
nodeRes.writeHead(404, { "Content-Type": "application/json" });
|
|
560
|
+
nodeRes.end(JSON.stringify({
|
|
561
|
+
jsonrpc: "2.0",
|
|
562
|
+
error: { code: -32001, message: "Session not found" },
|
|
563
|
+
id: body?.id ?? null,
|
|
564
|
+
}));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
transport = existing;
|
|
568
|
+
}
|
|
569
|
+
else if (isInitializeRequest(body)) {
|
|
570
|
+
transport = await createSession();
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
574
|
+
nodeRes.end(JSON.stringify({
|
|
575
|
+
jsonrpc: "2.0",
|
|
576
|
+
error: { code: -32000, message: "Bad Request: Missing session ID" },
|
|
577
|
+
id: body?.id ?? null,
|
|
578
|
+
}));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const request = new Request(url, { method: "POST", headers, body: rawBody });
|
|
582
|
+
const response = await transport.handleRequest(request, { parsedBody: body });
|
|
583
|
+
nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
|
|
584
|
+
nodeRes.end(Buffer.from(await response.arrayBuffer()));
|
|
585
|
+
log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (pathname === "/mcp") {
|
|
589
|
+
const headers = {};
|
|
590
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
|
591
|
+
if (typeof v === "string")
|
|
592
|
+
headers[k] = v;
|
|
593
|
+
}
|
|
594
|
+
// GET/DELETE must have a valid session
|
|
595
|
+
const sessionId = headers["mcp-session-id"];
|
|
596
|
+
if (!sessionId) {
|
|
597
|
+
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
598
|
+
nodeRes.end(JSON.stringify({
|
|
599
|
+
jsonrpc: "2.0",
|
|
600
|
+
error: { code: -32000, message: "Bad Request: Missing session ID" },
|
|
601
|
+
id: null,
|
|
602
|
+
}));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const transport = sessions.get(sessionId);
|
|
606
|
+
if (!transport) {
|
|
607
|
+
nodeRes.writeHead(404, { "Content-Type": "application/json" });
|
|
608
|
+
nodeRes.end(JSON.stringify({
|
|
609
|
+
jsonrpc: "2.0",
|
|
610
|
+
error: { code: -32001, message: "Session not found" },
|
|
611
|
+
id: null,
|
|
612
|
+
}));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const url = `http://localhost:${port}${pathname}`;
|
|
616
|
+
const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined;
|
|
617
|
+
const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) });
|
|
618
|
+
const response = await transport.handleRequest(request);
|
|
619
|
+
nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
|
|
620
|
+
nodeRes.end(Buffer.from(await response.arrayBuffer()));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
nodeRes.writeHead(404);
|
|
624
|
+
nodeRes.end("Not Found");
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
console.error("HTTP handler error:", err);
|
|
628
|
+
nodeRes.writeHead(500);
|
|
629
|
+
nodeRes.end("Internal Server Error");
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
await new Promise((resolve, reject) => {
|
|
633
|
+
httpServer.on("error", reject);
|
|
634
|
+
httpServer.listen(port, "localhost", () => resolve());
|
|
635
|
+
});
|
|
636
|
+
const actualPort = httpServer.address().port;
|
|
637
|
+
let stopping = false;
|
|
638
|
+
const stop = async () => {
|
|
639
|
+
if (stopping)
|
|
640
|
+
return;
|
|
641
|
+
stopping = true;
|
|
642
|
+
for (const transport of sessions.values()) {
|
|
643
|
+
await transport.close();
|
|
644
|
+
}
|
|
645
|
+
sessions.clear();
|
|
646
|
+
httpServer.close();
|
|
647
|
+
store.close();
|
|
648
|
+
await disposeDefaultLlamaCpp();
|
|
649
|
+
};
|
|
650
|
+
process.on("SIGTERM", async () => {
|
|
651
|
+
console.error("Shutting down (SIGTERM)...");
|
|
652
|
+
await stop();
|
|
653
|
+
process.exit(0);
|
|
654
|
+
});
|
|
655
|
+
process.on("SIGINT", async () => {
|
|
656
|
+
console.error("Shutting down (SIGINT)...");
|
|
657
|
+
await stop();
|
|
658
|
+
process.exit(0);
|
|
659
|
+
});
|
|
660
|
+
log(`KINDX MCP server listening on http://localhost:${actualPort}/mcp`);
|
|
661
|
+
return { httpServer, port: actualPort, stop };
|
|
662
|
+
}
|
|
663
|
+
// Run if this is the main module
|
|
664
|
+
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/protocol.js")) {
|
|
665
|
+
startMcpServer().catch(console.error);
|
|
666
|
+
}
|