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.
- package/README.md +76 -0
- package/bin/chub-mcp +2 -0
- package/package.json +11 -6
- package/skills/get-api-docs/SKILL.md +81 -0
- package/src/commands/annotate.js +83 -0
- package/src/commands/build.js +21 -4
- package/src/commands/feedback.js +12 -9
- package/src/commands/get.js +77 -12
- package/src/commands/help.js +34 -0
- package/src/commands/search.js +17 -8
- package/src/index.js +35 -67
- package/src/lib/analytics.js +13 -2
- package/src/lib/annotations.js +57 -0
- package/src/lib/bm25.js +303 -0
- package/src/lib/cache.js +108 -17
- package/src/lib/config.js +15 -2
- package/src/lib/help.js +158 -0
- package/src/lib/identity.js +12 -1
- package/src/lib/registry.js +283 -27
- package/src/lib/telemetry.js +7 -1
- package/src/lib/welcome.js +42 -0
- package/src/mcp/server.js +184 -0
- package/src/mcp/stdio-lifecycle.js +54 -0
- package/src/mcp/tools.js +286 -0
- package/dist/anthropic/docs/sdk/javascript/DOC.md +0 -499
- package/dist/anthropic/docs/sdk/python/DOC.md +0 -382
- package/dist/openai/docs/chat/javascript/DOC.md +0 -350
- package/dist/openai/docs/chat/python/DOC.md +0 -526
- package/dist/pinecone/docs/sdk/javascript/DOC.md +0 -984
- package/dist/pinecone/docs/sdk/python/DOC.md +0 -1395
- package/dist/registry.json +0 -276
- package/dist/resend/docs/sdk/DOC.md +0 -1271
- package/dist/stripe/docs/api/DOC.md +0 -1726
- package/dist/supabase/docs/sdk/DOC.md +0 -1606
- package/dist/twilio/docs/sdk/python/DOC.md +0 -469
- package/dist/twilio/docs/sdk/typescript/DOC.md +0 -946
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach stdio lifecycle guards so chub-mcp exits cleanly when the parent
|
|
3
|
+
* MCP host goes away (EOF / closed pipe).
|
|
4
|
+
*/
|
|
5
|
+
export function attachStdioShutdownHandlers({
|
|
6
|
+
stdin = process.stdin,
|
|
7
|
+
stdout = process.stdout,
|
|
8
|
+
stderr = process.stderr,
|
|
9
|
+
onShutdown = () => process.exit(0),
|
|
10
|
+
} = {}) {
|
|
11
|
+
let shuttingDown = false;
|
|
12
|
+
|
|
13
|
+
const shutdown = (reason) => {
|
|
14
|
+
if (shuttingDown) return;
|
|
15
|
+
shuttingDown = true;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
stderr.write(`[chub-mcp] ${reason}\n`);
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore stderr write errors during shutdown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onShutdown(0);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const onStdinEnd = () => shutdown('Stdin closed; exiting.');
|
|
27
|
+
const onStdinClose = () => shutdown('Stdin stream closed; exiting.');
|
|
28
|
+
const onStdinError = (err) => {
|
|
29
|
+
const detail = err?.code || err?.message || 'unknown';
|
|
30
|
+
shutdown(`Stdin error (${detail}); exiting.`);
|
|
31
|
+
};
|
|
32
|
+
const onStdoutError = (err) => {
|
|
33
|
+
if (err?.code === 'EPIPE') {
|
|
34
|
+
shutdown('Stdout pipe closed (EPIPE); exiting.');
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
stdin.on('end', onStdinEnd);
|
|
39
|
+
stdin.on('close', onStdinClose);
|
|
40
|
+
stdin.on('error', onStdinError);
|
|
41
|
+
stdout.on('error', onStdoutError);
|
|
42
|
+
|
|
43
|
+
// Keep stdin flowing so EOF/end is observed reliably across hosts.
|
|
44
|
+
if (typeof stdin.resume === 'function') {
|
|
45
|
+
stdin.resume();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
stdin.off('end', onStdinEnd);
|
|
50
|
+
stdin.off('close', onStdinClose);
|
|
51
|
+
stdin.off('error', onStdinError);
|
|
52
|
+
stdout.off('error', onStdoutError);
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/mcp/tools.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
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, isFeedbackEnabled } from '../lib/telemetry.js';
|
|
13
|
+
import { trackEvent, setCliVersion } from '../lib/analytics.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
let _cliVersion;
|
|
17
|
+
function getCliVersion() {
|
|
18
|
+
if (_cliVersion) return _cliVersion;
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
21
|
+
_cliVersion = pkg.version;
|
|
22
|
+
setCliVersion(_cliVersion);
|
|
23
|
+
} catch {
|
|
24
|
+
_cliVersion = 'unknown';
|
|
25
|
+
}
|
|
26
|
+
return _cliVersion;
|
|
27
|
+
}
|
|
28
|
+
// Initialize cli_version for analytics on module load
|
|
29
|
+
getCliVersion();
|
|
30
|
+
|
|
31
|
+
function textResult(data) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorResult(message, details = {}) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: JSON.stringify({ error: message, ...details }, null, 2) }],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Simplify an entry for agent-friendly output (strip internal fields).
|
|
46
|
+
*/
|
|
47
|
+
function simplifyEntry(entry) {
|
|
48
|
+
const result = {
|
|
49
|
+
id: entry.id,
|
|
50
|
+
name: entry.name,
|
|
51
|
+
type: entry._type || (entry.languages ? 'doc' : 'skill'),
|
|
52
|
+
description: entry.description,
|
|
53
|
+
tags: entry.tags || [],
|
|
54
|
+
};
|
|
55
|
+
if (entry.languages) {
|
|
56
|
+
result.languages = entry.languages.map((l) => l.language);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Tool Handlers ---
|
|
62
|
+
|
|
63
|
+
export async function handleSearch({ query, tags, lang, limit = 20 }) {
|
|
64
|
+
try {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
let entries;
|
|
67
|
+
if (query) {
|
|
68
|
+
entries = searchEntries(query, { tags, lang });
|
|
69
|
+
} else {
|
|
70
|
+
entries = listEntries({ tags, lang });
|
|
71
|
+
}
|
|
72
|
+
const sliced = entries.slice(0, limit);
|
|
73
|
+
if (query) {
|
|
74
|
+
trackEvent('search', {
|
|
75
|
+
query: query.slice(0, 1000),
|
|
76
|
+
query_length: query.length,
|
|
77
|
+
result_count: sliced.length,
|
|
78
|
+
results: sliced.map((e) => e.id || e.name || 'unknown'),
|
|
79
|
+
duration_ms: Date.now() - start,
|
|
80
|
+
has_tags: !!tags,
|
|
81
|
+
has_lang: !!lang,
|
|
82
|
+
tags: tags || undefined,
|
|
83
|
+
lang: lang || undefined,
|
|
84
|
+
via: 'mcp',
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
return textResult({
|
|
88
|
+
results: sliced.map(simplifyEntry),
|
|
89
|
+
total: entries.length,
|
|
90
|
+
showing: sliced.length,
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return errorResult(`Search failed: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function handleGet({ id, lang, version, full = false, file }) {
|
|
98
|
+
const start = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
// Validate file parameter early (before entry lookup) to reject path traversal
|
|
101
|
+
if (file) {
|
|
102
|
+
const normalizedFile = resolve('/', file).slice(1);
|
|
103
|
+
if (normalizedFile !== file || file.includes('..')) {
|
|
104
|
+
return errorResult(`Invalid file path: "${file}". Path traversal is not allowed.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = getEntry(id);
|
|
109
|
+
|
|
110
|
+
if (result.ambiguous) {
|
|
111
|
+
return errorResult(`Ambiguous entry ID "${id}". Be specific:`, {
|
|
112
|
+
alternatives: result.alternatives,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!result.entry) {
|
|
117
|
+
trackEvent('doc_not_found', { entry_id: id, via: 'mcp' }).catch(() => {});
|
|
118
|
+
return errorResult(`Entry "${id}" not found.`, {
|
|
119
|
+
suggestion: 'Use chub_search to find available entries.',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const entry = result.entry;
|
|
124
|
+
const type = entry.languages ? 'doc' : 'skill';
|
|
125
|
+
const resolved = resolveDocPath(entry, lang, version);
|
|
126
|
+
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
return errorResult(`Could not resolve path for "${id}".`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (resolved.versionNotFound) {
|
|
132
|
+
return errorResult(`Version "${resolved.requested}" not found for "${id}".`, {
|
|
133
|
+
available: resolved.available,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (resolved.needsLanguage) {
|
|
138
|
+
return errorResult(`Multiple languages available for "${id}". Specify the lang parameter.`, {
|
|
139
|
+
available: resolved.available,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entryFile = resolveEntryFile(resolved, type);
|
|
144
|
+
if (entryFile.error) {
|
|
145
|
+
return errorResult(`"${id}": ${entryFile.error}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let content;
|
|
149
|
+
|
|
150
|
+
if (file) {
|
|
151
|
+
// Fetch a specific file
|
|
152
|
+
if (!resolved.files.includes(file)) {
|
|
153
|
+
const entryFileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
|
|
154
|
+
const available = resolved.files.filter((f) => f !== entryFileName);
|
|
155
|
+
return errorResult(`File "${file}" not found in ${id}.`, {
|
|
156
|
+
available: available.length > 0 ? available : '(none)',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
content = await fetchDoc(resolved.source, join(resolved.path, file));
|
|
160
|
+
} else if (full && resolved.files.length > 0) {
|
|
161
|
+
// Fetch all files
|
|
162
|
+
const allFiles = await fetchDocFull(resolved.source, resolved.path, resolved.files);
|
|
163
|
+
content = allFiles.map((f) => `# FILE: ${f.name}\n\n${f.content}`).join('\n\n---\n\n');
|
|
164
|
+
} else {
|
|
165
|
+
// Fetch entry point only
|
|
166
|
+
content = await fetchDoc(resolved.source, entryFile.filePath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Append annotation if present
|
|
170
|
+
const annotation = readAnnotation(entry.id);
|
|
171
|
+
if (annotation) {
|
|
172
|
+
content += `\n\n---\n[Agent note — ${annotation.updatedAt}]\n${annotation.note}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const entryType = entry.languages ? 'doc' : 'skill';
|
|
176
|
+
const duration_ms = Date.now() - start;
|
|
177
|
+
// Emit same event names as CLI for consistent analytics
|
|
178
|
+
trackEvent(entryType === 'doc' ? 'doc_fetched' : 'skill_fetched', {
|
|
179
|
+
entry_id: entry.id,
|
|
180
|
+
full,
|
|
181
|
+
file: file || undefined,
|
|
182
|
+
lang: lang || undefined,
|
|
183
|
+
source: entry._source || undefined,
|
|
184
|
+
duration_ms,
|
|
185
|
+
via: 'mcp',
|
|
186
|
+
}).catch(() => {});
|
|
187
|
+
|
|
188
|
+
return textResult(content);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
trackEvent('fetch_error', { entry_id: id, via: 'mcp', error_type: err.code || err.name || 'unknown' }).catch(() => {});
|
|
191
|
+
return errorResult(`Failed to fetch "${id}": ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function handleList({ tags, lang, limit = 50 }) {
|
|
196
|
+
try {
|
|
197
|
+
const entries = listEntries({ tags, lang });
|
|
198
|
+
const sliced = entries.slice(0, limit);
|
|
199
|
+
return textResult({
|
|
200
|
+
entries: sliced.map(simplifyEntry),
|
|
201
|
+
total: entries.length,
|
|
202
|
+
showing: sliced.length,
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return errorResult(`List failed: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function handleAnnotate({ id, note, clear = false, list = false }) {
|
|
210
|
+
try {
|
|
211
|
+
if (list) {
|
|
212
|
+
const annotations = listAnnotations();
|
|
213
|
+
return textResult({ annotations, total: annotations.length });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!id) {
|
|
217
|
+
return errorResult('Missing required parameter: id. Provide an entry ID or use list mode.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Validate entry ID to prevent path traversal or filesystem abuse
|
|
221
|
+
if (id.length > 200) {
|
|
222
|
+
return errorResult('Entry ID too long (max 200 characters).');
|
|
223
|
+
}
|
|
224
|
+
if (!/^[a-zA-Z0-9._\-\/]+$/.test(id)) {
|
|
225
|
+
return errorResult('Entry ID contains invalid characters. Use only alphanumeric, hyphens, underscores, dots, and slashes.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (clear) {
|
|
229
|
+
const removed = clearAnnotation(id);
|
|
230
|
+
return textResult({
|
|
231
|
+
status: removed ? 'cleared' : 'not_found',
|
|
232
|
+
id,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (note) {
|
|
237
|
+
const saved = writeAnnotation(id, note);
|
|
238
|
+
return textResult({ status: 'saved', annotation: saved });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Read mode
|
|
242
|
+
const annotation = readAnnotation(id);
|
|
243
|
+
if (annotation) {
|
|
244
|
+
return textResult({ annotation });
|
|
245
|
+
}
|
|
246
|
+
return textResult({ status: 'no_annotation', id });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return errorResult(`Annotation failed: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function handleFeedback({ id, rating, comment, type, lang, version, file, labels }) {
|
|
253
|
+
try {
|
|
254
|
+
if (!isFeedbackEnabled()) {
|
|
255
|
+
return textResult({ status: 'skipped', reason: 'feedback_disabled' });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Auto-detect entry type if not provided
|
|
259
|
+
let entryType = type;
|
|
260
|
+
if (!entryType) {
|
|
261
|
+
try {
|
|
262
|
+
const result = getEntry(id);
|
|
263
|
+
if (result.entry) {
|
|
264
|
+
entryType = result.entry.languages ? 'doc' : 'skill';
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// Fall through with undefined type
|
|
268
|
+
}
|
|
269
|
+
entryType = entryType || 'doc';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const result = await sendFeedback(id, entryType, rating, {
|
|
273
|
+
comment,
|
|
274
|
+
docLang: lang,
|
|
275
|
+
docVersion: version,
|
|
276
|
+
targetFile: file,
|
|
277
|
+
labels,
|
|
278
|
+
agent: 'mcp-server',
|
|
279
|
+
cliVersion: getCliVersion(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return textResult(result);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return errorResult(`Feedback failed: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|