contextqmd-mcp 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/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +951 -0
- package/dist/lib/config.d.ts +12 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/doc-indexer.d.ts +116 -0
- package/dist/lib/doc-indexer.js +392 -0
- package/dist/lib/local-cache.d.ts +72 -0
- package/dist/lib/local-cache.js +210 -0
- package/dist/lib/registry-client.d.ts +37 -0
- package/dist/lib/registry-client.js +110 -0
- package/dist/lib/types.d.ts +113 -0
- package/dist/lib/types.js +2 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, posix, resolve as resolvePath } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { loadConfig } from "./lib/config.js";
|
|
12
|
+
import { RegistryClient } from "./lib/registry-client.js";
|
|
13
|
+
import { LocalCache, normalizeDocPath } from "./lib/local-cache.js";
|
|
14
|
+
import { DocIndexer } from "./lib/doc-indexer.js";
|
|
15
|
+
const VERSION = "0.1.0";
|
|
16
|
+
const DOC_INDEX_SCHEMA_VERSION = 2;
|
|
17
|
+
const DEFAULT_EXCERPT_MAX_LINES = 60;
|
|
18
|
+
const DEFAULT_EXPAND_BEFORE = 30;
|
|
19
|
+
const DEFAULT_EXPAND_AFTER = 60;
|
|
20
|
+
function textResult(text) {
|
|
21
|
+
return { content: [{ type: "text", text }] };
|
|
22
|
+
}
|
|
23
|
+
function structuredTextResult(text, structuredContent) {
|
|
24
|
+
return { content: [{ type: "text", text }], structuredContent };
|
|
25
|
+
}
|
|
26
|
+
function errorResult(text, code, details = {}) {
|
|
27
|
+
return {
|
|
28
|
+
isError: true,
|
|
29
|
+
content: [{ type: "text", text }],
|
|
30
|
+
structuredContent: {
|
|
31
|
+
error: {
|
|
32
|
+
code,
|
|
33
|
+
...details,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parseLibrary(library) {
|
|
39
|
+
const [namespace, name] = library.split("/");
|
|
40
|
+
return namespace && name ? { namespace, name } : null;
|
|
41
|
+
}
|
|
42
|
+
function librarySlug(namespace, name) {
|
|
43
|
+
return `${namespace}/${name}`;
|
|
44
|
+
}
|
|
45
|
+
function normalizeSha256(value) {
|
|
46
|
+
if (!value)
|
|
47
|
+
return null;
|
|
48
|
+
const normalized = value.trim().toLowerCase();
|
|
49
|
+
return normalized.startsWith("sha256:") ? normalized.slice("sha256:".length) : normalized;
|
|
50
|
+
}
|
|
51
|
+
function sha256Hex(input) {
|
|
52
|
+
return createHash("sha256").update(input).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
function installedVersions(cache, namespace, name) {
|
|
55
|
+
return cache
|
|
56
|
+
.listInstalled()
|
|
57
|
+
.filter(lib => lib.namespace === namespace && lib.name === name)
|
|
58
|
+
.map(lib => lib.version)
|
|
59
|
+
.sort();
|
|
60
|
+
}
|
|
61
|
+
function isSupportedBundle(bundle) {
|
|
62
|
+
const format = bundle.format.trim().toLowerCase();
|
|
63
|
+
return (format === "tar.gz" ||
|
|
64
|
+
format === "tgz" ||
|
|
65
|
+
format === "tar+gzip" ||
|
|
66
|
+
format === "application/gzip" ||
|
|
67
|
+
format === "application/x-gzip" ||
|
|
68
|
+
bundle.url.endsWith(".tar.gz") ||
|
|
69
|
+
bundle.url.endsWith(".tgz"));
|
|
70
|
+
}
|
|
71
|
+
function selectBundle(manifest) {
|
|
72
|
+
for (const profile of ["full", "slim"]) {
|
|
73
|
+
const bundle = manifest.profiles[profile]?.bundle;
|
|
74
|
+
if (bundle && isSupportedBundle(bundle)) {
|
|
75
|
+
return { profile, bundle };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function bundleArchiveFilename(namespace, name, version, profile, bundle) {
|
|
81
|
+
const suffix = bundle.url.endsWith(".tgz") ? ".tgz" : ".tar.gz";
|
|
82
|
+
return `${namespace}-${name}-${version}-${profile}${suffix}`;
|
|
83
|
+
}
|
|
84
|
+
function listBundleEntries(archivePath) {
|
|
85
|
+
const result = spawnSync("tar", ["-tvzf", archivePath], { encoding: "utf8" });
|
|
86
|
+
if (result.error) {
|
|
87
|
+
throw new Error(`Failed to inspect bundle archive: ${result.error.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (result.status !== 0) {
|
|
90
|
+
throw new Error(`Failed to inspect bundle archive: ${result.stderr.trim() || "tar exited non-zero"}`);
|
|
91
|
+
}
|
|
92
|
+
return result.stdout
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map(line => line.trim())
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((line) => {
|
|
97
|
+
const kind = line[0] ?? "";
|
|
98
|
+
const parts = line.split(/\s+/);
|
|
99
|
+
const path = parts.slice(8).join(" ").replace(/^\.\//, "");
|
|
100
|
+
return { kind, path };
|
|
101
|
+
})
|
|
102
|
+
.filter(entry => entry.path.length > 0);
|
|
103
|
+
}
|
|
104
|
+
function ensureSafeBundleEntries(entries) {
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const normalized = posix.normalize(entry.path);
|
|
107
|
+
if (normalized === ".." ||
|
|
108
|
+
normalized.startsWith("../") ||
|
|
109
|
+
normalized.includes("/../") ||
|
|
110
|
+
normalized.startsWith("/")) {
|
|
111
|
+
throw new Error(`Unsafe bundle entry: ${entry.path}`);
|
|
112
|
+
}
|
|
113
|
+
if (entry.kind !== "-" && entry.kind !== "d") {
|
|
114
|
+
throw new Error(`Unsupported bundle entry type for ${entry.path}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function extractBundleArchive(archivePath, destinationDir) {
|
|
119
|
+
const entries = listBundleEntries(archivePath);
|
|
120
|
+
ensureSafeBundleEntries(entries);
|
|
121
|
+
const result = spawnSync("tar", ["-xzf", archivePath, "-C", destinationDir], { encoding: "utf8" });
|
|
122
|
+
if (result.error) {
|
|
123
|
+
throw new Error(`Failed to extract bundle archive: ${result.error.message}`);
|
|
124
|
+
}
|
|
125
|
+
if (result.status !== 0) {
|
|
126
|
+
throw new Error(`Failed to extract bundle archive: ${result.stderr.trim() || "tar exited non-zero"}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function resolveExtractedDocsDir(extractionRoot) {
|
|
130
|
+
if (existsSync(join(extractionRoot, "manifest.json"))) {
|
|
131
|
+
return extractionRoot;
|
|
132
|
+
}
|
|
133
|
+
const dirs = readdirSync(extractionRoot, { withFileTypes: true })
|
|
134
|
+
.filter(entry => entry.isDirectory())
|
|
135
|
+
.map(entry => join(extractionRoot, entry.name));
|
|
136
|
+
if (dirs.length === 1 && existsSync(join(dirs[0], "manifest.json"))) {
|
|
137
|
+
return dirs[0];
|
|
138
|
+
}
|
|
139
|
+
throw new Error("Bundle archive did not extract to the expected docs layout");
|
|
140
|
+
}
|
|
141
|
+
function bundlePageEntry(pageUid) {
|
|
142
|
+
const normalized = pageUid.trim();
|
|
143
|
+
if (normalized.length === 0 ||
|
|
144
|
+
normalized.startsWith("/") ||
|
|
145
|
+
normalized.includes("\\") ||
|
|
146
|
+
normalized.split("/").includes("..")) {
|
|
147
|
+
throw new Error(`Unsafe bundle page_uid: ${pageUid}`);
|
|
148
|
+
}
|
|
149
|
+
return `${normalized}.md`;
|
|
150
|
+
}
|
|
151
|
+
function expectedBundlePageEntry(page) {
|
|
152
|
+
const bundlePath = page.bundle_path?.trim();
|
|
153
|
+
if (bundlePath) {
|
|
154
|
+
const normalized = posix.normalize(bundlePath);
|
|
155
|
+
if (normalized === ".." ||
|
|
156
|
+
normalized.startsWith("../") ||
|
|
157
|
+
normalized.includes("/../") ||
|
|
158
|
+
normalized.startsWith("/") ||
|
|
159
|
+
!normalized.endsWith(".md")) {
|
|
160
|
+
throw new Error(`Unsafe bundle page path: ${bundlePath}`);
|
|
161
|
+
}
|
|
162
|
+
return normalized;
|
|
163
|
+
}
|
|
164
|
+
return bundlePageEntry(page.page_uid);
|
|
165
|
+
}
|
|
166
|
+
function materializeLocalPageLayout(stagedDocsDir, pageIndex) {
|
|
167
|
+
const pagesDir = join(stagedDocsDir, "pages");
|
|
168
|
+
for (const page of pageIndex) {
|
|
169
|
+
const sourcePath = join(pagesDir, expectedBundlePageEntry(page));
|
|
170
|
+
const targetPath = join(pagesDir, bundlePageEntry(page.page_uid));
|
|
171
|
+
if (sourcePath === targetPath)
|
|
172
|
+
continue;
|
|
173
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
174
|
+
renameSync(sourcePath, targetPath);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function assertSafeExtractedTree(path) {
|
|
178
|
+
const stat = lstatSync(path);
|
|
179
|
+
if (stat.isSymbolicLink()) {
|
|
180
|
+
throw new Error(`Bundle archive contains an unsupported symlink: ${path}`);
|
|
181
|
+
}
|
|
182
|
+
if (!(stat.isDirectory() || stat.isFile())) {
|
|
183
|
+
throw new Error(`Bundle archive contains an unsupported entry type: ${path}`);
|
|
184
|
+
}
|
|
185
|
+
if (!stat.isDirectory()) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
189
|
+
assertSafeExtractedTree(join(path, entry.name));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function listMarkdownFiles(path, relativeDir = "") {
|
|
193
|
+
const files = [];
|
|
194
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
195
|
+
const nextRelative = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
196
|
+
const nextPath = join(path, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
files.push(...listMarkdownFiles(nextPath, nextRelative));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
202
|
+
files.push(nextRelative.replace(/\\/g, "/"));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return files.sort();
|
|
206
|
+
}
|
|
207
|
+
function validateExtractedBundle(stagedDocsDir) {
|
|
208
|
+
const manifestPath = join(stagedDocsDir, "manifest.json");
|
|
209
|
+
const pageIndexPath = join(stagedDocsDir, "page-index.json");
|
|
210
|
+
const pagesDir = join(stagedDocsDir, "pages");
|
|
211
|
+
assertSafeExtractedTree(stagedDocsDir);
|
|
212
|
+
if (!existsSync(manifestPath)) {
|
|
213
|
+
throw new Error("Bundle archive is missing manifest.json");
|
|
214
|
+
}
|
|
215
|
+
if (!existsSync(pageIndexPath)) {
|
|
216
|
+
throw new Error("Bundle archive is missing page-index.json");
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(pagesDir)) {
|
|
219
|
+
throw new Error("Bundle archive is missing pages/");
|
|
220
|
+
}
|
|
221
|
+
const pageIndex = JSON.parse(readFileSync(pageIndexPath, "utf8"));
|
|
222
|
+
if (!Array.isArray(pageIndex)) {
|
|
223
|
+
throw new Error("Bundle page-index.json is not an array");
|
|
224
|
+
}
|
|
225
|
+
const expectedFiles = pageIndex.map(page => expectedBundlePageEntry(page)).sort();
|
|
226
|
+
const actualFiles = listMarkdownFiles(pagesDir);
|
|
227
|
+
const missingFiles = expectedFiles.filter(file => !actualFiles.includes(file));
|
|
228
|
+
const extraFiles = actualFiles.filter(file => !expectedFiles.includes(file));
|
|
229
|
+
if (missingFiles.length > 0 || extraFiles.length > 0) {
|
|
230
|
+
throw new Error(`Bundle archive page set does not match page-index.json (missing: ${missingFiles.join(", ") || "none"}; extra: ${extraFiles.join(", ") || "none"})`);
|
|
231
|
+
}
|
|
232
|
+
materializeLocalPageLayout(stagedDocsDir, pageIndex);
|
|
233
|
+
return { pageCount: actualFiles.length };
|
|
234
|
+
}
|
|
235
|
+
async function ensureCurrentIndexSchema({ cache, indexer }, library, version) {
|
|
236
|
+
const installed = cache.listInstalled().filter(lib => {
|
|
237
|
+
const matchesLibrary = !library || `${lib.namespace}/${lib.name}` === library;
|
|
238
|
+
const matchesVersion = !version || lib.version === version;
|
|
239
|
+
return matchesLibrary && matchesVersion;
|
|
240
|
+
});
|
|
241
|
+
for (const lib of installed) {
|
|
242
|
+
if ((lib.index_schema_version ?? 0) >= DOC_INDEX_SCHEMA_VERSION)
|
|
243
|
+
continue;
|
|
244
|
+
await indexer.removeLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
245
|
+
await indexer.indexLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
246
|
+
cache.addInstalled({
|
|
247
|
+
...lib,
|
|
248
|
+
page_count: cache.countPages(lib.namespace, lib.name, lib.version),
|
|
249
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function resolveCachedPage(cache, input) {
|
|
254
|
+
const parsed = parseLibrary(input.library);
|
|
255
|
+
if (!parsed)
|
|
256
|
+
return null;
|
|
257
|
+
const { namespace, name } = parsed;
|
|
258
|
+
const hasDocPath = typeof input.doc_path === "string";
|
|
259
|
+
const hasPageUid = typeof input.page_uid === "string";
|
|
260
|
+
if ((hasDocPath ? 1 : 0) + (hasPageUid ? 1 : 0) !== 1) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (hasDocPath) {
|
|
264
|
+
const page = cache.findPageByPath(namespace, name, input.version, input.doc_path);
|
|
265
|
+
if (!page)
|
|
266
|
+
return null;
|
|
267
|
+
const content = cache.readPage(namespace, name, input.version, page.page_uid);
|
|
268
|
+
return {
|
|
269
|
+
library: input.library,
|
|
270
|
+
version: input.version,
|
|
271
|
+
pageUid: page.page_uid,
|
|
272
|
+
docPath: normalizeDocPath(page.path),
|
|
273
|
+
title: page.title,
|
|
274
|
+
url: page.url,
|
|
275
|
+
content,
|
|
276
|
+
hydrationState: content === null ? "missing_content" : "ready",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const pageUid = input.page_uid;
|
|
280
|
+
const page = cache.findPageByUid(namespace, name, input.version, pageUid);
|
|
281
|
+
const content = cache.readPage(namespace, name, input.version, pageUid);
|
|
282
|
+
if (!page && content === null)
|
|
283
|
+
return null;
|
|
284
|
+
return {
|
|
285
|
+
library: input.library,
|
|
286
|
+
version: input.version,
|
|
287
|
+
pageUid,
|
|
288
|
+
docPath: normalizeDocPath(page?.path ?? `${pageUid}.md`),
|
|
289
|
+
title: page?.title ?? extractTitle(content ?? "", pageUid),
|
|
290
|
+
url: page?.url,
|
|
291
|
+
content,
|
|
292
|
+
hydrationState: content === null ? "missing_content" : "ready",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function buildExcerpt(page, { fromLine = 1, maxLines = DEFAULT_EXCERPT_MAX_LINES, lineNumbers = false, }) {
|
|
296
|
+
const lines = (page.content ?? "").split("\n");
|
|
297
|
+
const totalLines = lines.length;
|
|
298
|
+
const clampedStart = totalLines === 0
|
|
299
|
+
? 1
|
|
300
|
+
: Math.min(Math.max(Math.trunc(fromLine), 1), totalLines);
|
|
301
|
+
const safeMaxLines = Math.max(Math.trunc(maxLines), 1);
|
|
302
|
+
const endExclusive = totalLines === 0
|
|
303
|
+
? 0
|
|
304
|
+
: Math.min(clampedStart - 1 + safeMaxLines, totalLines);
|
|
305
|
+
const excerptLines = totalLines === 0
|
|
306
|
+
? []
|
|
307
|
+
: lines.slice(clampedStart - 1, endExclusive);
|
|
308
|
+
const renderedLines = lineNumbers
|
|
309
|
+
? excerptLines.map((line, index) => `${clampedStart + index} | ${line}`)
|
|
310
|
+
: excerptLines;
|
|
311
|
+
const lineEnd = excerptLines.length === 0 ? clampedStart - 1 : clampedStart + excerptLines.length - 1;
|
|
312
|
+
return structuredTextResult(renderedLines.join("\n"), {
|
|
313
|
+
library: page.library,
|
|
314
|
+
version: page.version,
|
|
315
|
+
doc_path: page.docPath,
|
|
316
|
+
page_uid: page.pageUid,
|
|
317
|
+
title: page.title,
|
|
318
|
+
line_start: clampedStart,
|
|
319
|
+
line_end: lineEnd,
|
|
320
|
+
truncated: endExclusive < totalLines,
|
|
321
|
+
...(page.url ? { url: page.url } : {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
function resolveExcerptWindow(input) {
|
|
325
|
+
const hasAroundLine = typeof input.around_line === "number";
|
|
326
|
+
const hasFromLine = typeof input.from_line === "number";
|
|
327
|
+
const hasWindowBounds = typeof input.before === "number" || typeof input.after === "number";
|
|
328
|
+
if (hasAroundLine && hasFromLine) {
|
|
329
|
+
return errorResult("Use either around_line or from_line/max_lines, not both.", "INVALID_RANGE");
|
|
330
|
+
}
|
|
331
|
+
if (!hasAroundLine && hasWindowBounds) {
|
|
332
|
+
return errorResult("before/after can only be used with around_line.", "INVALID_RANGE");
|
|
333
|
+
}
|
|
334
|
+
if (hasAroundLine) {
|
|
335
|
+
const aroundLine = Math.max(Math.trunc(input.around_line ?? 1), 1);
|
|
336
|
+
const before = Math.max(Math.trunc(input.before ?? DEFAULT_EXPAND_BEFORE), 0);
|
|
337
|
+
const after = Math.max(Math.trunc(input.after ?? DEFAULT_EXPAND_AFTER), 0);
|
|
338
|
+
return {
|
|
339
|
+
fromLine: Math.max(1, aroundLine - before),
|
|
340
|
+
maxLines: before + after + 1,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
fromLine: input.from_line,
|
|
345
|
+
maxLines: input.max_lines,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function installFromPageApi(deps, namespace, name, version, manifest) {
|
|
349
|
+
const { cache, registryClient } = deps;
|
|
350
|
+
cache.saveManifest(namespace, name, version, manifest);
|
|
351
|
+
const allPages = await registryClient.getAllPageIndex(namespace, name, version);
|
|
352
|
+
cache.savePageIndex(namespace, name, version, allPages);
|
|
353
|
+
let downloadedCount = 0;
|
|
354
|
+
for (const page of allPages) {
|
|
355
|
+
const pageContent = await registryClient.getPageContent(namespace, name, version, page.page_uid);
|
|
356
|
+
cache.savePage(namespace, name, version, page.page_uid, pageContent.data.content_md);
|
|
357
|
+
downloadedCount++;
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
installMethod: "page_fallback",
|
|
361
|
+
profile: "full",
|
|
362
|
+
pageCount: downloadedCount,
|
|
363
|
+
totalPages: allPages.length,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async function installFromBundle(deps, namespace, name, version, manifest, selectedBundle) {
|
|
367
|
+
const { cache, registryClient } = deps;
|
|
368
|
+
const archivePath = cache.createTempArchivePath(namespace, name, version, bundleArchiveFilename(namespace, name, version, selectedBundle.profile, selectedBundle.bundle));
|
|
369
|
+
const archiveDir = dirname(archivePath);
|
|
370
|
+
const extractionRoot = join(archiveDir, "extracted");
|
|
371
|
+
try {
|
|
372
|
+
const expectedSha = normalizeSha256(selectedBundle.bundle.sha256);
|
|
373
|
+
let bundleBytes = await registryClient.downloadBundle(selectedBundle.bundle.url);
|
|
374
|
+
let actualSha = sha256Hex(bundleBytes);
|
|
375
|
+
if (expectedSha && actualSha !== expectedSha) {
|
|
376
|
+
bundleBytes = await registryClient.downloadBundle(selectedBundle.bundle.url);
|
|
377
|
+
actualSha = sha256Hex(bundleBytes);
|
|
378
|
+
if (actualSha !== expectedSha) {
|
|
379
|
+
throw new Error(`Bundle checksum mismatch for ${namespace}/${name}@${version}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
writeFileSync(archivePath, bundleBytes);
|
|
383
|
+
mkdirSync(extractionRoot, { recursive: true });
|
|
384
|
+
extractBundleArchive(archivePath, extractionRoot);
|
|
385
|
+
const stagedDocsDir = resolveExtractedDocsDir(extractionRoot);
|
|
386
|
+
const { pageCount } = validateExtractedBundle(stagedDocsDir);
|
|
387
|
+
writeFileSync(join(stagedDocsDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
388
|
+
cache.commitStagedVersion(namespace, name, version, stagedDocsDir);
|
|
389
|
+
return {
|
|
390
|
+
installMethod: "bundle",
|
|
391
|
+
profile: selectedBundle.profile,
|
|
392
|
+
pageCount,
|
|
393
|
+
totalPages: pageCount,
|
|
394
|
+
bundleFormat: selectedBundle.bundle.format,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
cache.cleanupTempPath(archiveDir);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function installResolvedVersion(deps, namespace, name, version, manifest) {
|
|
402
|
+
const { cache, indexer } = deps;
|
|
403
|
+
const resolvedManifest = manifest ?? (await deps.registryClient.getManifest(namespace, name, version)).data;
|
|
404
|
+
const manifestChecksum = resolvedManifest.provenance?.manifest_checksum ?? null;
|
|
405
|
+
const existingInstall = cache.findInstalled(namespace, name, version);
|
|
406
|
+
const backupDir = existingInstall ? cache.backupVersion(namespace, name, version) : null;
|
|
407
|
+
try {
|
|
408
|
+
let install;
|
|
409
|
+
let bundleFallbackReason;
|
|
410
|
+
const selectedBundle = selectBundle(resolvedManifest);
|
|
411
|
+
if (selectedBundle) {
|
|
412
|
+
try {
|
|
413
|
+
install = await installFromBundle(deps, namespace, name, version, resolvedManifest, selectedBundle);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
bundleFallbackReason = error.message;
|
|
417
|
+
install = await installFromPageApi(deps, namespace, name, version, resolvedManifest);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
install = await installFromPageApi(deps, namespace, name, version, resolvedManifest);
|
|
422
|
+
}
|
|
423
|
+
const indexedCount = await indexer.indexLibraryVersion(namespace, name, version);
|
|
424
|
+
if (backupDir) {
|
|
425
|
+
cache.discardBackup(backupDir);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
...install,
|
|
429
|
+
indexedCount,
|
|
430
|
+
manifestChecksum,
|
|
431
|
+
...(bundleFallbackReason ? { bundleFallbackReason } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
await indexer.removeLibraryVersion(namespace, name, version);
|
|
436
|
+
if (backupDir) {
|
|
437
|
+
cache.restoreVersionFromBackup(namespace, name, version, backupDir);
|
|
438
|
+
}
|
|
439
|
+
if (existingInstall) {
|
|
440
|
+
cache.addInstalled({
|
|
441
|
+
...existingInstall,
|
|
442
|
+
page_count: cache.countPages(namespace, name, version),
|
|
443
|
+
index_schema_version: 0,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
cache.removeVersion(namespace, name, version);
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
export async function handleSearchLibraries(deps, input) {
|
|
453
|
+
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 5), 1), 20);
|
|
454
|
+
const response = await deps.registryClient.searchLibraries(input.query);
|
|
455
|
+
const matches = response.data.slice(0, limit);
|
|
456
|
+
if (matches.length === 0) {
|
|
457
|
+
return structuredTextResult(`No libraries found for "${input.query}".`, {
|
|
458
|
+
query: input.query,
|
|
459
|
+
results: [],
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const versionResponses = await Promise.allSettled(matches.map(match => deps.registryClient.getVersions(match.namespace, match.name)));
|
|
463
|
+
const results = matches.map((match, index) => {
|
|
464
|
+
const versionResponse = versionResponses[index];
|
|
465
|
+
const versions = versionResponse?.status === "fulfilled"
|
|
466
|
+
? versionResponse.value.data.map(version => version.version)
|
|
467
|
+
: (match.default_version ? [match.default_version] : []);
|
|
468
|
+
const localVersions = installedVersions(deps.cache, match.namespace, match.name);
|
|
469
|
+
return {
|
|
470
|
+
library: librarySlug(match.namespace, match.name),
|
|
471
|
+
display_name: match.display_name,
|
|
472
|
+
namespace: match.namespace,
|
|
473
|
+
name: match.name,
|
|
474
|
+
aliases: match.aliases,
|
|
475
|
+
homepage_url: match.homepage_url,
|
|
476
|
+
default_version: match.default_version,
|
|
477
|
+
source_type: match.source_type,
|
|
478
|
+
license_status: match.license_status,
|
|
479
|
+
version_count: match.version_count ?? versions.length,
|
|
480
|
+
versions,
|
|
481
|
+
installed_versions: localVersions,
|
|
482
|
+
installed: localVersions.length > 0,
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
const lines = results.map(result => {
|
|
486
|
+
const installHint = result.installed_versions.length > 0
|
|
487
|
+
? `installed: ${result.installed_versions.join(", ")}`
|
|
488
|
+
: `install: install_docs({ library: "${result.library}", version: "${result.default_version}" })`;
|
|
489
|
+
const source = result.source_type ? ` | source: ${result.source_type}` : "";
|
|
490
|
+
const license = result.license_status ? ` | license: ${result.license_status}` : "";
|
|
491
|
+
return `- ${result.library} (${result.display_name}) | default: ${result.default_version} | versions: ${result.versions.join(", ") || "unknown"}${source}${license} | ${installHint}`;
|
|
492
|
+
});
|
|
493
|
+
return structuredTextResult(lines.join("\n"), {
|
|
494
|
+
query: input.query,
|
|
495
|
+
results,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
export async function handleInstallDocs(deps, input) {
|
|
499
|
+
const resolved = await deps.registryClient.resolve({
|
|
500
|
+
query: input.library,
|
|
501
|
+
version_hint: input.version,
|
|
502
|
+
});
|
|
503
|
+
const { namespace, name } = resolved.data.library;
|
|
504
|
+
const canonicalLibrary = librarySlug(namespace, name);
|
|
505
|
+
const targetVersion = resolved.data.version.version;
|
|
506
|
+
const manifest = (await deps.registryClient.getManifest(namespace, name, targetVersion)).data;
|
|
507
|
+
const targetChecksum = manifest.provenance?.manifest_checksum ?? null;
|
|
508
|
+
const existing = deps.cache.findInstalled(namespace, name, targetVersion);
|
|
509
|
+
if (existing) {
|
|
510
|
+
if (existing.manifest_checksum === targetChecksum) {
|
|
511
|
+
return structuredTextResult(`${canonicalLibrary}@${targetVersion} is already installed and current (${existing.page_count} pages, ${existing.profile} mode).`, {
|
|
512
|
+
library: canonicalLibrary,
|
|
513
|
+
version: targetVersion,
|
|
514
|
+
changed: false,
|
|
515
|
+
installed: true,
|
|
516
|
+
manifest_checksum: targetChecksum,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const outcome = await installResolvedVersion(deps, namespace, name, targetVersion, manifest);
|
|
521
|
+
deps.cache.addInstalled({
|
|
522
|
+
namespace,
|
|
523
|
+
name,
|
|
524
|
+
version: targetVersion,
|
|
525
|
+
profile: outcome.profile,
|
|
526
|
+
installed_at: new Date().toISOString(),
|
|
527
|
+
manifest_checksum: outcome.manifestChecksum,
|
|
528
|
+
page_count: outcome.pageCount,
|
|
529
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
530
|
+
});
|
|
531
|
+
const installLine = outcome.installMethod === "bundle"
|
|
532
|
+
? ` Installed from bundle (${outcome.profile}, ${outcome.bundleFormat ?? "tar.gz"})`
|
|
533
|
+
: ` Installed from page API fallback (${outcome.pageCount}/${outcome.totalPages ?? outcome.pageCount} pages)`;
|
|
534
|
+
const fallbackLine = outcome.bundleFallbackReason
|
|
535
|
+
? `\n Bundle fallback: ${outcome.bundleFallbackReason}`
|
|
536
|
+
: "";
|
|
537
|
+
const actionLine = existing ? "Reinstalled" : "Installed";
|
|
538
|
+
return structuredTextResult(`${actionLine} ${canonicalLibrary}@${targetVersion}\n` +
|
|
539
|
+
`${installLine}\n` +
|
|
540
|
+
` Indexed: ${outcome.indexedCount} pages for search${fallbackLine}`, {
|
|
541
|
+
library: canonicalLibrary,
|
|
542
|
+
version: targetVersion,
|
|
543
|
+
changed: true,
|
|
544
|
+
reinstall: Boolean(existing),
|
|
545
|
+
install_method: outcome.installMethod,
|
|
546
|
+
profile: outcome.profile,
|
|
547
|
+
page_count: outcome.pageCount,
|
|
548
|
+
indexed_count: outcome.indexedCount,
|
|
549
|
+
manifest_checksum: outcome.manifestChecksum,
|
|
550
|
+
...(outcome.bundleFormat ? { bundle_format: outcome.bundleFormat } : {}),
|
|
551
|
+
...(outcome.bundleFallbackReason ? { bundle_fallback_reason: outcome.bundleFallbackReason } : {}),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
export async function handleSearchDocs(deps, input) {
|
|
555
|
+
const { cache, indexer } = deps;
|
|
556
|
+
const installed = cache.listInstalled();
|
|
557
|
+
if (input.library) {
|
|
558
|
+
const parsed = parseLibrary(input.library);
|
|
559
|
+
if (!parsed) {
|
|
560
|
+
return errorResult("library must be in namespace/name format (for example 'vercel/nextjs').", "INVALID_LIBRARY");
|
|
561
|
+
}
|
|
562
|
+
const matchingVersions = installedVersions(cache, parsed.namespace, parsed.name);
|
|
563
|
+
const requestedInstalled = input.version
|
|
564
|
+
? matchingVersions.includes(input.version)
|
|
565
|
+
: matchingVersions.length > 0;
|
|
566
|
+
if (!requestedInstalled) {
|
|
567
|
+
return errorResult(input.version
|
|
568
|
+
? `${input.library}@${input.version} is not installed.`
|
|
569
|
+
: `${input.library} is not installed.`, "NOT_INSTALLED", {
|
|
570
|
+
library: input.library,
|
|
571
|
+
...(input.version ? { version: input.version } : {}),
|
|
572
|
+
installed_versions: matchingVersions,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
else if (installed.length === 0) {
|
|
577
|
+
return structuredTextResult("No documentation packages installed. Use install_docs first.", { results: [] });
|
|
578
|
+
}
|
|
579
|
+
await ensureCurrentIndexSchema(deps, input.library, input.version);
|
|
580
|
+
const results = await indexer.search(input.query, {
|
|
581
|
+
library: input.library,
|
|
582
|
+
version: input.version,
|
|
583
|
+
maxResults: input.max_results ?? 5,
|
|
584
|
+
mode: input.mode ?? "auto",
|
|
585
|
+
});
|
|
586
|
+
if (results.length === 0) {
|
|
587
|
+
return structuredTextResult(`No results found for "${input.query}"${input.library ? ` in ${input.library}` : ""}.`, {
|
|
588
|
+
query: input.query,
|
|
589
|
+
results: [],
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const usedMode = results[0]?.searchMode ?? "fts";
|
|
593
|
+
const summary = `Search: ${usedMode} | ${results.length} page-level local results`;
|
|
594
|
+
return structuredTextResult(summary, {
|
|
595
|
+
results: results.map(result => ({
|
|
596
|
+
library: result.library,
|
|
597
|
+
version: result.version,
|
|
598
|
+
doc_path: result.docPath,
|
|
599
|
+
page_uid: result.pageUid,
|
|
600
|
+
title: result.title,
|
|
601
|
+
content_md: result.contentMd,
|
|
602
|
+
score: result.score,
|
|
603
|
+
snippet: result.snippet,
|
|
604
|
+
line_start: result.lineStart,
|
|
605
|
+
line_end: result.lineEnd,
|
|
606
|
+
search_mode: result.searchMode,
|
|
607
|
+
...(result.url ? { url: result.url } : {}),
|
|
608
|
+
})),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
export async function handleGetDoc(deps, input) {
|
|
612
|
+
const parsed = parseLibrary(input.library);
|
|
613
|
+
if (!parsed) {
|
|
614
|
+
return errorResult("Error: library must be in namespace/name format", "INVALID_LIBRARY");
|
|
615
|
+
}
|
|
616
|
+
const installed = deps.cache.findInstalled(parsed.namespace, parsed.name, input.version);
|
|
617
|
+
if (!installed) {
|
|
618
|
+
return errorResult(`${input.library}@${input.version} is not installed.`, "NOT_INSTALLED");
|
|
619
|
+
}
|
|
620
|
+
const lookupCount = (input.doc_path ? 1 : 0) + (input.page_uid ? 1 : 0);
|
|
621
|
+
if (lookupCount !== 1) {
|
|
622
|
+
return errorResult("Exactly one of doc_path or page_uid must be provided.", "INVALID_LOOKUP");
|
|
623
|
+
}
|
|
624
|
+
const page = resolveCachedPage(deps.cache, input);
|
|
625
|
+
if (!page) {
|
|
626
|
+
return errorResult("Document not found in local cache.", "NOT_FOUND", {
|
|
627
|
+
library: input.library,
|
|
628
|
+
version: input.version,
|
|
629
|
+
...(input.doc_path ? { doc_path: normalizeDocPath(input.doc_path) } : {}),
|
|
630
|
+
...(input.page_uid ? { page_uid: input.page_uid } : {}),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (page.hydrationState === "missing_content") {
|
|
634
|
+
return errorResult("Page metadata exists locally, but page content is not hydrated. Reinstall with install_docs or refresh with update_docs.", "PAGE_NOT_HYDRATED", {
|
|
635
|
+
library: input.library,
|
|
636
|
+
version: input.version,
|
|
637
|
+
doc_path: page.docPath,
|
|
638
|
+
page_uid: page.pageUid,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const excerptWindow = resolveExcerptWindow(input);
|
|
642
|
+
if ("content" in excerptWindow) {
|
|
643
|
+
return excerptWindow;
|
|
644
|
+
}
|
|
645
|
+
if ((page.content ?? "").length === 0) {
|
|
646
|
+
return errorResult("Page content is empty.", "EMPTY_CONTENT", {
|
|
647
|
+
library: input.library,
|
|
648
|
+
version: input.version,
|
|
649
|
+
doc_path: page.docPath,
|
|
650
|
+
page_uid: page.pageUid,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
return buildExcerpt(page, {
|
|
654
|
+
fromLine: excerptWindow.fromLine,
|
|
655
|
+
maxLines: excerptWindow.maxLines,
|
|
656
|
+
lineNumbers: input.line_numbers ?? false,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
export function handleListInstalledDocs(deps) {
|
|
660
|
+
const installed = deps.cache.listInstalled();
|
|
661
|
+
if (installed.length === 0) {
|
|
662
|
+
return structuredTextResult("No documentation packages installed. Use install_docs to add some.", {
|
|
663
|
+
results: [],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const results = installed.map(lib => ({
|
|
667
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
668
|
+
version: lib.version,
|
|
669
|
+
profile: lib.profile,
|
|
670
|
+
page_count: lib.page_count,
|
|
671
|
+
installed_at: lib.installed_at,
|
|
672
|
+
manifest_checksum: lib.manifest_checksum,
|
|
673
|
+
}));
|
|
674
|
+
const lines = results.map(result => `- ${result.library}@${result.version} (${result.profile}, ${result.page_count} pages)`);
|
|
675
|
+
return structuredTextResult(`Installed documentation packages:\n\n${lines.join("\n")}`, {
|
|
676
|
+
results,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
export async function handleUpdateDocs(deps, input) {
|
|
680
|
+
const { cache, indexer, registryClient } = deps;
|
|
681
|
+
const installed = cache.listInstalled();
|
|
682
|
+
if (input.library && !parseLibrary(input.library)) {
|
|
683
|
+
return errorResult("library must be in namespace/name format (for example 'vercel/nextjs').", "INVALID_LIBRARY");
|
|
684
|
+
}
|
|
685
|
+
const targets = input.library
|
|
686
|
+
? installed.filter(l => librarySlug(l.namespace, l.name) === input.library)
|
|
687
|
+
: installed;
|
|
688
|
+
if (targets.length === 0) {
|
|
689
|
+
return structuredTextResult(input.library
|
|
690
|
+
? `${input.library} is not installed. Use install_docs first.`
|
|
691
|
+
: "No documentation packages installed.", { results: [] });
|
|
692
|
+
}
|
|
693
|
+
const messages = [];
|
|
694
|
+
const results = [];
|
|
695
|
+
for (const lib of targets) {
|
|
696
|
+
try {
|
|
697
|
+
const resolved = await registryClient.resolve({ query: librarySlug(lib.namespace, lib.name) });
|
|
698
|
+
const targetVersion = resolved.data.version.version;
|
|
699
|
+
const manifest = await registryClient.getManifest(lib.namespace, lib.name, targetVersion);
|
|
700
|
+
const targetChecksum = manifest.data.provenance?.manifest_checksum ?? null;
|
|
701
|
+
const versionChanged = targetVersion !== lib.version;
|
|
702
|
+
const checksumChanged = targetChecksum !== lib.manifest_checksum;
|
|
703
|
+
if (!versionChanged && !checksumChanged) {
|
|
704
|
+
messages.push(`${librarySlug(lib.namespace, lib.name)}@${lib.version}: already up to date`);
|
|
705
|
+
results.push({
|
|
706
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
707
|
+
version: lib.version,
|
|
708
|
+
status: "unchanged",
|
|
709
|
+
manifest_checksum: targetChecksum,
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const installOutcome = await installResolvedVersion(deps, lib.namespace, lib.name, targetVersion, manifest.data);
|
|
714
|
+
if (versionChanged) {
|
|
715
|
+
await indexer.removeLibraryVersion(lib.namespace, lib.name, lib.version);
|
|
716
|
+
cache.removeVersion(lib.namespace, lib.name, lib.version);
|
|
717
|
+
cache.removeInstalled(lib.namespace, lib.name, lib.version);
|
|
718
|
+
}
|
|
719
|
+
cache.addInstalled({
|
|
720
|
+
...lib,
|
|
721
|
+
version: targetVersion,
|
|
722
|
+
installed_at: new Date().toISOString(),
|
|
723
|
+
manifest_checksum: installOutcome.manifestChecksum,
|
|
724
|
+
page_count: installOutcome.pageCount,
|
|
725
|
+
profile: installOutcome.profile,
|
|
726
|
+
index_schema_version: DOC_INDEX_SCHEMA_VERSION,
|
|
727
|
+
});
|
|
728
|
+
let message = `${librarySlug(lib.namespace, lib.name)}: ${lib.version} → ${targetVersion}`;
|
|
729
|
+
if (!versionChanged) {
|
|
730
|
+
message = `${librarySlug(lib.namespace, lib.name)}@${lib.version}: refreshed in place`;
|
|
731
|
+
}
|
|
732
|
+
message += ` (${installOutcome.pageCount} pages, ${installOutcome.indexedCount} indexed via ${installOutcome.installMethod})`;
|
|
733
|
+
if (installOutcome.bundleFallbackReason) {
|
|
734
|
+
message += ` [bundle fallback: ${installOutcome.bundleFallbackReason}]`;
|
|
735
|
+
}
|
|
736
|
+
messages.push(message);
|
|
737
|
+
results.push({
|
|
738
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
739
|
+
previous_version: lib.version,
|
|
740
|
+
version: targetVersion,
|
|
741
|
+
status: versionChanged ? "updated" : "refreshed",
|
|
742
|
+
install_method: installOutcome.installMethod,
|
|
743
|
+
profile: installOutcome.profile,
|
|
744
|
+
page_count: installOutcome.pageCount,
|
|
745
|
+
indexed_count: installOutcome.indexedCount,
|
|
746
|
+
manifest_checksum: installOutcome.manifestChecksum,
|
|
747
|
+
...(installOutcome.bundleFormat ? { bundle_format: installOutcome.bundleFormat } : {}),
|
|
748
|
+
...(installOutcome.bundleFallbackReason ? { bundle_fallback_reason: installOutcome.bundleFallbackReason } : {}),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
const message = `${librarySlug(lib.namespace, lib.name)}: update failed — ${err.message}`;
|
|
753
|
+
messages.push(message);
|
|
754
|
+
results.push({
|
|
755
|
+
library: librarySlug(lib.namespace, lib.name),
|
|
756
|
+
version: lib.version,
|
|
757
|
+
status: "failed",
|
|
758
|
+
error: err.message,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return structuredTextResult(messages.join("\n"), { results });
|
|
763
|
+
}
|
|
764
|
+
export async function handleRemoveDocs(deps, input) {
|
|
765
|
+
const parsed = parseLibrary(input.library);
|
|
766
|
+
if (!parsed) {
|
|
767
|
+
return errorResult("Error: library must be in namespace/name format", "INVALID_LIBRARY");
|
|
768
|
+
}
|
|
769
|
+
const targets = deps.cache.listInstalled().filter(lib => {
|
|
770
|
+
if (lib.namespace !== parsed.namespace || lib.name !== parsed.name) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
return !input.version || lib.version === input.version;
|
|
774
|
+
});
|
|
775
|
+
if (targets.length === 0) {
|
|
776
|
+
return errorResult(input.version
|
|
777
|
+
? `${input.library}@${input.version} is not installed.`
|
|
778
|
+
: `${input.library} is not installed.`, "NOT_INSTALLED");
|
|
779
|
+
}
|
|
780
|
+
for (const target of targets) {
|
|
781
|
+
await deps.indexer.removeLibraryVersion(target.namespace, target.name, target.version);
|
|
782
|
+
deps.cache.removeVersion(target.namespace, target.name, target.version);
|
|
783
|
+
deps.cache.removeInstalled(target.namespace, target.name, target.version);
|
|
784
|
+
}
|
|
785
|
+
return structuredTextResult(`Removed ${targets.length} documentation package${targets.length === 1 ? "" : "s"} for ${input.library}.`, {
|
|
786
|
+
library: input.library,
|
|
787
|
+
removed_versions: targets.map(target => target.version),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
function extractTitle(content, fallback) {
|
|
791
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
792
|
+
return match ? match[1].trim() : fallback;
|
|
793
|
+
}
|
|
794
|
+
function createServer(deps) {
|
|
795
|
+
const server = new McpServer({ name: "ContextQMD", version: VERSION }, {
|
|
796
|
+
instructions: "Local-first docs package system. Preferred flow: search_libraries, install_docs, search_docs, then get_doc. search_docs is local-only and does not fetch from the network.",
|
|
797
|
+
});
|
|
798
|
+
// ── Tool 1: search_libraries ──────────────────────────────────────
|
|
799
|
+
server.registerTool("search_libraries", {
|
|
800
|
+
title: "Search Libraries",
|
|
801
|
+
description: "Search the remote library catalog and return candidate libraries, available versions, and local install status. Use this first when you do not already know the exact namespace/name identifier.",
|
|
802
|
+
inputSchema: {
|
|
803
|
+
query: z
|
|
804
|
+
.string()
|
|
805
|
+
.describe("Library search query or task phrase (for example 'inertia rails' or 'react forms')"),
|
|
806
|
+
limit: z
|
|
807
|
+
.number()
|
|
808
|
+
.int()
|
|
809
|
+
.positive()
|
|
810
|
+
.max(20)
|
|
811
|
+
.optional()
|
|
812
|
+
.describe("Maximum libraries to return (default: 5)"),
|
|
813
|
+
},
|
|
814
|
+
annotations: { readOnlyHint: true },
|
|
815
|
+
}, async (input) => handleSearchLibraries(deps, input));
|
|
816
|
+
// ── Tool 2: install_docs ──────────────────────────────────────────
|
|
817
|
+
server.registerTool("install_docs", {
|
|
818
|
+
title: "Install Docs",
|
|
819
|
+
description: "Install or refresh documentation for a library. This is idempotent: if the requested library/version is already installed with the same manifest checksum, it is a no-op. Otherwise it prefers a registry bundle, falls back to page fetches, and indexes everything locally for search.",
|
|
820
|
+
inputSchema: {
|
|
821
|
+
library: z
|
|
822
|
+
.string()
|
|
823
|
+
.describe("Library query, alias, or namespace/name identifier (e.g., 'next', 'kamal', or 'vercel/nextjs')"),
|
|
824
|
+
version: z.string().optional().describe("Version to install: exact version, 'stable', 'latest', or omit for default"),
|
|
825
|
+
},
|
|
826
|
+
}, async (input) => handleInstallDocs(deps, input));
|
|
827
|
+
// ── Tool 3: update_docs ───────────────────────────────────────────
|
|
828
|
+
server.registerTool("update_docs", {
|
|
829
|
+
title: "Update Docs",
|
|
830
|
+
description: "Update installed documentation to the latest resolved version. Also refreshes same-version installs when the manifest checksum changes.",
|
|
831
|
+
inputSchema: {
|
|
832
|
+
library: z
|
|
833
|
+
.string()
|
|
834
|
+
.optional()
|
|
835
|
+
.describe("Library to update in namespace/name format (updates all if omitted)"),
|
|
836
|
+
},
|
|
837
|
+
}, async (input) => handleUpdateDocs(deps, input));
|
|
838
|
+
// ── Tool 4: search_docs ───────────────────────────────────────────
|
|
839
|
+
server.registerTool("search_docs", {
|
|
840
|
+
title: "Search Docs",
|
|
841
|
+
description: "Search installed documentation locally through QMD and return page-level markdown content from the local cache. This tool never fetches from the network. If a requested library/version is not installed, it returns NOT_INSTALLED.",
|
|
842
|
+
inputSchema: {
|
|
843
|
+
query: z.string().describe("Search query"),
|
|
844
|
+
library: z
|
|
845
|
+
.string()
|
|
846
|
+
.optional()
|
|
847
|
+
.describe("Filter to specific library (namespace/name)"),
|
|
848
|
+
version: z.string().optional().describe("Filter to specific version"),
|
|
849
|
+
max_results: z
|
|
850
|
+
.number()
|
|
851
|
+
.optional()
|
|
852
|
+
.describe("Max results to return (default: 5)"),
|
|
853
|
+
mode: z
|
|
854
|
+
.enum(["fts", "vector", "hybrid", "auto"])
|
|
855
|
+
.optional()
|
|
856
|
+
.describe("Search mode: fts (keyword), vector (semantic), hybrid (combined + reranking), auto (smart routing). Default: auto"),
|
|
857
|
+
},
|
|
858
|
+
annotations: { readOnlyHint: true },
|
|
859
|
+
}, async (input) => handleSearchDocs(deps, input));
|
|
860
|
+
// ── Tool 5: get_doc ───────────────────────────────────────────────
|
|
861
|
+
server.registerTool("get_doc", {
|
|
862
|
+
title: "Get Doc",
|
|
863
|
+
description: "Read a bounded slice from a locally installed markdown page. Use doc_path or page_uid, and either from_line/max_lines or around_line/before/after.",
|
|
864
|
+
inputSchema: {
|
|
865
|
+
library: z
|
|
866
|
+
.string()
|
|
867
|
+
.describe("Library identifier (namespace/name)"),
|
|
868
|
+
version: z.string().describe("Version"),
|
|
869
|
+
doc_path: z.string().optional().describe("Canonical markdown doc path (for example reference/react/useRef.md)"),
|
|
870
|
+
page_uid: z.string().optional().describe("Internal page UID fallback"),
|
|
871
|
+
from_line: z.number().int().positive().optional().describe("1-based inclusive line number to start from"),
|
|
872
|
+
max_lines: z.number().int().positive().optional().describe(`Maximum lines to return (default: ${DEFAULT_EXCERPT_MAX_LINES})`),
|
|
873
|
+
around_line: z.number().int().positive().optional().describe("Anchor line for a bounded context window"),
|
|
874
|
+
before: z.number().int().min(0).optional().describe(`Lines to include before around_line (default: ${DEFAULT_EXPAND_BEFORE})`),
|
|
875
|
+
after: z.number().int().min(0).optional().describe(`Lines to include after around_line (default: ${DEFAULT_EXPAND_AFTER})`),
|
|
876
|
+
line_numbers: z.boolean().optional().describe("Include line numbers in the returned excerpt text"),
|
|
877
|
+
},
|
|
878
|
+
annotations: { readOnlyHint: true },
|
|
879
|
+
}, async (input) => handleGetDoc(deps, input));
|
|
880
|
+
// ── Tool 6: list_installed_docs ───────────────────────────────────
|
|
881
|
+
server.registerTool("list_installed_docs", {
|
|
882
|
+
title: "List Installed Docs",
|
|
883
|
+
description: "List all locally installed documentation packages with their versions, install modes, and page counts.",
|
|
884
|
+
inputSchema: {},
|
|
885
|
+
annotations: { readOnlyHint: true },
|
|
886
|
+
}, async () => handleListInstalledDocs(deps));
|
|
887
|
+
// ── Tool 7: remove_docs ───────────────────────────────────────────
|
|
888
|
+
server.registerTool("remove_docs", {
|
|
889
|
+
title: "Remove Docs",
|
|
890
|
+
description: "Remove one installed documentation version or every installed version for a library from the local cache and local QMD index.",
|
|
891
|
+
inputSchema: {
|
|
892
|
+
library: z
|
|
893
|
+
.string()
|
|
894
|
+
.describe("Library identifier (namespace/name)"),
|
|
895
|
+
version: z.string().optional().describe("Specific installed version to remove (removes all installed versions if omitted)"),
|
|
896
|
+
},
|
|
897
|
+
}, async (input) => handleRemoveDocs(deps, input));
|
|
898
|
+
return server;
|
|
899
|
+
}
|
|
900
|
+
async function main() {
|
|
901
|
+
const program = new Command();
|
|
902
|
+
program
|
|
903
|
+
.name("contextqmd-mcp")
|
|
904
|
+
.version(VERSION)
|
|
905
|
+
.option("--transport <type>", "Transport type (stdio or http)", "stdio")
|
|
906
|
+
.option("--port <number>", "HTTP port", "3001")
|
|
907
|
+
.option("--registry <url>", "Registry URL override")
|
|
908
|
+
.option("--token <token>", "API token")
|
|
909
|
+
.option("--cache-dir <path>", "Cache directory override")
|
|
910
|
+
.parse();
|
|
911
|
+
const opts = program.opts();
|
|
912
|
+
const config = loadConfig();
|
|
913
|
+
const registryUrl = opts.registry ?? config.registry_url;
|
|
914
|
+
const token = opts.token ?? process.env.CONTEXTQMD_API_TOKEN;
|
|
915
|
+
const cacheDir = opts["cache-dir"] ?? config.local_cache_dir;
|
|
916
|
+
const registryClient = new RegistryClient(registryUrl, token);
|
|
917
|
+
const cache = new LocalCache(cacheDir);
|
|
918
|
+
const indexer = new DocIndexer(join(cacheDir, "index.sqlite"), cache);
|
|
919
|
+
const server = createServer({ registryClient, cache, indexer });
|
|
920
|
+
if (opts.transport === "stdio") {
|
|
921
|
+
const transport = new StdioServerTransport();
|
|
922
|
+
await server.connect(transport);
|
|
923
|
+
console.error(`ContextQMD MCP Server v${VERSION} running on stdio`);
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
console.error("HTTP transport not yet implemented. Use --transport stdio");
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Export for testing
|
|
931
|
+
export { createServer };
|
|
932
|
+
function resolveEntrypointPath(path) {
|
|
933
|
+
try {
|
|
934
|
+
return realpathSync(path);
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
return resolvePath(path);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
export function isCliEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url) {
|
|
941
|
+
if (!argvPath) {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
return resolveEntrypointPath(argvPath) === resolveEntrypointPath(fileURLToPath(moduleUrl));
|
|
945
|
+
}
|
|
946
|
+
if (isCliEntrypoint()) {
|
|
947
|
+
main().catch((err) => {
|
|
948
|
+
console.error("Fatal:", err);
|
|
949
|
+
process.exit(1);
|
|
950
|
+
});
|
|
951
|
+
}
|