@tobilu/qmd 0.9.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 +34 -0
- package/LICENSE +21 -0
- package/README.md +615 -0
- package/package.json +80 -0
- package/qmd +55 -0
- package/src/collections.ts +390 -0
- package/src/formatter.ts +429 -0
- package/src/llm.ts +1208 -0
- package/src/mcp.ts +654 -0
- package/src/qmd.ts +2535 -0
- package/src/store.ts +3072 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* QMD MCP Server - Model Context Protocol server for QMD
|
|
4
|
+
*
|
|
5
|
+
* Exposes QMD search and document retrieval as MCP tools and resources.
|
|
6
|
+
* Documents are accessible via qmd:// URIs.
|
|
7
|
+
*
|
|
8
|
+
* Follows MCP spec 2025-06-18 for proper response types.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { WebStandardStreamableHTTPServerTransport }
|
|
14
|
+
from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import {
|
|
17
|
+
createStore,
|
|
18
|
+
extractSnippet,
|
|
19
|
+
addLineNumbers,
|
|
20
|
+
hybridQuery,
|
|
21
|
+
vectorSearchQuery,
|
|
22
|
+
DEFAULT_MULTI_GET_MAX_BYTES,
|
|
23
|
+
} from "./store.js";
|
|
24
|
+
import type { Store } from "./store.js";
|
|
25
|
+
import { getCollection, getGlobalContext } from "./collections.js";
|
|
26
|
+
import { disposeDefaultLlamaCpp } from "./llm.js";
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Types for structured content
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
type SearchResultItem = {
|
|
33
|
+
docid: string; // Short docid (#abc123) for quick reference
|
|
34
|
+
file: string;
|
|
35
|
+
title: string;
|
|
36
|
+
score: number;
|
|
37
|
+
context: string | null;
|
|
38
|
+
snippet: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type StatusResult = {
|
|
42
|
+
totalDocuments: number;
|
|
43
|
+
needsEmbedding: number;
|
|
44
|
+
hasVectorIndex: boolean;
|
|
45
|
+
collections: {
|
|
46
|
+
name: string;
|
|
47
|
+
path: string;
|
|
48
|
+
pattern: string;
|
|
49
|
+
documents: number;
|
|
50
|
+
lastUpdated: string;
|
|
51
|
+
}[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Helper functions
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Encode a path for use in qmd:// URIs.
|
|
60
|
+
* Encodes special characters but preserves forward slashes for readability.
|
|
61
|
+
*/
|
|
62
|
+
function encodeQmdPath(path: string): string {
|
|
63
|
+
// Encode each path segment separately to preserve slashes
|
|
64
|
+
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format search results as human-readable text summary
|
|
69
|
+
*/
|
|
70
|
+
function formatSearchSummary(results: SearchResultItem[], query: string): string {
|
|
71
|
+
if (results.length === 0) {
|
|
72
|
+
return `No results found for "${query}"`;
|
|
73
|
+
}
|
|
74
|
+
const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
|
|
77
|
+
}
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// MCP Server
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build dynamic server instructions from actual index state.
|
|
87
|
+
* Injected into the LLM's system prompt via MCP initialize response —
|
|
88
|
+
* gives the LLM immediate context about what's searchable without a tool call.
|
|
89
|
+
*/
|
|
90
|
+
function buildInstructions(store: Store): string {
|
|
91
|
+
const status = store.getStatus();
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
|
|
94
|
+
// --- What is this? ---
|
|
95
|
+
const globalCtx = getGlobalContext();
|
|
96
|
+
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
|
|
97
|
+
if (globalCtx) lines.push(`Context: ${globalCtx}`);
|
|
98
|
+
|
|
99
|
+
// --- What's searchable? ---
|
|
100
|
+
if (status.collections.length > 0) {
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push("Collections (scope with `collection` parameter):");
|
|
103
|
+
for (const col of status.collections) {
|
|
104
|
+
const collConfig = getCollection(col.name);
|
|
105
|
+
const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"];
|
|
106
|
+
const desc = rootCtx ? ` — ${rootCtx}` : "";
|
|
107
|
+
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Capability gaps ---
|
|
112
|
+
if (!status.hasVectorIndex) {
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push("Note: No vector embeddings. Only `search` (BM25) is available.");
|
|
115
|
+
} else if (status.needsEmbedding > 0) {
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- When to use which tool (escalation ladder) ---
|
|
121
|
+
// Tool schemas describe parameters; instructions describe strategy.
|
|
122
|
+
lines.push("");
|
|
123
|
+
lines.push("Search:");
|
|
124
|
+
lines.push(" - `search` (~30ms) — keyword and exact phrase matching.");
|
|
125
|
+
lines.push(" - `vector_search` (~2s) — meaning-based, finds adjacent concepts even when vocabulary differs.");
|
|
126
|
+
lines.push(" - `deep_search` (~10s) — auto-expands the query into variations, searches each by keyword and meaning, reranks for top hits.");
|
|
127
|
+
|
|
128
|
+
// --- Retrieval workflow ---
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push("Retrieval:");
|
|
131
|
+
lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`).");
|
|
132
|
+
lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list.");
|
|
133
|
+
|
|
134
|
+
// --- Non-obvious things that prevent mistakes ---
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push("Tips:");
|
|
137
|
+
lines.push(" - File paths in results are relative to their collection.");
|
|
138
|
+
lines.push(" - Use `minScore: 0.5` to filter low-confidence results.");
|
|
139
|
+
lines.push(" - Results include a `context` field describing the content type.");
|
|
140
|
+
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create an MCP server with all QMD tools, resources, and prompts registered.
|
|
146
|
+
* Shared by both stdio and HTTP transports.
|
|
147
|
+
*/
|
|
148
|
+
function createMcpServer(store: Store): McpServer {
|
|
149
|
+
const server = new McpServer(
|
|
150
|
+
{ name: "qmd", version: "1.0.0" },
|
|
151
|
+
{ instructions: buildInstructions(store) },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Resource: qmd://{path} - read-only access to documents by path
|
|
156
|
+
// Note: No list() - documents are discovered via search tools
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
server.registerResource(
|
|
160
|
+
"document",
|
|
161
|
+
new ResourceTemplate("qmd://{+path}", { list: undefined }),
|
|
162
|
+
{
|
|
163
|
+
title: "QMD Document",
|
|
164
|
+
description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
|
|
165
|
+
mimeType: "text/markdown",
|
|
166
|
+
},
|
|
167
|
+
async (uri, { path }) => {
|
|
168
|
+
// Decode URL-encoded path (MCP clients send encoded URIs)
|
|
169
|
+
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
|
170
|
+
const decodedPath = decodeURIComponent(pathStr);
|
|
171
|
+
|
|
172
|
+
// Parse virtual path: collection/relative/path
|
|
173
|
+
const parts = decodedPath.split('/');
|
|
174
|
+
const collection = parts[0] || '';
|
|
175
|
+
const relativePath = parts.slice(1).join('/');
|
|
176
|
+
|
|
177
|
+
// Find document by collection and path, join with content table
|
|
178
|
+
let doc = store.db.prepare(`
|
|
179
|
+
SELECT d.collection, d.path, d.title, c.doc as body
|
|
180
|
+
FROM documents d
|
|
181
|
+
JOIN content c ON c.hash = d.hash
|
|
182
|
+
WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
|
183
|
+
`).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
|
|
184
|
+
|
|
185
|
+
// Try suffix match if exact match fails
|
|
186
|
+
if (!doc) {
|
|
187
|
+
doc = store.db.prepare(`
|
|
188
|
+
SELECT d.collection, d.path, d.title, c.doc as body
|
|
189
|
+
FROM documents d
|
|
190
|
+
JOIN content c ON c.hash = d.hash
|
|
191
|
+
WHERE d.path LIKE ? AND d.active = 1
|
|
192
|
+
LIMIT 1
|
|
193
|
+
`).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!doc) {
|
|
197
|
+
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Construct virtual path for context lookup
|
|
201
|
+
const virtualPath = `qmd://${doc.collection}/${doc.path}`;
|
|
202
|
+
const context = store.getContextForFile(virtualPath);
|
|
203
|
+
|
|
204
|
+
let text = addLineNumbers(doc.body); // Default to line numbers
|
|
205
|
+
if (context) {
|
|
206
|
+
text = `<!-- Context: ${context} -->\n\n` + text;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const displayName = `${doc.collection}/${doc.path}`;
|
|
210
|
+
return {
|
|
211
|
+
contents: [{
|
|
212
|
+
uri: uri.href,
|
|
213
|
+
name: displayName,
|
|
214
|
+
title: doc.title || doc.path,
|
|
215
|
+
mimeType: "text/markdown",
|
|
216
|
+
text,
|
|
217
|
+
}],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Tool: qmd_search (keyword)
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
server.registerTool(
|
|
227
|
+
"search",
|
|
228
|
+
{
|
|
229
|
+
title: "Keyword Search",
|
|
230
|
+
description: "Search by keyword. Finds documents containing exact words and phrases in the query.",
|
|
231
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
232
|
+
inputSchema: {
|
|
233
|
+
query: z.string().describe("Search query - keywords or phrases to find"),
|
|
234
|
+
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
|
235
|
+
minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
|
|
236
|
+
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
async ({ query, limit, minScore, collection }) => {
|
|
240
|
+
// Note: Collection filtering is now done post-search since collections are managed in YAML
|
|
241
|
+
const results = store.searchFTS(query, limit || 10)
|
|
242
|
+
.filter(r => !collection || r.collectionName === collection);
|
|
243
|
+
const filtered: SearchResultItem[] = results
|
|
244
|
+
.filter(r => r.score >= (minScore || 0))
|
|
245
|
+
.map(r => {
|
|
246
|
+
const { line, snippet } = extractSnippet(r.body || "", query, 300, r.chunkPos);
|
|
247
|
+
return {
|
|
248
|
+
docid: `#${r.docid}`,
|
|
249
|
+
file: r.displayPath,
|
|
250
|
+
title: r.title,
|
|
251
|
+
score: Math.round(r.score * 100) / 100,
|
|
252
|
+
context: store.getContextForFile(r.filepath),
|
|
253
|
+
snippet: addLineNumbers(snippet, line), // Default to line numbers
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
|
259
|
+
structuredContent: { results: filtered },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Tool: qmd_vector_search (Vector semantic search)
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
server.registerTool(
|
|
269
|
+
"vector_search",
|
|
270
|
+
{
|
|
271
|
+
title: "Vector Search",
|
|
272
|
+
description: "Search by meaning. Finds relevant documents even when they use different words than the query — handles synonyms, paraphrases, and related concepts.",
|
|
273
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
274
|
+
inputSchema: {
|
|
275
|
+
query: z.string().describe("Natural language query - describe what you're looking for"),
|
|
276
|
+
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
|
277
|
+
minScore: z.number().optional().default(0.3).describe("Minimum relevance score 0-1 (default: 0.3)"),
|
|
278
|
+
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
async ({ query, limit, minScore, collection }) => {
|
|
282
|
+
const results = await vectorSearchQuery(store, query, { collection, limit, minScore });
|
|
283
|
+
|
|
284
|
+
if (results.length === 0) {
|
|
285
|
+
// Distinguish "no embeddings" from "no matches" — check if vector table exists
|
|
286
|
+
const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
287
|
+
if (!tableExists) {
|
|
288
|
+
return {
|
|
289
|
+
content: [{ type: "text", text: "Vector index not found. Run 'qmd embed' first to create embeddings." }],
|
|
290
|
+
isError: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const filtered: SearchResultItem[] = results.map(r => {
|
|
296
|
+
const { line, snippet } = extractSnippet(r.body, query, 300);
|
|
297
|
+
return {
|
|
298
|
+
docid: `#${r.docid}`,
|
|
299
|
+
file: r.displayPath,
|
|
300
|
+
title: r.title,
|
|
301
|
+
score: Math.round(r.score * 100) / 100,
|
|
302
|
+
context: r.context,
|
|
303
|
+
snippet: addLineNumbers(snippet, line),
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
|
309
|
+
structuredContent: { results: filtered },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Tool: qmd_deep_search (Deep search with expansion + reranking)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
server.registerTool(
|
|
319
|
+
"deep_search",
|
|
320
|
+
{
|
|
321
|
+
title: "Deep Search",
|
|
322
|
+
description: "Deep search. Auto-expands the query into variations, searches each by keyword and meaning, and reranks for top hits across all results.",
|
|
323
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
324
|
+
inputSchema: {
|
|
325
|
+
query: z.string().describe("Natural language query - describe what you're looking for"),
|
|
326
|
+
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
|
327
|
+
minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
|
|
328
|
+
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
async ({ query, limit, minScore, collection }) => {
|
|
332
|
+
const results = await hybridQuery(store, query, { collection, limit, minScore });
|
|
333
|
+
|
|
334
|
+
const filtered: SearchResultItem[] = results.map(r => {
|
|
335
|
+
const { line, snippet } = extractSnippet(r.bestChunk, query, 300);
|
|
336
|
+
return {
|
|
337
|
+
docid: `#${r.docid}`,
|
|
338
|
+
file: r.displayPath,
|
|
339
|
+
title: r.title,
|
|
340
|
+
score: Math.round(r.score * 100) / 100,
|
|
341
|
+
context: r.context,
|
|
342
|
+
snippet: addLineNumbers(snippet, line),
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
|
348
|
+
structuredContent: { results: filtered },
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Tool: qmd_get (Retrieve document)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
server.registerTool(
|
|
358
|
+
"get",
|
|
359
|
+
{
|
|
360
|
+
title: "Get Document",
|
|
361
|
+
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.",
|
|
362
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
363
|
+
inputSchema: {
|
|
364
|
+
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)"),
|
|
365
|
+
fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
|
|
366
|
+
maxLines: z.number().optional().describe("Maximum number of lines to return"),
|
|
367
|
+
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
async ({ file, fromLine, maxLines, lineNumbers }) => {
|
|
371
|
+
// Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
|
|
372
|
+
let parsedFromLine = fromLine;
|
|
373
|
+
let lookup = file;
|
|
374
|
+
const colonMatch = lookup.match(/:(\d+)$/);
|
|
375
|
+
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
|
|
376
|
+
parsedFromLine = parseInt(colonMatch[1], 10);
|
|
377
|
+
lookup = lookup.slice(0, -colonMatch[0].length);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const result = store.findDocument(lookup, { includeBody: false });
|
|
381
|
+
|
|
382
|
+
if ("error" in result) {
|
|
383
|
+
let msg = `Document not found: ${file}`;
|
|
384
|
+
if (result.similarFiles.length > 0) {
|
|
385
|
+
msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
content: [{ type: "text", text: msg }],
|
|
389
|
+
isError: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
|
|
394
|
+
let text = body;
|
|
395
|
+
if (lineNumbers) {
|
|
396
|
+
const startLine = parsedFromLine || 1;
|
|
397
|
+
text = addLineNumbers(text, startLine);
|
|
398
|
+
}
|
|
399
|
+
if (result.context) {
|
|
400
|
+
text = `<!-- Context: ${result.context} -->\n\n` + text;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
content: [{
|
|
405
|
+
type: "resource",
|
|
406
|
+
resource: {
|
|
407
|
+
uri: `qmd://${encodeQmdPath(result.displayPath)}`,
|
|
408
|
+
name: result.displayPath,
|
|
409
|
+
title: result.title,
|
|
410
|
+
mimeType: "text/markdown",
|
|
411
|
+
text,
|
|
412
|
+
},
|
|
413
|
+
}],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Tool: qmd_multi_get (Retrieve multiple documents)
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
server.registerTool(
|
|
423
|
+
"multi_get",
|
|
424
|
+
{
|
|
425
|
+
title: "Multi-Get Documents",
|
|
426
|
+
description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
|
|
427
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
428
|
+
inputSchema: {
|
|
429
|
+
pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
|
|
430
|
+
maxLines: z.number().optional().describe("Maximum lines per file"),
|
|
431
|
+
maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
|
|
432
|
+
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
|
436
|
+
const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
|
437
|
+
|
|
438
|
+
if (docs.length === 0 && errors.length === 0) {
|
|
439
|
+
return {
|
|
440
|
+
content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
|
|
441
|
+
isError: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
|
|
446
|
+
|
|
447
|
+
if (errors.length > 0) {
|
|
448
|
+
content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const result of docs) {
|
|
452
|
+
if (result.skipped) {
|
|
453
|
+
content.push({
|
|
454
|
+
type: "text",
|
|
455
|
+
text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let text = result.doc.body || "";
|
|
461
|
+
if (maxLines !== undefined) {
|
|
462
|
+
const lines = text.split("\n");
|
|
463
|
+
text = lines.slice(0, maxLines).join("\n");
|
|
464
|
+
if (lines.length > maxLines) {
|
|
465
|
+
text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (lineNumbers) {
|
|
469
|
+
text = addLineNumbers(text);
|
|
470
|
+
}
|
|
471
|
+
if (result.doc.context) {
|
|
472
|
+
text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
content.push({
|
|
476
|
+
type: "resource",
|
|
477
|
+
resource: {
|
|
478
|
+
uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
|
|
479
|
+
name: result.doc.displayPath,
|
|
480
|
+
title: result.doc.title,
|
|
481
|
+
mimeType: "text/markdown",
|
|
482
|
+
text,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { content };
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Tool: qmd_status (Index status)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
server.registerTool(
|
|
496
|
+
"status",
|
|
497
|
+
{
|
|
498
|
+
title: "Index Status",
|
|
499
|
+
description: "Show the status of the QMD index: collections, document counts, and health information.",
|
|
500
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
501
|
+
inputSchema: {},
|
|
502
|
+
},
|
|
503
|
+
async () => {
|
|
504
|
+
const status: StatusResult = store.getStatus();
|
|
505
|
+
|
|
506
|
+
const summary = [
|
|
507
|
+
`QMD Index Status:`,
|
|
508
|
+
` Total documents: ${status.totalDocuments}`,
|
|
509
|
+
` Needs embedding: ${status.needsEmbedding}`,
|
|
510
|
+
` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
|
|
511
|
+
` Collections: ${status.collections.length}`,
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
for (const col of status.collections) {
|
|
515
|
+
summary.push(` - ${col.path} (${col.documents} docs)`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
content: [{ type: "text", text: summary.join('\n') }],
|
|
520
|
+
structuredContent: status,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
return server;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// =============================================================================
|
|
529
|
+
// Transport: stdio (default)
|
|
530
|
+
// =============================================================================
|
|
531
|
+
|
|
532
|
+
export async function startMcpServer(): Promise<void> {
|
|
533
|
+
const store = createStore();
|
|
534
|
+
const server = createMcpServer(store);
|
|
535
|
+
const transport = new StdioServerTransport();
|
|
536
|
+
await server.connect(transport);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// =============================================================================
|
|
540
|
+
// Transport: Streamable HTTP
|
|
541
|
+
// =============================================================================
|
|
542
|
+
|
|
543
|
+
export type HttpServerHandle = {
|
|
544
|
+
httpServer: ReturnType<typeof Bun.serve>;
|
|
545
|
+
port: number;
|
|
546
|
+
stop: () => Promise<void>;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Start MCP server over Streamable HTTP (JSON responses, no SSE).
|
|
551
|
+
* Binds to localhost only. Returns a handle for shutdown and port discovery.
|
|
552
|
+
*/
|
|
553
|
+
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
|
|
554
|
+
const store = createStore();
|
|
555
|
+
const mcpServer = createMcpServer(store);
|
|
556
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
557
|
+
enableJsonResponse: true,
|
|
558
|
+
});
|
|
559
|
+
await mcpServer.connect(transport);
|
|
560
|
+
|
|
561
|
+
const startTime = Date.now();
|
|
562
|
+
const quiet = options?.quiet ?? false;
|
|
563
|
+
|
|
564
|
+
/** Format timestamp for request logging */
|
|
565
|
+
function ts(): string {
|
|
566
|
+
return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Extract a human-readable label from a JSON-RPC body */
|
|
570
|
+
function describeRequest(body: any): string {
|
|
571
|
+
const method = body?.method ?? "unknown";
|
|
572
|
+
if (method === "tools/call") {
|
|
573
|
+
const tool = body.params?.name ?? "?";
|
|
574
|
+
const args = body.params?.arguments;
|
|
575
|
+
// Show query string if present, truncated
|
|
576
|
+
if (args?.query) {
|
|
577
|
+
const q = String(args.query).slice(0, 80);
|
|
578
|
+
return `tools/call ${tool} "${q}"`;
|
|
579
|
+
}
|
|
580
|
+
if (args?.path) return `tools/call ${tool} ${args.path}`;
|
|
581
|
+
if (args?.pattern) return `tools/call ${tool} ${args.pattern}`;
|
|
582
|
+
return `tools/call ${tool}`;
|
|
583
|
+
}
|
|
584
|
+
return method;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function log(msg: string): void {
|
|
588
|
+
if (!quiet) console.error(msg);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const httpServer = Bun.serve({
|
|
592
|
+
port,
|
|
593
|
+
hostname: "localhost",
|
|
594
|
+
async fetch(req) {
|
|
595
|
+
const reqStart = Date.now();
|
|
596
|
+
const pathname = new URL(req.url).pathname;
|
|
597
|
+
|
|
598
|
+
if (pathname === "/health" && req.method === "GET") {
|
|
599
|
+
const res = Response.json({
|
|
600
|
+
status: "ok",
|
|
601
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
602
|
+
});
|
|
603
|
+
log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
|
|
604
|
+
return res;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (pathname === "/mcp" && req.method === "POST") {
|
|
608
|
+
const body = await req.json();
|
|
609
|
+
const label = describeRequest(body);
|
|
610
|
+
const res = await transport.handleRequest(req, { parsedBody: body });
|
|
611
|
+
log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
|
|
612
|
+
return res;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Pass other methods (GET, DELETE) to transport for protocol handling
|
|
616
|
+
if (pathname === "/mcp") {
|
|
617
|
+
return transport.handleRequest(req);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return new Response("Not Found", { status: 404 });
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const actualPort = httpServer.port;
|
|
625
|
+
|
|
626
|
+
let stopping = false;
|
|
627
|
+
const stop = async () => {
|
|
628
|
+
if (stopping) return;
|
|
629
|
+
stopping = true;
|
|
630
|
+
await transport.close();
|
|
631
|
+
httpServer.stop();
|
|
632
|
+
store.close();
|
|
633
|
+
await disposeDefaultLlamaCpp();
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
process.on("SIGTERM", async () => {
|
|
637
|
+
console.error("Shutting down (SIGTERM)...");
|
|
638
|
+
await stop();
|
|
639
|
+
process.exit(0);
|
|
640
|
+
});
|
|
641
|
+
process.on("SIGINT", async () => {
|
|
642
|
+
console.error("Shutting down (SIGINT)...");
|
|
643
|
+
await stop();
|
|
644
|
+
process.exit(0);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`);
|
|
648
|
+
return { httpServer, port: actualPort, stop };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Run if this is the main module
|
|
652
|
+
if (import.meta.main) {
|
|
653
|
+
startMcpServer().catch(console.error);
|
|
654
|
+
}
|