recker 1.0.12-alpha.04c18a7 → 1.0.12-alpha.1ab8aa7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,16 @@
1
1
  import { createServer } from 'http';
2
2
  import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
3
- import { join, relative } from 'path';
3
+ import { join, relative, extname, basename, dirname } from 'path';
4
4
  import { createInterface } from 'readline';
5
+ import { fileURLToPath } from 'url';
6
+ import { createHybridSearch } from './search/index.js';
5
7
  export class MCPServer {
6
8
  options;
7
9
  server;
10
+ hybridSearch;
8
11
  docsIndex = [];
12
+ codeExamples = [];
13
+ typeDefinitions = [];
9
14
  sseClients = new Set();
10
15
  initialized = false;
11
16
  constructor(options = {}) {
@@ -13,10 +18,14 @@ export class MCPServer {
13
18
  name: options.name || 'recker-docs',
14
19
  version: options.version || '1.0.0',
15
20
  docsPath: options.docsPath || this.findDocsPath(),
21
+ examplesPath: options.examplesPath || this.findExamplesPath(),
22
+ srcPath: options.srcPath || this.findSrcPath(),
16
23
  port: options.port || 3100,
17
24
  transport: options.transport || 'stdio',
18
25
  debug: options.debug || false,
26
+ toolsFilter: options.toolsFilter || [],
19
27
  };
28
+ this.hybridSearch = createHybridSearch({ debug: this.options.debug });
20
29
  this.buildIndex();
21
30
  }
22
31
  log(message, data) {
@@ -34,9 +43,12 @@ export class MCPServer {
34
43
  join(process.cwd(), 'docs'),
35
44
  join(process.cwd(), '..', 'docs'),
36
45
  ];
37
- if (typeof __dirname !== 'undefined') {
46
+ try {
47
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
48
  possiblePaths.push(join(__dirname, '..', '..', 'docs'), join(__dirname, '..', '..', '..', 'docs'));
39
49
  }
50
+ catch {
51
+ }
40
52
  for (const p of possiblePaths) {
41
53
  if (existsSync(p)) {
42
54
  return p;
@@ -44,13 +56,56 @@ export class MCPServer {
44
56
  }
45
57
  return join(process.cwd(), 'docs');
46
58
  }
47
- buildIndex() {
59
+ findExamplesPath() {
60
+ const docsPath = this.options?.docsPath || this.findDocsPath();
61
+ const possiblePaths = [
62
+ join(docsPath, 'examples'),
63
+ join(process.cwd(), 'examples'),
64
+ join(process.cwd(), 'docs', 'examples'),
65
+ ];
66
+ for (const p of possiblePaths) {
67
+ if (existsSync(p)) {
68
+ return p;
69
+ }
70
+ }
71
+ return join(docsPath, 'examples');
72
+ }
73
+ findSrcPath() {
74
+ const possiblePaths = [
75
+ join(process.cwd(), 'src'),
76
+ ];
77
+ try {
78
+ const __dirname = dirname(fileURLToPath(import.meta.url));
79
+ possiblePaths.push(join(__dirname, '..'), join(__dirname, '..', '..', 'src'));
80
+ }
81
+ catch {
82
+ }
83
+ for (const p of possiblePaths) {
84
+ if (existsSync(p)) {
85
+ return p;
86
+ }
87
+ }
88
+ return join(process.cwd(), 'src');
89
+ }
90
+ async buildIndex() {
91
+ await this.indexDocs();
92
+ this.indexCodeExamples();
93
+ this.indexTypeDefinitions();
94
+ await this.hybridSearch.initialize(this.docsIndex);
95
+ const stats = this.hybridSearch.getStats();
96
+ this.log(`Indexed ${stats.documents} docs, ${this.codeExamples.length} examples, ${this.typeDefinitions.length} types`);
97
+ if (stats.embeddings > 0) {
98
+ this.log(`Loaded ${stats.embeddings} embeddings (model: ${stats.model})`);
99
+ }
100
+ }
101
+ async indexDocs() {
48
102
  if (!existsSync(this.options.docsPath)) {
49
103
  this.log(`Docs path not found: ${this.options.docsPath}`);
50
104
  return;
51
105
  }
52
106
  const files = this.walkDir(this.options.docsPath);
53
- for (const file of files) {
107
+ for (let i = 0; i < files.length; i++) {
108
+ const file = files[i];
54
109
  if (!file.endsWith('.md'))
55
110
  continue;
56
111
  try {
@@ -60,6 +115,7 @@ export class MCPServer {
60
115
  const title = this.extractTitle(content) || relativePath;
61
116
  const keywords = this.extractKeywords(content);
62
117
  this.docsIndex.push({
118
+ id: `doc-${i}`,
63
119
  path: relativePath,
64
120
  title,
65
121
  category,
@@ -71,14 +127,239 @@ export class MCPServer {
71
127
  this.log(`Failed to index ${file}:`, err);
72
128
  }
73
129
  }
74
- this.log(`Indexed ${this.docsIndex.length} documentation files`);
130
+ }
131
+ indexCodeExamples() {
132
+ if (!existsSync(this.options.examplesPath)) {
133
+ this.log(`Examples path not found: ${this.options.examplesPath}`);
134
+ return;
135
+ }
136
+ const files = this.walkDir(this.options.examplesPath);
137
+ for (let i = 0; i < files.length; i++) {
138
+ const file = files[i];
139
+ const ext = extname(file);
140
+ if (!['.ts', '.js', '.mjs'].includes(ext))
141
+ continue;
142
+ try {
143
+ const content = readFileSync(file, 'utf-8');
144
+ const relativePath = relative(this.options.examplesPath, file);
145
+ const filename = basename(file, ext);
146
+ const example = this.parseCodeExample(content, relativePath, filename, i);
147
+ if (example) {
148
+ this.codeExamples.push(example);
149
+ }
150
+ }
151
+ catch (err) {
152
+ this.log(`Failed to index example ${file}:`, err);
153
+ }
154
+ }
155
+ }
156
+ parseCodeExample(content, path, filename, index) {
157
+ const docMatch = content.match(/^\/\*\*[\s\S]*?\*\//) || content.match(/^\/\/.*(?:\n\/\/.*)*/);
158
+ const docComment = docMatch ? docMatch[0] : '';
159
+ const titleMatch = docComment.match(/@title\s+(.+)/i) || docComment.match(/@example\s+(.+)/i);
160
+ const title = titleMatch ? titleMatch[1].trim() : this.humanizeFilename(filename);
161
+ const featureMatch = docComment.match(/@feature\s+(\w+)/i);
162
+ const feature = featureMatch ? featureMatch[1].toLowerCase() : this.inferFeature(path, content);
163
+ const complexity = this.inferComplexity(content, docComment);
164
+ const descMatch = docComment.match(/@description\s+(.+)/i);
165
+ const description = descMatch
166
+ ? descMatch[1].trim()
167
+ : docComment.replace(/^\/\*\*|\*\/|^\s*\*\s?|^\/\/\s?/gm, '').trim().split('\n')[0] || '';
168
+ const keywords = this.extractCodeKeywords(content, feature);
169
+ return {
170
+ id: `example-${index}`,
171
+ path,
172
+ title,
173
+ feature,
174
+ complexity,
175
+ code: content,
176
+ description,
177
+ keywords,
178
+ };
179
+ }
180
+ humanizeFilename(filename) {
181
+ return filename
182
+ .replace(/[-_]/g, ' ')
183
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
184
+ .replace(/\b\w/g, c => c.toUpperCase());
185
+ }
186
+ inferFeature(path, content) {
187
+ const pathParts = path.toLowerCase().split('/');
188
+ const featureKeywords = [
189
+ 'retry', 'cache', 'streaming', 'sse', 'websocket', 'batch',
190
+ 'pagination', 'middleware', 'hooks', 'auth', 'proxy', 'timeout',
191
+ 'mcp', 'ai', 'scraping', 'whois', 'dns', 'udp', 'webrtc',
192
+ ];
193
+ for (const keyword of featureKeywords) {
194
+ if (pathParts.some(p => p.includes(keyword))) {
195
+ return keyword;
196
+ }
197
+ }
198
+ const lowerContent = content.toLowerCase();
199
+ for (const keyword of featureKeywords) {
200
+ if (lowerContent.includes(keyword)) {
201
+ return keyword;
202
+ }
203
+ }
204
+ return 'general';
205
+ }
206
+ inferComplexity(content, docComment) {
207
+ if (/@complexity\s+(basic|intermediate|advanced)/i.test(docComment)) {
208
+ return docComment.match(/@complexity\s+(basic|intermediate|advanced)/i)[1].toLowerCase();
209
+ }
210
+ const lines = content.split('\n').length;
211
+ const hasAsyncAwait = /async|await/.test(content);
212
+ const hasClasses = /class\s+\w+/.test(content);
213
+ const hasComplexTypes = /\<[^>]+\>/.test(content);
214
+ const hasTryCatch = /try\s*\{/.test(content);
215
+ const hasMultipleFunctions = (content.match(/(?:function|const\s+\w+\s*=\s*(?:async\s*)?\()/g) || []).length > 2;
216
+ let score = 0;
217
+ if (lines > 50)
218
+ score += 2;
219
+ else if (lines > 20)
220
+ score += 1;
221
+ if (hasAsyncAwait)
222
+ score += 1;
223
+ if (hasClasses)
224
+ score += 2;
225
+ if (hasComplexTypes)
226
+ score += 1;
227
+ if (hasTryCatch)
228
+ score += 1;
229
+ if (hasMultipleFunctions)
230
+ score += 1;
231
+ if (score >= 5)
232
+ return 'advanced';
233
+ if (score >= 2)
234
+ return 'intermediate';
235
+ return 'basic';
236
+ }
237
+ extractCodeKeywords(content, feature) {
238
+ const keywords = new Set([feature]);
239
+ const funcMatches = content.match(/(?:function|const|let|var)\s+(\w+)/g) || [];
240
+ for (const m of funcMatches) {
241
+ const name = m.split(/\s+/)[1];
242
+ if (name && name.length > 2)
243
+ keywords.add(name.toLowerCase());
244
+ }
245
+ const importMatches = content.match(/from\s+['"]([^'"]+)['"]/g) || [];
246
+ for (const m of importMatches) {
247
+ const mod = m.match(/['"]([^'"]+)['"]/)?.[1];
248
+ if (mod && !mod.startsWith('.')) {
249
+ keywords.add(mod.split('/')[0]);
250
+ }
251
+ }
252
+ const methodMatches = content.match(/\.(\w+)\(/g) || [];
253
+ for (const m of methodMatches) {
254
+ const method = m.slice(1, -1);
255
+ if (method.length > 2)
256
+ keywords.add(method.toLowerCase());
257
+ }
258
+ return Array.from(keywords).slice(0, 20);
259
+ }
260
+ indexTypeDefinitions() {
261
+ if (!existsSync(this.options.srcPath)) {
262
+ this.log(`Source path not found: ${this.options.srcPath}`);
263
+ return;
264
+ }
265
+ const typePaths = [
266
+ join(this.options.srcPath, 'types'),
267
+ join(this.options.srcPath, 'core'),
268
+ ];
269
+ for (const typePath of typePaths) {
270
+ if (!existsSync(typePath))
271
+ continue;
272
+ const files = this.walkDir(typePath);
273
+ for (const file of files) {
274
+ if (!file.endsWith('.ts') || file.endsWith('.test.ts'))
275
+ continue;
276
+ try {
277
+ const content = readFileSync(file, 'utf-8');
278
+ const relativePath = relative(this.options.srcPath, file);
279
+ this.extractTypeDefinitions(content, relativePath);
280
+ }
281
+ catch (err) {
282
+ this.log(`Failed to parse types from ${file}:`, err);
283
+ }
284
+ }
285
+ }
286
+ }
287
+ extractTypeDefinitions(content, path) {
288
+ const interfaceRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+)?interface\s+(\w+)(?:\s+extends\s+[\w,\s<>]+)?\s*\{[^}]*\}/g;
289
+ let match;
290
+ while ((match = interfaceRegex.exec(content)) !== null) {
291
+ const name = match[2];
292
+ const fullMatch = match[0];
293
+ const docMatch = fullMatch.match(/\/\*\*[\s\S]*?\*\//);
294
+ const description = docMatch
295
+ ? docMatch[0].replace(/\/\*\*|\*\/|\*\s?/g, '').trim().split('\n')[0]
296
+ : '';
297
+ const propsMatch = fullMatch.match(/\{([^}]*)\}/);
298
+ const properties = propsMatch
299
+ ? this.parseInterfaceProperties(propsMatch[1])
300
+ : [];
301
+ this.typeDefinitions.push({
302
+ name,
303
+ kind: 'interface',
304
+ path,
305
+ definition: fullMatch,
306
+ description,
307
+ properties,
308
+ });
309
+ }
310
+ const typeRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+)?type\s+(\w+)(?:<[^>]+>)?\s*=\s*[^;]+;/g;
311
+ while ((match = typeRegex.exec(content)) !== null) {
312
+ const name = match[2];
313
+ const fullMatch = match[0];
314
+ const docMatch = fullMatch.match(/\/\*\*[\s\S]*?\*\//);
315
+ const description = docMatch
316
+ ? docMatch[0].replace(/\/\*\*|\*\/|\*\s?/g, '').trim().split('\n')[0]
317
+ : '';
318
+ this.typeDefinitions.push({
319
+ name,
320
+ kind: 'type',
321
+ path,
322
+ definition: fullMatch,
323
+ description,
324
+ });
325
+ }
326
+ const enumRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+)?enum\s+(\w+)\s*\{[^}]*\}/g;
327
+ while ((match = enumRegex.exec(content)) !== null) {
328
+ const name = match[2];
329
+ const fullMatch = match[0];
330
+ const docMatch = fullMatch.match(/\/\*\*[\s\S]*?\*\//);
331
+ const description = docMatch
332
+ ? docMatch[0].replace(/\/\*\*|\*\/|\*\s?/g, '').trim().split('\n')[0]
333
+ : '';
334
+ this.typeDefinitions.push({
335
+ name,
336
+ kind: 'enum',
337
+ path,
338
+ definition: fullMatch,
339
+ description,
340
+ });
341
+ }
342
+ }
343
+ parseInterfaceProperties(propsStr) {
344
+ const props = [];
345
+ const propRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(?:\/\/.*\n\s*)?(\w+)(\?)?:\s*([^;]+);/g;
346
+ let match;
347
+ while ((match = propRegex.exec(propsStr)) !== null) {
348
+ const name = match[1];
349
+ const optional = !!match[2];
350
+ const type = match[3].trim();
351
+ const commentMatch = propsStr.slice(0, match.index).match(/\/\/\s*(.+)$|\/\*\*\s*(.+?)\s*\*\//);
352
+ const description = commentMatch ? (commentMatch[1] || commentMatch[2] || '').trim() : '';
353
+ props.push({ name, type, optional, description });
354
+ }
355
+ return props;
75
356
  }
76
357
  walkDir(dir) {
77
358
  const files = [];
78
359
  try {
79
360
  const entries = readdirSync(dir);
80
361
  for (const entry of entries) {
81
- if (entry.startsWith('_') || entry.startsWith('.'))
362
+ if (entry.startsWith('_') || entry.startsWith('.') || entry === 'node_modules')
82
363
  continue;
83
364
  const fullPath = join(dir, entry);
84
365
  const stat = statSync(fullPath);
@@ -115,16 +396,16 @@ export class MCPServer {
115
396
  return Array.from(keywords).slice(0, 50);
116
397
  }
117
398
  getTools() {
118
- return [
399
+ const allTools = [
119
400
  {
120
401
  name: 'search_docs',
121
- description: 'Search Recker documentation by keyword. Returns matching doc files with titles and snippets. Use this first to find relevant documentation.',
402
+ description: 'Search Recker documentation using hybrid search (fuzzy + semantic). Returns matching docs with relevance scores and snippets. Use this first to find relevant documentation.',
122
403
  inputSchema: {
123
404
  type: 'object',
124
405
  properties: {
125
406
  query: {
126
407
  type: 'string',
127
- description: 'Search query (e.g., "retry", "cache", "streaming", "websocket")',
408
+ description: 'Search query (e.g., "retry with exponential backoff", "streaming SSE responses", "cache strategies")',
128
409
  },
129
410
  category: {
130
411
  type: 'string',
@@ -132,7 +413,12 @@ export class MCPServer {
132
413
  },
133
414
  limit: {
134
415
  type: 'number',
135
- description: 'Max results to return (default: 5)',
416
+ description: 'Max results to return (default: 5, max: 10)',
417
+ },
418
+ mode: {
419
+ type: 'string',
420
+ enum: ['hybrid', 'fuzzy', 'semantic'],
421
+ description: 'Search mode: hybrid (default), fuzzy (text matching), or semantic (meaning-based)',
136
422
  },
137
423
  },
138
424
  required: ['query'],
@@ -152,7 +438,95 @@ export class MCPServer {
152
438
  required: ['path'],
153
439
  },
154
440
  },
441
+ {
442
+ name: 'code_examples',
443
+ description: 'Get runnable code examples for Recker features. Returns complete, working examples with explanations.',
444
+ inputSchema: {
445
+ type: 'object',
446
+ properties: {
447
+ feature: {
448
+ type: 'string',
449
+ description: 'Feature to get examples for (e.g., "retry", "cache", "streaming", "websocket", "mcp", "batch", "pagination", "middleware")',
450
+ },
451
+ complexity: {
452
+ type: 'string',
453
+ enum: ['basic', 'intermediate', 'advanced'],
454
+ description: 'Complexity level of the example (default: all levels)',
455
+ },
456
+ limit: {
457
+ type: 'number',
458
+ description: 'Max examples to return (default: 3)',
459
+ },
460
+ },
461
+ required: ['feature'],
462
+ },
463
+ },
464
+ {
465
+ name: 'api_schema',
466
+ description: 'Get TypeScript types, interfaces, and API schemas for Recker. Useful for generating type-safe code.',
467
+ inputSchema: {
468
+ type: 'object',
469
+ properties: {
470
+ type: {
471
+ type: 'string',
472
+ description: 'Type/interface to look up (e.g., "Client", "RequestOptions", "RetryOptions", "CacheOptions", "MCPServer")',
473
+ },
474
+ include: {
475
+ type: 'string',
476
+ enum: ['definition', 'properties', 'both'],
477
+ description: 'What to include: just definition, properties breakdown, or both (default: both)',
478
+ },
479
+ },
480
+ required: ['type'],
481
+ },
482
+ },
483
+ {
484
+ name: 'suggest',
485
+ description: 'Get implementation suggestions based on use case description. Analyzes requirements and suggests the best Recker patterns.',
486
+ inputSchema: {
487
+ type: 'object',
488
+ properties: {
489
+ useCase: {
490
+ type: 'string',
491
+ description: 'Describe what you want to achieve (e.g., "call an API with retry and cache", "stream AI responses", "scrape multiple sites in parallel")',
492
+ },
493
+ constraints: {
494
+ type: 'array',
495
+ items: { type: 'string' },
496
+ description: 'Any constraints or requirements (e.g., ["must handle rate limits", "need progress tracking"])',
497
+ },
498
+ },
499
+ required: ['useCase'],
500
+ },
501
+ },
155
502
  ];
503
+ if (this.options.toolsFilter.length > 0) {
504
+ return allTools.filter(tool => this.isToolEnabled(tool.name));
505
+ }
506
+ return allTools;
507
+ }
508
+ isToolEnabled(name) {
509
+ const filter = this.options.toolsFilter;
510
+ if (!filter.length)
511
+ return true;
512
+ const positive = filter.filter(p => !p.startsWith('!'));
513
+ const negative = filter.filter(p => p.startsWith('!')).map(p => p.slice(1));
514
+ if (negative.some(p => this.matchPattern(name, p)))
515
+ return false;
516
+ if (!positive.length)
517
+ return true;
518
+ return positive.some(p => this.matchPattern(name, p));
519
+ }
520
+ matchPattern(name, pattern) {
521
+ if (pattern === '*')
522
+ return true;
523
+ if (pattern.endsWith('*')) {
524
+ return name.startsWith(pattern.slice(0, -1));
525
+ }
526
+ if (pattern.startsWith('*')) {
527
+ return name.endsWith(pattern.slice(1));
528
+ }
529
+ return name === pattern;
156
530
  }
157
531
  handleToolCall(name, args) {
158
532
  switch (name) {
@@ -160,6 +534,12 @@ export class MCPServer {
160
534
  return this.searchDocs(args);
161
535
  case 'get_doc':
162
536
  return this.getDoc(args);
537
+ case 'code_examples':
538
+ return this.getCodeExamples(args);
539
+ case 'api_schema':
540
+ return this.getApiSchema(args);
541
+ case 'suggest':
542
+ return this.getSuggestions(args);
163
543
  default:
164
544
  return {
165
545
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
@@ -168,22 +548,24 @@ export class MCPServer {
168
548
  }
169
549
  }
170
550
  searchDocs(args) {
171
- const query = String(args.query || '').toLowerCase();
172
- const category = args.category ? String(args.category).toLowerCase() : null;
551
+ const query = String(args.query || '');
552
+ const category = args.category ? String(args.category) : undefined;
173
553
  const limit = Math.min(Number(args.limit) || 5, 10);
554
+ const mode = args.mode || 'hybrid';
174
555
  if (!query) {
175
556
  return {
176
557
  content: [{ type: 'text', text: 'Error: query is required' }],
177
558
  isError: true,
178
559
  };
179
560
  }
180
- const results = [];
181
- for (const doc of this.docsIndex) {
182
- if (category && !doc.category.toLowerCase().includes(category)) {
183
- continue;
184
- }
561
+ const searchPromise = this.hybridSearch.search(query, { limit, category, mode });
562
+ let results = [];
563
+ const queryLower = query.toLowerCase();
564
+ const queryTerms = queryLower.split(/\s+/);
565
+ const scored = this.docsIndex
566
+ .filter(doc => !category || doc.category.toLowerCase().includes(category.toLowerCase()))
567
+ .map(doc => {
185
568
  let score = 0;
186
- const queryTerms = query.split(/\s+/);
187
569
  for (const term of queryTerms) {
188
570
  if (doc.title.toLowerCase().includes(term))
189
571
  score += 10;
@@ -194,14 +576,21 @@ export class MCPServer {
194
576
  if (doc.content.toLowerCase().includes(term))
195
577
  score += 1;
196
578
  }
197
- if (score > 0) {
198
- const snippet = this.extractSnippet(doc.content, query);
199
- results.push({ doc, score, snippet });
200
- }
201
- }
202
- results.sort((a, b) => b.score - a.score);
203
- const topResults = results.slice(0, limit);
204
- if (topResults.length === 0) {
579
+ return { doc, score };
580
+ })
581
+ .filter(r => r.score > 0)
582
+ .sort((a, b) => b.score - a.score)
583
+ .slice(0, limit);
584
+ results = scored.map(r => ({
585
+ id: r.doc.id,
586
+ path: r.doc.path,
587
+ title: r.doc.title,
588
+ content: r.doc.content,
589
+ snippet: this.extractSnippet(r.doc.content, query),
590
+ score: Math.min(1, r.score / 20),
591
+ source: 'fuzzy',
592
+ }));
593
+ if (results.length === 0) {
205
594
  return {
206
595
  content: [{
207
596
  type: 'text',
@@ -209,23 +598,33 @@ export class MCPServer {
209
598
  }],
210
599
  };
211
600
  }
212
- const output = topResults.map((r, i) => `${i + 1}. **${r.doc.title}**\n Path: \`${r.doc.path}\`\n Category: ${r.doc.category}\n ${r.snippet}`).join('\n\n');
601
+ const stats = this.hybridSearch.getStats();
602
+ const searchMode = stats.embeddings > 0 ? mode : 'fuzzy';
603
+ const output = results.map((r, i) => `${i + 1}. **${r.title}** (${(r.score * 100).toFixed(0)}% match)\n Path: \`${r.path}\`\n ${r.snippet}`).join('\n\n');
213
604
  return {
214
605
  content: [{
215
606
  type: 'text',
216
- text: `Found ${topResults.length} result(s) for "${query}":\n\n${output}\n\nUse get_doc with the path to read full content.`,
607
+ text: `Found ${results.length} result(s) for "${query}" (${searchMode} search):\n\n${output}\n\nUse get_doc with the path to read full content.`,
217
608
  }],
218
609
  };
219
610
  }
220
611
  extractSnippet(content, query) {
221
612
  const lowerContent = content.toLowerCase();
222
- const index = lowerContent.indexOf(query.split(/\s+/)[0]);
223
- if (index === -1) {
613
+ const queryTerms = query.toLowerCase().split(/\s+/);
614
+ let bestIndex = -1;
615
+ for (const term of queryTerms) {
616
+ const idx = lowerContent.indexOf(term);
617
+ if (idx !== -1 && (bestIndex === -1 || idx < bestIndex)) {
618
+ bestIndex = idx;
619
+ break;
620
+ }
621
+ }
622
+ if (bestIndex === -1) {
224
623
  const firstPara = content.split('\n\n')[1] || content.substring(0, 200);
225
624
  return firstPara.substring(0, 150).trim() + '...';
226
625
  }
227
- const start = Math.max(0, index - 50);
228
- const end = Math.min(content.length, index + 150);
626
+ const start = Math.max(0, bestIndex - 50);
627
+ const end = Math.min(content.length, bestIndex + 150);
229
628
  let snippet = content.substring(start, end).trim();
230
629
  if (start > 0)
231
630
  snippet = '...' + snippet;
@@ -262,6 +661,252 @@ export class MCPServer {
262
661
  }],
263
662
  };
264
663
  }
664
+ getCodeExamples(args) {
665
+ const feature = String(args.feature || '').toLowerCase();
666
+ const complexity = args.complexity;
667
+ const limit = Math.min(Number(args.limit) || 3, 5);
668
+ if (!feature) {
669
+ return {
670
+ content: [{ type: 'text', text: 'Error: feature is required' }],
671
+ isError: true,
672
+ };
673
+ }
674
+ let examples = this.codeExamples.filter(ex => ex.feature === feature ||
675
+ ex.keywords.includes(feature) ||
676
+ ex.title.toLowerCase().includes(feature));
677
+ if (complexity) {
678
+ examples = examples.filter(ex => ex.complexity === complexity);
679
+ }
680
+ examples = examples.slice(0, limit);
681
+ if (examples.length === 0) {
682
+ const availableFeatures = [...new Set(this.codeExamples.map(ex => ex.feature))];
683
+ return {
684
+ content: [{
685
+ type: 'text',
686
+ text: `No examples found for feature "${feature}".\n\nAvailable features: ${availableFeatures.join(', ')}`,
687
+ }],
688
+ };
689
+ }
690
+ const output = examples.map((ex, i) => {
691
+ return `## ${i + 1}. ${ex.title}\n\n**Complexity:** ${ex.complexity}\n**Path:** \`${ex.path}\`\n\n${ex.description ? `${ex.description}\n\n` : ''}\`\`\`typescript\n${ex.code}\n\`\`\``;
692
+ }).join('\n\n---\n\n');
693
+ return {
694
+ content: [{
695
+ type: 'text',
696
+ text: `Found ${examples.length} example(s) for "${feature}":\n\n${output}`,
697
+ }],
698
+ };
699
+ }
700
+ getApiSchema(args) {
701
+ const typeName = String(args.type || '');
702
+ const include = args.include || 'both';
703
+ if (!typeName) {
704
+ return {
705
+ content: [{ type: 'text', text: 'Error: type is required' }],
706
+ isError: true,
707
+ };
708
+ }
709
+ const matches = this.typeDefinitions.filter(td => td.name.toLowerCase() === typeName.toLowerCase() ||
710
+ td.name.toLowerCase().includes(typeName.toLowerCase()));
711
+ if (matches.length === 0) {
712
+ const availableTypes = this.typeDefinitions
713
+ .map(td => td.name)
714
+ .slice(0, 20);
715
+ return {
716
+ content: [{
717
+ type: 'text',
718
+ text: `Type "${typeName}" not found.\n\nAvailable types: ${availableTypes.join(', ')}`,
719
+ }],
720
+ };
721
+ }
722
+ const output = matches.map(td => {
723
+ let result = `## ${td.name} (${td.kind})\n\n`;
724
+ if (td.description) {
725
+ result += `${td.description}\n\n`;
726
+ }
727
+ result += `**Path:** \`${td.path}\`\n\n`;
728
+ if (include === 'definition' || include === 'both') {
729
+ result += `### Definition\n\n\`\`\`typescript\n${td.definition}\n\`\`\`\n\n`;
730
+ }
731
+ if ((include === 'properties' || include === 'both') && td.properties && td.properties.length > 0) {
732
+ result += `### Properties\n\n`;
733
+ for (const prop of td.properties) {
734
+ const optionalMark = prop.optional ? '?' : '';
735
+ result += `- **${prop.name}${optionalMark}**: \`${prop.type}\``;
736
+ if (prop.description) {
737
+ result += ` - ${prop.description}`;
738
+ }
739
+ result += '\n';
740
+ }
741
+ }
742
+ return result;
743
+ }).join('\n---\n\n');
744
+ return {
745
+ content: [{
746
+ type: 'text',
747
+ text: output,
748
+ }],
749
+ };
750
+ }
751
+ getSuggestions(args) {
752
+ const useCase = String(args.useCase || '').toLowerCase();
753
+ const constraints = args.constraints || [];
754
+ if (!useCase) {
755
+ return {
756
+ content: [{ type: 'text', text: 'Error: useCase is required' }],
757
+ isError: true,
758
+ };
759
+ }
760
+ const suggestions = [];
761
+ const featurePatterns = [
762
+ { keywords: ['retry', 'fail', 'error', 'resilient', 'reliable'], feature: 'retry', reason: 'Handle transient failures' },
763
+ { keywords: ['cache', 'fast', 'repeated', 'memoize'], feature: 'cache', reason: 'Speed up repeated requests' },
764
+ { keywords: ['stream', 'sse', 'real-time', 'live', 'event'], feature: 'streaming', reason: 'Handle streaming responses' },
765
+ { keywords: ['parallel', 'concurrent', 'batch', 'multiple', 'bulk'], feature: 'batch', reason: 'Execute requests concurrently' },
766
+ { keywords: ['paginate', 'page', 'cursor', 'next', 'all'], feature: 'pagination', reason: 'Fetch paginated data' },
767
+ { keywords: ['websocket', 'ws', 'bidirectional', 'push'], feature: 'websocket', reason: 'Real-time bidirectional communication' },
768
+ { keywords: ['rate limit', 'throttle', 'limit'], feature: 'rate-limiting', reason: 'Respect API rate limits' },
769
+ { keywords: ['auth', 'token', 'bearer', 'api key'], feature: 'auth', reason: 'Handle authentication' },
770
+ { keywords: ['progress', 'download', 'upload', 'track'], feature: 'progress', reason: 'Track transfer progress' },
771
+ { keywords: ['timeout', 'slow', 'hang'], feature: 'timeout', reason: 'Prevent hanging requests' },
772
+ { keywords: ['scrape', 'html', 'parse', 'extract'], feature: 'scraping', reason: 'Parse HTML content' },
773
+ { keywords: ['ai', 'llm', 'openai', 'anthropic', 'gpt', 'claude'], feature: 'ai', reason: 'AI/LLM integrations' },
774
+ ];
775
+ for (const pattern of featurePatterns) {
776
+ if (pattern.keywords.some(k => useCase.includes(k))) {
777
+ suggestions.push({
778
+ feature: pattern.feature,
779
+ reason: pattern.reason,
780
+ config: this.getFeatureConfig(pattern.feature),
781
+ });
782
+ }
783
+ }
784
+ for (const constraint of constraints) {
785
+ const lowerConstraint = constraint.toLowerCase();
786
+ for (const pattern of featurePatterns) {
787
+ if (pattern.keywords.some(k => lowerConstraint.includes(k))) {
788
+ if (!suggestions.some(s => s.feature === pattern.feature)) {
789
+ suggestions.push({
790
+ feature: pattern.feature,
791
+ reason: pattern.reason,
792
+ config: this.getFeatureConfig(pattern.feature),
793
+ });
794
+ }
795
+ }
796
+ }
797
+ }
798
+ if (suggestions.length === 0) {
799
+ return {
800
+ content: [{
801
+ type: 'text',
802
+ text: `I couldn't identify specific features for "${useCase}".\n\nTry describing your use case with keywords like:\n- retry, fail, error (for resilience)\n- cache, fast, repeated (for caching)\n- stream, sse, live (for streaming)\n- parallel, batch, concurrent (for batching)\n- paginate, cursor, next (for pagination)`,
803
+ }],
804
+ };
805
+ }
806
+ const output = suggestions.map((s, i) => {
807
+ return `### ${i + 1}. ${s.feature.charAt(0).toUpperCase() + s.feature.slice(1)}\n\n**Why:** ${s.reason}\n\n**Configuration:**\n\`\`\`typescript\n${s.config}\n\`\`\``;
808
+ }).join('\n\n');
809
+ const combinedConfig = this.getCombinedConfig(suggestions.map(s => s.feature));
810
+ return {
811
+ content: [{
812
+ type: 'text',
813
+ text: `# Suggested Implementation for: "${useCase}"\n\n${output}\n\n---\n\n## Combined Configuration\n\n\`\`\`typescript\n${combinedConfig}\n\`\`\``,
814
+ }],
815
+ };
816
+ }
817
+ getFeatureConfig(feature) {
818
+ const configs = {
819
+ retry: `retry: {
820
+ attempts: 3,
821
+ backoff: 'exponential',
822
+ delay: 1000,
823
+ jitter: true
824
+ }`,
825
+ cache: `cache: {
826
+ ttl: 60000, // 1 minute
827
+ strategy: 'stale-while-revalidate'
828
+ }`,
829
+ streaming: `// Streaming with async iteration
830
+ for await (const chunk of client.get('/stream')) {
831
+ console.log(chunk);
832
+ }
833
+
834
+ // SSE parsing
835
+ for await (const event of client.get('/events').sse()) {
836
+ console.log(event.data);
837
+ }`,
838
+ batch: `// Batch requests
839
+ const { results } = await client.batch([
840
+ { path: '/users/1' },
841
+ { path: '/users/2' },
842
+ { path: '/users/3' }
843
+ ], { concurrency: 5 });`,
844
+ pagination: `// Auto-pagination
845
+ const allItems = await client.getAll('/items', {
846
+ paginate: { maxPages: 10 }
847
+ });`,
848
+ websocket: `const ws = await client.ws('wss://api.example.com/ws');
849
+ ws.on('message', (data) => console.log(data));
850
+ ws.send({ type: 'subscribe', channel: 'updates' });`,
851
+ 'rate-limiting': `concurrency: {
852
+ requestsPerInterval: 100,
853
+ interval: 1000 // 100 req/sec
854
+ }`,
855
+ auth: `headers: {
856
+ Authorization: 'Bearer YOUR_TOKEN'
857
+ }
858
+ // or use beforeRequest hook
859
+ client.beforeRequest((req) => {
860
+ return req.withHeader('Authorization', getToken());
861
+ })`,
862
+ progress: `const response = await client.get('/large-file', {
863
+ onDownloadProgress: ({ percent, rate }) => {
864
+ console.log(\`\${percent}% at \${rate} bytes/sec\`);
865
+ }
866
+ });`,
867
+ timeout: `timeout: 30000, // 30 seconds`,
868
+ scraping: `const $ = await client.get('/page').scrape();
869
+ const title = $('h1').text();
870
+ const links = $('a').map((_, el) => $(el).attr('href')).get();`,
871
+ ai: `// OpenAI streaming
872
+ for await (const chunk of client.get('/chat/completions').sse()) {
873
+ process.stdout.write(chunk.choices[0].delta.content || '');
874
+ }`,
875
+ };
876
+ return configs[feature] || `// Configure ${feature}`;
877
+ }
878
+ getCombinedConfig(features) {
879
+ const parts = [
880
+ `import { createClient } from 'recker';
881
+
882
+ const client = createClient({
883
+ baseUrl: 'https://api.example.com',`,
884
+ ];
885
+ if (features.includes('retry')) {
886
+ parts.push(` retry: {
887
+ attempts: 3,
888
+ backoff: 'exponential',
889
+ delay: 1000
890
+ },`);
891
+ }
892
+ if (features.includes('cache')) {
893
+ parts.push(` cache: {
894
+ ttl: 60000,
895
+ strategy: 'stale-while-revalidate'
896
+ },`);
897
+ }
898
+ if (features.includes('rate-limiting')) {
899
+ parts.push(` concurrency: {
900
+ requestsPerInterval: 100,
901
+ interval: 1000
902
+ },`);
903
+ }
904
+ if (features.includes('timeout')) {
905
+ parts.push(` timeout: 30000,`);
906
+ }
907
+ parts.push(`});`);
908
+ return parts.join('\n');
909
+ }
265
910
  handleRequest(req) {
266
911
  const { method, params, id } = req;
267
912
  this.log(`Request: ${method}`, params);
@@ -440,12 +1085,16 @@ export class MCPServer {
440
1085
  return;
441
1086
  }
442
1087
  if (req.method === 'GET' && url === '/health') {
1088
+ const stats = this.hybridSearch.getStats();
443
1089
  res.writeHead(200, { 'Content-Type': 'application/json' });
444
1090
  res.end(JSON.stringify({
445
1091
  status: 'ok',
446
1092
  name: this.options.name,
447
1093
  version: this.options.version,
448
- docsCount: this.docsIndex.length,
1094
+ docsCount: stats.documents,
1095
+ examplesCount: this.codeExamples.length,
1096
+ typesCount: this.typeDefinitions.length,
1097
+ embeddingsLoaded: stats.embeddings > 0,
449
1098
  sseClients: this.sseClients.size,
450
1099
  }));
451
1100
  return;
@@ -494,6 +1143,12 @@ export class MCPServer {
494
1143
  getDocsCount() {
495
1144
  return this.docsIndex.length;
496
1145
  }
1146
+ getExamplesCount() {
1147
+ return this.codeExamples.length;
1148
+ }
1149
+ getTypesCount() {
1150
+ return this.typeDefinitions.length;
1151
+ }
497
1152
  getTransport() {
498
1153
  return this.options.transport;
499
1154
  }