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.
- package/README.md +55 -0
- package/bin/chub-mcp +2 -0
- package/dist/airtable/docs/database/javascript/DOC.md +1437 -0
- package/dist/airtable/docs/database/python/DOC.md +1735 -0
- package/dist/amplitude/docs/analytics/javascript/DOC.md +1282 -0
- package/dist/amplitude/docs/analytics/python/DOC.md +1199 -0
- package/dist/anthropic/docs/claude-api/javascript/DOC.md +503 -0
- package/dist/anthropic/docs/claude-api/python/DOC.md +389 -0
- package/dist/asana/docs/tasks/DOC.md +1396 -0
- package/dist/assemblyai/docs/transcription/DOC.md +1043 -0
- package/dist/atlassian/docs/confluence/javascript/DOC.md +1347 -0
- package/dist/atlassian/docs/confluence/python/DOC.md +1604 -0
- package/dist/auth0/docs/identity/javascript/DOC.md +968 -0
- package/dist/auth0/docs/identity/python/DOC.md +1199 -0
- package/dist/aws/docs/s3/javascript/DOC.md +1773 -0
- package/dist/aws/docs/s3/python/DOC.md +1807 -0
- package/dist/binance/docs/trading/javascript/DOC.md +1315 -0
- package/dist/binance/docs/trading/python/DOC.md +1454 -0
- package/dist/braintree/docs/gateway/javascript/DOC.md +1278 -0
- package/dist/braintree/docs/gateway/python/DOC.md +1179 -0
- package/dist/chromadb/docs/embeddings-db/javascript/DOC.md +1263 -0
- package/dist/chromadb/docs/embeddings-db/python/DOC.md +1707 -0
- package/dist/clerk/docs/auth/javascript/DOC.md +1220 -0
- package/dist/clerk/docs/auth/python/DOC.md +274 -0
- package/dist/cloudflare/docs/workers/javascript/DOC.md +918 -0
- package/dist/cloudflare/docs/workers/python/DOC.md +994 -0
- package/dist/cockroachdb/docs/distributed-db/DOC.md +1500 -0
- package/dist/cohere/docs/llm/DOC.md +1335 -0
- package/dist/datadog/docs/monitoring/javascript/DOC.md +1740 -0
- package/dist/datadog/docs/monitoring/python/DOC.md +1815 -0
- package/dist/deepgram/docs/speech/javascript/DOC.md +885 -0
- package/dist/deepgram/docs/speech/python/DOC.md +685 -0
- package/dist/deepl/docs/translation/javascript/DOC.md +887 -0
- package/dist/deepl/docs/translation/python/DOC.md +944 -0
- package/dist/deepseek/docs/llm/DOC.md +1220 -0
- package/dist/directus/docs/headless-cms/javascript/DOC.md +1128 -0
- package/dist/directus/docs/headless-cms/python/DOC.md +1276 -0
- package/dist/discord/docs/bot/javascript/DOC.md +1090 -0
- package/dist/discord/docs/bot/python/DOC.md +1130 -0
- package/dist/elasticsearch/docs/search/DOC.md +1634 -0
- package/dist/elevenlabs/docs/text-to-speech/javascript/DOC.md +336 -0
- package/dist/elevenlabs/docs/text-to-speech/python/DOC.md +552 -0
- package/dist/firebase/docs/auth/DOC.md +1015 -0
- package/dist/gemini/docs/genai/javascript/DOC.md +691 -0
- package/dist/gemini/docs/genai/python/DOC.md +555 -0
- package/dist/github/docs/octokit/DOC.md +1560 -0
- package/dist/google/docs/bigquery/javascript/DOC.md +1688 -0
- package/dist/google/docs/bigquery/python/DOC.md +1503 -0
- package/dist/hubspot/docs/crm/javascript/DOC.md +1805 -0
- package/dist/hubspot/docs/crm/python/DOC.md +2033 -0
- package/dist/huggingface/docs/transformers/DOC.md +948 -0
- package/dist/intercom/docs/messaging/javascript/DOC.md +1844 -0
- package/dist/intercom/docs/messaging/python/DOC.md +1797 -0
- package/dist/jira/docs/issues/javascript/DOC.md +1420 -0
- package/dist/jira/docs/issues/python/DOC.md +1492 -0
- package/dist/kafka/docs/streaming/javascript/DOC.md +1671 -0
- package/dist/kafka/docs/streaming/python/DOC.md +1464 -0
- package/dist/landingai-ade/docs/api/DOC.md +620 -0
- package/dist/landingai-ade/docs/sdk/python/DOC.md +489 -0
- package/dist/landingai-ade/docs/sdk/typescript/DOC.md +542 -0
- package/dist/landingai-ade/skills/SKILL.md +489 -0
- package/dist/launchdarkly/docs/feature-flags/javascript/DOC.md +1191 -0
- package/dist/launchdarkly/docs/feature-flags/python/DOC.md +1671 -0
- package/dist/linear/docs/tracker/DOC.md +1554 -0
- package/dist/livekit/docs/realtime/javascript/DOC.md +303 -0
- package/dist/livekit/docs/realtime/python/DOC.md +163 -0
- package/dist/mailchimp/docs/marketing/DOC.md +1420 -0
- package/dist/meilisearch/docs/search/DOC.md +1241 -0
- package/dist/microsoft/docs/onedrive/javascript/DOC.md +1421 -0
- package/dist/microsoft/docs/onedrive/python/DOC.md +1549 -0
- package/dist/mongodb/docs/atlas/DOC.md +2041 -0
- package/dist/notion/docs/workspace-api/javascript/DOC.md +1435 -0
- package/dist/notion/docs/workspace-api/python/DOC.md +1400 -0
- package/dist/okta/docs/identity/javascript/DOC.md +1171 -0
- package/dist/okta/docs/identity/python/DOC.md +1401 -0
- package/dist/openai/docs/chat/javascript/DOC.md +407 -0
- package/dist/openai/docs/chat/python/DOC.md +568 -0
- package/dist/paypal/docs/checkout/DOC.md +278 -0
- package/dist/pinecone/docs/sdk/javascript/DOC.md +984 -0
- package/dist/pinecone/docs/sdk/python/DOC.md +1395 -0
- package/dist/plaid/docs/banking/javascript/DOC.md +1163 -0
- package/dist/plaid/docs/banking/python/DOC.md +1203 -0
- package/dist/playwright-community/skills/login-flows/SKILL.md +108 -0
- package/dist/postmark/docs/transactional-email/DOC.md +1168 -0
- package/dist/prisma/docs/orm/javascript/DOC.md +1419 -0
- package/dist/prisma/docs/orm/python/DOC.md +1317 -0
- package/dist/qdrant/docs/vector-search/javascript/DOC.md +1221 -0
- package/dist/qdrant/docs/vector-search/python/DOC.md +1653 -0
- package/dist/rabbitmq/docs/message-queue/javascript/DOC.md +1193 -0
- package/dist/rabbitmq/docs/message-queue/python/DOC.md +1243 -0
- package/dist/razorpay/docs/payments/javascript/DOC.md +1219 -0
- package/dist/razorpay/docs/payments/python/DOC.md +1330 -0
- package/dist/redis/docs/key-value/javascript/DOC.md +1851 -0
- package/dist/redis/docs/key-value/python/DOC.md +2054 -0
- package/dist/registry.json +2817 -0
- package/dist/replicate/docs/model-hosting/DOC.md +1318 -0
- package/dist/resend/docs/email/DOC.md +1271 -0
- package/dist/salesforce/docs/crm/javascript/DOC.md +1241 -0
- package/dist/salesforce/docs/crm/python/DOC.md +1183 -0
- package/dist/search-index.json +1 -0
- package/dist/sendgrid/docs/email-api/javascript/DOC.md +371 -0
- package/dist/sendgrid/docs/email-api/python/DOC.md +656 -0
- package/dist/sentry/docs/error-tracking/javascript/DOC.md +1073 -0
- package/dist/sentry/docs/error-tracking/python/DOC.md +1309 -0
- package/dist/shopify/docs/storefront/DOC.md +457 -0
- package/dist/slack/docs/workspace/javascript/DOC.md +933 -0
- package/dist/slack/docs/workspace/python/DOC.md +271 -0
- package/dist/square/docs/payments/javascript/DOC.md +1855 -0
- package/dist/square/docs/payments/python/DOC.md +1728 -0
- package/dist/stripe/docs/api/DOC.md +1727 -0
- package/dist/stripe/docs/payments/DOC.md +1726 -0
- package/dist/stytch/docs/auth/javascript/DOC.md +1813 -0
- package/dist/stytch/docs/auth/python/DOC.md +1962 -0
- package/dist/supabase/docs/client/DOC.md +1606 -0
- package/dist/twilio/docs/messaging/python/DOC.md +469 -0
- package/dist/twilio/docs/messaging/typescript/DOC.md +946 -0
- package/dist/vercel/docs/platform/DOC.md +1940 -0
- package/dist/weaviate/docs/vector-db/javascript/DOC.md +1268 -0
- package/dist/weaviate/docs/vector-db/python/DOC.md +1388 -0
- package/dist/zendesk/docs/support/javascript/DOC.md +2150 -0
- package/dist/zendesk/docs/support/python/DOC.md +2297 -0
- package/package.json +22 -6
- package/skills/get-api-docs/SKILL.md +84 -0
- package/src/commands/annotate.js +83 -0
- package/src/commands/build.js +12 -1
- package/src/commands/feedback.js +150 -0
- package/src/commands/get.js +83 -42
- package/src/commands/search.js +7 -0
- package/src/index.js +43 -17
- package/src/lib/analytics.js +90 -0
- package/src/lib/annotations.js +57 -0
- package/src/lib/bm25.js +170 -0
- package/src/lib/cache.js +69 -6
- package/src/lib/config.js +8 -3
- package/src/lib/identity.js +99 -0
- package/src/lib/registry.js +103 -20
- package/src/lib/telemetry.js +86 -0
- package/src/mcp/server.js +177 -0
- package/src/mcp/tools.js +251 -0
package/src/lib/registry.js
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
216
|
+
results = deduped.map((entry) => {
|
|
217
|
+
let score = 0;
|
|
146
218
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
if (
|
|
153
|
-
if (nameLower.includes(
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
package/src/mcp/tools.js
ADDED
|
@@ -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
|
+
}
|