chub-dev 0.2.0-beta.3 → 0.3.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.
@@ -1,8 +1,191 @@
1
- import { loadSourceRegistry } from './cache.js';
1
+ import { loadSourceRegistry, loadSearchIndex } from './cache.js';
2
2
  import { loadConfig } from './config.js';
3
3
  import { normalizeLanguage } from './normalize.js';
4
+ import { buildIndexFromDocuments, compactIdentifier, search as bm25Search, tokenize } from './bm25.js';
4
5
 
5
6
  let _merged = null;
7
+ let _searchIndex = null;
8
+
9
+ function getSearchLookupId(sourceName, entryId) {
10
+ return `${sourceName}:${entryId}`;
11
+ }
12
+
13
+ function normalizeQuery(query) {
14
+ return String(query || '')
15
+ .trim()
16
+ .replace(/\s+/g, ' ');
17
+ }
18
+
19
+ function splitCompactSegments(text) {
20
+ return [...new Set([
21
+ ...String(text || '').split('/').map((segment) => compactIdentifier(segment)),
22
+ ...String(text || '').split(/[\/_.\s-]+/).map((segment) => compactIdentifier(segment)),
23
+ ])].filter(Boolean);
24
+ }
25
+
26
+ function levenshteinDistance(a, b, maxDistance = Infinity) {
27
+ if (a === b) return 0;
28
+ if (!a.length) return b.length;
29
+ if (!b.length) return a.length;
30
+ if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
31
+
32
+ let previous = Array.from({ length: b.length + 1 }, (_, idx) => idx);
33
+ let current = new Array(b.length + 1);
34
+
35
+ for (let i = 1; i <= a.length; i++) {
36
+ current[0] = i;
37
+ let rowMin = current[0];
38
+
39
+ for (let j = 1; j <= b.length; j++) {
40
+ const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
41
+ current[j] = Math.min(
42
+ previous[j] + 1,
43
+ current[j - 1] + 1,
44
+ previous[j - 1] + substitutionCost,
45
+ );
46
+ rowMin = Math.min(rowMin, current[j]);
47
+ }
48
+
49
+ if (rowMin > maxDistance) return maxDistance + 1;
50
+ [previous, current] = [current, previous];
51
+ }
52
+
53
+ return previous[b.length];
54
+ }
55
+
56
+ function scoreCompactCandidate(queryCompact, candidateCompact, weights) {
57
+ if (!queryCompact || !candidateCompact) return 0;
58
+ if (candidateCompact === queryCompact) return weights.exact;
59
+ if (queryCompact.length < 3) return 0;
60
+
61
+ const lengthPenalty = Math.abs(candidateCompact.length - queryCompact.length);
62
+ const lengthRatio = Math.min(candidateCompact.length, queryCompact.length)
63
+ / Math.max(candidateCompact.length, queryCompact.length);
64
+
65
+ if ((candidateCompact.startsWith(queryCompact) || queryCompact.startsWith(candidateCompact)) && lengthRatio >= 0.6) {
66
+ return Math.max(weights.prefix - lengthPenalty, 0);
67
+ }
68
+
69
+ if ((candidateCompact.includes(queryCompact) || queryCompact.includes(candidateCompact)) && lengthRatio >= 0.75) {
70
+ return Math.max(weights.contains - lengthPenalty, 0);
71
+ }
72
+
73
+ if (queryCompact.length < 5) return 0;
74
+
75
+ const maxDistance = queryCompact.length <= 5 ? 1 : queryCompact.length <= 8 ? 2 : 3;
76
+ const distance = levenshteinDistance(queryCompact, candidateCompact, maxDistance);
77
+ if (distance > maxDistance) return 0;
78
+
79
+ return Math.max(weights.fuzzy - (distance * 20) - lengthPenalty, 0);
80
+ }
81
+
82
+ function scoreEntryLexicalVariant(entry, queryCompact) {
83
+ if (queryCompact.length < 2) return 0;
84
+
85
+ const nameCompact = compactIdentifier(entry.name);
86
+ const idCompact = compactIdentifier(entry.id);
87
+ const idSegments = splitCompactSegments(entry.id);
88
+ const nameSegments = splitCompactSegments(entry.name);
89
+
90
+ let best = 0;
91
+
92
+ best = Math.max(best, scoreCompactCandidate(queryCompact, nameCompact, {
93
+ exact: 620,
94
+ prefix: 560,
95
+ contains: 520,
96
+ fuzzy: 500,
97
+ }));
98
+
99
+ best = Math.max(best, scoreCompactCandidate(queryCompact, idCompact, {
100
+ exact: 600,
101
+ prefix: 540,
102
+ contains: 500,
103
+ fuzzy: 470,
104
+ }));
105
+
106
+ for (let idx = 0; idx < idSegments.length; idx++) {
107
+ const segment = idSegments[idx];
108
+ const segmentScore = scoreCompactCandidate(queryCompact, segment, {
109
+ exact: 580,
110
+ prefix: 530,
111
+ contains: 490,
112
+ fuzzy: 460,
113
+ });
114
+ if (segmentScore === 0) continue;
115
+
116
+ let bonus = 0;
117
+ const isFirst = idx === 0;
118
+ const isLast = idx === idSegments.length - 1;
119
+ if (isFirst) bonus += 10;
120
+ if (isLast) bonus += 10;
121
+ if (queryCompact === idSegments[0]) bonus += 60;
122
+ if (queryCompact === idSegments[idSegments.length - 1]) bonus += 25;
123
+ if (idSegments.length > 1 && queryCompact === idSegments[0] && queryCompact === idSegments[idSegments.length - 1]) {
124
+ bonus += 40;
125
+ }
126
+
127
+ best = Math.max(best, segmentScore + bonus);
128
+ }
129
+
130
+ for (const segment of nameSegments) {
131
+ best = Math.max(best, scoreCompactCandidate(queryCompact, segment, {
132
+ exact: 560,
133
+ prefix: 520,
134
+ contains: 480,
135
+ fuzzy: 450,
136
+ }));
137
+ }
138
+
139
+ return best;
140
+ }
141
+
142
+ function scoreEntryLexicalBoost(entry, normalizedQuery, rescueTerms = []) {
143
+ const queryCompacts = [...new Set([
144
+ compactIdentifier(normalizedQuery),
145
+ ...rescueTerms.map((term) => compactIdentifier(term)),
146
+ ])].filter((queryCompact) => queryCompact.length >= 2);
147
+
148
+ let best = 0;
149
+ for (const queryCompact of queryCompacts) {
150
+ best = Math.max(best, scoreEntryLexicalVariant(entry, queryCompact));
151
+ }
152
+ return best;
153
+ }
154
+
155
+ function getMissingQueryTerms(normalizedQuery) {
156
+ if (!_searchIndex?.invertedIndex) {
157
+ return [];
158
+ }
159
+
160
+ return tokenize(normalizedQuery).filter((term) => !_searchIndex.invertedIndex[term]?.length);
161
+ }
162
+
163
+ function shouldRunGlobalLexicalScan(normalizedQuery, resultByKey) {
164
+ if (!_searchIndex || resultByKey.size === 0) {
165
+ return true;
166
+ }
167
+
168
+ if (!_searchIndex.invertedIndex) {
169
+ return false;
170
+ }
171
+
172
+ const queryTerms = tokenize(normalizedQuery);
173
+ if (queryTerms.length < 2) {
174
+ return false;
175
+ }
176
+
177
+ return getMissingQueryTerms(normalizedQuery).length > 0;
178
+ }
179
+
180
+ function namespaceSearchIndex(index, sourceName) {
181
+ return {
182
+ ...index,
183
+ documents: (index.documents || []).map((doc) => ({
184
+ ...doc,
185
+ id: getSearchLookupId(sourceName, doc.id),
186
+ })),
187
+ };
188
+ }
6
189
 
