chub-dev 0.1.0 → 0.1.2-beta.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.
Files changed (139) hide show
  1. package/README.md +55 -0
  2. package/bin/chub-mcp +2 -0
  3. package/dist/airtable/docs/database/javascript/DOC.md +1437 -0
  4. package/dist/airtable/docs/database/python/DOC.md +1735 -0
  5. package/dist/amplitude/docs/analytics/javascript/DOC.md +1282 -0
  6. package/dist/amplitude/docs/analytics/python/DOC.md +1199 -0
  7. package/dist/anthropic/docs/claude-api/javascript/DOC.md +503 -0
  8. package/dist/anthropic/docs/claude-api/python/DOC.md +389 -0
  9. package/dist/asana/docs/tasks/DOC.md +1396 -0
  10. package/dist/assemblyai/docs/transcription/DOC.md +1043 -0
  11. package/dist/atlassian/docs/confluence/javascript/DOC.md +1347 -0
  12. package/dist/atlassian/docs/confluence/python/DOC.md +1604 -0
  13. package/dist/auth0/docs/identity/javascript/DOC.md +968 -0
  14. package/dist/auth0/docs/identity/python/DOC.md +1199 -0
  15. package/dist/aws/docs/s3/javascript/DOC.md +1773 -0
  16. package/dist/aws/docs/s3/python/DOC.md +1807 -0
  17. package/dist/binance/docs/trading/javascript/DOC.md +1315 -0
  18. package/dist/binance/docs/trading/python/DOC.md +1454 -0
  19. package/dist/braintree/docs/gateway/javascript/DOC.md +1278 -0
  20. package/dist/braintree/docs/gateway/python/DOC.md +1179 -0
  21. package/dist/chromadb/docs/embeddings-db/javascript/DOC.md +1263 -0
  22. package/dist/chromadb/docs/embeddings-db/python/DOC.md +1707 -0
  23. package/dist/clerk/docs/auth/javascript/DOC.md +1220 -0
  24. package/dist/clerk/docs/auth/python/DOC.md +274 -0
  25. package/dist/cloudflare/docs/workers/javascript/DOC.md +918 -0
  26. package/dist/cloudflare/docs/workers/python/DOC.md +994 -0
  27. package/dist/cockroachdb/docs/distributed-db/DOC.md +1500 -0
  28. package/dist/cohere/docs/llm/DOC.md +1335 -0
  29. package/dist/datadog/docs/monitoring/javascript/DOC.md +1740 -0
  30. package/dist/datadog/docs/monitoring/python/DOC.md +1815 -0
  31. package/dist/deepgram/docs/speech/javascript/DOC.md +885 -0
  32. package/dist/deepgram/docs/speech/python/DOC.md +685 -0
  33. package/dist/deepl/docs/translation/javascript/DOC.md +887 -0
  34. package/dist/deepl/docs/translation/python/DOC.md +944 -0
  35. package/dist/deepseek/docs/llm/DOC.md +1220 -0
  36. package/dist/directus/docs/headless-cms/javascript/DOC.md +1128 -0
  37. package/dist/directus/docs/headless-cms/python/DOC.md +1276 -0
  38. package/dist/discord/docs/bot/javascript/DOC.md +1090 -0
  39. package/dist/discord/docs/bot/python/DOC.md +1130 -0
  40. package/dist/elasticsearch/docs/search/DOC.md +1634 -0
  41. package/dist/elevenlabs/docs/text-to-speech/javascript/DOC.md +336 -0
  42. package/dist/elevenlabs/docs/text-to-speech/python/DOC.md +552 -0
  43. package/dist/firebase/docs/auth/DOC.md +1015 -0
  44. package/dist/gemini/docs/genai/javascript/DOC.md +691 -0
  45. package/dist/gemini/docs/genai/python/DOC.md +555 -0
  46. package/dist/github/docs/octokit/DOC.md +1560 -0
  47. package/dist/google/docs/bigquery/javascript/DOC.md +1688 -0
  48. package/dist/google/docs/bigquery/python/DOC.md +1503 -0
  49. package/dist/hubspot/docs/crm/javascript/DOC.md +1805 -0
  50. package/dist/hubspot/docs/crm/python/DOC.md +2033 -0
  51. package/dist/huggingface/docs/transformers/DOC.md +948 -0
  52. package/dist/intercom/docs/messaging/javascript/DOC.md +1844 -0
  53. package/dist/intercom/docs/messaging/python/DOC.md +1797 -0
  54. package/dist/jira/docs/issues/javascript/DOC.md +1420 -0
  55. package/dist/jira/docs/issues/python/DOC.md +1492 -0
  56. package/dist/kafka/docs/streaming/javascript/DOC.md +1671 -0
  57. package/dist/kafka/docs/streaming/python/DOC.md +1464 -0
  58. package/dist/landingai-ade/docs/api/DOC.md +620 -0
  59. package/dist/landingai-ade/docs/sdk/python/DOC.md +489 -0
  60. package/dist/landingai-ade/docs/sdk/typescript/DOC.md +542 -0
  61. package/dist/landingai-ade/skills/SKILL.md +489 -0
  62. package/dist/launchdarkly/docs/feature-flags/javascript/DOC.md +1191 -0
  63. package/dist/launchdarkly/docs/feature-flags/python/DOC.md +1671 -0
  64. package/dist/linear/docs/tracker/DOC.md +1554 -0
  65. package/dist/livekit/docs/realtime/javascript/DOC.md +303 -0
  66. package/dist/livekit/docs/realtime/python/DOC.md +163 -0
  67. package/dist/mailchimp/docs/marketing/DOC.md +1420 -0
  68. package/dist/meilisearch/docs/search/DOC.md +1241 -0
  69. package/dist/microsoft/docs/onedrive/javascript/DOC.md +1421 -0
  70. package/dist/microsoft/docs/onedrive/python/DOC.md +1549 -0
  71. package/dist/mongodb/docs/atlas/DOC.md +2041 -0
  72. package/dist/notion/docs/workspace-api/javascript/DOC.md +1435 -0
  73. package/dist/notion/docs/workspace-api/python/DOC.md +1400 -0
  74. package/dist/okta/docs/identity/javascript/DOC.md +1171 -0
  75. package/dist/okta/docs/identity/python/DOC.md +1401 -0
  76. package/dist/openai/docs/chat/javascript/DOC.md +407 -0
  77. package/dist/openai/docs/chat/python/DOC.md +568 -0
  78. package/dist/paypal/docs/checkout/DOC.md +278 -0
  79. package/dist/pinecone/docs/sdk/javascript/DOC.md +984 -0
  80. package/dist/pinecone/docs/sdk/python/DOC.md +1395 -0
  81. package/dist/plaid/docs/banking/javascript/DOC.md +1163 -0
  82. package/dist/plaid/docs/banking/python/DOC.md +1203 -0
  83. package/dist/playwright-community/skills/login-flows/SKILL.md +108 -0
  84. package/dist/postmark/docs/transactional-email/DOC.md +1168 -0
  85. package/dist/prisma/docs/orm/javascript/DOC.md +1419 -0
  86. package/dist/prisma/docs/orm/python/DOC.md +1317 -0
  87. package/dist/qdrant/docs/vector-search/javascript/DOC.md +1221 -0
  88. package/dist/qdrant/docs/vector-search/python/DOC.md +1653 -0
  89. package/dist/rabbitmq/docs/message-queue/javascript/DOC.md +1193 -0
  90. package/dist/rabbitmq/docs/message-queue/python/DOC.md +1243 -0
  91. package/dist/razorpay/docs/payments/javascript/DOC.md +1219 -0
  92. package/dist/razorpay/docs/payments/python/DOC.md +1330 -0
  93. package/dist/redis/docs/key-value/javascript/DOC.md +1851 -0
  94. package/dist/redis/docs/key-value/python/DOC.md +2054 -0
  95. package/dist/registry.json +2817 -0
  96. package/dist/replicate/docs/model-hosting/DOC.md +1318 -0
  97. package/dist/resend/docs/email/DOC.md +1271 -0
  98. package/dist/salesforce/docs/crm/javascript/DOC.md +1241 -0
  99. package/dist/salesforce/docs/crm/python/DOC.md +1183 -0
  100. package/dist/search-index.json +1 -0
  101. package/dist/sendgrid/docs/email-api/javascript/DOC.md +371 -0
  102. package/dist/sendgrid/docs/email-api/python/DOC.md +656 -0
  103. package/dist/sentry/docs/error-tracking/javascript/DOC.md +1073 -0
  104. package/dist/sentry/docs/error-tracking/python/DOC.md +1309 -0
  105. package/dist/shopify/docs/storefront/DOC.md +457 -0
  106. package/dist/slack/docs/workspace/javascript/DOC.md +933 -0
  107. package/dist/slack/docs/workspace/python/DOC.md +271 -0
  108. package/dist/square/docs/payments/javascript/DOC.md +1855 -0
  109. package/dist/square/docs/payments/python/DOC.md +1728 -0
  110. package/dist/stripe/docs/api/DOC.md +1727 -0
  111. package/dist/stripe/docs/payments/DOC.md +1726 -0
  112. package/dist/stytch/docs/auth/javascript/DOC.md +1813 -0
  113. package/dist/stytch/docs/auth/python/DOC.md +1962 -0
  114. package/dist/supabase/docs/client/DOC.md +1606 -0
  115. package/dist/twilio/docs/messaging/python/DOC.md +469 -0
  116. package/dist/twilio/docs/messaging/typescript/DOC.md +946 -0
  117. package/dist/vercel/docs/platform/DOC.md +1940 -0
  118. package/dist/weaviate/docs/vector-db/javascript/DOC.md +1268 -0
  119. package/dist/weaviate/docs/vector-db/python/DOC.md +1388 -0
  120. package/dist/zendesk/docs/support/javascript/DOC.md +2150 -0
  121. package/dist/zendesk/docs/support/python/DOC.md +2297 -0
  122. package/package.json +22 -6
  123. package/skills/get-api-docs/SKILL.md +84 -0
  124. package/src/commands/annotate.js +83 -0
  125. package/src/commands/build.js +12 -1
  126. package/src/commands/feedback.js +150 -0
  127. package/src/commands/get.js +83 -42
  128. package/src/commands/search.js +7 -0
  129. package/src/index.js +43 -17
  130. package/src/lib/analytics.js +90 -0
  131. package/src/lib/annotations.js +57 -0
  132. package/src/lib/bm25.js +170 -0
  133. package/src/lib/cache.js +69 -6
  134. package/src/lib/config.js +8 -3
  135. package/src/lib/identity.js +99 -0
  136. package/src/lib/registry.js +103 -20
  137. package/src/lib/telemetry.js +86 -0
  138. package/src/mcp/server.js +177 -0
  139. package/src/mcp/tools.js +251 -0
