@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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 +201 -0
- package/README.md +497 -0
- package/bin/corpus.mjs +4 -0
- package/bin/docs-kit.mjs +4 -0
- package/package.json +58 -3
- package/schemas/decision-record.schema.json +85 -0
- package/src/assets.mjs +67 -0
- package/src/build.mjs +535 -0
- package/src/built-on.mjs +238 -0
- package/src/bundle.mjs +174 -0
- package/src/check.mjs +520 -0
- package/src/cli.mjs +226 -0
- package/src/content-freshness.mjs +37 -0
- package/src/content.mjs +222 -0
- package/src/decisions.mjs +317 -0
- package/src/errors.mjs +24 -0
- package/src/git.mjs +171 -0
- package/src/hub.mjs +198 -0
- package/src/index.mjs +12 -0
- package/src/init.mjs +63 -0
- package/src/locate.mjs +341 -0
- package/src/manifest.mjs +589 -0
- package/src/markdown.mjs +547 -0
- package/src/mcp.mjs +583 -0
- package/src/paths.mjs +122 -0
- package/src/search.mjs +508 -0
- package/src/site-client.js +622 -0
- package/src/site-template.mjs +191 -0
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { bundleFromCheckout, normalizeBaseUrl } from './bundle.mjs';
|
|
8
|
+
import { UsageError } from './errors.mjs';
|
|
9
|
+
import { gitStatusPorcelain, headShaOrNull } from './git.mjs';
|
|
10
|
+
import { locateInBundle } from './locate.mjs';
|
|
11
|
+
import { MANIFEST_FILE, RETIRED_MANIFEST_FILE, manifestFile, loadManifest, validateProducts } from './manifest.mjs';
|
|
12
|
+
import { toFsPath } from './paths.mjs';
|
|
13
|
+
import { hierarchyFields, searchBundles, splitWords, stamp, synonymsError } from './search.mjs';
|
|
14
|
+
|
|
15
|
+
export { searchBundles };
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_HUB = 'https://kontourai.github.io/';
|
|
18
|
+
export const HUB_CACHE_MS = 10 * 60 * 1000;
|
|
19
|
+
export const HUB_RETRY_MS = 30 * 1000;
|
|
20
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
23
|
+
|
|
24
|
+
const isString = (value) => typeof value === 'string';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shape check for a docs-bundle.json, down to each document and topic, so a
|
|
28
|
+
* malformed product is reported as unavailable instead of throwing inside a
|
|
29
|
+
* tool. `landing` is optional for bundles built before it existed.
|
|
30
|
+
*/
|
|
31
|
+
export function bundleShapeError(bundle, product) {
|
|
32
|
+
if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) return 'bundle is not a JSON object';
|
|
33
|
+
if (![1, 2].includes(bundle.schemaVersion)) return `unsupported bundle schemaVersion ${JSON.stringify(bundle.schemaVersion)}`;
|
|
34
|
+
if (bundle.schemaVersion === 2) {
|
|
35
|
+
if (!isString(bundle.purpose) || !bundle.purpose.trim()) return 'bundle purpose must be a non-empty string';
|
|
36
|
+
if (!Array.isArray(bundle.builtOn)) return 'bundle builtOn must be an array';
|
|
37
|
+
for (const entry of bundle.builtOn) {
|
|
38
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some((key) => !['product', 'via', 'for', 'evidence', 'bin'].includes(key)) || (Object.hasOwn(entry, 'bin') && (typeof entry.bin !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(entry.bin))) || (Object.hasOwn(entry, 'evidence') && entry.evidence !== 'cli') || ['product', 'for'].some((key) => !isString(entry[key]) || !entry[key].trim()) || !(Array.isArray(entry.via) ? entry.via.length > 0 && entry.via.every((via) => isString(via) && via.trim()) : isString(entry.via) && entry.via.trim())) return 'bundle builtOn entries must contain product and for strings, and via as a string or non-empty string array';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (product && bundle.product !== product) return `bundle is for product "${bundle.product}", expected "${product}"`;
|
|
42
|
+
for (const key of ['product', 'title', 'tagline', 'repo', 'builtAt', 'siteUrl', 'digest'])
|
|
43
|
+
if (!isString(bundle[key])) return `bundle field "${key}" must be a string`;
|
|
44
|
+
if (!(isString(bundle.sha) || bundle.sha === null)) return 'bundle field "sha" must be a string or null';
|
|
45
|
+
if (!Array.isArray(bundle.documents) || !Array.isArray(bundle.topics)) return 'bundle documents and topics must be arrays';
|
|
46
|
+
for (const [index, document] of bundle.documents.entries()) {
|
|
47
|
+
if (document === null || typeof document !== 'object') return `documents[${index}] is not an object`;
|
|
48
|
+
for (const key of ['source', 'title', 'url', 'markdown']) if (!isString(document[key])) return `documents[${index}].${key} must be a string`;
|
|
49
|
+
if (!(isString(document.section) || document.section === null)) return `documents[${index}].section must be a string or null`;
|
|
50
|
+
if (document.landing !== undefined && typeof document.landing !== 'boolean') return `documents[${index}].landing must be a boolean`;
|
|
51
|
+
// Added in 0.6.0; optional so older bundles stay readable.
|
|
52
|
+
if (document.repoPath !== undefined && !isString(document.repoPath)) return `documents[${index}].repoPath must be a string`;
|
|
53
|
+
if (document.lineOffset !== undefined && !(Number.isInteger(document.lineOffset) && document.lineOffset >= 0))
|
|
54
|
+
return `documents[${index}].lineOffset must be a non-negative integer`;
|
|
55
|
+
}
|
|
56
|
+
// Added with BM25 search; optional so older bundles stay readable.
|
|
57
|
+
if (bundle.synonyms !== undefined) {
|
|
58
|
+
const problem = synonymsError(bundle.synonyms, 'bundle synonyms');
|
|
59
|
+
if (problem) return problem;
|
|
60
|
+
}
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
for (const [index, topic] of bundle.topics.entries()) {
|
|
63
|
+
if (topic === null || typeof topic !== 'object') return `topics[${index}] is not an object`;
|
|
64
|
+
for (const key of ['id', 'title', 'source', 'url', 'markdown']) if (!isString(topic[key])) return `topics[${index}].${key} must be a string`;
|
|
65
|
+
// A repeated id makes get_topic and parentId ambiguous, and can hide a parent cycle.
|
|
66
|
+
if (ids.has(topic.id)) return `topics[${index}].id "${topic.id}" is used more than once`;
|
|
67
|
+
ids.add(topic.id);
|
|
68
|
+
// Hierarchy fields: optional, so bundles without them stay readable.
|
|
69
|
+
if (topic.summary !== undefined && !isString(topic.summary)) return `topics[${index}].summary must be a string`;
|
|
70
|
+
if (topic.tags !== undefined && !(Array.isArray(topic.tags) && topic.tags.every((tag) => isString(tag) && tag.trim())))
|
|
71
|
+
return `topics[${index}].tags must be an array of non-empty strings`;
|
|
72
|
+
if (topic.parentId !== undefined && !(isString(topic.parentId) && topic.parentId.trim())) return `topics[${index}].parentId must be a non-empty string`;
|
|
73
|
+
}
|
|
74
|
+
return topicTreeError(bundle.topics);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A `parentId` must name another topic in the same bundle, and following
|
|
79
|
+
* parents must end at a root. A dangling parent would be a branch no tool can
|
|
80
|
+
* list or read, so it is refused rather than served. Topic ids are unique by
|
|
81
|
+
* now. Each walk stops at a topic already known to reach a root, so the whole
|
|
82
|
+
* check is linear in the number of topics, however deep the tree.
|
|
83
|
+
*/
|
|
84
|
+
function topicTreeError(topics) {
|
|
85
|
+
const parentOf = new Map(topics.map((topic) => [topic.id, topic.parentId]));
|
|
86
|
+
const reachesRoot = new Set();
|
|
87
|
+
for (const [index, topic] of topics.entries()) {
|
|
88
|
+
if (topic.parentId === undefined) continue;
|
|
89
|
+
if (!parentOf.has(topic.parentId)) return `topics[${index}].parentId "${topic.parentId}" is not a topic id in this bundle`;
|
|
90
|
+
const walked = new Set([topic.id]);
|
|
91
|
+
for (let at = topic.parentId; at !== undefined && !reachesRoot.has(at); at = parentOf.get(at)) {
|
|
92
|
+
if (walked.has(at)) return `topics[${index}].parentId "${topic.parentId}" makes a cycle through "${at}"`;
|
|
93
|
+
walked.add(at);
|
|
94
|
+
}
|
|
95
|
+
for (const id of walked) reachesRoot.add(id);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A loaded snapshot: available bundles by product plus the products that failed. */
|
|
101
|
+
function snapshot(bundles, unavailable) {
|
|
102
|
+
return { bundles: new Map(bundles.map((bundle) => [bundle.product, bundle])), unavailable };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Non-landing documents: the count the hub's "thin" rule and list_products use. */
|
|
106
|
+
export function pageCount(bundle) {
|
|
107
|
+
return bundle.documents.filter((document) => !document.landing).length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Rebuild a checkout's bundle only when its content key changes: HEAD,
|
|
112
|
+
* a hash of `git status --porcelain`, the manifest's mtime, and the mtime and
|
|
113
|
+
* size of each published source (porcelain alone does not change when an
|
|
114
|
+
* already-modified file is edited again).
|
|
115
|
+
*/
|
|
116
|
+
export function createCheckoutCache(build = bundleFromCheckout) {
|
|
117
|
+
const cache = new Map(); // root -> { key, bundle, sources }
|
|
118
|
+
const statKey = (file) => {
|
|
119
|
+
try {
|
|
120
|
+
const stat = statSync(file);
|
|
121
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
122
|
+
} catch {
|
|
123
|
+
return 'missing';
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const keyFor = (root, sources) =>
|
|
127
|
+
createHash('sha256')
|
|
128
|
+
.update(
|
|
129
|
+
[
|
|
130
|
+
headShaOrNull(root) ?? 'no-git',
|
|
131
|
+
gitStatusPorcelain(root),
|
|
132
|
+
// The retired name too: one appearing mid-session must make the next build refuse.
|
|
133
|
+
...[MANIFEST_FILE, RETIRED_MANIFEST_FILE].map((name) => statKey(path.join(root, name))),
|
|
134
|
+
...sources.map((source) => `${source}=${statKey(toFsPath(root, source))}`),
|
|
135
|
+
].join('\n'),
|
|
136
|
+
)
|
|
137
|
+
.digest('hex');
|
|
138
|
+
return (root) => {
|
|
139
|
+
const hit = cache.get(root);
|
|
140
|
+
if (hit && keyFor(root, hit.sources) === hit.key) return hit.bundle;
|
|
141
|
+
// Key first, then build: an edit that lands while the bundle is being
|
|
142
|
+
// built changes the key, so the next call rebuilds instead of serving
|
|
143
|
+
// pre-edit content under a post-edit key.
|
|
144
|
+
const manifest = loadManifest(root);
|
|
145
|
+
const sources = [manifest.landing, ...manifest.documents.map((document) => document.source)];
|
|
146
|
+
const key = keyFor(root, sources);
|
|
147
|
+
const bundle = build(root);
|
|
148
|
+
cache.set(root, { key, bundle, sources });
|
|
149
|
+
return bundle;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One checkout, rebuilt when its content key changes so a long session sees
|
|
155
|
+
* current docs. An invalid checkout at startup is a usage error; a later failure
|
|
156
|
+
* (mid-edit) is reported as unavailable.
|
|
157
|
+
*/
|
|
158
|
+
export function repoSource(dir) {
|
|
159
|
+
const root = path.resolve(dir);
|
|
160
|
+
const build = createCheckoutCache();
|
|
161
|
+
const first = build(root);
|
|
162
|
+
return {
|
|
163
|
+
describe: `repo ${root}`,
|
|
164
|
+
load: async () => {
|
|
165
|
+
try {
|
|
166
|
+
return snapshot([build(root)], []);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return snapshot([], [{ product: first.product, error: error.message }]);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function manifestProduct(dir, fallback) {
|
|
175
|
+
try {
|
|
176
|
+
const raw = JSON.parse(readFileSync(path.join(dir, manifestFile(dir)), 'utf8'));
|
|
177
|
+
return typeof raw?.product === 'string' && raw.product.trim() ? raw.product : fallback;
|
|
178
|
+
} catch {
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The stderr note for a workspace subdirectory that has only a retired docs-kit.json. */
|
|
184
|
+
const noteToStderr = (root, name) =>
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
`Corpus: skipping ${path.join(root, name)}: it has a retired v1 ${RETIRED_MANIFEST_FILE} and no ${MANIFEST_FILE}; ` +
|
|
187
|
+
`rename it to ${MANIFEST_FILE} with schemaVersion 2, purpose and builtOn to serve it\n`,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Immediate subdirectories with corpus.json. One with only a retired
|
|
192
|
+
* docs-kit.json is not a product: it is skipped and passed to `onRetired`, so
|
|
193
|
+
* one stale checkout neither takes the workspace down nor vanishes silently.
|
|
194
|
+
* One with both files is kept, and its build reports the conflict as unavailable.
|
|
195
|
+
*/
|
|
196
|
+
function scanWorkspace(root, onRetired) {
|
|
197
|
+
const names = [];
|
|
198
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
199
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
200
|
+
if (existsSync(path.join(root, entry.name, MANIFEST_FILE))) names.push(entry.name);
|
|
201
|
+
else if (existsSync(path.join(root, entry.name, RETIRED_MANIFEST_FILE))) onRetired(root, entry.name);
|
|
202
|
+
}
|
|
203
|
+
return names.sort();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Build every product in a workspace. Unavailable entries are keyed by the manifest's product. */
|
|
207
|
+
export function loadWorkspace(root, build = bundleFromCheckout, onRetired = noteToStderr) {
|
|
208
|
+
const byProduct = new Map();
|
|
209
|
+
for (const name of scanWorkspace(root, onRetired)) {
|
|
210
|
+
const dir = path.join(root, name);
|
|
211
|
+
const product = manifestProduct(dir, name);
|
|
212
|
+
if (!byProduct.has(product)) byProduct.set(product, []);
|
|
213
|
+
byProduct.get(product).push({ name, dir });
|
|
214
|
+
}
|
|
215
|
+
const bundles = [];
|
|
216
|
+
const unavailable = [];
|
|
217
|
+
for (const [product, dirs] of byProduct) {
|
|
218
|
+
if (dirs.length > 1) {
|
|
219
|
+
const names = dirs.map((entry) => entry.name).join(', ');
|
|
220
|
+
for (const entry of dirs)
|
|
221
|
+
unavailable.push({ product, directory: entry.name, error: `product "${product}" is declared by more than one directory (${names}); rename or remove one` });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
bundles.push(build(dirs[0].dir));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
unavailable.push({ product, directory: dirs[0].name, error: error.message });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return snapshot(bundles, unavailable);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Every immediate subdirectory with corpus.json, rescanned on every call; each
|
|
235
|
+
* product rebuilt only when its key changes. A subdirectory with only a retired
|
|
236
|
+
* docs-kit.json is noted on stderr once per server, not on every rescan.
|
|
237
|
+
*/
|
|
238
|
+
export function workspaceSource(dir) {
|
|
239
|
+
const root = path.resolve(dir);
|
|
240
|
+
const noted = new Set();
|
|
241
|
+
const onRetired = (base, name) => {
|
|
242
|
+
if (noted.has(name)) return;
|
|
243
|
+
noted.add(name);
|
|
244
|
+
noteToStderr(base, name);
|
|
245
|
+
};
|
|
246
|
+
let candidates;
|
|
247
|
+
try {
|
|
248
|
+
candidates = scanWorkspace(root, onRetired);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
throw new UsageError(`--workspace ${dir}: ${error.message}`);
|
|
251
|
+
}
|
|
252
|
+
if (candidates.length === 0) throw new UsageError(`--workspace ${dir}: no immediate subdirectory contains ${MANIFEST_FILE}`);
|
|
253
|
+
const build = createCheckoutCache();
|
|
254
|
+
return {
|
|
255
|
+
describe: `workspace ${root}`,
|
|
256
|
+
load: async () => {
|
|
257
|
+
try {
|
|
258
|
+
return loadWorkspace(root, build, onRetired);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return snapshot([], [{ product: '*', error: `workspace ${root} unavailable: ${error.message}` }]);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function fetchJson(url, fetchImpl) {
|
|
267
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'application/json' } });
|
|
268
|
+
if (!response.ok) throw new Error(`GET ${url} -> HTTP ${response.status}`);
|
|
269
|
+
try {
|
|
270
|
+
return await response.json();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new Error(`GET ${url} -> invalid JSON: ${error.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fetch <hub>/products.json and each <hub>/<product>/docs-bundle.json.
|
|
278
|
+
* Successes are cached for HUB_CACHE_MS; failures only for HUB_RETRY_MS, so a
|
|
279
|
+
* product that comes back is picked up on a later call. A failed product is
|
|
280
|
+
* reported as unavailable; a failed products.json makes every result report
|
|
281
|
+
* the hub error.
|
|
282
|
+
*/
|
|
283
|
+
export function hubSource(hub, { fetchImpl = fetch, now = Date.now, ttlMs = HUB_CACHE_MS, retryMs = HUB_RETRY_MS } = {}) {
|
|
284
|
+
const base = normalizeBaseUrl(hub, '--hub');
|
|
285
|
+
const cache = new Map(); // url -> { at, ok, value | error }
|
|
286
|
+
const inflight = new Map();
|
|
287
|
+
// `validate` returns an error string for a fetched-but-malformed body; that
|
|
288
|
+
// is a failure too and gets the short retry TTL, not the 10-minute one.
|
|
289
|
+
const cachedFetch = (url, validate) => {
|
|
290
|
+
const hit = cache.get(url);
|
|
291
|
+
if (hit && now() - hit.at < (hit.ok ? ttlMs : retryMs)) return Promise.resolve(hit);
|
|
292
|
+
if (!inflight.has(url))
|
|
293
|
+
inflight.set(
|
|
294
|
+
url,
|
|
295
|
+
fetchJson(url, fetchImpl)
|
|
296
|
+
.then(
|
|
297
|
+
(value) => {
|
|
298
|
+
const problem = validate(value);
|
|
299
|
+
return problem ? { ok: false, error: problem } : { ok: true, value };
|
|
300
|
+
},
|
|
301
|
+
(error) => ({ ok: false, error: error.message }),
|
|
302
|
+
)
|
|
303
|
+
.then((entry) => {
|
|
304
|
+
cache.set(url, { at: now(), ...entry });
|
|
305
|
+
inflight.delete(url);
|
|
306
|
+
return entry;
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
return inflight.get(url);
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
describe: `hub ${base}`,
|
|
313
|
+
load: async () => {
|
|
314
|
+
const list = await cachedFetch(new URL('products.json', base).href, (value) => {
|
|
315
|
+
const { errors } = validateProducts(value);
|
|
316
|
+
return errors.length > 0 ? `products.json is invalid: ${errors.join('; ')}` : null;
|
|
317
|
+
});
|
|
318
|
+
if (!list.ok) return snapshot([], [{ product: '*', error: `hub ${base} unavailable: ${list.error}` }]);
|
|
319
|
+
const { products } = validateProducts(list.value);
|
|
320
|
+
const results = await Promise.all(
|
|
321
|
+
products.map(async ({ product, repo }) => {
|
|
322
|
+
const entry = await cachedFetch(new URL(`${product}/docs-bundle.json`, base).href, (value) => bundleShapeError(value, product));
|
|
323
|
+
return entry.ok ? { bundle: entry.value } : { unavailable: { product, repo, error: entry.error } };
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
return snapshot(
|
|
327
|
+
results.filter((result) => result.bundle).map((result) => result.bundle),
|
|
328
|
+
results.filter((result) => result.unavailable).map((result) => result.unavailable),
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Prebuilt bundles held in memory for the life of the server: nothing is
|
|
336
|
+
* rebuilt, refetched, or reread. Every bundle is validated up front, so a
|
|
337
|
+
* malformed one or two bundles for one product is a usage error at startup
|
|
338
|
+
* instead of an unavailable product later. `label(index)` names a bundle in
|
|
339
|
+
* those errors.
|
|
340
|
+
*/
|
|
341
|
+
export function staticSource(bundles, { describe = 'static bundles', label = (index) => `bundles[${index}]` } = {}) {
|
|
342
|
+
if (!Array.isArray(bundles) || bundles.length === 0) throw new UsageError(`${describe}: at least one bundle is required`);
|
|
343
|
+
const seen = new Map();
|
|
344
|
+
// Serve private copies, so what was validated is what is served even if the caller later mutates its objects.
|
|
345
|
+
const copies = bundles.map((bundle, index) => {
|
|
346
|
+
let copy;
|
|
347
|
+
try {
|
|
348
|
+
copy = structuredClone(bundle);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error instanceof RangeError) throw new UsageError(`${label(index)}: bundle nesting too deep to copy (${error.message})`);
|
|
351
|
+
throw new UsageError(`${label(index)}: bundle is not plain data: ${error.message}`);
|
|
352
|
+
}
|
|
353
|
+
const problem = bundleShapeError(copy);
|
|
354
|
+
if (problem) throw new UsageError(`${label(index)}: ${problem}`);
|
|
355
|
+
if (seen.has(copy.product)) throw new UsageError(`${label(index)}: product "${copy.product}" is already served by ${label(seen.get(copy.product))}`);
|
|
356
|
+
seen.set(copy.product, index);
|
|
357
|
+
return copy;
|
|
358
|
+
});
|
|
359
|
+
const loaded = snapshot(copies, []);
|
|
360
|
+
return { describe, load: async () => loaded };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `mcp --bundle <file>...`: read each docs-bundle.json once, then serve it as a static source. */
|
|
364
|
+
export function bundleFileSource(files) {
|
|
365
|
+
const resolved = files.map((file) => path.resolve(file));
|
|
366
|
+
const bundles = resolved.map((file, index) => {
|
|
367
|
+
let text;
|
|
368
|
+
try {
|
|
369
|
+
text = readFileSync(file, 'utf8');
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new UsageError(`--bundle ${files[index]}: ${error.message}`);
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(text);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
throw new UsageError(`--bundle ${files[index]}: invalid JSON: ${error.message}`);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return staticSource(bundles, { describe: `bundle ${resolved.join(', ')}`, label: (index) => `--bundle ${files[index]}` });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function json(data) {
|
|
383
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toolError(message, extra = {}) {
|
|
387
|
+
return { ...json({ error: message, ...extra }), isError: true };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Resolve a product to its bundle, or a tool error naming what is available. */
|
|
391
|
+
function pickBundle(loaded, product) {
|
|
392
|
+
const bundle = loaded.bundles.get(product);
|
|
393
|
+
if (bundle) return { bundle };
|
|
394
|
+
const down = loaded.unavailable.find((entry) => entry.product === product || entry.product === '*');
|
|
395
|
+
if (down) return { error: toolError(`product "${product}" is unavailable: ${down.error}`) };
|
|
396
|
+
return { error: toolError(`unknown product "${product}"`, { availableProducts: [...loaded.bundles.keys()] }) };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function createDocsServer(source) {
|
|
400
|
+
const server = new McpServer({ name: 'corpus', version: PACKAGE_VERSION });
|
|
401
|
+
const readOnly = { readOnlyHint: true };
|
|
402
|
+
// A tool never throws: anything unexpected (a malformed bundle that slipped
|
|
403
|
+
// past validation, a filesystem race) becomes an isError result.
|
|
404
|
+
const tool = (name, config, handler) =>
|
|
405
|
+
server.registerTool(name, config, async (args) => {
|
|
406
|
+
try {
|
|
407
|
+
return await handler(args ?? {});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return toolError(`${name} failed: ${error?.message ?? error}`);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
tool(
|
|
414
|
+
'list_products',
|
|
415
|
+
{
|
|
416
|
+
description: 'List the products whose published documentation this server can answer from, with each bundle\'s source commit (sha), build time, site URL, and topic/document counts. Products whose docs could not be loaded are listed as unavailable.',
|
|
417
|
+
inputSchema: z.object({}),
|
|
418
|
+
annotations: readOnly,
|
|
419
|
+
},
|
|
420
|
+
async () => {
|
|
421
|
+
const loaded = await source.load();
|
|
422
|
+
return json({
|
|
423
|
+
products: [
|
|
424
|
+
...[...loaded.bundles.values()].map((bundle) => ({
|
|
425
|
+
product: bundle.product,
|
|
426
|
+
title: bundle.title,
|
|
427
|
+
tagline: bundle.tagline,
|
|
428
|
+
...(bundle.schemaVersion === 2 ? { purpose: bundle.purpose, builtOn: bundle.builtOn } : {}),
|
|
429
|
+
...stamp(bundle),
|
|
430
|
+
builtAt: bundle.builtAt,
|
|
431
|
+
siteUrl: bundle.siteUrl,
|
|
432
|
+
topicCount: bundle.topics.length,
|
|
433
|
+
docCount: pageCount(bundle),
|
|
434
|
+
status: 'available',
|
|
435
|
+
})),
|
|
436
|
+
...loaded.unavailable.map((entry) => ({ ...entry, status: 'unavailable' })),
|
|
437
|
+
],
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
tool(
|
|
443
|
+
'list_topics',
|
|
444
|
+
{
|
|
445
|
+
description:
|
|
446
|
+
'List curated documentation topics (id, title, source, url, and parentId, tags, summary where the bundle has them) for one product or all products. Pass parentId to list one topic\'s children. Use get_topic to read one.',
|
|
447
|
+
inputSchema: z.object({
|
|
448
|
+
product: z.string().optional().describe('Product slug, e.g. "flow". Omit for all products.'),
|
|
449
|
+
parentId: z.string().optional().describe('Only the topics whose parentId is this topic id.'),
|
|
450
|
+
}),
|
|
451
|
+
annotations: readOnly,
|
|
452
|
+
},
|
|
453
|
+
async ({ product, parentId }) => {
|
|
454
|
+
const loaded = await source.load();
|
|
455
|
+
let bundles = [...loaded.bundles.values()];
|
|
456
|
+
if (product !== undefined) {
|
|
457
|
+
const picked = pickBundle(loaded, product);
|
|
458
|
+
if (picked.error) return picked.error;
|
|
459
|
+
bundles = [picked.bundle];
|
|
460
|
+
}
|
|
461
|
+
if (parentId !== undefined && !bundles.some((bundle) => bundle.topics.some((topic) => topic.id === parentId)))
|
|
462
|
+
return toolError(`unknown parent topic "${parentId}"${product === undefined ? '' : ` in ${product}`}`);
|
|
463
|
+
return json({
|
|
464
|
+
topics: bundles.flatMap((bundle) =>
|
|
465
|
+
bundle.topics
|
|
466
|
+
.filter((topic) => parentId === undefined || topic.parentId === parentId)
|
|
467
|
+
.map((topic) => ({ product: bundle.product, id: topic.id, title: topic.title, source: topic.source, url: topic.url, ...hierarchyFields(topic), ...stamp(bundle) })),
|
|
468
|
+
),
|
|
469
|
+
...(product === undefined && loaded.unavailable.length > 0 ? { unavailable: loaded.unavailable } : {}),
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
tool(
|
|
475
|
+
'get_topic',
|
|
476
|
+
{
|
|
477
|
+
description: 'Read one documentation topic as Markdown (the heading section it points at). Use list_topics for ids.',
|
|
478
|
+
inputSchema: z.object({ product: z.string().describe('Product slug.'), id: z.string().describe('Topic id from list_topics.') }),
|
|
479
|
+
annotations: readOnly,
|
|
480
|
+
},
|
|
481
|
+
async ({ product, id }) => {
|
|
482
|
+
const picked = pickBundle(await source.load(), product);
|
|
483
|
+
if (picked.error) return picked.error;
|
|
484
|
+
const topic = picked.bundle.topics.find((entry) => entry.id === id);
|
|
485
|
+
if (!topic) return toolError(`unknown topic "${id}" in ${product}`, { availableTopics: picked.bundle.topics.map((entry) => entry.id), ...stamp(picked.bundle) });
|
|
486
|
+
return json({ product, ...stamp(picked.bundle), topic });
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
tool(
|
|
491
|
+
'get_document',
|
|
492
|
+
{
|
|
493
|
+
description: 'Read one whole document as Markdown by its repository source path (e.g. "docs/getting-started.md"; the landing page, usually README.md, is included).',
|
|
494
|
+
inputSchema: z.object({ product: z.string().describe('Product slug.'), source: z.string().describe('Document source path from list_products/search.') }),
|
|
495
|
+
annotations: readOnly,
|
|
496
|
+
},
|
|
497
|
+
async ({ product, source: wanted }) => {
|
|
498
|
+
const picked = pickBundle(await source.load(), product);
|
|
499
|
+
if (picked.error) return picked.error;
|
|
500
|
+
const document = picked.bundle.documents.find((entry) => entry.source === wanted);
|
|
501
|
+
if (!document)
|
|
502
|
+
return toolError(`unknown document "${wanted}" in ${product}`, { availableDocuments: picked.bundle.documents.map((entry) => entry.source), ...stamp(picked.bundle) });
|
|
503
|
+
return json({ product, ...stamp(picked.bundle), document });
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
tool(
|
|
508
|
+
'search',
|
|
509
|
+
{
|
|
510
|
+
description: 'Ranked search (BM25, stemmed, with synonyms) over the topics, documents, and named table rows (a key, tool, command, or flag in a table\'s first column) of one or all products. Returns snippets with product, kind, source, url, and the source commit sha; a row result also carries its name and the headings above it.',
|
|
511
|
+
inputSchema: z.object({
|
|
512
|
+
query: z.string().describe('Words to look for, e.g. "evidence gate".'),
|
|
513
|
+
product: z.string().optional().describe('Limit to one product slug.'),
|
|
514
|
+
limit: z.number().int().min(1).max(50).optional().describe('Maximum results (default 10).'),
|
|
515
|
+
}),
|
|
516
|
+
annotations: readOnly,
|
|
517
|
+
},
|
|
518
|
+
async ({ query, product, limit }) => {
|
|
519
|
+
if (splitWords(query).length === 0) return toolError('query must contain at least one word');
|
|
520
|
+
const loaded = await source.load();
|
|
521
|
+
let bundles = [...loaded.bundles.values()];
|
|
522
|
+
if (product !== undefined) {
|
|
523
|
+
const picked = pickBundle(loaded, product);
|
|
524
|
+
if (picked.error) return picked.error;
|
|
525
|
+
bundles = [picked.bundle];
|
|
526
|
+
}
|
|
527
|
+
return json({
|
|
528
|
+
query,
|
|
529
|
+
results: searchBundles(bundles, query, limit ?? 10),
|
|
530
|
+
...(product === undefined && loaded.unavailable.length > 0 ? { unavailable: loaded.unavailable } : {}),
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
tool(
|
|
536
|
+
'locate',
|
|
537
|
+
{
|
|
538
|
+
description:
|
|
539
|
+
'Find where a quoted passage (for example text selected on a docs page) lives in a product\'s Markdown source. Tolerates whitespace, Markdown formatting, and punctuation differences. Returns the source path, 1-based file line range, sha, enclosing heading and anchor, the surrounding Markdown, a GitHub blob URL with #Lstart-Lend, a confidence, and alternatives when the quote matches more than one place.',
|
|
540
|
+
inputSchema: z.object({
|
|
541
|
+
product: z.string().describe('Product slug.'),
|
|
542
|
+
quote: z.string().describe('The passage to find, as copied from the page or the Markdown.'),
|
|
543
|
+
source: z.string().optional().describe('Limit the search to one document source path, e.g. "docs/concepts.md".'),
|
|
544
|
+
}),
|
|
545
|
+
annotations: readOnly,
|
|
546
|
+
},
|
|
547
|
+
async ({ product, quote, source: only }) => {
|
|
548
|
+
const picked = pickBundle(await source.load(), product);
|
|
549
|
+
if (picked.error) return picked.error;
|
|
550
|
+
const { bundle } = picked;
|
|
551
|
+
const found = locateInBundle(bundle, quote, { source: only });
|
|
552
|
+
if (found.error) return toolError(found.error, { ...(found.availableDocuments ? { availableDocuments: found.availableDocuments } : {}), ...stamp(bundle) });
|
|
553
|
+
if (found.best === null)
|
|
554
|
+
return toolError(`no match for the quote in ${product}${only === undefined ? '' : ` (${only})`}`, { documentsSearched: found.documentsSearched, ...stamp(bundle) });
|
|
555
|
+
return json({
|
|
556
|
+
product,
|
|
557
|
+
...stamp(bundle),
|
|
558
|
+
confidence: found.confidence,
|
|
559
|
+
...found.best,
|
|
560
|
+
// Only the first MAX_ALTERNATIVES others are listed; matchCount is the total.
|
|
561
|
+
...(found.matchCount > 1 ? { ambiguous: true, matchCount: found.matchCount, alternatives: found.alternatives } : {}),
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
return server;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** `corpus mcp`: choose exactly one source, then serve over stdio until stdin closes. */
|
|
570
|
+
export async function runMcp({ repo, workspace, hub, bundle = [] }) {
|
|
571
|
+
const chosen = [repo && 'repo', workspace && 'workspace', hub && 'hub', bundle.length > 0 && 'bundle'].filter(Boolean);
|
|
572
|
+
if (chosen.length > 1) throw new UsageError(`mcp takes exactly one source; got --${chosen.join(' and --')}`);
|
|
573
|
+
const source = repo
|
|
574
|
+
? repoSource(repo)
|
|
575
|
+
: workspace
|
|
576
|
+
? workspaceSource(workspace)
|
|
577
|
+
: bundle.length > 0
|
|
578
|
+
? bundleFileSource(bundle)
|
|
579
|
+
: hubSource(hub ?? DEFAULT_HUB);
|
|
580
|
+
const server = createDocsServer(source);
|
|
581
|
+
await server.connect(new StdioServerTransport());
|
|
582
|
+
process.stderr.write(`docs-kit ${PACKAGE_VERSION} MCP server ready (${source.describe})\n`);
|
|
583
|
+
}
|