7
190
  /**
8
191
  * Load and merge entries from all configured sources.
@@ -14,11 +197,16 @@ function getMerged() {
14
197
  const config = loadConfig();
15
198
  const allDocs = [];
16
199
  const allSkills = [];
200
+ const searchIndexes = [];
17
201
 
18
202
  for (const source of config.sources) {
19
203
  const registry = loadSourceRegistry(source);
20
204
  if (!registry) continue;
21
205
 
206
+ // Load BM25 search index if available
207
+ const idx = loadSearchIndex(source);
208
+ if (idx) searchIndexes.push(namespaceSearchIndex(idx, source.name));
209
+
22
210
  // Support both new format (docs/skills) and old format (entries)
23
211
  if (registry.docs) {
24
212
  for (const doc of registry.docs) {
@@ -46,6 +234,19 @@ function getMerged() {
46
234
  }
47
235
  }
48
236
 
237
+ // Merge search indexes (combine documents and recompute IDF)
238
+ if (searchIndexes.length > 0) {
239
+ if (searchIndexes.length === 1) {
240
+ const [singleIndex] = searchIndexes;
241
+ _searchIndex = singleIndex.invertedIndex
242
+ ? singleIndex
243
+ : buildIndexFromDocuments(singleIndex.documents, singleIndex.params);
244
+ } else {
245
+ const allDocuments = searchIndexes.flatMap((idx) => idx.documents);
246
+ _searchIndex = buildIndexFromDocuments(allDocuments, searchIndexes[0].params);
247
+ }
248
+ }
249
+
49
250
  _merged = { docs: allDocs, skills: allSkills };
50
251
  return _merged;
51
252
  }
@@ -121,11 +322,11 @@ export function getDisplayId(entry) {
121
322
 
122
323
  /**
123
324
  * Search entries by query string. Searches both docs and skills.
325
+ * Uses BM25 when a search index is available, falls back to keyword matching.
124
326
  */