@@ -1,8 +1,10 @@
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 { search as bm25Search } from './bm25.js';
4
5
 
5
6
  let _merged = null;
7
+ let _searchIndex = null;
6
8
 
7
9
  /**
8
10
  * Load and merge entries from all configured sources.
@@ -14,11 +16,16 @@ function getMerged() {
14
16
  const config = loadConfig();
15
17
  const allDocs = [];
16
18
  const allSkills = [];
19
+ const searchIndexes = [];
17
20
 
18
21
  for (const source of config.sources) {
19
22
  const registry = loadSourceRegistry(source);
20
23
  if (!registry) continue;
21
24
 
25
+ // Load BM25 search index if available
26
+ const idx = loadSearchIndex(source);
27
+ if (idx) searchIndexes.push(idx);
28
+
22
29
  // Support both new format (docs/skills) and old format (entries)
23
30
  if (registry.docs) {
24
31
  for (const doc of registry.docs) {
@@ -46,6 +53,53 @@ function getMerged() {
46
53
  }
47
54
  }
48
55
 
56
+ // Merge search indexes (combine documents and recompute IDF)
57
+ if (searchIndexes.length > 0) {
58
+ if (searchIndexes.length === 1) {
59
+ _searchIndex = searchIndexes[0];
60
+ } else {
61
+ // Merge multiple indexes: combine documents, recompute global IDF
62
+ const allDocuments = searchIndexes.flatMap((idx) => idx.documents);
63
+ const N = allDocuments.length;
64
+ const dfMap = {};
65
+ const fieldLengths = { name: [], description: [], tags: [] };
66
+
67
+ for (const doc of allDocuments) {
68
+ const allTerms = new Set([
69
+ ...(doc.tokens.name || []),
70
+ ...(doc.tokens.description || []),
71
+ ...(doc.tokens.tags || []),
72
+ ]);
73
+ for (const term of allTerms) {
74
+ dfMap[term] = (dfMap[term] || 0) + 1;
75
+ }
76
+ fieldLengths.name.push((doc.tokens.name || []).length);
77
+ fieldLengths.description.push((doc.tokens.description || []).length);
78
+ fieldLengths.tags.push((doc.tokens.tags || []).length);
79
+ }
80
+
81
+ const idf = {};
82
+ for (const [term, df] of Object.entries(dfMap)) {
83
+ idf[term] = Math.log((N - df + 0.5) / (df + 0.5) + 1);
84
+ }
85
+
86
+ const avg = (arr) => arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length;
87
+ _searchIndex = {
88
+ version: '1.0.0',
89
+ algorithm: 'bm25',
90
+ params: searchIndexes[0].params,
91
+ totalDocs: N,
92
+ avgFieldLengths: {
93
+ name: avg(fieldLengths.name),
94
+ description: avg(fieldLengths.description),
95
+ tags: avg(fieldLengths.tags),
96
+ },
97
+ idf,
98
+ documents: allDocuments,
99
+ };
100
+ }
101
+ }
102
+
49
103
  _merged = { docs: allDocs, skills: allSkills };
50
104
  return _merged;
51
105
  }
@@ -121,11 +175,10 @@ export function getDisplayId(entry) {
121
175
 
122
176
  /**
123
177
  * Search entries by query string. Searches both docs and skills.
178
+ * Uses BM25 when a search index is available, falls back to keyword matching.
124
179
  */
