@wei840222/qmd 2026.8.23
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 +1373 -0
- package/LICENSE +45 -0
- package/README.md +1439 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/qmd +192 -0
- package/dist/ast.d.ts +65 -0
- package/dist/ast.js +334 -0
- package/dist/bench/bench.d.ts +35 -0
- package/dist/bench/bench.js +338 -0
- package/dist/bench/cjk-baseline.d.ts +36 -0
- package/dist/bench/cjk-baseline.js +111 -0
- package/dist/bench/fixture.d.ts +2 -0
- package/dist/bench/fixture.js +84 -0
- package/dist/bench/score.d.ts +38 -0
- package/dist/bench/score.js +107 -0
- package/dist/bench/types.d.ts +110 -0
- package/dist/bench/types.js +8 -0
- package/dist/cli/build-info.json +4 -0
- package/dist/cli/embed-lock.d.ts +24 -0
- package/dist/cli/embed-lock.js +94 -0
- package/dist/cli/embedding-owner.d.ts +10 -0
- package/dist/cli/embedding-owner.js +20 -0
- package/dist/cli/formatter.d.ts +120 -0
- package/dist/cli/formatter.js +355 -0
- package/dist/cli/mcp-pid.d.ts +25 -0
- package/dist/cli/mcp-pid.js +86 -0
- package/dist/cli/qmd.d.ts +72 -0
- package/dist/cli/qmd.js +4806 -0
- package/dist/cli/version.d.ts +42 -0
- package/dist/cli/version.js +80 -0
- package/dist/collections.d.ts +200 -0
- package/dist/collections.js +433 -0
- package/dist/db.d.ts +65 -0
- package/dist/db.js +143 -0
- package/dist/diagnostics.d.ts +62 -0
- package/dist/diagnostics.js +260 -0
- package/dist/embedding/config.d.ts +52 -0
- package/dist/embedding/config.js +229 -0
- package/dist/embedding/identity.d.ts +58 -0
- package/dist/embedding/identity.js +321 -0
- package/dist/embedding/local-identity.d.ts +1 -0
- package/dist/embedding/local-identity.js +15 -0
- package/dist/embedding/local.d.ts +34 -0
- package/dist/embedding/local.js +290 -0
- package/dist/embedding/openai.d.ts +79 -0
- package/dist/embedding/openai.js +477 -0
- package/dist/embedding/owner.d.ts +13 -0
- package/dist/embedding/owner.js +36 -0
- package/dist/embedding/provider.d.ts +68 -0
- package/dist/embedding/provider.js +16 -0
- package/dist/embedding/remote-chunking.d.ts +22 -0
- package/dist/embedding/remote-chunking.js +83 -0
- package/dist/embedding/remote-embedding.d.ts +15 -0
- package/dist/embedding/remote-embedding.js +77 -0
- package/dist/hybrid-llm.d.ts +18 -0
- package/dist/hybrid-llm.js +53 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +418 -0
- package/dist/llm.d.ts +566 -0
- package/dist/llm.js +1847 -0
- package/dist/maintenance.d.ts +33 -0
- package/dist/maintenance.js +52 -0
- package/dist/mcp/origin-guard.d.ts +67 -0
- package/dist/mcp/origin-guard.js +137 -0
- package/dist/mcp/server.d.ts +116 -0
- package/dist/mcp/server.js +919 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +4 -0
- package/dist/remote-llm.d.ts +52 -0
- package/dist/remote-llm.js +464 -0
- package/dist/search/cjk-analyzer.d.ts +33 -0
- package/dist/search/cjk-analyzer.js +158 -0
- package/dist/search/cjk-index.d.ts +104 -0
- package/dist/search/cjk-index.js +1031 -0
- package/dist/search/jieba-loader.d.ts +23 -0
- package/dist/search/jieba-loader.js +79 -0
- package/dist/search/query-expansion.d.ts +23 -0
- package/dist/search/query-expansion.js +43 -0
- package/dist/search/zh-dict.txt +624013 -0
- package/dist/store.d.ts +1218 -0
- package/dist/store.js +6076 -0
- package/dist/trust.d.ts +152 -0
- package/dist/trust.js +249 -0
- package/package.json +139 -0
- package/scripts/build.mjs +83 -0
- package/scripts/check-package-grammars.mjs +29 -0
- package/scripts/package-smoke.mjs +205 -0
- package/scripts/sync-zh-dict.mjs +187 -0
- package/scripts/test-all.mjs +45 -0
- package/skills/qmd/SKILL.md +324 -0
- package/skills/qmd/references/mcp-setup.md +119 -0
- package/skills/release/SKILL.md +141 -0
- package/skills/release/scripts/install-hooks.sh +38 -0
- package/skills/release/scripts/release-context.sh +129 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD MCP Server - Model Context Protocol server for QMD
|
|
3
|
+
*
|
|
4
|
+
* Exposes QMD search and document retrieval as MCP tools and resources.
|
|
5
|
+
* Documents are accessible via qmd:// URIs.
|
|
6
|
+
*
|
|
7
|
+
* Speaks MCP spec 2026-07-28 (stateless, no initialize handshake) and dual-speaks
|
|
8
|
+
* 2025-era clients via the official SDK entries (`serveStdio` / `createMcpHandler`).
|
|
9
|
+
*/
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { createMcpHandler, McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
|
|
15
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { existsSync } from "fs";
|
|
18
|
+
import { createStore, extractSnippet, addLineNumbers, getDefaultDbPath, DEFAULT_MULTI_GET_MAX_BYTES, } from "../index.js";
|
|
19
|
+
import { getConfigPath } from "../collections.js";
|
|
20
|
+
import { enableProductionMode } from "../store.js";
|
|
21
|
+
import { checkRequestOrigin, resolveOriginGuard } from "./origin-guard.js";
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Helper functions
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Encode a path for use in qmd:// URIs.
|
|
27
|
+
* Encodes special characters but preserves forward slashes for readability.
|
|
28
|
+
*/
|
|
29
|
+
function encodeQmdPath(path) {
|
|
30
|
+
// Encode each path segment separately to preserve slashes
|
|
31
|
+
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Format search results as human-readable text summary
|
|
35
|
+
*/
|
|
36
|
+
function formatSearchSummary(results, query) {
|
|
37
|
+
if (results.length === 0) {
|
|
38
|
+
return `No results found for "${query}"`;
|
|
39
|
+
}
|
|
40
|
+
const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
|
|
41
|
+
for (const r of results) {
|
|
42
|
+
lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
function getPackageVersion() {
|
|
47
|
+
try {
|
|
48
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
|
|
49
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
50
|
+
return pkg.version ?? "unknown";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// MCP Server
|
|
58
|
+
// =============================================================================
|
|
59
|
+
/**
|
|
60
|
+
* Build dynamic server instructions from actual index state.
|
|
61
|
+
* Injected into the LLM's system prompt via MCP initialize (2025-era) and
|
|
62
|
+
* server/discover (2026-07-28) — gives the LLM immediate context about what's
|
|
63
|
+
* searchable without a tool call.
|
|
64
|
+
*/
|
|
65
|
+
async function buildInstructions(store) {
|
|
66
|
+
const status = await store.getStatus();
|
|
67
|
+
const globalCtx = await store.getGlobalContext();
|
|
68
|
+
const lines = [];
|
|
69
|
+
// --- What is this? ---
|
|
70
|
+
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
|
|
71
|
+
if (globalCtx)
|
|
72
|
+
lines.push(`Context: ${globalCtx}`);
|
|
73
|
+
// --- What's searchable? ---
|
|
74
|
+
// Emit names only — the per-collection doc counts and descriptions can run to ~1.5 KB
|
|
75
|
+
// across a dozen collections, and the same info is available on demand via the `status` tool.
|
|
76
|
+
if (status.collections.length > 0) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
const names = status.collections.map(c => c.name).join(", ");
|
|
79
|
+
lines.push(`Collections (scope with \`collections\` parameter): ${names}`);
|
|
80
|
+
lines.push("Call the `status` tool for collection descriptions, paths, and per-collection doc counts.");
|
|
81
|
+
}
|
|
82
|
+
// --- Capability gaps ---
|
|
83
|
+
if (!status.hasVectorIndex) {
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push("Note: No vector embeddings yet. Run `qmd embed` to enable semantic search (vec/hyde).");
|
|
86
|
+
}
|
|
87
|
+
else if (status.needsEmbedding > 0) {
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`);
|
|
90
|
+
}
|
|
91
|
+
// --- Search tool ---
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):");
|
|
94
|
+
lines.push(" - type:'lex' — BM25 keyword search (exact terms, fast)");
|
|
95
|
+
lines.push(" - type:'vec' — semantic vector search (meaning-based)");
|
|
96
|
+
lines.push(" - type:'hyde' — hypothetical document (write what the answer looks like)");
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push(" Use `rerankContext` to disambiguate results and improve snippets when needed.");
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push("Examples:");
|
|
101
|
+
lines.push(" Quick keyword lookup: [{type:'lex', query:'error handling'}]");
|
|
102
|
+
lines.push(" Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
|
|
103
|
+
lines.push(" Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
|
|
104
|
+
lines.push(" With context: searches=[{type:'lex', query:'performance'}], rerankContext='web page load times'");
|
|
105
|
+
// --- Retrieval workflow ---
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push("Retrieval:");
|
|
108
|
+
lines.push(" - `get` — single document by path or docid (#abc123). Supports a line-range suffix: `file.md:100` (from line 100) or `file.md:100:40` (40 lines from line 100).");
|
|
109
|
+
lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`), comma-separated list, or docids (#abc123).");
|
|
110
|
+
// --- Non-obvious things that prevent mistakes ---
|
|
111
|
+
lines.push("");
|
|
112
|
+
lines.push("Tips:");
|
|
113
|
+
lines.push(" - File paths in results are relative to their collection.");
|
|
114
|
+
lines.push(" - Use `minScore: 0.5` to filter low-confidence results.");
|
|
115
|
+
lines.push(" - Results include a `context` field describing the content type.");
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Create an MCP server with all QMD tools, resources, and prompts registered.
|
|
120
|
+
* Shared by both stdio and HTTP transports.
|
|
121
|
+
*/
|
|
122
|
+
async function createMcpServer(store, inflight) {
|
|
123
|
+
// Wraps request handlers so a stdio EOF shutdown can wait for in-flight
|
|
124
|
+
// work to settle before disposing the store/llm underneath it.
|
|
125
|
+
const track = inflight?.track ?? ((fn) => fn);
|
|
126
|
+
const server = new McpServer({ name: "qmd", version: getPackageVersion() }, {
|
|
127
|
+
instructions: await buildInstructions(store),
|
|
128
|
+
// tools/list is static for the process lifetime; resources/read stays
|
|
129
|
+
// uncacheable because the index can change under us.
|
|
130
|
+
cacheHints: {
|
|
131
|
+
"tools/list": { ttlMs: 60_000, cacheScope: "private" },
|
|
132
|
+
"server/discover": { ttlMs: 60_000, cacheScope: "private" },
|
|
133
|
+
"resources/read": { ttlMs: 0, cacheScope: "private" },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// Pre-fetch default collection names for search tools
|
|
137
|
+
const defaultCollectionNames = await store.getDefaultCollectionNames();
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Resource: qmd://{path} - read-only access to documents by path
|
|
140
|
+
// Note: No list() - documents are discovered via search tools
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
server.registerResource("document", new ResourceTemplate("qmd://{+path}", { list: undefined }), {
|
|
143
|
+
title: "QMD Document",
|
|
144
|
+
description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
|
|
145
|
+
mimeType: "text/markdown",
|
|
146
|
+
}, track(async (uri, { path }) => {
|
|
147
|
+
// Decode URL-encoded path (MCP clients send encoded URIs)
|
|
148
|
+
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
|
149
|
+
const decodedPath = decodeURIComponent(pathStr);
|
|
150
|
+
// Use SDK to find document — findDocument handles collection/path resolution
|
|
151
|
+
const result = await store.get(decodedPath, { includeBody: true });
|
|
152
|
+
if ("error" in result) {
|
|
153
|
+
const text = result.error === "excluded_by_ignore"
|
|
154
|
+
? `Document excluded by ignore rule: ${decodedPath}\nCollection: ${result.collection}\nMatched path: ${result.path}\nIgnore rule: ${result.rule}`
|
|
155
|
+
: `Document not found: ${decodedPath}`;
|
|
156
|
+
return { contents: [{ uri: uri.href, text }] };
|
|
157
|
+
}
|
|
158
|
+
let text = addLineNumbers(result.body || ""); // Default to line numbers
|
|
159
|
+
if (result.context) {
|
|
160
|
+
text = `<!-- Context: ${result.context} -->\n\n` + text;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
contents: [{
|
|
164
|
+
uri: uri.href,
|
|
165
|
+
name: result.displayPath,
|
|
166
|
+
title: result.title || result.displayPath,
|
|
167
|
+
mimeType: "text/markdown",
|
|
168
|
+
text,
|
|
169
|
+
}],
|
|
170
|
+
};
|
|
171
|
+
}));
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Tool: query (Primary search tool)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
const subSearchSchema = z.object({
|
|
176
|
+
type: z.enum(['lex', 'vec', 'hyde']).describe("lex = BM25 keywords (supports \"phrase\" and -negation); " +
|
|
177
|
+
"vec = semantic question; hyde = hypothetical answer passage"),
|
|
178
|
+
query: z.string().describe("The query text. For lex: use keywords, \"quoted phrases\", and -negation. " +
|
|
179
|
+
"For vec: natural language question. For hyde: 50-100 word answer passage."),
|
|
180
|
+
});
|
|
181
|
+
server.registerTool("query", {
|
|
182
|
+
title: "Query",
|
|
183
|
+
description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.
|
|
184
|
+
|
|
185
|
+
Each result includes a \`line\` field with the absolute 1-indexed line of the best match in the source markdown. To read more context around a hit, call \`get(file, fromLine = max(1, line - 20), maxLines = 80, lineNumbers = true)\`.
|
|
186
|
+
|
|
187
|
+
## Query Types
|
|
188
|
+
|
|
189
|
+
**lex** — BM25 keyword search. Fast, exact, no LLM needed.
|
|
190
|
+
Full lex syntax:
|
|
191
|
+
- \`term\` — prefix match ("perf" matches "performance")
|
|
192
|
+
- \`"exact phrase"\` — phrase must appear verbatim
|
|
193
|
+
- \`-term\` or \`-"phrase"\` — exclude documents containing this
|
|
194
|
+
|
|
195
|
+
Good lex examples:
|
|
196
|
+
- \`"connection pool" timeout -redis\`
|
|
197
|
+
- \`"machine learning" -sports -athlete\`
|
|
198
|
+
- \`handleError async typescript\`
|
|
199
|
+
|
|
200
|
+
**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.
|
|
201
|
+
- \`how does the rate limiter handle burst traffic?\`
|
|
202
|
+
- \`what is the tradeoff between consistency and availability?\`
|
|
203
|
+
|
|
204
|
+
**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.
|
|
205
|
+
- \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\`
|
|
206
|
+
|
|
207
|
+
## Strategy
|
|
208
|
+
|
|
209
|
+
Combine types for best results. First sub-query gets 2× weight — put your strongest signal first.
|
|
210
|
+
|
|
211
|
+
| Goal | Approach |
|
|
212
|
+
|------|----------|
|
|
213
|
+
| General search (recommended) | Pass \`query\` — evaluated by the shared expansion policy, fused, reranked |
|
|
214
|
+
| Know exact term/name | \`lex\` only |
|
|
215
|
+
| Concept search | \`vec\` only |
|
|
216
|
+
| Best recall | \`lex\` + \`vec\` |
|
|
217
|
+
| Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` |
|
|
218
|
+
| Unknown vocabulary | Pass \`query\` with natural language; use \`expansion: "force"\` when expansion must run |
|
|
219
|
+
|
|
220
|
+
## Examples
|
|
221
|
+
|
|
222
|
+
Simple lookup:
|
|
223
|
+
\`\`\`json
|
|
224
|
+
[{ "type": "lex", "query": "CAP theorem" }]
|
|
225
|
+
\`\`\`
|
|
226
|
+
|
|
227
|
+
Best recall on a technical topic:
|
|
228
|
+
\`\`\`json
|
|
229
|
+
[
|
|
230
|
+
{ "type": "lex", "query": "\\"connection pool\\" timeout -redis" },
|
|
231
|
+
{ "type": "vec", "query": "why do database connections time out under load" },
|
|
232
|
+
{ "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." }
|
|
233
|
+
]
|
|
234
|
+
\`\`\`
|
|
235
|
+
|
|
236
|
+
Context-aware lex (C++ performance, not sports):
|
|
237
|
+
\`\`\`json
|
|
238
|
+
[
|
|
239
|
+
{ "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" },
|
|
240
|
+
{ "type": "vec", "query": "how to optimize C++ program performance" }
|
|
241
|
+
]
|
|
242
|
+
\`\`\``,
|
|
243
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
244
|
+
inputSchema: z.object({
|
|
245
|
+
query: z.string().optional().describe("Plain-text query, expanded according to the shared expansion policy, fused via " +
|
|
246
|
+
"RRF and reranked. Recommended default for most searches. Mutually exclusive with 'searches'."),
|
|
247
|
+
expansion: z.enum(["auto", "force", "skip"]).optional().default("auto").describe("Query expansion policy: auto skips CJK/strong lexical matches, force always expands, skip never expands"),
|
|
248
|
+
searches: z.array(subSearchSchema).max(10).optional().describe("Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight. Use for precise " +
|
|
249
|
+
"control over retrieval strategy. Mutually exclusive with 'query'."),
|
|
250
|
+
limit: z.number().optional().default(10).describe("Max results (default: 10)"),
|
|
251
|
+
minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"),
|
|
252
|
+
candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"),
|
|
253
|
+
collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
|
|
254
|
+
expansionContext: z.string().optional().describe("Additional context used only to generate lex, vec, and hyde query expansions."),
|
|
255
|
+
rerankContext: z.string().optional().describe("Additional context used only to rerank results and select snippets/chunks."),
|
|
256
|
+
rerank: z.boolean().optional().default(true).describe("Rerank results using LLM (default: true). Set to false for faster results on CPU-only machines."),
|
|
257
|
+
explain: z.boolean().optional().default(false).describe("Include retrieval traces and the shared query-expansion decision or typed expansion error"),
|
|
258
|
+
}),
|
|
259
|
+
}, track(async ({ query, searches, expansion, limit, minScore, candidateLimit, collections, expansionContext, rerankContext, rerank, explain }) => {
|
|
260
|
+
// Require exactly one of `query` (plain text with an expansion policy) or `searches` (typed sub-queries).
|
|
261
|
+
if (!query && (!searches || searches.length === 0)) {
|
|
262
|
+
return {
|
|
263
|
+
content: [{ type: "text", text: "Error: provide either 'query' (plain text) or 'searches' (typed sub-queries)" }],
|
|
264
|
+
isError: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (query && searches && searches.length > 0) {
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text", text: "Error: 'query' and 'searches' are mutually exclusive; provide only one" }],
|
|
270
|
+
isError: true,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Use default collections if none specified
|
|
274
|
+
const effectiveCollections = collections ?? defaultCollectionNames;
|
|
275
|
+
// Plain `query` follows the requested SDK expansion policy before fusion and reranking;
|
|
276
|
+
// `searches` runs the caller's typed sub-queries directly.
|
|
277
|
+
const searchOptions = query
|
|
278
|
+
? { query }
|
|
279
|
+
: { queries: (searches ?? []).map(s => ({ type: s.type, query: s.query })) };
|
|
280
|
+
let expansionDecision;
|
|
281
|
+
let expansionError;
|
|
282
|
+
let results;
|
|
283
|
+
try {
|
|
284
|
+
results = await store.search({
|
|
285
|
+
...searchOptions,
|
|
286
|
+
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
287
|
+
limit,
|
|
288
|
+
minScore,
|
|
289
|
+
candidateLimit,
|
|
290
|
+
rerank,
|
|
291
|
+
expansionContext,
|
|
292
|
+
rerankContext,
|
|
293
|
+
explain,
|
|
294
|
+
expansion: query ? expansion : undefined,
|
|
295
|
+
hooks: explain && query ? {
|
|
296
|
+
onExpansionDecision: decision => { expansionDecision = decision; },
|
|
297
|
+
onExpansionError: event => { expansionError = event; },
|
|
298
|
+
} : undefined,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!expansionError)
|
|
303
|
+
throw error;
|
|
304
|
+
return {
|
|
305
|
+
content: [{ type: "text", text: `Query expansion failed: ${expansionError.reason}` }],
|
|
306
|
+
structuredContent: { results: [], expansionError },
|
|
307
|
+
isError: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// Use the plain query, or the first lex/vec sub-query, for snippet extraction
|
|
311
|
+
const primaryQuery = query
|
|
312
|
+
|| searches?.find(s => s.type === 'lex')?.query
|
|
313
|
+
|| searches?.find(s => s.type === 'vec')?.query
|
|
314
|
+
|| searches?.[0]?.query
|
|
315
|
+
|| "";
|
|
316
|
+
const filtered = results.map(r => {
|
|
317
|
+
const { line, snippet } = extractSnippet(r.body, primaryQuery, 300, r.bestChunkPos, r.bestChunk.length, rerankContext);
|
|
318
|
+
return {
|
|
319
|
+
docid: `#${r.docid}`,
|
|
320
|
+
file: r.displayPath,
|
|
321
|
+
title: r.title,
|
|
322
|
+
score: Math.round(r.score * 100) / 100,
|
|
323
|
+
context: r.context,
|
|
324
|
+
line,
|
|
325
|
+
snippet: addLineNumbers(snippet, line),
|
|
326
|
+
...(explain && r.explain ? { explain: r.explain } : {}),
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }],
|
|
331
|
+
structuredContent: {
|
|
332
|
+
results: filtered,
|
|
333
|
+
...(explain && expansionDecision ? { expansion: expansionDecision } : {}),
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}));
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Tool: qmd_get (Retrieve document)
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
server.registerTool("get", {
|
|
341
|
+
title: "Get Document",
|
|
342
|
+
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.",
|
|
343
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
344
|
+
inputSchema: z.object({
|
|
345
|
+
file: z.string().describe("File path or docid from search results. Supports a line-range suffix: 'pages/meeting.md:100' starts at line 100; 'pages/meeting.md:100:40' (or '#abc123:100:40') reads 40 lines from line 100."),
|
|
346
|
+
fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
|
|
347
|
+
maxLines: z.number().optional().describe("Maximum number of lines to return"),
|
|
348
|
+
lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."),
|
|
349
|
+
}),
|
|
350
|
+
}, track(async ({ file, fromLine, maxLines, lineNumbers }) => {
|
|
351
|
+
// Support :line and :from:count suffixes in `file` (e.g. "foo.md:120" or
|
|
352
|
+
// "foo.md:120:40"). Explicit fromLine/maxLines args take precedence.
|
|
353
|
+
let parsedFromLine = fromLine;
|
|
354
|
+
let parsedMaxLines = maxLines;
|
|
355
|
+
let lookup = file;
|
|
356
|
+
const rangeMatch = lookup.match(/:(\d+):(\d+)$/);
|
|
357
|
+
if (rangeMatch) {
|
|
358
|
+
if (parsedFromLine === undefined)
|
|
359
|
+
parsedFromLine = parseInt(rangeMatch[1], 10);
|
|
360
|
+
if (parsedMaxLines === undefined)
|
|
361
|
+
parsedMaxLines = parseInt(rangeMatch[2], 10);
|
|
362
|
+
lookup = lookup.slice(0, -rangeMatch[0].length);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const colonMatch = lookup.match(/:(\d+)$/);
|
|
366
|
+
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
|
|
367
|
+
parsedFromLine = parseInt(colonMatch[1], 10);
|
|
368
|
+
lookup = lookup.slice(0, -colonMatch[0].length);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (parsedFromLine !== undefined)
|
|
372
|
+
parsedFromLine = Math.max(1, parsedFromLine);
|
|
373
|
+
const result = await store.get(lookup, { includeBody: false });
|
|
374
|
+
if ("error" in result) {
|
|
375
|
+
let msg = result.error === "excluded_by_ignore"
|
|
376
|
+
? `Document excluded by ignore rule: ${file}\nCollection: ${result.collection}\nMatched path: ${result.path}\nIgnore rule: ${result.rule}`
|
|
377
|
+
: `Document not found: ${file}`;
|
|
378
|
+
if (result.error === "not_found" && result.similarFiles.length > 0) {
|
|
379
|
+
msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
content: [{ type: "text", text: msg }],
|
|
383
|
+
isError: true,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines: parsedMaxLines }) ?? "";
|
|
387
|
+
let text = body;
|
|
388
|
+
if (lineNumbers) {
|
|
389
|
+
const startLine = parsedFromLine || 1;
|
|
390
|
+
text = addLineNumbers(text, startLine);
|
|
391
|
+
}
|
|
392
|
+
if (result.context) {
|
|
393
|
+
text = `<!-- Context: ${result.context} -->\n\n` + text;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
content: [{
|
|
397
|
+
type: "resource",
|
|
398
|
+
resource: {
|
|
399
|
+
uri: `qmd://${encodeQmdPath(result.displayPath)}`,
|
|
400
|
+
name: result.displayPath,
|
|
401
|
+
title: result.title,
|
|
402
|
+
mimeType: "text/markdown",
|
|
403
|
+
text,
|
|
404
|
+
},
|
|
405
|
+
}],
|
|
406
|
+
};
|
|
407
|
+
}));
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Tool: qmd_multi_get (Retrieve multiple documents)
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
server.registerTool("multi_get", {
|
|
412
|
+
title: "Multi-Get Documents",
|
|
413
|
+
description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md'), comma-separated list, or docids. Skips files larger than maxBytes.",
|
|
414
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
415
|
+
inputSchema: z.object({
|
|
416
|
+
pattern: z.string().describe("Glob pattern, docid, or comma-separated list of file paths/docids"),
|
|
417
|
+
maxLines: z.number().optional().describe("Maximum lines per file"),
|
|
418
|
+
maxBytes: z.number().optional().default(DEFAULT_MULTI_GET_MAX_BYTES).describe("Skip files larger than this (default: 65536 = 64KB)"),
|
|
419
|
+
lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."),
|
|
420
|
+
}),
|
|
421
|
+
}, track(async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
|
422
|
+
const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
|
423
|
+
if (docs.length === 0 && errors.length === 0) {
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
|
|
426
|
+
isError: true,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const content = [];
|
|
430
|
+
if (errors.length > 0) {
|
|
431
|
+
content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
|
|
432
|
+
}
|
|
433
|
+
for (const result of docs) {
|
|
434
|
+
if (result.skipped) {
|
|
435
|
+
content.push({
|
|
436
|
+
type: "text",
|
|
437
|
+
text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
|
|
438
|
+
});
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
let text = result.doc.body || "";
|
|
442
|
+
if (maxLines !== undefined) {
|
|
443
|
+
const lines = text.split("\n");
|
|
444
|
+
text = lines.slice(0, maxLines).join("\n");
|
|
445
|
+
if (lines.length > maxLines) {
|
|
446
|
+
text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (lineNumbers) {
|
|
450
|
+
text = addLineNumbers(text);
|
|
451
|
+
}
|
|
452
|
+
if (result.doc.context) {
|
|
453
|
+
text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
|
|
454
|
+
}
|
|
455
|
+
content.push({
|
|
456
|
+
type: "resource",
|
|
457
|
+
resource: {
|
|
458
|
+
uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
|
|
459
|
+
name: result.doc.displayPath,
|
|
460
|
+
title: result.doc.title,
|
|
461
|
+
mimeType: "text/markdown",
|
|
462
|
+
text,
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return { content };
|
|
467
|
+
}));
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Tool: qmd_status (Index status)
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
server.registerTool("status", {
|
|
472
|
+
title: "Index Status",
|
|
473
|
+
description: "Show the status of the QMD index: collections, document counts, and health information.",
|
|
474
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
475
|
+
inputSchema: z.object({}),
|
|
476
|
+
}, track(async () => {
|
|
477
|
+
const status = await store.getStatus();
|
|
478
|
+
const summary = [
|
|
479
|
+
`QMD Index Status:`,
|
|
480
|
+
` Total documents: ${status.totalDocuments}`,
|
|
481
|
+
` Needs embedding: ${status.needsEmbedding}`,
|
|
482
|
+
` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
|
|
483
|
+
` Collections: ${status.collections.length}`,
|
|
484
|
+
];
|
|
485
|
+
if (status.diagnostics) {
|
|
486
|
+
summary.push(` Embedding: ${status.diagnostics.embedding.build.state}`);
|
|
487
|
+
summary.push(` CJK lexical: ${status.diagnostics.lexical.state}`);
|
|
488
|
+
}
|
|
489
|
+
for (const col of status.collections) {
|
|
490
|
+
summary.push(` - ${col.name}: ${col.path} (${col.documents} docs)`);
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
content: [{ type: "text", text: summary.join('\n') }],
|
|
494
|
+
structuredContent: status,
|
|
495
|
+
};
|
|
496
|
+
}));
|
|
497
|
+
return server;
|
|
498
|
+
}
|
|
499
|
+
export function createInflightGate() {
|
|
500
|
+
// `active` is a running-handler counter, not a closed admission barrier.
|
|
501
|
+
// The barrier comes from the caller's ordering: registerStdioEofShutdown
|
|
502
|
+
// runs closeServer() (which stops the transport from dispatching new
|
|
503
|
+
// requests) BEFORE waitForIdle(), so by the time we wait, the only handlers
|
|
504
|
+
// that can still be running are ones already dispatched — there is no source
|
|
505
|
+
// of late admissions to guard against under the stdio transport.
|
|
506
|
+
let active = 0;
|
|
507
|
+
const waiters = [];
|
|
508
|
+
return {
|
|
509
|
+
track(fn) {
|
|
510
|
+
const wrapped = async (...args) => {
|
|
511
|
+
active += 1;
|
|
512
|
+
try {
|
|
513
|
+
return await fn(...args);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
active -= 1;
|
|
517
|
+
if (active === 0) {
|
|
518
|
+
while (waiters.length > 0)
|
|
519
|
+
waiters.shift()();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
return wrapped;
|
|
524
|
+
},
|
|
525
|
+
waitForIdle(timeoutMs) {
|
|
526
|
+
if (active === 0)
|
|
527
|
+
return Promise.resolve(true);
|
|
528
|
+
return new Promise((resolve) => {
|
|
529
|
+
const onIdle = () => {
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
resolve(true);
|
|
532
|
+
};
|
|
533
|
+
const timer = setTimeout(() => {
|
|
534
|
+
const i = waiters.indexOf(onIdle);
|
|
535
|
+
if (i >= 0)
|
|
536
|
+
waiters.splice(i, 1);
|
|
537
|
+
resolve(false);
|
|
538
|
+
}, timeoutMs);
|
|
539
|
+
timer.unref?.();
|
|
540
|
+
waiters.push(onIdle);
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Shut the stdio MCP server down when stdin reaches EOF (#751).
|
|
547
|
+
*
|
|
548
|
+
* The SDK's StdioServerTransport subscribes to stdin "data"/"error" only and
|
|
549
|
+
* never notices "end"/"close". When the parent MCP client dies, nothing tears
|
|
550
|
+
* the process down: the warm llama.cpp model's native handles keep the event
|
|
551
|
+
* loop alive, so the server reparents to PID 1, leaks RAM, and keeps the
|
|
552
|
+
* SQLite index open. stdin EOF means the client is gone, so this treats it as
|
|
553
|
+
* a disconnect: no new requests are accepted and nobody is left to read a
|
|
554
|
+
* response — but handlers that are already running get a bounded window to
|
|
555
|
+
* settle (waitForIdle) before their llm/store dependencies are torn down.
|
|
556
|
+
*
|
|
557
|
+
* Teardown order matters. Close the transport first so no further requests
|
|
558
|
+
* are dispatched, wait for in-flight handlers, then close the store last —
|
|
559
|
+
* which disposes the store's own llama.cpp instance and then the database, so
|
|
560
|
+
* the dispose path cannot hit an already-closed DB. (disposeLlm is an optional
|
|
561
|
+
* extra step for callers that own a separate instance; the MCP store does
|
|
562
|
+
* not.) Failures are logged best-effort (the parent's death may have closed
|
|
563
|
+
* stderr too) and do not stop the remaining steps. The function sets process.exitCode
|
|
564
|
+
* instead of calling process.exit() so `beforeExit` still fires and
|
|
565
|
+
* node-llama-cpp's auto-dispose runs before libc's static destructors —
|
|
566
|
+
* process.exit() during native-addon unload has caused exit-time crashes
|
|
567
|
+
* before (#59, #129; same rationale as finishSuccessfulCliCommand in the CLI).
|
|
568
|
+
*
|
|
569
|
+
* Returns the idempotent shutdown function: every invocation (manual, "end",
|
|
570
|
+
* "close", or already-ended stdin) shares one promise, and the promise never
|
|
571
|
+
* rejects.
|
|
572
|
+
*/
|
|
573
|
+
export function registerStdioEofShutdown(options) {
|
|
574
|
+
const stdin = options.stdin ?? process.stdin;
|
|
575
|
+
const stderr = options.stderr ?? process.stderr;
|
|
576
|
+
const setExitCode = options.setExitCode ?? ((code) => { process.exitCode = code; });
|
|
577
|
+
const getExitCode = options.getExitCode ?? (() => (typeof process.exitCode === "number" ? process.exitCode : undefined));
|
|
578
|
+
let shutdownPromise = null;
|
|
579
|
+
// If the parent died, its stderr pipe may be gone: writes can throw
|
|
580
|
+
// synchronously or emit an async stream error. Logging must never take the
|
|
581
|
+
// teardown down with it.
|
|
582
|
+
stderr.on?.("error", () => { });
|
|
583
|
+
const safeWrite = (chunk) => {
|
|
584
|
+
try {
|
|
585
|
+
stderr.write(chunk);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// stderr went away with the parent
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const performShutdown = async () => {
|
|
592
|
+
try {
|
|
593
|
+
stdin.off("end", onStdinEof);
|
|
594
|
+
stdin.off("close", onStdinEof);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// an exotic stdin may throw on off(); shutdown continues regardless
|
|
598
|
+
}
|
|
599
|
+
// Same stderr breadcrumb style as the HTTP transport's SIGTERM/SIGINT
|
|
600
|
+
// handlers; also gives tests an observable signal that the EOF path ran.
|
|
601
|
+
safeWrite("Shutting down (stdin closed)...\n");
|
|
602
|
+
let failed = false;
|
|
603
|
+
const step = async (name, run) => {
|
|
604
|
+
try {
|
|
605
|
+
await run();
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
failed = true;
|
|
609
|
+
safeWrite(`QMD Warning: ${name} failed during stdio shutdown (${error instanceof Error ? error.message : String(error)}); continuing shutdown.\n`);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
await step("server.close()", options.closeServer);
|
|
613
|
+
if (options.waitForIdle) {
|
|
614
|
+
await step("in-flight drain", async () => {
|
|
615
|
+
const idle = await options.waitForIdle(options.idleTimeoutMs ?? 5000);
|
|
616
|
+
if (!idle) {
|
|
617
|
+
safeWrite("QMD Warning: in-flight request did not settle before the shutdown deadline; continuing shutdown.\n");
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (options.disposeLlm) {
|
|
622
|
+
await step("llama disposal", options.disposeLlm);
|
|
623
|
+
}
|
|
624
|
+
await step("store.close()", options.closeStore);
|
|
625
|
+
try {
|
|
626
|
+
const prior = getExitCode();
|
|
627
|
+
if (failed) {
|
|
628
|
+
setExitCode(1);
|
|
629
|
+
}
|
|
630
|
+
else if (prior === undefined || prior === 0) {
|
|
631
|
+
setExitCode(0);
|
|
632
|
+
}
|
|
633
|
+
// else: keep an earlier nonzero status instead of masking it
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// injected setExitCode/getExitCode must not break the shutdown promise
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const shutdown = () => (shutdownPromise ??= performShutdown());
|
|
640
|
+
const onStdinEof = () => { void shutdown().catch(() => { }); };
|
|
641
|
+
stdin.once("end", onStdinEof);
|
|
642
|
+
stdin.once("close", onStdinEof);
|
|
643
|
+
// The parent can die between spawn and listener registration; check the
|
|
644
|
+
// stream flags after subscribing so an already-ended stdin still shuts down.
|
|
645
|
+
if (stdin.readableEnded || stdin.destroyed) {
|
|
646
|
+
onStdinEof();
|
|
647
|
+
}
|
|
648
|
+
return shutdown;
|
|
649
|
+
}
|
|
650
|
+
export async function startMcpServer(options = {}) {
|
|
651
|
+
// Opt into production mode when the MCP server is actually started, not
|
|
652
|
+
// when this module is merely imported for its exports. Importing the module
|
|
653
|
+
// at the top level flipped the global production flag and broke test
|
|
654
|
+
// isolation for downstream suites that expect the default (development)
|
|
655
|
+
// database path behaviour.
|
|
656
|
+
enableProductionMode();
|
|
657
|
+
const configPath = getConfigPath();
|
|
658
|
+
const store = await createStore({
|
|
659
|
+
dbPath: options.dbPath ?? getDefaultDbPath(),
|
|
660
|
+
...(existsSync(configPath) ? { configPath } : {}),
|
|
661
|
+
readOnly: true,
|
|
662
|
+
});
|
|
663
|
+
const inflight = createInflightGate();
|
|
664
|
+
// serveStdio dual-speaks 2026-07-28 and 2025-era clients on one connection
|
|
665
|
+
// (opening exchange pins the era). A hand-wired StdioServerTransport would
|
|
666
|
+
// stay 2025-only even on SDK 2.x.
|
|
667
|
+
const handle = serveStdio(() => createMcpServer(store, inflight));
|
|
668
|
+
// Follow the parent's lifecycle: when stdin reaches EOF the client is gone
|
|
669
|
+
// and the server must exit instead of orphaning to PID 1 (#751). No
|
|
670
|
+
// disposeLlm here — store.close() disposes this store's own LlamaCpp
|
|
671
|
+
// instance, so passing the global disposeDefaultLlamaCpp would only risk
|
|
672
|
+
// tearing down an unrelated instance in an embedded process.
|
|
673
|
+
registerStdioEofShutdown({
|
|
674
|
+
closeServer: () => handle.close(),
|
|
675
|
+
waitForIdle: (timeoutMs) => inflight.waitForIdle(timeoutMs),
|
|
676
|
+
closeStore: () => store.close(),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Start MCP server over Streamable HTTP (JSON responses by default).
|
|
681
|
+
* Binds to `options.host` (default "localhost", overridable via the QMD_HOST
|
|
682
|
+
* env var) — set "0.0.0.0" to accept connections from other hosts, e.g. a
|
|
683
|
+
* container liveness probe. Returns a handle for shutdown and port discovery.
|
|
684
|
+
*
|
|
685
|
+
* HTTP is sessionless (MCP 2026-07-28): there is no `Mcp-Session-Id`, no
|
|
686
|
+
* initialize handshake, and no idle-session TTL. 2025-era clients are still
|
|
687
|
+
* served per-request via the SDK's stateless legacy fallback (initialize
|
|
688
|
+
* works as a standalone call; subsequent 2025 methods need a modern envelope
|
|
689
|
+
* or a stdio connection). The previous session reaper (#816) is gone because
|
|
690
|
+
* there are no sessions to reap.
|
|
691
|
+
*/
|
|
692
|
+
export async function startMcpHttpServer(port, options = {}) {
|
|
693
|
+
// See startMcpServer() for the rationale — flip production mode here so the
|
|
694
|
+
// HTTP transport resolves the real database path, without leaking state into
|
|
695
|
+
// callers that only import this module for its exports (e.g. tests).
|
|
696
|
+
enableProductionMode();
|
|
697
|
+
const configPath = getConfigPath();
|
|
698
|
+
const store = await createStore({
|
|
699
|
+
dbPath: options.dbPath ?? getDefaultDbPath(),
|
|
700
|
+
...(existsSync(configPath) ? { configPath } : {}),
|
|
701
|
+
readOnly: true,
|
|
702
|
+
});
|
|
703
|
+
// Pre-fetch default collection names for REST endpoint
|
|
704
|
+
const defaultCollectionNames = await store.getDefaultCollectionNames();
|
|
705
|
+
// Official 2026-07-28 HTTP entry: one factory, per-request instance, JSON
|
|
706
|
+
// responses (matches the previous enableJsonResponse: true). Dual-speaks
|
|
707
|
+
// 2025-era traffic statelessly by default (`legacy: "stateless"`).
|
|
708
|
+
const mcpHandler = createMcpHandler(() => createMcpServer(store), { responseMode: "json" });
|
|
709
|
+
const startTime = Date.now();
|
|
710
|
+
const quiet = options?.quiet ?? false;
|
|
711
|
+
/** Format timestamp for request logging */
|
|
712
|
+
function ts() {
|
|
713
|
+
return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
|
|
714
|
+
}
|
|
715
|
+
/** Extract a human-readable label from a JSON-RPC body */
|
|
716
|
+
function describeRequest(body) {
|
|
717
|
+
const method = typeof body.method === "string" ? body.method : "unknown";
|
|
718
|
+
if (method === "tools/call") {
|
|
719
|
+
const tool = body.params?.name ?? "?";
|
|
720
|
+
const args = body.params?.arguments;
|
|
721
|
+
// Show query string if present, truncated
|
|
722
|
+
if (args?.query) {
|
|
723
|
+
const q = String(args.query).slice(0, 80);
|
|
724
|
+
return `tools/call ${tool} "${q}"`;
|
|
725
|
+
}
|
|
726
|
+
if (args?.file)
|
|
727
|
+
return `tools/call ${tool} ${args.file}`;
|
|
728
|
+
if (args?.path)
|
|
729
|
+
return `tools/call ${tool} ${args.path}`;
|
|
730
|
+
if (args?.pattern)
|
|
731
|
+
return `tools/call ${tool} ${args.pattern}`;
|
|
732
|
+
return `tools/call ${tool}`;
|
|
733
|
+
}
|
|
734
|
+
return method;
|
|
735
|
+
}
|
|
736
|
+
function log(msg) {
|
|
737
|
+
if (!quiet)
|
|
738
|
+
console.error(msg);
|
|
739
|
+
}
|
|
740
|
+
function nodeHeadersToWeb(nodeReq) {
|
|
741
|
+
const headers = new Headers();
|
|
742
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
|
743
|
+
if (typeof v === "string")
|
|
744
|
+
headers.set(k, v);
|
|
745
|
+
else if (Array.isArray(v)) {
|
|
746
|
+
for (const item of v)
|
|
747
|
+
headers.append(k, item);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return headers;
|
|
751
|
+
}
|
|
752
|
+
// Helper to collect request body
|
|
753
|
+
async function collectBody(req) {
|
|
754
|
+
const chunks = [];
|
|
755
|
+
for await (const chunk of req)
|
|
756
|
+
chunks.push(chunk);
|
|
757
|
+
return Buffer.concat(chunks).toString();
|
|
758
|
+
}
|
|
759
|
+
const host = options.host ?? process.env.QMD_HOST ?? "localhost";
|
|
760
|
+
const bindHost = host === "localhost" ? "127.0.0.1" : host;
|
|
761
|
+
const originGuard = resolveOriginGuard({
|
|
762
|
+
host,
|
|
763
|
+
...(options.allowedOrigins ? { allowedOrigins: options.allowedOrigins } : {}),
|
|
764
|
+
...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}),
|
|
765
|
+
});
|
|
766
|
+
const httpServer = createServer(async (nodeReq, nodeRes) => {
|
|
767
|
+
const reqStart = Date.now();
|
|
768
|
+
const pathname = (nodeReq.url || "/").split("?")[0];
|
|
769
|
+
try {
|
|
770
|
+
// DNS-rebinding screen, ahead of routing so REST /query /search are
|
|
771
|
+
// covered too — they bypass the MCP transport entirely (#881).
|
|
772
|
+
const origin = nodeReq.headers.origin;
|
|
773
|
+
const hostHeader = nodeReq.headers.host;
|
|
774
|
+
const verdict = checkRequestOrigin({
|
|
775
|
+
origin: typeof origin === "string" ? origin : undefined,
|
|
776
|
+
host: typeof hostHeader === "string" ? hostHeader : undefined,
|
|
777
|
+
}, originGuard);
|
|
778
|
+
if (!verdict.ok) {
|
|
779
|
+
nodeRes.writeHead(403, { "Content-Type": "application/json" });
|
|
780
|
+
nodeRes.end(JSON.stringify({
|
|
781
|
+
jsonrpc: "2.0",
|
|
782
|
+
error: { code: -32003, message: `Forbidden: ${verdict.reason}` },
|
|
783
|
+
id: null,
|
|
784
|
+
}));
|
|
785
|
+
log(`${ts()} ${nodeReq.method} ${pathname} 403 — ${verdict.reason}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (pathname === "/health" && nodeReq.method === "GET") {
|
|
789
|
+
const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) });
|
|
790
|
+
nodeRes.writeHead(200, { "Content-Type": "application/json" });
|
|
791
|
+
nodeRes.end(body);
|
|
792
|
+
log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// REST endpoint: POST /search — structured search without MCP protocol
|
|
796
|
+
// REST endpoint: POST /query (alias: /search) — structured search without MCP protocol
|
|
797
|
+
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
|
|
798
|
+
const rawBody = await collectBody(nodeReq);
|
|
799
|
+
const params = JSON.parse(rawBody);
|
|
800
|
+
// Validate required fields
|
|
801
|
+
if (!params.searches || !Array.isArray(params.searches)) {
|
|
802
|
+
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
803
|
+
nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" }));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// Map to internal format
|
|
807
|
+
const searches = params.searches;
|
|
808
|
+
const queries = searches.map((s) => ({
|
|
809
|
+
type: s.type,
|
|
810
|
+
query: String(s.query || ""),
|
|
811
|
+
}));
|
|
812
|
+
// Use default collections if none specified
|
|
813
|
+
const effectiveCollections = Array.isArray(params.collections) ? params.collections.map(String) : defaultCollectionNames;
|
|
814
|
+
const results = await store.search({
|
|
815
|
+
queries,
|
|
816
|
+
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
817
|
+
limit: typeof params.limit === "number" ? params.limit : 10,
|
|
818
|
+
minScore: typeof params.minScore === "number" ? params.minScore : 0,
|
|
819
|
+
candidateLimit: typeof params.candidateLimit === "number" ? params.candidateLimit : undefined,
|
|
820
|
+
expansionContext: typeof params.expansionContext === "string" ? params.expansionContext : undefined,
|
|
821
|
+
rerankContext: typeof params.rerankContext === "string" ? params.rerankContext : undefined,
|
|
822
|
+
rerank: typeof params.rerank === "boolean" ? params.rerank : undefined,
|
|
823
|
+
});
|
|
824
|
+
// Use first lex or vec query for snippet extraction
|
|
825
|
+
const primaryQuery = searches.find((s) => s.type === 'lex')?.query
|
|
826
|
+
|| searches.find((s) => s.type === 'vec')?.query
|
|
827
|
+
|| searches[0]?.query || "";
|
|
828
|
+
const formatted = results.map(r => {
|
|
829
|
+
const { line, snippet } = extractSnippet(r.body, String(primaryQuery), 300, r.bestChunkPos, r.bestChunk.length, typeof params.rerankContext === "string" ? params.rerankContext : undefined);
|
|
830
|
+
return {
|
|
831
|
+
docid: `#${r.docid}`,
|
|
832
|
+
file: `qmd://${encodeQmdPath(r.displayPath)}`,
|
|
833
|
+
title: r.title,
|
|
834
|
+
score: Math.round(r.score * 100) / 100,
|
|
835
|
+
context: r.context,
|
|
836
|
+
line,
|
|
837
|
+
snippet: addLineNumbers(snippet, line),
|
|
838
|
+
};
|
|
839
|
+
});
|
|
840
|
+
nodeRes.writeHead(200, { "Content-Type": "application/json" });
|
|
841
|
+
nodeRes.end(JSON.stringify({ results: formatted }));
|
|
842
|
+
log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (pathname === "/mcp") {
|
|
846
|
+
const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD"
|
|
847
|
+
? await collectBody(nodeReq)
|
|
848
|
+
: undefined;
|
|
849
|
+
let parsedBody;
|
|
850
|
+
if (rawBody) {
|
|
851
|
+
try {
|
|
852
|
+
parsedBody = JSON.parse(rawBody);
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
parsedBody = undefined;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const label = parsedBody && typeof parsedBody === "object" && parsedBody !== null
|
|
859
|
+
? describeRequest(parsedBody)
|
|
860
|
+
: (nodeReq.method || "GET");
|
|
861
|
+
const hostHeader = typeof nodeReq.headers.host === "string" ? nodeReq.headers.host : `localhost:${port}`;
|
|
862
|
+
const url = `http://${hostHeader}${pathname}`;
|
|
863
|
+
const request = new Request(url, {
|
|
864
|
+
method: nodeReq.method || "GET",
|
|
865
|
+
headers: nodeHeadersToWeb(nodeReq),
|
|
866
|
+
...(rawBody !== undefined ? { body: rawBody } : {}),
|
|
867
|
+
});
|
|
868
|
+
const response = await mcpHandler.fetch(request, parsedBody !== undefined ? { parsedBody } : undefined);
|
|
869
|
+
nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
|
|
870
|
+
nodeRes.end(Buffer.from(await response.arrayBuffer()));
|
|
871
|
+
log(`${ts()} ${nodeReq.method} /mcp ${label} (${Date.now() - reqStart}ms)`);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
nodeRes.writeHead(404);
|
|
875
|
+
nodeRes.end("Not Found");
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
console.error("HTTP handler error:", err);
|
|
879
|
+
nodeRes.writeHead(500);
|
|
880
|
+
nodeRes.end("Internal Server Error");
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
await new Promise((resolve, reject) => {
|
|
884
|
+
httpServer.on("error", reject);
|
|
885
|
+
httpServer.listen(port, bindHost, () => resolve());
|
|
886
|
+
});
|
|
887
|
+
const actualPort = httpServer.address().port;
|
|
888
|
+
let stopping = false;
|
|
889
|
+
const stop = async () => {
|
|
890
|
+
if (stopping)
|
|
891
|
+
return;
|
|
892
|
+
stopping = true;
|
|
893
|
+
await mcpHandler.close();
|
|
894
|
+
httpServer.close();
|
|
895
|
+
await store.close();
|
|
896
|
+
};
|
|
897
|
+
process.on("SIGTERM", async () => {
|
|
898
|
+
console.error("Shutting down (SIGTERM)...");
|
|
899
|
+
await stop();
|
|
900
|
+
process.exit(0);
|
|
901
|
+
});
|
|
902
|
+
process.on("SIGINT", async () => {
|
|
903
|
+
console.error("Shutting down (SIGINT)...");
|
|
904
|
+
await stop();
|
|
905
|
+
process.exit(0);
|
|
906
|
+
});
|
|
907
|
+
log(`QMD MCP server listening on http://${host}:${actualPort}/mcp`);
|
|
908
|
+
if (originGuard.disabled) {
|
|
909
|
+
log("Warning: QMD_ALLOWED_ORIGINS=* — DNS-rebinding protection is off. Only do this behind your own authenticating proxy.");
|
|
910
|
+
}
|
|
911
|
+
else if (!originGuard.enforceHost) {
|
|
912
|
+
log(`Warning: bound to ${host} with no QMD_ALLOWED_HOSTS — Host validation is off and the index is readable by anyone who can reach this port.`);
|
|
913
|
+
}
|
|
914
|
+
return { httpServer, port: actualPort, stop };
|
|
915
|
+
}
|
|
916
|
+
// Run if this is the main module
|
|
917
|
+
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) {
|
|
918
|
+
startMcpServer().catch(console.error);
|
|
919
|
+
}
|