125
327
  export function searchEntries(query, filters = {}) {
328
+ const normalizedQuery = normalizeQuery(query);
126
329
  const entries = applySourceFilter(getAllEntries());
127
- const q = query.toLowerCase();
128
- const words = q.split(/\s+/);
129
330
 
130
331
  // Deduplicate: same id+source appearing as both doc and skill → show once
131
332
  const seen = new Set();
@@ -138,27 +339,76 @@ export function searchEntries(query, filters = {}) {
138
339
  }
139
340
  }
140
341
 
141
- let results = deduped.map((entry) => {
142
- let score = 0;
342
+ // Build entry lookup by id
343
+ const entryById = new Map();
344
+ for (const entry of deduped) {
345
+ entryById.set(getSearchLookupId(entry._source, entry.id), entry);
346
+ }
143
347
 
144
- if (entry.id === q) score += 100;
145
- else if (entry.id.includes(q)) score += 50;
348
+ if (!normalizedQuery) {
349
+ return applyFilters(deduped, filters).map((entry) => ({ ...entry, _score: 0 }));
350
+ }
146
351
 
147
- const nameLower = entry.name.toLowerCase();
148
- if (nameLower === q) score += 80;
149
- else if (nameLower.includes(q)) score += 40;
352
+ const resultByKey = new Map();
150
353
 
151
- for (const word of words) {
152
- if (entry.id.includes(word)) score += 10;
153
- if (nameLower.includes(word)) score += 10;
154
- if (entry.description?.toLowerCase().includes(word)) score += 5;
155
- if (entry.tags?.some((t) => t.toLowerCase().includes(word))) score += 15;
354
+ if (_searchIndex) {
355
+ // BM25 search
356
+ for (const match of bm25Search(normalizedQuery, _searchIndex)) {
357
+ const entry = entryById.get(match.id);
358
+ if (!entry) continue;
359
+ const key = getSearchLookupId(entry._source, entry.id);
360
+ resultByKey.set(key, { entry, score: match.score });
156
361
  }
362
+ } else {
363
+ // Fallback: keyword matching
364
+ const q = normalizedQuery.toLowerCase();
365
+ const words = q.split(/\s+/);
366
+
367
+ for (const entry of deduped) {
368
+ let score = 0;
157
369
 
158
- return { entry, score };
159
- });
370
+ if (entry.id === q) score += 100;
371
+ else if (entry.id.includes(q)) score += 50;
160
372
 
161
- results = results.filter((r) => r.score > 0);
373
+ const nameLower = entry.name.toLowerCase();
374
+ if (nameLower === q) score += 80;
375
+ else if (nameLower.includes(q)) score += 40;
376
+
377
+ for (const word of words) {
378
+ if (entry.id.includes(word)) score += 10;
379
+ if (nameLower.includes(word)) score += 10;
380
+ if (entry.description?.toLowerCase().includes(word)) score += 5;
381
+ if (entry.tags?.some((t) => t.toLowerCase().includes(word))) score += 15;
382
+ }
383
+
384
+ if (score > 0) {
385
+ const key = getSearchLookupId(entry._source, entry.id);
386
+ resultByKey.set(key, { entry, score });
387
+ }
388
+ }
389
+ }
390
+
391
+ const lexicalCandidates = !shouldRunGlobalLexicalScan(normalizedQuery, resultByKey)
392
+ ? [...new Set([...resultByKey.values()].map(({ entry }) => entry))]
393
+ : deduped;
394
+ const rescueTerms = resultByKey.size > 0
395
+ ? getMissingQueryTerms(normalizedQuery).filter((term) => term.length >= 5)
396
+ : [];
397
+
398
+ for (const entry of lexicalCandidates) {
399
+ const boost = scoreEntryLexicalBoost(entry, normalizedQuery, rescueTerms);
400
+ if (boost === 0) continue;
401
+
402
+ const key = getSearchLookupId(entry._source, entry.id);
403
+ const current = resultByKey.get(key);
404
+ if (current) {
405
+ current.score += boost;
406
+ } else {
407
+ resultByKey.set(key, { entry, score: boost });
408
+ }
409
+ }
410
+
411
+ let results = [...resultByKey.values()];
162
412
 
163
413
  const filtered = applyFilters(results.map((r) => r.entry), filters);
164
414
  const filteredSet = new Set(filtered);
@@ -173,6 +423,7 @@ export function searchEntries(query, filters = {}) {
173
423
  * type: "doc" or "skill". If null, searches both.
174
424
  */
175
425
  export function getEntry(idOrNamespacedId, type = null) {
426
+ const normalizedId = normalizeQuery(idOrNamespacedId);
176
427
  const { docs, skills } = getMerged();
177
428
  let pool;
178
429
  if (type === 'doc') pool = applySourceFilter(docs);
@@ -180,16 +431,16 @@ export function getEntry(idOrNamespacedId, type = null) {
180
431
  else pool = applySourceFilter([...docs, ...skills]);
181
432
 
182
433
  // Check for source:id format (colon separates source from id)
183
- if (idOrNamespacedId.includes(':')) {
184
- const colonIdx = idOrNamespacedId.indexOf(':');
185
- const sourceName = idOrNamespacedId.slice(0, colonIdx);
186
- const id = idOrNamespacedId.slice(colonIdx + 1);
434
+ if (normalizedId.includes(':')) {
435
+ const colonIdx = normalizedId.indexOf(':');
436
+ const sourceName = normalizedId.slice(0, colonIdx);
437
+ const id = normalizedId.slice(colonIdx + 1);
187
438
  const entry = pool.find((e) => e._source === sourceName && e.id === id);
188
439
  return entry ? { entry, ambiguous: false } : { entry: null, ambiguous: false };
189
440
  }
190
441
 
191
442
  // Bare id (may contain slashes like author/name)
192
- const matches = pool.filter((e) => e.id === idOrNamespacedId);
443
+ const matches = pool.filter((e) => e.id === normalizedId);
193
444
  if (matches.length === 0) return { entry: null, ambiguous: false };
194
445
  if (matches.length === 1) return { entry: matches[0], ambiguous: false };
195
446
 
@@ -241,9 +492,7 @@ export function resolveDocPath(entry, language, version) {
241
492
  let langObj = null;
242
493
  if (lang) {
243
494
  langObj = entry.languages.find((l) => l.language === lang);
244
- } else if (entry.languages.length === 1) {
245
- langObj = entry.languages[0];
246
- } else if (entry.languages.length > 1) {
495
+ } else {
247
496
  return {
248
497
  needsLanguage: true,
249
498
  available: entry.languages.map((l) => l.language),
@@ -255,6 +504,13 @@ export function resolveDocPath(entry, language, version) {
255
504
  let verObj = null;
256
505
  if (version) {
257
506
  verObj = langObj.versions?.find((v) => v.version === version);
507
+ if (!verObj) {
508
+ return {
509
+ versionNotFound: true,
510
+ requested: version,
511
+ available: langObj.versions?.map((v) => v.version) || [],
512
+ };
513
+ }
258
514
  } else {
259
515
  const rec = langObj.recommendedVersion;
260
516
  verObj = langObj.versions?.find((v) => v.version === rec) || langObj.versions?.[0];
@@ -272,7 +528,7 @@ export function resolveDocPath(entry, language, version) {
272
528
  * Given a resolved path and a type ("doc" or "skill"), return the entry file path.
273
529
  */
274
530
  export function resolveEntryFile(resolved, type) {
275
- if (!resolved || resolved.needsLanguage) return { error: 'unresolved' };
531
+ if (!resolved || resolved.needsLanguage || resolved.versionNotFound) return { error: 'unresolved' };
276
532
 
277
533
  const fileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
278
534
 
@@ -8,6 +8,12 @@ export function isTelemetryEnabled() {
8
8
  return config.telemetry !== false;
9
9
  }
10
10
 
11
+ export function isFeedbackEnabled() {
12
+ if (process.env.CHUB_FEEDBACK === '0' || process.env.CHUB_FEEDBACK === 'false') return false;
13
+ const config = loadConfig();
14
+ return config.feedback !== false;
15
+ }
16
+
11
17
  export function getTelemetryUrl() {
12
18
  const url = process.env.CHUB_TELEMETRY_URL;
13
19
  if (url) return url;
@@ -33,7 +39,7 @@ export function getTelemetryUrl() {
33
39
  * @param {string} [opts.source] - Registry source name
34
40
  */
35
41
  export async function sendFeedback(entryId, entryType, rating, opts = {}) {
36
- if (!isTelemetryEnabled()) return { status: 'skipped', reason: 'telemetry_disabled' };
42
+ if (!isFeedbackEnabled()) return { status: 'skipped', reason: 'feedback_disabled' };
37
43
 
38
44
  const { getOrCreateClientId, detectAgent, detectAgentVersion } = await import('./identity.js');
39
45
  const clientId = await getOrCreateClientId();
@@ -0,0 +1,42 @@
1
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { getChubDir } from './config.js';
5
+
6
+ const WELCOME_MARKER = '.welcome_shown';
7
+
8
+ /**
9
+ * Show the first-run welcome notice if it hasn't been shown yet.
10
+ * Creates a marker file so it only displays once.
11
+ */
12
+ export function showWelcomeIfNeeded(opts = {}) {
13
+ if (opts.json) return;
14
+ if (!process.stdout.isTTY || !process.stderr.isTTY) return;
15
+
16
+ const chubDir = getChubDir();
17
+ const markerPath = join(chubDir, WELCOME_MARKER);
18
+ const configPath = join(chubDir, 'config.yaml');
19
+
20
+ if (existsSync(markerPath)) return;
21
+
22
+ // Print to stderr so it doesn't interfere with JSON/piped output
23
+ console.error(`
24
+ ${chalk.bold('Welcome to Context Hub (chub)!')} Chub helps your AI coding agents make API calls correctly, by providing \
25
+ the latest documentation.
26
+
27
+ By using chub, you agree to the Terms of Service at ${chalk.underline('https://www.aichub.org/tos.html')}
28
+
29
+ Chub asks agents to provide feedback on documentation, and this feedback is used to improve docs for the developer \
30
+ community. If you wish to disable this feedback, add ${chalk.bold('"feedback: false"')} to ${chalk.bold(configPath)}. See \
31
+ ${chalk.underline('https://github.com/andrewyng/context-hub')} for details.
32
+ `);
33
+
34
+ try {
35
+ if (!existsSync(chubDir)) {
36
+ mkdirSync(chubDir, { recursive: true });
37
+ }
38
+ writeFileSync(markerPath, new Date().toISOString(), 'utf8');
39
+ } catch {
40
+ // Best-effort — don't block CLI if marker can't be written
41
+ }
42
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Context Hub MCP Server.
3
+ *
4
+ * Exposes chub search, get, list, annotate, and feedback as MCP tools
5
+ * for use with Claude Code, Cursor, and other MCP-compatible agents.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { z } from 'zod';
14
+ import { ensureRegistry } from '../lib/cache.js';
15
+ import { listEntries } from '../lib/registry.js';
16
+ import { handleSearch, handleGet, handleList, handleAnnotate, handleFeedback } from './tools.js';
17
+ import { attachStdioShutdownHandlers } from './stdio-lifecycle.js';
18
+
19
+ // Prevent console.log from corrupting the stdio JSON-RPC protocol.
20
+ // Any transitive dependency (e.g. posthog-node) that calls console.log
21
+ // would break the MCP transport without this redirect.
22
+ const _stderr = process.stderr;
23
+ console.log = (...args) => _stderr.write(args.join(' ') + '\n');
24
+ console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
25
+ console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
26
+ console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
27
+
28
+ // Read package version
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
31
+
32
+ // Create server
33
+ const server = new McpServer({
34
+ name: 'chub',
35
+ version: pkg.version,
36
+ });
37
+
38
+ // --- Register Tools ---
39
+
40
+ server.tool(
41
+ 'chub_search',
42
+ 'Search Context Hub for docs and skills by query, tags, or language',
43
+ {
44
+ query: z.string().optional().describe('Search query. Omit to list all entries.'),
45
+ tags: z.string().optional().describe('Comma-separated tag filter (e.g. "openai,chat")'),
46
+ lang: z.string().optional().describe('Filter by language (e.g. "python", "js")'),
47
+ limit: z.number().int().min(1).max(100).optional().describe('Max results (default 20)'),
48
+ },
49
+ async (args) => handleSearch(args),
50
+ );
51
+
52
+ server.tool(
53
+ 'chub_get',
54
+ 'Fetch the content of a doc or skill by ID from Context Hub',
55
+ {
56
+ id: z.string().describe('Entry ID (e.g. "openai/chat", "stripe/api"). Use source:id for disambiguation.'),
57
+ lang: z.string().optional().describe('Language variant (e.g. "python", "js"). Auto-selected if only one.'),
58
+ version: z.string().optional().describe('Specific version (e.g. "1.52.0"). Defaults to recommended.'),
59
+ full: z.boolean().optional().describe('Fetch all files, not just the entry point (default false)'),
60
+ file: z.string().optional().describe('Fetch a specific file by path (e.g. "references/streaming.md")'),
61
+ },
62
+ async (args) => handleGet(args),
63
+ );
64
+
65
+ server.tool(
66
+ 'chub_list',
67
+ 'List all available docs and skills in Context Hub',
68
+ {
69
+ tags: z.string().optional().describe('Comma-separated tag filter'),
70
+ lang: z.string().optional().describe('Filter by language'),
71
+ limit: z.number().int().min(1).max(500).optional().describe('Max entries (default 50)'),
72
+ },
73
+ async (args) => handleList(args),
74
+ );
75
+
76
+ server.tool(
77
+ 'chub_annotate',
78
+ 'Read, write, clear, or list agent annotations. Modes: (1) list=true to list all, (2) id+note to write, (3) id+clear=true to delete, (4) id alone to read. Annotations persist locally across sessions.',
79
+ {
80
+ id: z.string().optional().describe('Entry ID to annotate (e.g. "openai/chat"). Required unless using list mode.'),
81
+ note: z.string().optional().describe('Annotation text to save. Omit to read existing annotation.'),
82
+ clear: z.boolean().optional().describe('Remove the annotation for this entry (default false)'),
83
+ list: z.boolean().optional().describe('List all annotations (default false). When true, id is not needed.'),
84
+ },
85
+ async (args) => handleAnnotate(args),
86
+ );
87
+
88
+ server.tool(
89
+ 'chub_feedback',
90
+ 'Send quality feedback (thumbs up/down) for a doc or skill to help authors improve content',
91
+ {
92
+ id: z.string().describe('Entry ID to rate (e.g. "openai/chat")'),
93
+ rating: z.enum(['up', 'down']).describe('Thumbs up or down'),
94
+ comment: z.string().optional().describe('Optional comment explaining the rating'),
95
+ type: z.enum(['doc', 'skill']).optional().describe('Entry type. Auto-detected if omitted.'),
96
+ lang: z.string().optional().describe('Language variant rated'),
97
+ version: z.string().optional().describe('Version rated'),
98
+ file: z.string().optional().describe('Specific file rated'),
99
+ labels: z.array(z.enum([
100
+ 'accurate', 'well-structured', 'helpful', 'good-examples',
101
+ 'outdated', 'inaccurate', 'incomplete', 'wrong-examples',
102
+ 'wrong-version', 'poorly-structured',
103
+ ])).optional().describe('Structured feedback labels'),
104
+ },
105
+ async (args) => handleFeedback(args),
106
+ );
107
+
108
+ // --- Register Resource ---
109
+
110
+ server.resource(
111
+ 'registry',
112
+ 'chub://registry',
113
+ {
114
+ title: 'Context Hub Registry',
115
+ description: 'Browse the full Context Hub registry of docs and skills',
116
+ mimeType: 'application/json',
117
+ },
118
+ async (uri) => {
119
+ try {
120
+ const entries = listEntries({});
121
+ const simplified = entries.map((entry) => ({
122
+ id: entry.id,
123
+ name: entry.name,
124
+ type: entry._type || (entry.languages ? 'doc' : 'skill'),
125
+ description: entry.description,
126
+ tags: entry.tags || [],
127
+ ...(entry.languages
128
+ ? {
129
+ languages: entry.languages.map((l) => ({
130
+ language: l.language,
131
+ versions: l.versions?.map((v) => v.version) || [],
132
+ recommended: l.recommendedVersion,
133
+ })),
134
+ }
135
+ : {}),
136
+ }));
137
+ return {
138
+ contents: [{
139
+ uri: uri.href,
140
+ mimeType: 'application/json',
141
+ text: JSON.stringify({ entries: simplified, total: simplified.length }, null, 2),
142
+ }],
143
+ };
144
+ } catch (err) {
145
+ console.warn(`Registry resource error: ${err.message}`);
146
+ return {
147
+ contents: [{
148
+ uri: uri.href,
149
+ mimeType: 'application/json',
150
+ text: JSON.stringify({ error: 'Registry not loaded. Run "chub update" first.' }),
151
+ }],
152
+ };
153
+ }
154
+ },
155
+ );
156
+
157
+ // --- Process Safety ---
158
+
159
+ // Prevent the server from crashing on unhandled errors (long-lived process)
160
+ process.on('uncaughtException', (err) => {
161
+ _stderr.write(`[chub-mcp] Uncaught exception: ${err.message}\n`);
162
+ });
163
+ process.on('unhandledRejection', (reason) => {
164
+ _stderr.write(`[chub-mcp] Unhandled rejection: ${reason}\n`);
165
+ });
166
+
167
+ // --- Start Server ---
168
+
169
+ // Best-effort registry load — server starts even if this fails
170
+ try {
171
+ await ensureRegistry();
172
+ } catch (err) {
173
+ _stderr.write(`[chub-mcp] Warning: Registry not loaded: ${err.message}\n`);
174
+ }
175
+
176
+ const transport = new StdioServerTransport();
177
+ await server.connect(transport);
178
+
179
+ // Exit promptly when MCP host disconnects stdio.
180
+ // Must be after server.connect() so StdioServerTransport's data handler
181
+ // is already wired — otherwise stdin.resume() discards incoming bytes.
182
+ attachStdioShutdownHandlers({ stderr: _stderr });
183
+
184
+ _stderr.write(`[chub-mcp] Server started (v${pkg.version})\n`);