125
180
  export function searchEntries(query, filters = {}) {
126
181
  const entries = applySourceFilter(getAllEntries());
127
- const q = query.toLowerCase();
128
- const words = q.split(/\s+/);
129
182
 
130
183
  // Deduplicate: same id+source appearing as both doc and skill → show once
131
184
  const seen = new Set();
@@ -138,27 +191,50 @@ export function searchEntries(query, filters = {}) {
138
191
  }
139
192
  }
140
193
 
141
- let results = deduped.map((entry) => {
142
- let score = 0;
194
+ // Build entry lookup by id
195
+ const entryById = new Map();
196
+ for (const entry of deduped) {
197
+ entryById.set(entry.id, entry);
198
+ }
199
+
200
+ let results;
201
+
202
+ if (_searchIndex) {
203
+ // BM25 search
204
+ const bm25Results = bm25Search(query, _searchIndex);
205
+ results = bm25Results
206
+ .map((r) => {
207
+ const entry = entryById.get(r.id);
208
+ return entry ? { entry, score: r.score } : null;
209
+ })
210
+ .filter(Boolean);
211
+ } else {
212
+ // Fallback: keyword matching
213
+ const q = query.toLowerCase();
214
+ const words = q.split(/\s+/);
143
215
 
144
- if (entry.id === q) score += 100;
145
- else if (entry.id.includes(q)) score += 50;
216
+ results = deduped.map((entry) => {
217
+ let score = 0;
146
218
 
147
- const nameLower = entry.name.toLowerCase();
148
- if (nameLower === q) score += 80;
149
- else if (nameLower.includes(q)) score += 40;
219
+ if (entry.id === q) score += 100;
220
+ else if (entry.id.includes(q)) score += 50;
150
221
 
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;
156
- }
222
+ const nameLower = entry.name.toLowerCase();
223
+ if (nameLower === q) score += 80;
224
+ else if (nameLower.includes(q)) score += 40;
157
225
 
158
- return { entry, score };
159
- });
226
+ for (const word of words) {
227
+ if (entry.id.includes(word)) score += 10;
228
+ if (nameLower.includes(word)) score += 10;
229
+ if (entry.description?.toLowerCase().includes(word)) score += 5;
230
+ if (entry.tags?.some((t) => t.toLowerCase().includes(word))) score += 15;
231
+ }
232
+
233
+ return { entry, score };
234
+ });
160
235
 
