chub-dev 0.2.0-beta.4 → 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 +10 -5
- package/skills/get-api-docs/SKILL.md +81 -0
- package/src/commands/annotate.js +1 -1
- package/src/commands/build.js +12 -4
- package/src/commands/feedback.js +12 -9
- package/src/commands/get.js +32 -11
- package/src/commands/help.js +34 -0
- package/src/commands/search.js +17 -8
- package/src/index.js +31 -65
- package/src/lib/analytics.js +13 -2
- package/src/lib/bm25.js +185 -52
- package/src/lib/cache.js +94 -17
- package/src/lib/config.js +14 -1
- package/src/lib/help.js +158 -0
- package/src/lib/identity.js +12 -1
- package/src/lib/registry.js +236 -63
- 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
|
@@ -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`);
|
|
@@ -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
|
+
}
|