161
- results = results.filter((r) => r.score > 0);
236
+ results = results.filter((r) => r.score > 0);
237
+ }
162
238
 
163
239
  const filtered = applyFilters(results.map((r) => r.entry), filters);
164
240
  const filteredSet = new Set(filtered);
@@ -255,6 +331,13 @@ export function resolveDocPath(entry, language, version) {
255
331
  let verObj = null;
256
332
  if (version) {
257
333
  verObj = langObj.versions?.find((v) => v.version === version);
334
+ if (!verObj) {
335
+ return {
336
+ versionNotFound: true,
337
+ requested: version,
338
+ available: langObj.versions?.map((v) => v.version) || [],
339
+ };
340
+ }
258
341
  } else {
259
342
  const rec = langObj.recommendedVersion;
260
343
  verObj = langObj.versions?.find((v) => v.version === rec) || langObj.versions?.[0];
@@ -272,7 +355,7 @@ export function resolveDocPath(entry, language, version) {
272
355
  * Given a resolved path and a type ("doc" or "skill"), return the entry file path.
273
356
  */
274
357
  export function resolveEntryFile(resolved, type) {
275
- if (!resolved || resolved.needsLanguage) return { error: 'unresolved' };
358
+ if (!resolved || resolved.needsLanguage || resolved.versionNotFound) return { error: 'unresolved' };
276
359
 
277
360
  const fileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
278
361
 
@@ -0,0 +1,86 @@
1
+ import { loadConfig } from './config.js';
2
+
3
+ const DEFAULT_TELEMETRY_URL = 'https://api.aichub.org/v1';
4
+
5
+ export function isTelemetryEnabled() {
6
+ if (process.env.CHUB_TELEMETRY === '0' || process.env.CHUB_TELEMETRY === 'false') return false;
7
+ const config = loadConfig();
8
+ return config.telemetry !== false;
9
+ }
10
+
11
+ export function getTelemetryUrl() {
12
+ const url = process.env.CHUB_TELEMETRY_URL;
13
+ if (url) return url;
14
+ const config = loadConfig();
15
+ return config.telemetry_url || DEFAULT_TELEMETRY_URL;
16
+ }
17
+
18
+ /**
19
+ * Send feedback to the API.
20
+ *
21
+ * @param {string} entryId - e.g. "openai/chat"
22
+ * @param {string} entryType - "doc" or "skill"
23
+ * @param {string} rating - "up" or "down"
24
+ * @param {object} opts - Additional context
25
+ * @param {string} [opts.comment]
26
+ * @param {string} [opts.docLang] - Language variant fetched
27
+ * @param {string} [opts.docVersion] - Version fetched
28
+ * @param {string} [opts.targetFile] - Specific file within the entry
29
+ * @param {string[]} [opts.labels] - Structured feedback labels
30
+ * @param {string} [opts.agent] - Agent name override
31
+ * @param {string} [opts.model] - LLM model override
32
+ * @param {string} [opts.cliVersion]
33
+ * @param {string} [opts.source] - Registry source name
34
+ */
35
+ export async function sendFeedback(entryId, entryType, rating, opts = {}) {
36
+ if (!isTelemetryEnabled()) return { status: 'skipped', reason: 'telemetry_disabled' };
37
+
38
+ const { getOrCreateClientId, detectAgent, detectAgentVersion } = await import('./identity.js');
39
+ const clientId = await getOrCreateClientId();
40
+ const telemetryUrl = getTelemetryUrl();
41
+
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), 3000);
44
+
45
+ try {
46
+ const res = await fetch(`${telemetryUrl}/feedback`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'X-Client-ID': clientId,
51
+ },
52
+ body: JSON.stringify({
53
+ entry_id: entryId,
54
+ entry_type: entryType,
55
+ rating,
56
+ // Doc-specific dimensions
57
+ doc_lang: opts.docLang || undefined,
58
+ doc_version: opts.docVersion || undefined,
59
+ target_file: opts.targetFile || undefined,
60
+ // Structured feedback
61
+ labels: opts.labels || undefined,
62
+ comment: opts.comment || undefined,
63
+ // Agent info
64
+ agent: {
65
+ name: opts.agent || detectAgent(),
66
+ version: detectAgentVersion(),
67
+ model: opts.model || undefined,
68
+ },
69
+ // Context
70
+ cli_version: opts.cliVersion || undefined,
71
+ source: opts.source || undefined,
72
+ }),
73
+ signal: controller.signal,
74
+ });
75
+ clearTimeout(timeout);
76
+
77
+ if (res.ok) {
78
+ const data = await res.json();
79
+ return { status: 'sent', feedback_id: data.feedback_id || data.id };
80
+ }
81
+ return { status: 'error', code: res.status };
82
+ } catch (err) {
83
+ clearTimeout(timeout);
84
+ return { status: 'error', reason: 'network' };
85
+ }
86
+ }
@@ -0,0 +1,177 @@
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
+
18
+ // Prevent console.log from corrupting the stdio JSON-RPC protocol.
19
+ // Any transitive dependency (e.g. posthog-node) that calls console.log
20
+ // would break the MCP transport without this redirect.
21
+ const _stderr = process.stderr;
22
+ console.log = (...args) => _stderr.write(args.join(' ') + '\n');
23
+ console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
24
+ console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
25
+ console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
26
+
27
+ // Read package version
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
30
+
31
+ // Create server
32
+ const server = new McpServer({
33
+ name: 'chub',
34
+ version: pkg.version,
35
+ });
36
+
37
+ // --- Register Tools ---
38
+
39
+ server.tool(
40
+ 'chub_search',
41
+ 'Search Context Hub for docs and skills by query, tags, or language',
42
+ {
43
+ query: z.string().optional().describe('Search query. Omit to list all entries.'),
44
+ tags: z.string().optional().describe('Comma-separated tag filter (e.g. "openai,chat")'),
45
+ lang: z.string().optional().describe('Filter by language (e.g. "python", "js")'),
46
+ limit: z.number().int().min(1).max(100).optional().describe('Max results (default 20)'),
47
+ },
48
+ async (args) => handleSearch(args),
49
+ );
50
+
51
+ server.tool(
52
+ 'chub_get',
53
+ 'Fetch the content of a doc or skill by ID from Context Hub',
54
+ {
55
+ id: z.string().describe('Entry ID (e.g. "openai/chat", "stripe/api"). Use source:id for disambiguation.'),
56
+ lang: z.string().optional().describe('Language variant (e.g. "python", "js"). Auto-selected if only one.'),
57
+ version: z.string().optional().describe('Specific version (e.g. "1.52.0"). Defaults to recommended.'),
58
+ full: z.boolean().optional().describe('Fetch all files, not just the entry point (default false)'),
59
+ file: z.string().optional().describe('Fetch a specific file by path (e.g. "references/streaming.md")'),
60
+ },
61
+ async (args) => handleGet(args),
62
+ );
63
+
64
+ server.tool(
65
+ 'chub_list',
66
+ 'List all available docs and skills in Context Hub',
67
+ {
68
+ tags: z.string().optional().describe('Comma-separated tag filter'),
69
+ lang: z.string().optional().describe('Filter by language'),
70
+ limit: z.number().int().min(1).max(500).optional().describe('Max entries (default 50)'),
71
+ },
72
+ async (args) => handleList(args),
73
+ );
74
+
75
+ server.tool(
76
+ 'chub_annotate',
77
+ '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.',
78
+ {
79
+ id: z.string().optional().describe('Entry ID to annotate (e.g. "openai/chat"). Required unless using list mode.'),
80
+ note: z.string().optional().describe('Annotation text to save. Omit to read existing annotation.'),
81
+ clear: z.boolean().optional().describe('Remove the annotation for this entry (default false)'),
82
+ list: z.boolean().optional().describe('List all annotations (default false). When true, id is not needed.'),
83
+ },
84
+ async (args) => handleAnnotate(args),
85
+ );
86
+
87
+ server.tool(
88
+ 'chub_feedback',
89
+ 'Send quality feedback (thumbs up/down) for a doc or skill to help authors improve content',
90
+ {
91
+ id: z.string().describe('Entry ID to rate (e.g. "openai/chat")'),
92
+ rating: z.enum(['up', 'down']).describe('Thumbs up or down'),
93
+ comment: z.string().optional().describe('Optional comment explaining the rating'),
94
+ type: z.enum(['doc', 'skill']).optional().describe('Entry type. Auto-detected if omitted.'),
95
+ lang: z.string().optional().describe('Language variant rated'),
96
+ version: z.string().optional().describe('Version rated'),
97
+ file: z.string().optional().describe('Specific file rated'),
98
+ labels: z.array(z.enum([
99
+ 'accurate', 'well-structured', 'helpful', 'good-examples',
100
+ 'outdated', 'inaccurate', 'incomplete', 'wrong-examples',
101
+ 'wrong-version', 'poorly-structured',
102
+ ])).optional().describe('Structured feedback labels'),
103
+ },
104
+ async (args) => handleFeedback(args),
105
+ );
106
+
107
+ // --- Register Resource ---
108
+
109
+ server.resource(
110
+ 'registry',
111
+ 'chub://registry',
112
+ {
113
+ title: 'Context Hub Registry',
114
+ description: 'Browse the full Context Hub registry of docs and skills',
115
+ mimeType: 'application/json',
116
+ },
117
+ async (uri) => {
118
+ try {
119
+ const entries = listEntries({});
120
+ const simplified = entries.map((entry) => ({
121
+ id: entry.id,
122
+ name: entry.name,
123
+ type: entry._type || (entry.languages ? 'doc' : 'skill'),
124
+ description: entry.description,
125
+ tags: entry.tags || [],
126
+ ...(entry.languages
127
+ ? {
128
+ languages: entry.languages.map((l) => ({
129
+ language: l.language,
130
+ versions: l.versions?.map((v) => v.version) || [],
131
+ recommended: l.recommendedVersion,
132
+ })),
133
+ }
134
+ : {}),
135
+ }));
136
+ return {
137
+ contents: [{
138
+ uri: uri.href,
139
+ mimeType: 'application/json',
140
+ text: JSON.stringify({ entries: simplified, total: simplified.length }, null, 2),
141
+ }],
142
+ };
143
+ } catch (err) {
144
+ console.warn(`Registry resource error: ${err.message}`);
145
+ return {
146
+ contents: [{
147
+ uri: uri.href,
148
+ mimeType: 'application/json',
149
+ text: JSON.stringify({ error: 'Registry not loaded. Run "chub update" first.' }),
150
+ }],
151
+ };
152
+ }
153
+ },
154
+ );
155
+
156
+ // --- Process Safety ---
157
+
158
+ // Prevent the server from crashing on unhandled errors (long-lived process)
159
+ process.on('uncaughtException', (err) => {
160
+ _stderr.write(`[chub-mcp] Uncaught exception: ${err.message}\n`);
161
+ });
162
+ process.on('unhandledRejection', (reason) => {
163
+ _stderr.write(`[chub-mcp] Unhandled rejection: ${reason}\n`);
164
+ });
165
+
166
+ // --- Start Server ---
167
+
168
+ // Best-effort registry load — server starts even if this fails
169
+ try {
170
+ await ensureRegistry();
171
+ } catch (err) {
172
+ _stderr.write(`[chub-mcp] Warning: Registry not loaded: ${err.message}\n`);
173
+ }
174
+
175
+ const transport = new StdioServerTransport();
176
+ await server.connect(transport);
177
+ _stderr.write(`[chub-mcp] Server started (v${pkg.version})\n`);
@@ -0,0 +1,251 @@
1
+ /**
2
+ * MCP tool handler implementations.
3
+ * Each handler wraps existing lib/ functions and returns MCP-compatible results.
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, join, resolve, relative } from 'node:path';
9
+ import { searchEntries, getEntry, listEntries, resolveDocPath, resolveEntryFile } from '../lib/registry.js';
10
+ import { fetchDoc, fetchDocFull } from '../lib/cache.js';
11
+ import { readAnnotation, writeAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
12
+ import { sendFeedback, isTelemetryEnabled } from '../lib/telemetry.js';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ let _cliVersion;
16
+ function getCliVersion() {
17
+ if (_cliVersion) return _cliVersion;
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
20
+ _cliVersion = pkg.version;
21
+ } catch {
22
+ _cliVersion = 'unknown';
23
+ }
24
+ return _cliVersion;
25
+ }
26
+
27
+ function textResult(data) {
28
+ return {
29
+ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
30
+ };
31
+ }
32
+
33
+ function errorResult(message, details = {}) {
34
+ return {
35
+ content: [{ type: 'text', text: JSON.stringify({ error: message, ...details }, null, 2) }],
36
+ isError: true,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Simplify an entry for agent-friendly output (strip internal fields).
42
+ */
43
+ function simplifyEntry(entry) {
44
+ const result = {
45
+ id: entry.id,
46
+ name: entry.name,
47
+ type: entry._type || (entry.languages ? 'doc' : 'skill'),
48
+ description: entry.description,
49
+ tags: entry.tags || [],
50
+ };
51
+ if (entry.languages) {
52
+ result.languages = entry.languages.map((l) => l.language);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ // --- Tool Handlers ---
58
+
59
+ export async function handleSearch({ query, tags, lang, limit = 20 }) {
60
+ try {
61
+ let entries;
62
+ if (query) {
63
+ entries = searchEntries(query, { tags, lang });
64
+ } else {
65
+ entries = listEntries({ tags, lang });
66
+ }
67
+ const sliced = entries.slice(0, limit);
68
+ return textResult({
69
+ results: sliced.map(simplifyEntry),
70
+ total: entries.length,
71
+ showing: sliced.length,
72
+ });
73
+ } catch (err) {
74
+ return errorResult(`Search failed: ${err.message}`);
75
+ }
76
+ }
77
+
78
+ export async function handleGet({ id, lang, version, full = false, file }) {
79
+ try {
80
+ // Validate file parameter early (before entry lookup) to reject path traversal
81
+ if (file) {
82
+ const normalizedFile = resolve('/', file).slice(1);
83
+ if (normalizedFile !== file || file.includes('..')) {
84
+ return errorResult(`Invalid file path: "${file}". Path traversal is not allowed.`);
85
+ }
86
+ }
87
+
88
+ const result = getEntry(id);
89
+
90
+ if (result.ambiguous) {
91
+ return errorResult(`Ambiguous entry ID "${id}". Be specific:`, {
92
+ alternatives: result.alternatives,
93
+ });
94
+ }
95
+
96
+ if (!result.entry) {
97
+ return errorResult(`Entry "${id}" not found.`, {
98
+ suggestion: 'Use chub_search to find available entries.',
99
+ });
100
+ }
101
+
102
+ const entry = result.entry;
103
+ const type = entry.languages ? 'doc' : 'skill';
104
+ const resolved = resolveDocPath(entry, lang, version);
105
+
106
+ if (!resolved) {
107
+ return errorResult(`Could not resolve path for "${id}".`);
108
+ }
109
+
110
+ if (resolved.versionNotFound) {
111
+ return errorResult(`Version "${resolved.requested}" not found for "${id}".`, {
112
+ available: resolved.available,
113
+ });
114
+ }
115
+
116
+ if (resolved.needsLanguage) {
117
+ return errorResult(`Multiple languages available for "${id}". Specify the lang parameter.`, {
118
+ available: resolved.available,
119
+ });
120
+ }
121
+
122
+ const entryFile = resolveEntryFile(resolved, type);
123
+ if (entryFile.error) {
124
+ return errorResult(`"${id}": ${entryFile.error}`);
125
+ }
126
+
127
+ let content;
128
+
129
+ if (file) {
130
+ // Fetch a specific file
131
+ if (!resolved.files.includes(file)) {
132
+ const entryFileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
133
+ const available = resolved.files.filter((f) => f !== entryFileName);
134
+ return errorResult(`File "${file}" not found in ${id}.`, {
135
+ available: available.length > 0 ? available : '(none)',
136
+ });
137
+ }
138
+ content = await fetchDoc(resolved.source, join(resolved.path, file));
139
+ } else if (full && resolved.files.length > 0) {
140
+ // Fetch all files
141
+ const allFiles = await fetchDocFull(resolved.source, resolved.path, resolved.files);
142
+ content = allFiles.map((f) => `# FILE: ${f.name}\n\n${f.content}`).join('\n\n---\n\n');
143
+ } else {
144
+ // Fetch entry point only
145
+ content = await fetchDoc(resolved.source, entryFile.filePath);
146
+ }
147
+
148
+ // Append annotation if present
149
+ const annotation = readAnnotation(entry.id);
150
+ if (annotation) {
151
+ content += `\n\n---\n[Agent note — ${annotation.updatedAt}]\n${annotation.note}\n`;
152
+ }
153
+
154
+ return textResult(content);
155
+ } catch (err) {
156
+ return errorResult(`Failed to fetch "${id}": ${err.message}`);
157
+ }
158
+ }
159
+
160
+ export async function handleList({ tags, lang, limit = 50 }) {
161
+ try {
162
+ const entries = listEntries({ tags, lang });
163
+ const sliced = entries.slice(0, limit);
164
+ return textResult({
165
+ entries: sliced.map(simplifyEntry),
166
+ total: entries.length,
167
+ showing: sliced.length,
168
+ });
169
+ } catch (err) {
170
+ return errorResult(`List failed: ${err.message}`);
171
+ }
172
+ }
173
+
174
+ export async function handleAnnotate({ id, note, clear = false, list = false }) {
175
+ try {
176
+ if (list) {
177
+ const annotations = listAnnotations();
178
+ return textResult({ annotations, total: annotations.length });
179
+ }
180
+
181
+ if (!id) {
182
+ return errorResult('Missing required parameter: id. Provide an entry ID or use list mode.');
183
+ }
184
+
185
+ // Validate entry ID to prevent path traversal or filesystem abuse
186
+ if (id.length > 200) {
187
+ return errorResult('Entry ID too long (max 200 characters).');
188
+ }
189
+ if (!/^[a-zA-Z0-9._\-\/]+$/.test(id)) {
190
+ return errorResult('Entry ID contains invalid characters. Use only alphanumeric, hyphens, underscores, dots, and slashes.');
191
+ }
192
+
193
+ if (clear) {
194
+ const removed = clearAnnotation(id);
195
+ return textResult({
196
+ status: removed ? 'cleared' : 'not_found',
197
+ id,
198
+ });
199
+ }
200
+
201
+ if (note) {
202
+ const saved = writeAnnotation(id, note);
203
+ return textResult({ status: 'saved', annotation: saved });
204
+ }
205
+
206
+ // Read mode
207
+ const annotation = readAnnotation(id);
208
+ if (annotation) {
209
+ return textResult({ annotation });
210
+ }
211
+ return textResult({ status: 'no_annotation', id });
212
+ } catch (err) {
213
+ return errorResult(`Annotation failed: ${err.message}`);
214
+ }
215
+ }
216
+
217
+ export async function handleFeedback({ id, rating, comment, type, lang, version, file, labels }) {
218
+ try {
219
+ if (!isTelemetryEnabled()) {
220
+ return textResult({ status: 'skipped', reason: 'telemetry_disabled' });
221
+ }
222
+
223
+ // Auto-detect entry type if not provided
224
+ let entryType = type;
225
+ if (!entryType) {
226
+ try {
227
+ const result = getEntry(id);
228
+ if (result.entry) {
229
+ entryType = result.entry.languages ? 'doc' : 'skill';
230
+ }
231
+ } catch {
232
+ // Fall through with undefined type
233
+ }
234
+ entryType = entryType || 'doc';
235
+ }
236
+
237
+ const result = await sendFeedback(id, entryType, rating, {
238
+ comment,
239
+ docLang: lang,
240
+ docVersion: version,
241
+ targetFile: file,
242
+ labels,
243
+ agent: 'mcp-server',
244
+ cliVersion: getCliVersion(),
245
+ });
246
+
247
+ return textResult(result);
248
+ } catch (err) {
249
+ return errorResult(`Feedback failed: ${err.message}`);
250
+ }
251
+ }