@prodisco/k8s-mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +127 -38
  2. package/dist/kube/client.d.ts +6 -0
  3. package/dist/kube/client.d.ts.map +1 -1
  4. package/dist/kube/client.js +11 -0
  5. package/dist/kube/client.js.map +1 -1
  6. package/dist/kube/client.test.d.ts +2 -0
  7. package/dist/kube/client.test.d.ts.map +1 -0
  8. package/dist/kube/client.test.js +59 -0
  9. package/dist/kube/client.test.js.map +1 -0
  10. package/dist/server.js +129 -50
  11. package/dist/server.js.map +1 -1
  12. package/dist/tools/api/apiTools.d.ts +6 -0
  13. package/dist/tools/api/apiTools.d.ts.map +1 -0
  14. package/dist/tools/api/apiTools.js +132 -0
  15. package/dist/tools/api/apiTools.js.map +1 -0
  16. package/dist/tools/kubernetes/metadata.d.ts +169 -27
  17. package/dist/tools/kubernetes/metadata.d.ts.map +1 -1
  18. package/dist/tools/kubernetes/metadata.js +2 -7
  19. package/dist/tools/kubernetes/metadata.js.map +1 -1
  20. package/dist/tools/kubernetes/searchTools.d.ts +307 -5
  21. package/dist/tools/kubernetes/searchTools.d.ts.map +1 -1
  22. package/dist/tools/kubernetes/searchTools.js +2058 -273
  23. package/dist/tools/kubernetes/searchTools.js.map +1 -1
  24. package/dist/util/apiClient.d.ts +20 -0
  25. package/dist/util/apiClient.d.ts.map +1 -0
  26. package/dist/util/apiClient.js +90 -0
  27. package/dist/util/apiClient.js.map +1 -0
  28. package/dist/util/k8sClient.d.ts +38 -0
  29. package/dist/util/k8sClient.d.ts.map +1 -0
  30. package/dist/util/k8sClient.js +85 -0
  31. package/dist/util/k8sClient.js.map +1 -0
  32. package/dist/util/logger.d.ts +26 -0
  33. package/dist/util/logger.d.ts.map +1 -0
  34. package/dist/util/logger.js +62 -0
  35. package/dist/util/logger.js.map +1 -0
  36. package/dist/util/summary.d.ts +16 -16
  37. package/package.json +4 -1
@@ -1,36 +1,90 @@
1
1
  import { z } from 'zod';
2
2
  import * as k8s from '@kubernetes/client-node';
3
3
  import * as ts from 'typescript';
4
- import { readFileSync, existsSync, readdirSync, mkdirSync, symlinkSync } from 'fs';
5
- import { join } from 'path';
4
+ import { readFileSync, existsSync, readdirSync, mkdirSync, symlinkSync, realpathSync, unlinkSync } from 'fs';
5
+ import { join, basename } from 'path';
6
6
  import * as os from 'os';
7
7
  import { PACKAGE_ROOT } from '../../util/paths.js';
8
+ import { create, insert, search, remove } from '@orama/orama';
9
+ import chokidar from 'chokidar';
10
+ import { logger } from '../../util/logger.js';
11
+ // ============================================================================
12
+ // Search Configuration Constants
13
+ // ============================================================================
14
+ /** Maximum number of resource types to extract from script content to prevent noise */
15
+ const MAX_RESOURCE_TYPES_FROM_CONTENT = 10;
16
+ /** Multiplier for initial search results to allow for post-filtering and pagination */
17
+ const SEARCH_RESULTS_MULTIPLIER = 3;
18
+ /** Minimum number of search results to fetch before post-filtering */
19
+ const MIN_SEARCH_RESULTS = 100;
20
+ /** Maximum number of relevant scripts to show in method search results */
21
+ const MAX_RELEVANT_SCRIPTS = 5;
22
+ /** Default maximum number of properties to show when formatting type definitions */
23
+ const DEFAULT_MAX_TYPE_PROPERTIES = 20;
24
+ // ============================================================================
8
25
  const SearchToolsInputSchema = z.object({
26
+ // Mode selection - determines which operation to perform
27
+ mode: z
28
+ .enum(['methods', 'types', 'scripts', 'prometheus'])
29
+ .default('methods')
30
+ .optional()
31
+ .describe('Search mode: "methods" for K8s API, "types" for type defs, "scripts" for cached scripts, "prometheus" for metrics/analytics libraries'),
32
+ // === Method mode parameters (mode: 'methods') ===
9
33
  resourceType: z
10
34
  .string()
11
- .describe('Kubernetes resource type (e.g., "Pod", "Deployment", "Service", "ConfigMap")'),
35
+ .optional()
36
+ .describe('(methods mode) Kubernetes resource type (e.g., "Pod", "Deployment", "Service", "ConfigMap")'),
12
37
  action: z
13
38
  .string()
14
39
  .optional()
15
- .describe('API action: list, read, create, delete, patch, replace, connect, get, watch. Omit to return all actions for the resource.'),
40
+ .describe('(methods mode) API action: list, read, create, delete, patch, replace, connect, get, watch'),
16
41
  scope: z
17
42
  .enum(['namespaced', 'cluster', 'all'])
18
43
  .optional()
19
44
  .default('all')
20
- .describe('Resource scope: "namespaced" for namespace-scoped resources, "cluster" for cluster-wide resources, "all" for both'),
45
+ .describe('(methods mode) Resource scope: "namespaced", "cluster", or "all"'),
21
46
  exclude: z
22
47
  .object({
23
48
  actions: z
24
49
  .array(z.string())
25
50
  .optional()
26
- .describe('Actions to exclude (e.g., ["connect", "watch"]). Filters out methods with these action prefixes.'),
51
+ .describe('Actions to exclude (e.g., ["connect", "watch"])'),
27
52
  apiClasses: z
28
53
  .array(z.string())
29
54
  .optional()
30
- .describe('API classes to exclude (e.g., ["CustomObjectsApi"]). Filters out methods from these API classes.'),
55
+ .describe('API classes to exclude (e.g., ["CustomObjectsApi"])'),
31
56
  })
32
57
  .optional()
33
- .describe('Exclusion criteria. If both actions and apiClasses are specified, both must match (AND logic) to exclude a method.'),
58
+ .describe('(methods mode) Exclusion criteria'),
59
+ // === Type mode parameters (mode: 'types') ===
60
+ types: z
61
+ .array(z.string())
62
+ .optional()
63
+ .describe('(types mode) Type names or paths (e.g., ["V1Pod", "V1Deployment.spec.template.spec"])'),
64
+ depth: z
65
+ .number()
66
+ .int()
67
+ .positive()
68
+ .max(2)
69
+ .default(1)
70
+ .optional()
71
+ .describe('(types mode) Depth of nested type definitions (1-2, default: 1)'),
72
+ // === Script mode parameters (mode: 'scripts') ===
73
+ searchTerm: z
74
+ .string()
75
+ .optional()
76
+ .describe('(scripts mode) Search term to find cached scripts (e.g., "pod", "logs"). If omitted, shows all scripts.'),
77
+ // === Prometheus mode parameters (mode: 'prometheus') ===
78
+ category: z
79
+ .enum(['query', 'metadata', 'alerts', 'metrics', 'all'])
80
+ .optional()
81
+ .default('all')
82
+ .describe('(prometheus mode) Filter by category: "query" (PromQL), "metadata" (labels/series), "alerts", or "metrics" (discover cluster metrics)'),
83
+ methodPattern: z
84
+ .string()
85
+ .optional()
86
+ .describe('(prometheus mode) Search pattern for method names (e.g., "mean", "query", "percentile")'),
87
+ // === Shared parameters ===
34
88
  limit: z
35
89
  .number()
36
90
  .int()
@@ -39,9 +93,1310 @@ const SearchToolsInputSchema = z.object({
39
93
  .default(10)
40
94
  .optional()
41
95
  .describe('Maximum number of results to return'),
96
+ offset: z
97
+ .number()
98
+ .int()
99
+ .nonnegative()
100
+ .default(0)
101
+ .optional()
102
+ .describe('Number of results to skip for pagination (default: 0)'),
42
103
  });
43
- // Cache for Kubernetes API methods
44
- let apiMethodsCache = null;
104
+ /**
105
+ * Extract JSDoc comment from a node
106
+ */
107
+ function getJSDocDescription(node) {
108
+ const jsDocComments = ts.getJSDocCommentsAndTags(node);
109
+ for (const comment of jsDocComments) {
110
+ if (ts.isJSDoc(comment) && comment.comment) {
111
+ if (typeof comment.comment === 'string') {
112
+ return comment.comment;
113
+ }
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+ /**
119
+ * Extract nested type references from a TypeNode using TypeScript AST
120
+ */
121
+ function extractNestedTypeRefsFromNode(typeNode) {
122
+ if (!typeNode) {
123
+ return [];
124
+ }
125
+ const refs = [];
126
+ function visit(node) {
127
+ if (ts.isTypeReferenceNode(node)) {
128
+ const typeName = node.typeName.getText();
129
+ // Only include K8s types (V1*, K8*, Core*)
130
+ if ((typeName.startsWith('V') || typeName.startsWith('K') || typeName.startsWith('Core')) &&
131
+ !refs.includes(typeName)) {
132
+ refs.push(typeName);
133
+ }
134
+ }
135
+ ts.forEachChild(node, visit);
136
+ }
137
+ visit(typeNode);
138
+ return refs;
139
+ }
140
+ /**
141
+ * Extract type definition from TypeScript declaration file using TypeScript compiler API
142
+ */
143
+ function extractTypeDefinitionWithTS(typeName, filePath) {
144
+ const sourceCode = readFileSync(filePath, 'utf-8');
145
+ const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true);
146
+ let typeInfo = null;
147
+ const nestedTypes = new Set();
148
+ function visit(node) {
149
+ if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name && node.name.text === typeName) {
150
+ const properties = [];
151
+ const description = getJSDocDescription(node);
152
+ node.members?.forEach((member) => {
153
+ if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) {
154
+ if (member.name) {
155
+ const propName = member.name.getText(sourceFile);
156
+ const propType = member.type?.getText(sourceFile) || 'any';
157
+ const isOptional = !!member.questionToken;
158
+ const propDescription = getJSDocDescription(member);
159
+ properties.push({
160
+ name: propName.replace(/['"]/g, ''),
161
+ type: propType,
162
+ optional: isOptional,
163
+ description: propDescription,
164
+ });
165
+ const typeRefs = extractNestedTypeRefsFromNode(member.type);
166
+ for (const ref of typeRefs) {
167
+ if (ref !== typeName) {
168
+ nestedTypes.add(ref);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ });
174
+ typeInfo = {
175
+ name: typeName,
176
+ properties,
177
+ description,
178
+ };
179
+ }
180
+ ts.forEachChild(node, visit);
181
+ }
182
+ visit(sourceFile);
183
+ if (!typeInfo) {
184
+ return null;
185
+ }
186
+ return {
187
+ typeInfo,
188
+ nestedTypes: Array.from(nestedTypes),
189
+ };
190
+ }
191
+ /**
192
+ * Extract the main type identifier from a TypeScript type node
193
+ * Handles: Array<V1Pod>, V1PodSpec | undefined, V1Container[], etc.
194
+ */
195
+ function extractTypeIdentifier(typeNode) {
196
+ if (ts.isUnionTypeNode(typeNode)) {
197
+ for (const type of typeNode.types) {
198
+ if (type.kind === ts.SyntaxKind.UndefinedKeyword || type.kind === ts.SyntaxKind.NullKeyword) {
199
+ continue;
200
+ }
201
+ return extractTypeIdentifier(type);
202
+ }
203
+ return null;
204
+ }
205
+ if (ts.isArrayTypeNode(typeNode)) {
206
+ return extractTypeIdentifier(typeNode.elementType);
207
+ }
208
+ if (ts.isTypeReferenceNode(typeNode)) {
209
+ const typeName = typeNode.typeName.getText();
210
+ if (typeName === 'Array' && typeNode.typeArguments && typeNode.typeArguments.length > 0) {
211
+ const firstArg = typeNode.typeArguments[0];
212
+ if (firstArg) {
213
+ return extractTypeIdentifier(firstArg);
214
+ }
215
+ }
216
+ return typeName;
217
+ }
218
+ if (ts.isTypeLiteralNode(typeNode)) {
219
+ return null;
220
+ }
221
+ return null;
222
+ }
223
+ /**
224
+ * Format type info as a readable string
225
+ */
226
+ function formatTypeInfo(typeInfo, maxProperties = DEFAULT_MAX_TYPE_PROPERTIES) {
227
+ let result = `${typeInfo.name} {\n`;
228
+ const propsToShow = typeInfo.properties.slice(0, maxProperties);
229
+ const hasMore = typeInfo.properties.length > maxProperties;
230
+ for (const prop of propsToShow) {
231
+ const optionalMarker = prop.optional ? '?' : '';
232
+ result += ` ${prop.name}${optionalMarker}: ${prop.type}\n`;
233
+ }
234
+ if (hasMore) {
235
+ result += ` ... ${typeInfo.properties.length - maxProperties} more properties\n`;
236
+ }
237
+ result += `}`;
238
+ return result;
239
+ }
240
+ /**
241
+ * Find type definition file in Kubernetes client-node package
242
+ */
243
+ function findTypeDefinitionFile(typeName, basePath) {
244
+ const k8sPath = join(basePath, 'node_modules', '@kubernetes', 'client-node', 'dist', 'gen', 'models');
245
+ const filePath = join(k8sPath, `${typeName}.d.ts`);
246
+ if (existsSync(filePath)) {
247
+ return filePath;
248
+ }
249
+ return null;
250
+ }
251
+ /**
252
+ * Parse a type path into base type and property path
253
+ * e.g., "V1Deployment.spec.template" -> { baseType: "V1Deployment", path: ["spec", "template"] }
254
+ */
255
+ function parseTypePath(typePath) {
256
+ const parts = typePath.split('.');
257
+ const baseType = parts[0];
258
+ if (!baseType) {
259
+ return null;
260
+ }
261
+ const path = parts.slice(1);
262
+ return { baseType, path };
263
+ }
264
+ /**
265
+ * Navigate through type properties to find a subtype
266
+ */
267
+ function navigateToSubtype(typeInfo, propertyPath, basePath, cache) {
268
+ if (propertyPath.length === 0) {
269
+ return null;
270
+ }
271
+ let currentTypeInfo = typeInfo;
272
+ let currentTypeName = typeInfo.name;
273
+ const pathSegments = [currentTypeName];
274
+ for (let i = 0; i < propertyPath.length; i++) {
275
+ const propertyName = propertyPath[i];
276
+ if (!propertyName) {
277
+ return null;
278
+ }
279
+ const property = currentTypeInfo.properties.find(p => p.name === propertyName);
280
+ if (!property) {
281
+ return null;
282
+ }
283
+ pathSegments.push(propertyName);
284
+ const filePath = findTypeDefinitionFile(currentTypeName, basePath);
285
+ if (!filePath) {
286
+ return null;
287
+ }
288
+ const sourceCode = readFileSync(filePath, 'utf-8');
289
+ const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true);
290
+ let propertyTypeNode = null;
291
+ function findPropertyType(node) {
292
+ if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) &&
293
+ node.name && node.name.text === currentTypeName) {
294
+ node.members?.forEach((member) => {
295
+ if ((ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) &&
296
+ member.name && member.type) {
297
+ const memberName = member.name.getText(sourceFile).replace(/['"]/g, '');
298
+ if (memberName === propertyName) {
299
+ propertyTypeNode = member.type;
300
+ }
301
+ }
302
+ });
303
+ }
304
+ if (!propertyTypeNode) {
305
+ ts.forEachChild(node, findPropertyType);
306
+ }
307
+ }
308
+ findPropertyType(sourceFile);
309
+ if (!propertyTypeNode) {
310
+ return null;
311
+ }
312
+ const nextTypeName = extractTypeIdentifier(propertyTypeNode);
313
+ if (!nextTypeName) {
314
+ return null;
315
+ }
316
+ if (i === propertyPath.length - 1) {
317
+ return {
318
+ typeInfo: currentTypeInfo,
319
+ propertyPath: pathSegments.join('.'),
320
+ typeName: nextTypeName,
321
+ };
322
+ }
323
+ let nextTypeInfo = cache.get(nextTypeName);
324
+ if (!nextTypeInfo) {
325
+ const filePath = findTypeDefinitionFile(nextTypeName, basePath);
326
+ if (!filePath) {
327
+ return null;
328
+ }
329
+ const extracted = extractTypeDefinitionWithTS(nextTypeName, filePath);
330
+ if (!extracted) {
331
+ return null;
332
+ }
333
+ nextTypeInfo = extracted.typeInfo;
334
+ cache.set(nextTypeName, nextTypeInfo);
335
+ }
336
+ currentTypeInfo = nextTypeInfo;
337
+ currentTypeName = nextTypeName;
338
+ }
339
+ return null;
340
+ }
341
+ /**
342
+ * Get type information for a subtype at a specific path
343
+ */
344
+ function getSubtypeInfo(baseTypeName, propertyPath, basePath, cache) {
345
+ let baseTypeInfo = cache.get(baseTypeName);
346
+ if (!baseTypeInfo) {
347
+ const filePath = findTypeDefinitionFile(baseTypeName, basePath);
348
+ if (!filePath) {
349
+ return null;
350
+ }
351
+ const extracted = extractTypeDefinitionWithTS(baseTypeName, filePath);
352
+ if (!extracted) {
353
+ return null;
354
+ }
355
+ baseTypeInfo = extracted.typeInfo;
356
+ cache.set(baseTypeName, baseTypeInfo);
357
+ }
358
+ if (propertyPath.length === 0) {
359
+ return {
360
+ typeInfo: baseTypeInfo,
361
+ fullPath: baseTypeName,
362
+ originalType: baseTypeName,
363
+ };
364
+ }
365
+ const result = navigateToSubtype(baseTypeInfo, propertyPath, basePath, cache);
366
+ if (!result) {
367
+ return null;
368
+ }
369
+ const targetTypeName = result.typeName;
370
+ let targetTypeInfo = cache.get(targetTypeName);
371
+ if (!targetTypeInfo) {
372
+ const filePath = findTypeDefinitionFile(targetTypeName, basePath);
373
+ if (filePath) {
374
+ const extracted = extractTypeDefinitionWithTS(targetTypeName, filePath);
375
+ if (extracted) {
376
+ targetTypeInfo = extracted.typeInfo;
377
+ cache.set(targetTypeName, targetTypeInfo);
378
+ }
379
+ }
380
+ }
381
+ if (!targetTypeInfo) {
382
+ const lastProp = propertyPath[propertyPath.length - 1];
383
+ if (!lastProp) {
384
+ return null;
385
+ }
386
+ const property = result.typeInfo.properties.find(p => p.name === lastProp);
387
+ if (property) {
388
+ targetTypeInfo = {
389
+ name: `${result.propertyPath}`,
390
+ properties: [{
391
+ name: lastProp,
392
+ type: property.type,
393
+ optional: property.optional,
394
+ description: property.description || undefined,
395
+ }],
396
+ description: `Property type: ${property.type}`,
397
+ };
398
+ }
399
+ else {
400
+ return null;
401
+ }
402
+ }
403
+ return {
404
+ typeInfo: targetTypeInfo,
405
+ fullPath: result.propertyPath,
406
+ originalType: targetTypeName,
407
+ };
408
+ }
409
+ // ============================================================================
410
+ // End Type Definition Helper Functions
411
+ // ============================================================================
412
+ // ============================================================================
413
+ // SearchToolsService Class - Encapsulates All Module State
414
+ // ============================================================================
415
+ /**
416
+ * Service class that encapsulates the search tools state and operations.
417
+ * This provides:
418
+ * - Proper lifecycle management (initialize/shutdown)
419
+ * - Testability through class instantiation
420
+ * - Clean separation of state from functions
421
+ */
422
+ class SearchToolsService {
423
+ /** Cache for Kubernetes API methods */
424
+ apiMethodsCache = null;
425
+ /** Orama database instance cache */
426
+ oramaDb = null;
427
+ /** Track indexed scripts to support incremental re-indexing */
428
+ indexedScriptPaths = new Set();
429
+ /** Filesystem watcher instance */
430
+ scriptWatcher = null;
431
+ /** Whether the service has been initialized */
432
+ initialized = false;
433
+ /** Prometheus metrics indexing status */
434
+ metricsIndexingStatus = 'unavailable';
435
+ /** Interval for refreshing Prometheus metrics */
436
+ metricsRefreshInterval = null;
437
+ /** Refresh interval in milliseconds (30 minutes) */
438
+ static METRICS_REFRESH_INTERVAL = 30 * 60 * 1000;
439
+ /**
440
+ * Initialize the search index and start the script watcher.
441
+ * This is called automatically on first use, but can be called explicitly
442
+ * for pre-warming during server startup.
443
+ */
444
+ async initialize() {
445
+ if (this.initialized) {
446
+ return;
447
+ }
448
+ await this.initializeOramaDb();
449
+ this.initialized = true;
450
+ // Start background Prometheus metrics indexing (non-blocking)
451
+ if (process.env.PROMETHEUS_URL) {
452
+ this.startPrometheusMetricsIndexing();
453
+ }
454
+ }
455
+ /**
456
+ * Get the current Prometheus metrics indexing status
457
+ */
458
+ getMetricsIndexingStatus() {
459
+ return this.metricsIndexingStatus;
460
+ }
461
+ /**
462
+ * Shutdown the service, stopping the script watcher.
463
+ * Call this during graceful shutdown.
464
+ */
465
+ async shutdown() {
466
+ if (this.scriptWatcher) {
467
+ await this.scriptWatcher.close();
468
+ this.scriptWatcher = null;
469
+ logger.info('Orama: Stopped script watcher');
470
+ }
471
+ if (this.metricsRefreshInterval) {
472
+ clearInterval(this.metricsRefreshInterval);
473
+ this.metricsRefreshInterval = null;
474
+ logger.info('Orama: Stopped Prometheus metrics refresh');
475
+ }
476
+ this.oramaDb = null;
477
+ this.apiMethodsCache = null;
478
+ this.indexedScriptPaths.clear();
479
+ this.metricsIndexingStatus = 'unavailable';
480
+ this.initialized = false;
481
+ // Clear module-level caches
482
+ clearPrometheusMethodsCache();
483
+ }
484
+ /**
485
+ * Get the Orama database instance, initializing it if needed
486
+ */
487
+ async getOramaDb() {
488
+ if (!this.oramaDb) {
489
+ await this.initializeOramaDb();
490
+ }
491
+ return this.oramaDb;
492
+ }
493
+ /**
494
+ * Get the cached API methods, extracting them if needed
495
+ */
496
+ getApiMethods() {
497
+ if (!this.apiMethodsCache) {
498
+ this.apiMethodsCache = this.extractKubernetesApiMethods();
499
+ }
500
+ return this.apiMethodsCache;
501
+ }
502
+ /**
503
+ * Initialize and populate the Orama search database
504
+ */
505
+ async initializeOramaDb() {
506
+ if (this.oramaDb) {
507
+ return this.oramaDb;
508
+ }
509
+ // Create Orama instance with optimized configuration
510
+ const db = await create({
511
+ schema: oramaSchema,
512
+ components: {
513
+ tokenizer: {
514
+ stemming: true,
515
+ // Skip stemming for code identifiers - they should match exactly
516
+ stemmerSkipProperties: ['methodName', 'resourceType', 'apiClass', 'id'],
517
+ },
518
+ },
519
+ });
520
+ // Get all API methods and index them
521
+ const methods = this.getApiMethods();
522
+ for (const method of methods) {
523
+ // Skip WithHttpInfo variants
524
+ if (method.methodName.toLowerCase().includes('withhttpinfo')) {
525
+ continue;
526
+ }
527
+ // Build searchTokens from identifiers for better matching
528
+ const searchTokens = [
529
+ method.resourceType,
530
+ method.methodName,
531
+ method.apiClass,
532
+ ].join(' ');
533
+ const doc = {
534
+ id: `${method.apiClass}.${method.methodName}`,
535
+ documentType: 'method',
536
+ resourceType: method.resourceType,
537
+ methodName: method.methodName,
538
+ description: method.description,
539
+ searchTokens,
540
+ action: extractAction(method.methodName),
541
+ scope: extractScope(method.methodName),
542
+ apiClass: method.apiClass,
543
+ filePath: '',
544
+ };
545
+ await insert(db, doc);
546
+ }
547
+ // Index cached scripts
548
+ const scriptCount = await this.indexCachedScripts(db);
549
+ // Index prometheus library methods
550
+ const prometheusCount = await this.indexPrometheusMethods(db);
551
+ // Start filesystem watcher for script changes
552
+ this.startScriptWatcher(db);
553
+ this.oramaDb = db;
554
+ logger.info(`Orama: Indexed ${methods.length} API methods, ${scriptCount} cached scripts, and ${prometheusCount} prometheus methods`);
555
+ return db;
556
+ }
557
+ /**
558
+ * Index cached scripts into the Orama database.
559
+ */
560
+ async indexCachedScripts(db) {
561
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
562
+ let indexedCount = 0;
563
+ try {
564
+ if (!existsSync(scriptsDirectory)) {
565
+ return 0;
566
+ }
567
+ const files = readdirSync(scriptsDirectory)
568
+ .filter(f => f.endsWith('.ts'))
569
+ .map(f => join(scriptsDirectory, f));
570
+ for (const filePath of files) {
571
+ // Skip if already indexed
572
+ if (this.indexedScriptPaths.has(filePath)) {
573
+ continue;
574
+ }
575
+ const script = parseScriptFile(filePath);
576
+ if (!script) {
577
+ continue;
578
+ }
579
+ const doc = buildScriptDocument(script);
580
+ await insert(db, doc);
581
+ this.indexedScriptPaths.add(filePath);
582
+ indexedCount++;
583
+ }
584
+ }
585
+ catch (error) {
586
+ logger.error('Error indexing cached scripts', error);
587
+ }
588
+ return indexedCount;
589
+ }
590
+ /**
591
+ * Index prometheus library methods into the Orama database.
592
+ */
593
+ async indexPrometheusMethods(db) {
594
+ const methods = getPrometheusMethods();
595
+ let indexedCount = 0;
596
+ for (const method of methods) {
597
+ // Build searchTokens from identifiers for better matching
598
+ const searchTokens = [
599
+ method.methodName,
600
+ method.className || '',
601
+ method.library,
602
+ method.category,
603
+ method.description,
604
+ ].join(' ');
605
+ const doc = {
606
+ id: `prometheus:${method.library}:${method.className || 'fn'}:${method.methodName}`,
607
+ documentType: 'prometheus',
608
+ resourceType: method.category, // Use category as resourceType for search
609
+ methodName: method.methodName,
610
+ description: method.description,
611
+ searchTokens,
612
+ action: 'prometheus',
613
+ scope: 'prometheus',
614
+ apiClass: method.library,
615
+ filePath: '',
616
+ library: method.library,
617
+ category: method.category,
618
+ };
619
+ await insert(db, doc);
620
+ indexedCount++;
621
+ }
622
+ return indexedCount;
623
+ }
624
+ /**
625
+ * Start background Prometheus metrics indexing (non-blocking).
626
+ * Fetches metric metadata from Prometheus and indexes into Orama.
627
+ */
628
+ startPrometheusMetricsIndexing() {
629
+ this.metricsIndexingStatus = 'in_progress';
630
+ // Run indexing in background (don't await)
631
+ this.indexPrometheusMetrics()
632
+ .then(() => {
633
+ this.metricsIndexingStatus = 'ready';
634
+ // Schedule incremental refresh
635
+ this.metricsRefreshInterval = setInterval(() => {
636
+ this.refreshPrometheusMetrics();
637
+ }, SearchToolsService.METRICS_REFRESH_INTERVAL);
638
+ })
639
+ .catch((error) => {
640
+ logger.error('Failed to index Prometheus metrics', error);
641
+ this.metricsIndexingStatus = 'unavailable';
642
+ });
643
+ }
644
+ /**
645
+ * Index Prometheus metrics from the cluster into Orama.
646
+ */
647
+ async indexPrometheusMetrics() {
648
+ const prometheusUrl = process.env.PROMETHEUS_URL;
649
+ if (!prometheusUrl) {
650
+ return 0;
651
+ }
652
+ const { PrometheusDriver } = await import('prometheus-query');
653
+ const prom = new PrometheusDriver({ endpoint: prometheusUrl });
654
+ const metadata = await prom.metadata();
655
+ const db = await this.getOramaDb();
656
+ let count = 0;
657
+ for (const [name, info] of Object.entries(metadata)) {
658
+ const metricInfo = Array.isArray(info) ? info[0] : info;
659
+ const metricType = metricInfo?.type || 'unknown';
660
+ const description = metricInfo?.help || 'No description available';
661
+ const doc = {
662
+ id: `metric:${name}`,
663
+ documentType: 'prometheus-metric',
664
+ resourceType: '',
665
+ methodName: name,
666
+ description,
667
+ searchTokens: `${name.replace(/_/g, ' ')} ${metricType} ${description}`,
668
+ action: 'metric',
669
+ scope: 'prometheus',
670
+ apiClass: 'prometheus-metric',
671
+ filePath: '',
672
+ metricType,
673
+ };
674
+ await insert(db, doc);
675
+ count++;
676
+ }
677
+ logger.info(`Orama: Indexed ${count} Prometheus metrics from cluster`);
678
+ return count;
679
+ }
680
+ /**
681
+ * Incrementally refresh Prometheus metrics.
682
+ * Adds new metrics, removes stale ones.
683
+ */
684
+ async refreshPrometheusMetrics() {
685
+ const prometheusUrl = process.env.PROMETHEUS_URL;
686
+ if (!prometheusUrl) {
687
+ return;
688
+ }
689
+ try {
690
+ const { PrometheusDriver } = await import('prometheus-query');
691
+ const prom = new PrometheusDriver({ endpoint: prometheusUrl });
692
+ const metadata = await prom.metadata();
693
+ const db = await this.getOramaDb();
694
+ const currentMetrics = new Set(Object.keys(metadata));
695
+ // Get existing indexed metrics
696
+ const existingResults = await search(db, {
697
+ term: '',
698
+ properties: ['methodName'],
699
+ limit: 10000,
700
+ });
701
+ const existingMetrics = new Map();
702
+ for (const hit of existingResults.hits) {
703
+ if (hit.document.documentType === 'prometheus-metric') {
704
+ existingMetrics.set(hit.document.methodName, hit.document.id);
705
+ }
706
+ }
707
+ let added = 0;
708
+ let removed = 0;
709
+ // Add new metrics
710
+ for (const [name, info] of Object.entries(metadata)) {
711
+ if (!existingMetrics.has(name)) {
712
+ const metricInfo = Array.isArray(info) ? info[0] : info;
713
+ const metricType = metricInfo?.type || 'unknown';
714
+ const description = metricInfo?.help || 'No description available';
715
+ const doc = {
716
+ id: `metric:${name}`,
717
+ documentType: 'prometheus-metric',
718
+ resourceType: '',
719
+ methodName: name,
720
+ description,
721
+ searchTokens: `${name.replace(/_/g, ' ')} ${metricType} ${description}`,
722
+ action: 'metric',
723
+ scope: 'prometheus',
724
+ apiClass: 'prometheus-metric',
725
+ filePath: '',
726
+ metricType,
727
+ };
728
+ await insert(db, doc);
729
+ added++;
730
+ }
731
+ }
732
+ // Remove stale metrics
733
+ for (const [name, id] of existingMetrics) {
734
+ if (!currentMetrics.has(name)) {
735
+ try {
736
+ await remove(db, id);
737
+ removed++;
738
+ }
739
+ catch {
740
+ // Ignore removal errors
741
+ }
742
+ }
743
+ }
744
+ if (added > 0 || removed > 0) {
745
+ logger.info(`Orama: Prometheus metrics refresh - added ${added}, removed ${removed}`);
746
+ }
747
+ }
748
+ catch (error) {
749
+ logger.error('Failed to refresh Prometheus metrics', error);
750
+ }
751
+ }
752
+ /**
753
+ * Start filesystem watcher for cached scripts directory.
754
+ */
755
+ startScriptWatcher(db) {
756
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
757
+ // Ensure directory exists before watching
758
+ if (!existsSync(scriptsDirectory)) {
759
+ try {
760
+ mkdirSync(scriptsDirectory, { recursive: true });
761
+ }
762
+ catch {
763
+ return;
764
+ }
765
+ }
766
+ this.scriptWatcher = chokidar.watch(join(scriptsDirectory, '*.ts'), {
767
+ persistent: true,
768
+ ignoreInitial: true,
769
+ });
770
+ this.scriptWatcher.on('add', async (filePath) => {
771
+ const script = parseScriptFile(filePath);
772
+ if (script) {
773
+ const doc = buildScriptDocument(script);
774
+ await insert(db, doc);
775
+ this.indexedScriptPaths.add(filePath);
776
+ logger.debug(`Orama: Indexed new script ${basename(filePath)}`);
777
+ }
778
+ });
779
+ this.scriptWatcher.on('unlink', async (filePath) => {
780
+ const docId = `script:${basename(filePath)}`;
781
+ try {
782
+ await remove(db, docId);
783
+ this.indexedScriptPaths.delete(filePath);
784
+ logger.debug(`Orama: Removed script ${basename(filePath)} from index`);
785
+ }
786
+ catch (error) {
787
+ logger.debug(`Could not remove script ${basename(filePath)} from index: ${error instanceof Error ? error.message : String(error)}`);
788
+ }
789
+ });
790
+ this.scriptWatcher.on('change', async (filePath) => {
791
+ const docId = `script:${basename(filePath)}`;
792
+ try {
793
+ await remove(db, docId);
794
+ }
795
+ catch (error) {
796
+ logger.debug(`Script ${basename(filePath)} was not in index, will add: ${error instanceof Error ? error.message : String(error)}`);
797
+ }
798
+ const script = parseScriptFile(filePath);
799
+ if (script) {
800
+ const doc = buildScriptDocument(script);
801
+ await insert(db, doc);
802
+ logger.debug(`Orama: Re-indexed modified script ${basename(filePath)}`);
803
+ }
804
+ });
805
+ logger.info(`Orama: Watching for script changes in ${scriptsDirectory}`);
806
+ }
807
+ /**
808
+ * Search using Orama with advanced features
809
+ */
810
+ async searchWithOrama(resourceType, action, scope, exclude, limit, offset = 0) {
811
+ const db = await this.getOramaDb();
812
+ const searchParams = {
813
+ term: resourceType,
814
+ properties: ['resourceType', 'methodName', 'description', 'searchTokens'],
815
+ boost: {
816
+ resourceType: 3,
817
+ searchTokens: 2.5,
818
+ methodName: 2,
819
+ description: 1,
820
+ },
821
+ tolerance: 1,
822
+ limit: Math.max((offset + limit) * SEARCH_RESULTS_MULTIPLIER, MIN_SEARCH_RESULTS),
823
+ facets: {
824
+ apiClass: {},
825
+ action: {},
826
+ scope: {},
827
+ },
828
+ };
829
+ const startTime = performance.now();
830
+ const searchResult = await search(db, searchParams);
831
+ const searchTime = performance.now() - startTime;
832
+ // Separate results by documentType FIRST
833
+ const allScriptHits = searchResult.hits.filter(hit => hit.document.documentType === 'script');
834
+ let methodHits = searchResult.hits.filter(hit => hit.document.documentType === 'method');
835
+ // Apply method-specific filters to methods only
836
+ if (action) {
837
+ const lowerAction = action.toLowerCase();
838
+ methodHits = methodHits.filter(hit => hit.document.action === lowerAction);
839
+ }
840
+ if (scope === 'namespaced') {
841
+ methodHits = methodHits.filter(hit => hit.document.scope === 'namespaced');
842
+ }
843
+ else if (scope === 'cluster') {
844
+ methodHits = methodHits.filter(hit => hit.document.scope === 'cluster' || hit.document.scope === 'forAllNamespaces');
845
+ }
846
+ // Apply exclusions to methods only
847
+ if (exclude) {
848
+ methodHits = methodHits.filter(hit => {
849
+ const doc = hit.document;
850
+ const hasActions = exclude.actions && exclude.actions.length > 0;
851
+ const hasApiClasses = exclude.apiClasses && exclude.apiClasses.length > 0;
852
+ if (hasActions && hasApiClasses) {
853
+ const matchesAction = exclude.actions.some(a => doc.action === a.toLowerCase() || doc.methodName.toLowerCase().includes(a.toLowerCase()));
854
+ const matchesApiClass = exclude.apiClasses.includes(doc.apiClass);
855
+ return !(matchesAction && matchesApiClass);
856
+ }
857
+ else if (hasActions) {
858
+ const matchesAction = exclude.actions.some(a => doc.action === a.toLowerCase() || doc.methodName.toLowerCase().includes(a.toLowerCase()));
859
+ return !matchesAction;
860
+ }
861
+ else if (hasApiClasses) {
862
+ return !exclude.apiClasses.includes(doc.apiClass);
863
+ }
864
+ return true;
865
+ });
866
+ }
867
+ // Extract facets (filter out script-related values)
868
+ const facets = {
869
+ apiClass: {},
870
+ action: {},
871
+ scope: {},
872
+ };
873
+ if (searchResult.facets) {
874
+ if (searchResult.facets.apiClass?.values) {
875
+ for (const [key, value] of Object.entries(searchResult.facets.apiClass.values)) {
876
+ if (key !== 'CachedScript') {
877
+ facets.apiClass[key] = value;
878
+ }
879
+ }
880
+ }
881
+ if (searchResult.facets.action?.values) {
882
+ for (const [key, value] of Object.entries(searchResult.facets.action.values)) {
883
+ if (key !== 'script') {
884
+ facets.action[key] = value;
885
+ }
886
+ }
887
+ }
888
+ if (searchResult.facets.scope?.values) {
889
+ for (const [key, value] of Object.entries(searchResult.facets.scope.values)) {
890
+ if (key !== 'script') {
891
+ facets.scope[key] = value;
892
+ }
893
+ }
894
+ }
895
+ }
896
+ // Sort methods to prioritize exact resourceType matches
897
+ const sortedMethodHits = methodHits.sort((a, b) => {
898
+ const aExact = a.document.resourceType.toLowerCase() === resourceType.toLowerCase();
899
+ const bExact = b.document.resourceType.toLowerCase() === resourceType.toLowerCase();
900
+ if (aExact && !bExact)
901
+ return -1;
902
+ if (!aExact && bExact)
903
+ return 1;
904
+ return (b.score || 0) - (a.score || 0);
905
+ });
906
+ // Sort scripts by relevance score
907
+ const sortedScriptHits = allScriptHits.sort((a, b) => (b.score || 0) - (a.score || 0));
908
+ const totalMethodCount = sortedMethodHits.length;
909
+ const totalScriptCount = sortedScriptHits.length;
910
+ return {
911
+ methodResults: sortedMethodHits.slice(offset, offset + limit).map(hit => hit.document),
912
+ scriptResults: sortedScriptHits.slice(0, MAX_RELEVANT_SCRIPTS).map(hit => hit.document),
913
+ totalMethodCount,
914
+ totalScriptCount,
915
+ facets,
916
+ searchTime,
917
+ };
918
+ }
919
+ /**
920
+ * Extract all API methods from @kubernetes/client-node
921
+ */
922
+ extractKubernetesApiMethods() {
923
+ if (this.apiMethodsCache) {
924
+ return this.apiMethodsCache;
925
+ }
926
+ const methods = [];
927
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
928
+ const apiClasses = [
929
+ { class: 'CoreV1Api', constructor: k8s.CoreV1Api, description: 'Core Kubernetes resources (Pods, Services, ConfigMaps, Secrets, Namespaces, Nodes, etc.)' },
930
+ { class: 'AppsV1Api', constructor: k8s.AppsV1Api, description: 'Applications API (Deployments, StatefulSets, DaemonSets, ReplicaSets)' },
931
+ { class: 'BatchV1Api', constructor: k8s.BatchV1Api, description: 'Batch operations (Jobs, CronJobs)' },
932
+ { class: 'NetworkingV1Api', constructor: k8s.NetworkingV1Api, description: 'Networking resources (Ingresses, NetworkPolicies, IngressClasses)' },
933
+ { class: 'RbacAuthorizationV1Api', constructor: k8s.RbacAuthorizationV1Api, description: 'RBAC (Roles, RoleBindings, ClusterRoles, ClusterRoleBindings, ServiceAccounts)' },
934
+ { class: 'StorageV1Api', constructor: k8s.StorageV1Api, description: 'Storage resources (StorageClasses, PersistentVolumes, VolumeAttachments)' },
935
+ { class: 'CustomObjectsApi', constructor: k8s.CustomObjectsApi, description: 'Custom Resource Definitions (CRDs) and custom resources' },
936
+ { class: 'ApiextensionsV1Api', constructor: k8s.ApiextensionsV1Api, description: 'API extensions (CustomResourceDefinitions for discovering and managing CRDs)' },
937
+ { class: 'AutoscalingV1Api', constructor: k8s.AutoscalingV1Api, description: 'Autoscaling resources (HorizontalPodAutoscalers)' },
938
+ { class: 'PolicyV1Api', constructor: k8s.PolicyV1Api, description: 'Policy resources (PodDisruptionBudgets)' },
939
+ ];
940
+ for (const { class: className, constructor: ApiClass, description: classDesc } of apiClasses) {
941
+ if (!ApiClass)
942
+ continue;
943
+ const proto = ApiClass.prototype;
944
+ const methodNames = Object.getOwnPropertyNames(proto);
945
+ for (const methodName of methodNames) {
946
+ if (methodName === 'constructor' || methodName.startsWith('_') ||
947
+ methodName === 'setDefaultAuthentication' || typeof proto[methodName] !== 'function') {
948
+ continue;
949
+ }
950
+ const resourceType = extractResourceType(methodName);
951
+ const description = generateDescriptionFromMethodName(methodName, classDesc);
952
+ const parameters = inferParameters(methodName, className);
953
+ const example = generateUsageExample(className, methodName, parameters);
954
+ const inputSchema = generateInputSchema(parameters);
955
+ const outputSchema = generateOutputSchema(methodName, resourceType);
956
+ const typeDefinitionFile = `node_modules/@kubernetes/client-node/dist/gen/apis/${className}.d.ts`;
957
+ const typeDefinitions = extractMethodTypeDefinitions(className, methodName, resourceType);
958
+ methods.push({
959
+ apiClass: className,
960
+ methodName,
961
+ resourceType,
962
+ description,
963
+ parameters,
964
+ returnType: 'Promise<any>',
965
+ example,
966
+ typeDefinitionFile,
967
+ inputSchema,
968
+ outputSchema,
969
+ typeDefinitions: Object.keys(typeDefinitions).length > 0 ? typeDefinitions : undefined,
970
+ });
971
+ }
972
+ }
973
+ this.apiMethodsCache = methods;
974
+ logger.info(`Indexed ${methods.length} Kubernetes API methods`);
975
+ return methods;
976
+ }
977
+ }
978
+ // Export singleton for production use
979
+ export const searchToolsService = new SearchToolsService();
980
+ // Export class for testing
981
+ export { SearchToolsService };
982
+ // ============================================================================
983
+ // Orama Search Engine Configuration
984
+ // ============================================================================
985
+ /**
986
+ * Orama schema for Kubernetes API methods and Prometheus library methods
987
+ *
988
+ * Design decisions based on Orama best practices:
989
+ * - `string` types for full-text searchable fields (resourceType, methodName, description)
990
+ * - `enum` types for exact-match filterable fields (action, scope, apiClass)
991
+ * - stemmerSkipProperties for code identifiers that shouldn't be stemmed
992
+ * - Boosting configured at search time for relevance tuning
993
+ */
994
+ const oramaSchema = {
995
+ // Document type discriminator
996
+ documentType: 'enum', // "method" | "script" | "prometheus" | "prometheus-metric"
997
+ // Full-text searchable fields
998
+ resourceType: 'string', // "Pod", "Deployment" - boosted 3x
999
+ methodName: 'string', // "listNamespacedPod" or script filename - boosted 2x
1000
+ description: 'string', // Full description text - boosted 1x
1001
+ // Enhanced search field: CamelCase split for better matching
1002
+ // e.g., "PodExec" becomes "Pod Exec", "ServiceAccountToken" becomes "Service Account Token"
1003
+ searchTokens: 'string',
1004
+ // Filterable enum fields (exact match, used in where clause)
1005
+ action: 'enum', // "list", "create", "read", "delete", "patch", "replace", "connect", "watch", "script", "prometheus"
1006
+ scope: 'enum', // "namespaced", "cluster", "forAllNamespaces", "script", "prometheus"
1007
+ apiClass: 'enum', // "CoreV1Api", "AppsV1Api", "CachedScript", "prometheus-query"
1008
+ // Stored metadata
1009
+ id: 'string', // Unique identifier: apiClass.methodName or script:filename
1010
+ filePath: 'string', // Full path for scripts (empty for methods)
1011
+ // Prometheus-specific fields
1012
+ library: 'enum', // "prometheus-query" (empty for non-prometheus)
1013
+ category: 'enum', // "query" | "metadata" | "alerts" (empty for non-prometheus)
1014
+ // Prometheus metric fields (for prometheus-metric documentType)
1015
+ metricType: 'enum', // "gauge" | "counter" | "histogram" | "summary" | "unknown"
1016
+ };
1017
+ /**
1018
+ * Extract the action from a method name
1019
+ */
1020
+ function extractAction(methodName) {
1021
+ const lowerMethod = methodName.toLowerCase();
1022
+ const actions = ['list', 'read', 'create', 'delete', 'patch', 'replace', 'connect', 'watch', 'get'];
1023
+ for (const action of actions) {
1024
+ if (lowerMethod.startsWith(action)) {
1025
+ return action;
1026
+ }
1027
+ }
1028
+ return 'unknown';
1029
+ }
1030
+ /**
1031
+ * Extract the scope from a method name
1032
+ */
1033
+ function extractScope(methodName) {
1034
+ const lowerMethod = methodName.toLowerCase();
1035
+ if (lowerMethod.includes('forallnamespaces')) {
1036
+ return 'forAllNamespaces';
1037
+ }
1038
+ if (lowerMethod.includes('namespaced')) {
1039
+ return 'namespaced';
1040
+ }
1041
+ return 'cluster';
1042
+ }
1043
+ // ============================================================================
1044
+ // Prometheus Library Methods (Dynamic Extraction from .d.ts files)
1045
+ // ============================================================================
1046
+ /**
1047
+ * Extract JSDoc comment text from a node using TypeScript AST
1048
+ */
1049
+ function extractJSDocComment(node, _sourceFile) {
1050
+ const jsDocComments = ts.getJSDocCommentsAndTags(node);
1051
+ for (const comment of jsDocComments) {
1052
+ if (ts.isJSDoc(comment) && comment.comment) {
1053
+ if (typeof comment.comment === 'string') {
1054
+ return comment.comment;
1055
+ }
1056
+ // Handle JSDocComment array (multiple parts)
1057
+ if (Array.isArray(comment.comment)) {
1058
+ return comment.comment
1059
+ .map(part => typeof part === 'string' ? part : part.text)
1060
+ .join('')
1061
+ .trim();
1062
+ }
1063
+ }
1064
+ }
1065
+ return '';
1066
+ }
1067
+ /**
1068
+ * Extract parameter info from TypeScript function parameters
1069
+ */
1070
+ function extractParameterInfo(params, sourceFile) {
1071
+ return params.map(param => {
1072
+ const name = param.name.getText(sourceFile);
1073
+ const type = param.type?.getText(sourceFile) || 'any';
1074
+ const optional = !!param.questionToken || !!param.initializer;
1075
+ return { name, type, optional };
1076
+ });
1077
+ }
1078
+ /**
1079
+ * Determine category for a prometheus-query method based on its name
1080
+ */
1081
+ function categorizePrometheusQueryMethod(methodName, _description) {
1082
+ const lowerName = methodName.toLowerCase();
1083
+ if (lowerName.includes('query') || lowerName === 'instantquery' || lowerName === 'rangequery') {
1084
+ return 'query';
1085
+ }
1086
+ if (lowerName.includes('alert') || lowerName.includes('rule')) {
1087
+ return 'alerts';
1088
+ }
1089
+ return 'metadata';
1090
+ }
1091
+ /**
1092
+ * Generate example code for a prometheus-query method
1093
+ */
1094
+ function generatePrometheusQueryExample(methodName, params) {
1095
+ const requiredParams = params.filter(p => !p.optional);
1096
+ const paramExamples = [];
1097
+ for (const p of requiredParams) {
1098
+ switch (p.name) {
1099
+ case 'query':
1100
+ paramExamples.push("'up{job=\"prometheus\"}'");
1101
+ break;
1102
+ case 'time':
1103
+ case 'start':
1104
+ paramExamples.push('new Date(Date.now() - 3600000)');
1105
+ break;
1106
+ case 'end':
1107
+ paramExamples.push('new Date()');
1108
+ break;
1109
+ case 'step':
1110
+ paramExamples.push("'1m'");
1111
+ break;
1112
+ case 'matchs':
1113
+ case 'match':
1114
+ paramExamples.push("['{job=\"prometheus\"}']");
1115
+ break;
1116
+ case 'labelName':
1117
+ paramExamples.push("'job'");
1118
+ break;
1119
+ default:
1120
+ paramExamples.push(`/* ${p.name} */`);
1121
+ }
1122
+ }
1123
+ return `import { PrometheusDriver } from 'prometheus-query';
1124
+
1125
+ const prom = new PrometheusDriver({ endpoint: process.env.PROMETHEUS_URL || 'http://prometheus:9090' });
1126
+ const result = await prom.${methodName}(${paramExamples.join(', ')});
1127
+ console.log(result);`;
1128
+ }
1129
+ /**
1130
+ * Dynamically extract methods from prometheus-query library .d.ts files
1131
+ */
1132
+ function extractPrometheusQueryMethods() {
1133
+ const methods = [];
1134
+ try {
1135
+ const driverPath = join(process.cwd(), 'node_modules', 'prometheus-query', 'dist', 'driver.d.ts');
1136
+ if (!existsSync(driverPath)) {
1137
+ logger.debug('prometheus-query driver.d.ts not found');
1138
+ return methods;
1139
+ }
1140
+ const sourceCode = readFileSync(driverPath, 'utf-8');
1141
+ const sourceFile = ts.createSourceFile(driverPath, sourceCode, ts.ScriptTarget.Latest, true);
1142
+ function visit(node) {
1143
+ if (ts.isClassDeclaration(node) && node.name?.text === 'PrometheusDriver') {
1144
+ for (const member of node.members) {
1145
+ if (ts.isMethodDeclaration(member) && member.name) {
1146
+ const methodName = member.name.getText(sourceFile);
1147
+ if (methodName.startsWith('_') || methodName === 'constructor' ||
1148
+ member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword)) {
1149
+ continue;
1150
+ }
1151
+ const description = extractJSDocComment(member, sourceFile) ||
1152
+ `${methodName.charAt(0).toUpperCase() + methodName.slice(1).replace(/([A-Z])/g, ' $1').trim()} from Prometheus API`;
1153
+ const params = extractParameterInfo(member.parameters, sourceFile);
1154
+ const returnType = member.type?.getText(sourceFile) || 'Promise<any>';
1155
+ const category = categorizePrometheusQueryMethod(methodName, description);
1156
+ const example = generatePrometheusQueryExample(methodName, params);
1157
+ methods.push({
1158
+ library: 'prometheus-query',
1159
+ className: 'PrometheusDriver',
1160
+ methodName,
1161
+ category,
1162
+ description,
1163
+ parameters: params,
1164
+ returnType,
1165
+ example,
1166
+ });
1167
+ }
1168
+ }
1169
+ }
1170
+ ts.forEachChild(node, visit);
1171
+ }
1172
+ visit(sourceFile);
1173
+ logger.debug(`Extracted ${methods.length} methods from prometheus-query`);
1174
+ }
1175
+ catch (error) {
1176
+ logger.debug(`Failed to extract prometheus-query methods: ${error instanceof Error ? error.message : String(error)}`);
1177
+ }
1178
+ return methods;
1179
+ }
1180
+ /**
1181
+ * Get all Prometheus library methods (dynamically extracted from .d.ts files)
1182
+ */
1183
+ function getAllPrometheusMethods() {
1184
+ const startTime = Date.now();
1185
+ const methods = extractPrometheusQueryMethods();
1186
+ const elapsed = Date.now() - startTime;
1187
+ logger.info(`Dynamically extracted ${methods.length} prometheus-query methods in ${elapsed}ms`);
1188
+ return methods;
1189
+ }
1190
+ /**
1191
+ * Prometheus methods cache (populated at service initialization)
1192
+ */
1193
+ let prometheusMethodsCache = null;
1194
+ /**
1195
+ * Get cached Prometheus methods
1196
+ */
1197
+ function getPrometheusMethods() {
1198
+ if (!prometheusMethodsCache) {
1199
+ prometheusMethodsCache = getAllPrometheusMethods();
1200
+ }
1201
+ return prometheusMethodsCache;
1202
+ }
1203
+ /**
1204
+ * Clear the prometheus methods cache (used during shutdown/reset)
1205
+ */
1206
+ function clearPrometheusMethodsCache() {
1207
+ prometheusMethodsCache = null;
1208
+ }
1209
+ // ============================================================================
1210
+ // Script Parsing Functions
1211
+ // ============================================================================
1212
+ /**
1213
+ * Extract the first comment block from a TypeScript file using TypeScript AST.
1214
+ * Supports block comments and consecutive single-line comments.
1215
+ */
1216
+ function extractFirstCommentBlock(filePath) {
1217
+ try {
1218
+ const content = readFileSync(filePath, 'utf-8');
1219
+ // Get leading comments from the start of the file using TypeScript's comment parser
1220
+ const leadingComments = ts.getLeadingCommentRanges(content, 0);
1221
+ if (!leadingComments || leadingComments.length === 0) {
1222
+ return '';
1223
+ }
1224
+ // Collect all consecutive comments at the start
1225
+ const commentTexts = [];
1226
+ for (const comment of leadingComments) {
1227
+ const commentText = content.slice(comment.pos, comment.end);
1228
+ if (comment.kind === ts.SyntaxKind.MultiLineCommentTrivia) {
1229
+ // Block comment - extract content between /* and */
1230
+ const inner = commentText.slice(2, -2); // Remove /* and */
1231
+ const lines = inner.split('\n');
1232
+ for (const line of lines) {
1233
+ // Remove leading asterisks and whitespace
1234
+ let cleaned = line.trim();
1235
+ if (cleaned.startsWith('*')) {
1236
+ cleaned = cleaned.slice(1).trim();
1237
+ }
1238
+ if (cleaned.length > 0) {
1239
+ commentTexts.push(cleaned);
1240
+ }
1241
+ }
1242
+ }
1243
+ else if (comment.kind === ts.SyntaxKind.SingleLineCommentTrivia) {
1244
+ // Single-line comment - remove leading //
1245
+ const cleaned = commentText.slice(2).trim();
1246
+ if (cleaned.length > 0) {
1247
+ commentTexts.push(cleaned);
1248
+ }
1249
+ }
1250
+ }
1251
+ return commentTexts.join(' ').trim();
1252
+ }
1253
+ catch (error) {
1254
+ logger.debug(`Failed to extract comment from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
1255
+ return '';
1256
+ }
1257
+ }
1258
+ /**
1259
+ * Extract likely resource types from a script filename.
1260
+ * Examples:
1261
+ * "get-pod-logs.ts" -> ["pod", "log", "logs"]
1262
+ * "list-nodes.ts" -> ["node", "nodes"]
1263
+ */
1264
+ function extractResourceTypesFromFilename(filename) {
1265
+ // Remove extension
1266
+ const baseName = filename.replace(/\.ts$/, '');
1267
+ // Split by common separators and filter out action words
1268
+ const parts = baseName
1269
+ .split(/[-_]/)
1270
+ .filter(part => part.length > 0)
1271
+ .filter(part => !['get', 'list', 'create', 'delete', 'update', 'patch', 'watch'].includes(part.toLowerCase()));
1272
+ // Add singular/plural variants
1273
+ const resourceTypes = [];
1274
+ for (const part of parts) {
1275
+ resourceTypes.push(part.toLowerCase());
1276
+ // Add singular if plural
1277
+ if (part.endsWith('s') && part.length > 2) {
1278
+ resourceTypes.push(part.slice(0, -1).toLowerCase());
1279
+ }
1280
+ }
1281
+ return [...new Set(resourceTypes)];
1282
+ }
1283
+ /**
1284
+ * Extract K8s API signals from script content using TypeScript AST.
1285
+ * Extracts API class references and resource type references.
1286
+ */
1287
+ function extractApiSignals(filePath) {
1288
+ try {
1289
+ const content = readFileSync(filePath, 'utf-8');
1290
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
1291
+ const apiClasses = new Set();
1292
+ const resourceTypes = new Set();
1293
+ // Known K8s API class names
1294
+ const knownApiClasses = new Set([
1295
+ 'CoreV1Api', 'AppsV1Api', 'BatchV1Api', 'NetworkingV1Api',
1296
+ 'RbacAuthorizationV1Api', 'StorageV1Api', 'CustomObjectsApi',
1297
+ 'ApiextensionsV1Api', 'AutoscalingV1Api', 'PolicyV1Api',
1298
+ ]);
1299
+ function visit(node) {
1300
+ // Find type references (V1Pod, V1Deployment, etc.)
1301
+ if (ts.isTypeReferenceNode(node)) {
1302
+ const typeName = node.typeName.getText(sourceFile);
1303
+ // K8s types start with V followed by version number
1304
+ if (typeName.startsWith('V') && typeName.length > 2) {
1305
+ const secondChar = typeName.charAt(1);
1306
+ if (secondChar >= '0' && secondChar <= '9') {
1307
+ // Filter out Api and List types
1308
+ if (!typeName.includes('Api') && !typeName.includes('List') && typeName.length < 30) {
1309
+ resourceTypes.add(typeName);
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ // Find identifier references to API classes
1315
+ if (ts.isIdentifier(node)) {
1316
+ const name = node.text;
1317
+ if (knownApiClasses.has(name)) {
1318
+ apiClasses.add(name);
1319
+ }
1320
+ }
1321
+ // Find property access like k8s.CoreV1Api
1322
+ if (ts.isPropertyAccessExpression(node)) {
1323
+ const propName = node.name.text;
1324
+ if (knownApiClasses.has(propName)) {
1325
+ apiClasses.add(propName);
1326
+ }
1327
+ }
1328
+ ts.forEachChild(node, visit);
1329
+ }
1330
+ visit(sourceFile);
1331
+ return {
1332
+ apiClasses: [...apiClasses],
1333
+ resourceTypes: [...resourceTypes].slice(0, MAX_RESOURCE_TYPES_FROM_CONTENT),
1334
+ };
1335
+ }
1336
+ catch (error) {
1337
+ logger.debug(`Failed to extract API signals from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
1338
+ return { apiClasses: [], resourceTypes: [] };
1339
+ }
1340
+ }
1341
+ /**
1342
+ * Parse a cached script file and extract searchable metadata.
1343
+ */
1344
+ function parseScriptFile(filePath) {
1345
+ try {
1346
+ const filename = basename(filePath);
1347
+ const description = extractFirstCommentBlock(filePath);
1348
+ const filenameResourceTypes = extractResourceTypesFromFilename(filename);
1349
+ const { apiClasses, resourceTypes: contentResourceTypes } = extractApiSignals(filePath);
1350
+ // Combine resource types from filename and content
1351
+ const resourceTypes = [...new Set([...filenameResourceTypes, ...contentResourceTypes.map(t => t.toLowerCase())])];
1352
+ // Extract additional keywords from description
1353
+ const keywords = description
1354
+ .toLowerCase()
1355
+ .split(/\s+/)
1356
+ .filter(word => word.length > 2)
1357
+ .filter(word => !['the', 'and', 'for', 'from', 'with', 'this', 'that'].includes(word));
1358
+ return {
1359
+ filename,
1360
+ filePath,
1361
+ description: description || `Script: ${filename.replace(/\.ts$/, '')}`,
1362
+ resourceTypes,
1363
+ apiClasses,
1364
+ keywords,
1365
+ };
1366
+ }
1367
+ catch (error) {
1368
+ logger.debug(`Failed to parse script ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
1369
+ return null;
1370
+ }
1371
+ }
1372
+ /**
1373
+ * Build an Orama document from a CachedScript
1374
+ */
1375
+ function buildScriptDocument(script) {
1376
+ // Build search tokens from filename, description, and API signals
1377
+ const searchTokens = [
1378
+ script.filename.replace(/\.ts$/, '').replace(/[-_]/g, ' '),
1379
+ ...script.resourceTypes,
1380
+ script.description,
1381
+ ...script.apiClasses,
1382
+ ...script.keywords,
1383
+ ].join(' ');
1384
+ return {
1385
+ id: `script:${script.filename}`,
1386
+ documentType: 'script',
1387
+ resourceType: script.resourceTypes.join(' '),
1388
+ methodName: script.filename.replace(/\.ts$/, ''),
1389
+ description: script.description,
1390
+ searchTokens,
1391
+ action: 'script',
1392
+ scope: 'script',
1393
+ apiClass: script.apiClasses.length > 0 ? script.apiClasses[0] : 'CachedScript',
1394
+ filePath: script.filePath,
1395
+ };
1396
+ }
1397
+ // ============================================================================
1398
+ // End Script Parsing Functions
1399
+ // ============================================================================
45
1400
  /**
46
1401
  * Initialize scripts directory with node_modules symlink for package resolution
47
1402
  */
@@ -51,25 +1406,38 @@ function initializeScriptsDirectory(scriptsDir) {
51
1406
  if (!existsSync(scriptsDir)) {
52
1407
  mkdirSync(scriptsDir, { recursive: true });
53
1408
  }
54
- // Create symlink to node_modules if it doesn't exist
55
- // When installed via npx, dependencies are hoisted to the cache root
56
- // PACKAGE_ROOT is like: /path/to/npx/cache/node_modules/@prodisco/k8s-mcp
57
- // Dependencies are in: /path/to/npx/cache/node_modules
1409
+ // Create symlink to node_modules
1410
+ // When installed via npx: PACKAGE_ROOT = /path/npx/node_modules/@prodisco/k8s-mcp
1411
+ // -> dependencies are in: /path/npx/node_modules (go up 2 levels)
1412
+ // When running in dev: PACKAGE_ROOT = /path/to/project
1413
+ // -> dependencies are in: /path/to/project/node_modules
58
1414
  const nodeModulesLink = join(scriptsDir, 'node_modules');
59
- const nodeModulesTarget = join(PACKAGE_ROOT, '../..');
60
- if (!existsSync(nodeModulesLink) && existsSync(nodeModulesTarget)) {
61
- try {
62
- symlinkSync(nodeModulesTarget, nodeModulesLink, 'dir');
63
- }
64
- catch (err) {
65
- // Symlink creation might fail on some systems, that's okay
66
- console.error('Could not create symlink to node_modules:', err);
67
- }
1415
+ // Detect if running from npx cache (path contains node_modules/@prodisco)
1416
+ const isNpxInstall = PACKAGE_ROOT.includes('node_modules/@prodisco') ||
1417
+ PACKAGE_ROOT.includes('node_modules\\@prodisco');
1418
+ const nodeModulesTarget = isNpxInstall
1419
+ ? realpathSync(join(PACKAGE_ROOT, '../..')) // npx: go up from node_modules/@prodisco/k8s-mcp
1420
+ : realpathSync(join(PACKAGE_ROOT, 'node_modules')); // dev: use project's node_modules
1421
+ if (!existsSync(nodeModulesTarget)) {
1422
+ logger.warn(`node_modules target does not exist: ${nodeModulesTarget}`);
1423
+ return;
1424
+ }
1425
+ // Always remove existing symlink and recreate to ensure it points to current location
1426
+ try {
1427
+ unlinkSync(nodeModulesLink);
1428
+ }
1429
+ catch {
1430
+ // Ignore - doesn't exist
1431
+ }
1432
+ try {
1433
+ symlinkSync(nodeModulesTarget, nodeModulesLink, 'dir');
1434
+ }
1435
+ catch (err) {
1436
+ logger.warn(`Could not create symlink to node_modules: ${err instanceof Error ? err.message : String(err)}`);
68
1437
  }
69
1438
  }
70
1439
  catch (err) {
71
- // If initialization fails, scripts can still work with NODE_PATH
72
- console.error('Could not initialize scripts directory:', err);
1440
+ logger.warn(`Could not initialize scripts directory: ${err instanceof Error ? err.message : String(err)}`);
73
1441
  }
74
1442
  }
75
1443
  /**
@@ -133,64 +1501,6 @@ function extractMethodTypeDefinitions(apiClass, methodName, resourceType) {
133
1501
  }
134
1502
  return result;
135
1503
  }
136
- /**
137
- * Extract all API methods from @kubernetes/client-node
138
- */
139
- function extractKubernetesApiMethods() {
140
- if (apiMethodsCache) {
141
- return apiMethodsCache;
142
- }
143
- const methods = [];
144
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
- const apiClasses = [
146
- { class: 'CoreV1Api', constructor: k8s.CoreV1Api, description: 'Core Kubernetes resources (Pods, Services, ConfigMaps, Secrets, Namespaces, Nodes, etc.)' },
147
- { class: 'AppsV1Api', constructor: k8s.AppsV1Api, description: 'Applications API (Deployments, StatefulSets, DaemonSets, ReplicaSets)' },
148
- { class: 'BatchV1Api', constructor: k8s.BatchV1Api, description: 'Batch operations (Jobs, CronJobs)' },
149
- { class: 'NetworkingV1Api', constructor: k8s.NetworkingV1Api, description: 'Networking resources (Ingresses, NetworkPolicies, IngressClasses)' },
150
- { class: 'RbacAuthorizationV1Api', constructor: k8s.RbacAuthorizationV1Api, description: 'RBAC (Roles, RoleBindings, ClusterRoles, ClusterRoleBindings, ServiceAccounts)' },
151
- { class: 'StorageV1Api', constructor: k8s.StorageV1Api, description: 'Storage resources (StorageClasses, PersistentVolumes, VolumeAttachments)' },
152
- { class: 'CustomObjectsApi', constructor: k8s.CustomObjectsApi, description: 'Custom Resource Definitions (CRDs) and custom resources' },
153
- { class: 'ApiextensionsV1Api', constructor: k8s.ApiextensionsV1Api, description: 'API extensions (CustomResourceDefinitions for discovering and managing CRDs)' },
154
- { class: 'AutoscalingV1Api', constructor: k8s.AutoscalingV1Api, description: 'Autoscaling resources (HorizontalPodAutoscalers)' },
155
- { class: 'PolicyV1Api', constructor: k8s.PolicyV1Api, description: 'Policy resources (PodDisruptionBudgets)' },
156
- ];
157
- for (const { class: className, constructor: ApiClass, description: classDesc } of apiClasses) {
158
- if (!ApiClass)
159
- continue;
160
- const proto = ApiClass.prototype;
161
- const methodNames = Object.getOwnPropertyNames(proto);
162
- for (const methodName of methodNames) {
163
- if (methodName === 'constructor' || methodName.startsWith('_') ||
164
- methodName === 'setDefaultAuthentication' || typeof proto[methodName] !== 'function') {
165
- continue;
166
- }
167
- const resourceType = extractResourceType(methodName);
168
- const description = generateDescriptionFromMethodName(methodName, className, classDesc);
169
- const parameters = inferParameters(methodName, className);
170
- const example = generateUsageExample(className, methodName, parameters);
171
- const inputSchema = generateInputSchema(methodName, parameters);
172
- const outputSchema = generateOutputSchema(methodName, resourceType);
173
- const typeDefinitionFile = `node_modules/@kubernetes/client-node/dist/gen/apis/${className}.d.ts`;
174
- const typeDefinitions = extractMethodTypeDefinitions(className, methodName, resourceType);
175
- methods.push({
176
- apiClass: className,
177
- methodName,
178
- resourceType,
179
- description,
180
- parameters,
181
- returnType: 'Promise<any>',
182
- example,
183
- typeDefinitionFile,
184
- inputSchema,
185
- outputSchema,
186
- typeDefinitions: Object.keys(typeDefinitions).length > 0 ? typeDefinitions : undefined,
187
- });
188
- }
189
- }
190
- apiMethodsCache = methods;
191
- console.error(`Indexed ${methods.length} Kubernetes API methods`);
192
- return methods;
193
- }
194
1504
  function extractResourceType(methodName) {
195
1505
  let resource = methodName
196
1506
  .replace(/^(list|read|create|delete|patch|replace|connect|get|watch)/, '')
@@ -203,7 +1513,7 @@ function extractResourceType(methodName) {
203
1513
  }
204
1514
  return resource || 'Resource';
205
1515
  }
206
- function generateDescriptionFromMethodName(methodName, apiClass, classDesc) {
1516
+ function generateDescriptionFromMethodName(methodName, classDesc) {
207
1517
  const words = methodName.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
208
1518
  const resourceMatch = methodName.match(/(?:list|read|create|delete|patch|replace)(?:Namespaced)?(.+?)(?:ForAllNamespaces)?$/);
209
1519
  const resource = resourceMatch ? resourceMatch[1] : '';
@@ -257,7 +1567,7 @@ function inferParameters(methodName, apiClass) {
257
1567
  }
258
1568
  return parameters;
259
1569
  }
260
- function generateInputSchema(methodName, parameters) {
1570
+ function generateInputSchema(parameters) {
261
1571
  const properties = {};
262
1572
  const required = [];
263
1573
  for (const param of parameters) {
@@ -345,220 +1655,695 @@ function generateUsageExample(apiClass, methodName, parameters) {
345
1655
  example += `\n}\n\n// Execute the function\nmain();`;
346
1656
  return example;
347
1657
  }
1658
+ // ============================================================================
1659
+ // Execute Functions for Each Mode
1660
+ // ============================================================================
348
1661
  /**
349
- * Match methods based on structured parameters: resourceType, action, scope, exclude
1662
+ * Execute type definition lookup mode
350
1663
  */
351
- function matchMethods(resourceType, action, scope, exclude, methods, limit) {
352
- const lowerResourceType = resourceType.toLowerCase();
353
- // Filter methods based on criteria
354
- const filtered = methods.filter(method => {
355
- const lowerMethod = method.methodName.toLowerCase();
356
- const lowerResource = method.resourceType.toLowerCase();
357
- // 1. Exclude WithHttpInfo variants (duplicates)
358
- if (lowerMethod.includes('withhttpinfo')) {
359
- return false;
360
- }
361
- // 2. Apply exclusion criteria
362
- if (exclude) {
363
- const hasActions = exclude.actions && exclude.actions.length > 0;
364
- const hasApiClasses = exclude.apiClasses && exclude.apiClasses.length > 0;
365
- if (hasActions || hasApiClasses) {
366
- const matchesActionExclusion = hasActions &&
367
- exclude.actions.some(a => lowerMethod.startsWith(a.toLowerCase()) || lowerMethod.includes(a.toLowerCase()));
368
- const matchesApiClassExclusion = hasApiClasses &&
369
- exclude.apiClasses.includes(method.apiClass);
370
- // If both specified, must match both (AND logic) to be excluded
371
- if (hasActions && hasApiClasses) {
372
- if (matchesActionExclusion && matchesApiClassExclusion) {
373
- return false;
374
- }
1664
+ async function executeTypeMode(input) {
1665
+ const { types, depth = 1 } = input;
1666
+ if (!types || types.length === 0) {
1667
+ return {
1668
+ mode: 'types',
1669
+ summary: 'Error: types parameter is required when mode is "types"',
1670
+ types: {},
1671
+ };
1672
+ }
1673
+ const basePath = process.cwd();
1674
+ const results = {};
1675
+ const typesToProcess = new Set(types);
1676
+ const processedTypes = new Set();
1677
+ let currentDepth = 0;
1678
+ while (typesToProcess.size > 0 && currentDepth < depth) {
1679
+ const currentBatch = Array.from(typesToProcess);
1680
+ typesToProcess.clear();
1681
+ for (const typePath of currentBatch) {
1682
+ if (processedTypes.has(typePath)) {
1683
+ continue;
1684
+ }
1685
+ processedTypes.add(typePath);
1686
+ const parsedPath = parseTypePath(typePath);
1687
+ if (!parsedPath) {
1688
+ results[typePath] = {
1689
+ name: typePath,
1690
+ definition: `// Invalid type path: ${typePath}`,
1691
+ file: 'error',
1692
+ nestedTypes: [],
1693
+ };
1694
+ continue;
1695
+ }
1696
+ const { baseType, path: propertyPath } = parsedPath;
1697
+ if (propertyPath.length > 0) {
1698
+ const cache = new Map();
1699
+ const subtypeInfo = getSubtypeInfo(baseType, propertyPath, basePath, cache);
1700
+ if (subtypeInfo) {
1701
+ const definition = formatTypeInfo(subtypeInfo.typeInfo);
1702
+ results[typePath] = {
1703
+ name: subtypeInfo.typeInfo.name,
1704
+ definition,
1705
+ file: findTypeDefinitionFile(subtypeInfo.originalType, basePath)?.replace(basePath, '.') || 'resolved',
1706
+ nestedTypes: [],
1707
+ };
1708
+ }
1709
+ else {
1710
+ results[typePath] = {
1711
+ name: typePath,
1712
+ definition: `// Could not resolve property path: ${typePath}`,
1713
+ file: 'not found',
1714
+ nestedTypes: [],
1715
+ };
375
1716
  }
376
- // If only actions specified, exclude if action matches
377
- else if (hasActions && matchesActionExclusion) {
378
- return false;
1717
+ }
1718
+ else {
1719
+ const filePath = findTypeDefinitionFile(baseType, basePath);
1720
+ if (filePath) {
1721
+ try {
1722
+ const extracted = extractTypeDefinitionWithTS(baseType, filePath);
1723
+ if (extracted) {
1724
+ const definition = formatTypeInfo(extracted.typeInfo);
1725
+ results[typePath] = {
1726
+ name: baseType,
1727
+ definition,
1728
+ file: filePath.replace(basePath, '.'),
1729
+ nestedTypes: extracted.nestedTypes,
1730
+ };
1731
+ if (currentDepth < depth - 1) {
1732
+ for (const nestedType of extracted.nestedTypes) {
1733
+ if (!processedTypes.has(nestedType)) {
1734
+ typesToProcess.add(nestedType);
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ else {
1740
+ results[typePath] = {
1741
+ name: baseType,
1742
+ definition: `// Type ${baseType} not found in file ${filePath}`,
1743
+ file: filePath.replace(basePath, '.'),
1744
+ nestedTypes: [],
1745
+ };
1746
+ }
1747
+ }
1748
+ catch (error) {
1749
+ results[typePath] = {
1750
+ name: baseType,
1751
+ definition: `// Error extracting type ${baseType}: ${error instanceof Error ? error.message : String(error)}`,
1752
+ file: filePath.replace(basePath, '.'),
1753
+ nestedTypes: [],
1754
+ };
1755
+ }
379
1756
  }
380
- // If only apiClasses specified, exclude if apiClass matches
381
- else if (hasApiClasses && matchesApiClassExclusion) {
382
- return false;
1757
+ else {
1758
+ results[typePath] = {
1759
+ name: baseType,
1760
+ definition: `// Type ${baseType} not found in @kubernetes/client-node type definitions`,
1761
+ file: 'not found',
1762
+ nestedTypes: [],
1763
+ };
383
1764
  }
384
1765
  }
385
1766
  }
386
- // 3. Match resource type (case-insensitive)
387
- // Support both exact match and partial match (e.g., "pod" matches "Pod", "PodTemplate")
388
- const resourceMatches = lowerResource === lowerResourceType ||
389
- lowerResource === lowerResourceType.replace(/s$/, '') || // handle plurals
390
- lowerResourceType === lowerResource.replace(/s$/, '') ||
391
- lowerResource.includes(lowerResourceType) ||
392
- lowerResourceType.includes(lowerResource);
393
- if (!resourceMatches) {
394
- return false;
1767
+ currentDepth++;
1768
+ }
1769
+ const foundCount = Object.values(results).filter(r => r.file !== 'not found').length;
1770
+ const totalTypes = Object.keys(results).length;
1771
+ let summary = `Fetched ${foundCount} type definition(s)`;
1772
+ if (totalTypes > types.length) {
1773
+ summary += ` (${types.length} requested, ${totalTypes - types.length} nested)\n\n`;
1774
+ }
1775
+ else {
1776
+ summary += `\n\n`;
1777
+ }
1778
+ for (const typeName of types) {
1779
+ const typeInfo = results[typeName];
1780
+ if (typeInfo && typeInfo.file !== 'not found') {
1781
+ summary += `${typeName}: ${typeInfo.nestedTypes.length} nested type(s)\n`;
395
1782
  }
396
- // 4. Match action if provided
397
- if (action) {
398
- const lowerAction = action.toLowerCase();
399
- // Flexible matching: method can start with action or contain it early (for compound actions)
400
- // e.g., "delete" matches both "deleteNamespacedPod" and "deleteCollectionNamespacedPod"
401
- const actionMatches = lowerMethod.startsWith(lowerAction) ||
402
- lowerMethod.includes(lowerAction);
403
- if (!actionMatches) {
404
- return false;
1783
+ }
1784
+ return {
1785
+ mode: 'types',
1786
+ summary,
1787
+ types: results,
1788
+ };
1789
+ }
1790
+ /**
1791
+ * Execute method search mode
1792
+ */
1793
+ async function executeMethodMode(input) {
1794
+ const { resourceType, action, scope = 'all', exclude, limit = 10, offset = 0 } = input;
1795
+ if (!resourceType) {
1796
+ return {
1797
+ mode: 'methods',
1798
+ summary: 'Error: resourceType parameter is required when mode is "methods"',
1799
+ tools: [],
1800
+ totalMatches: 0,
1801
+ usage: '',
1802
+ paths: { scriptsDirectory: '' },
1803
+ cachedScripts: [],
1804
+ relevantScripts: [],
1805
+ pagination: { offset: 0, limit: 10, hasMore: false },
1806
+ };
1807
+ }
1808
+ const { methodResults: oramaResults, scriptResults, totalMethodCount, facets, searchTime } = await searchToolsService.searchWithOrama(resourceType, action, scope, exclude, limit, offset);
1809
+ const allMethods = searchToolsService.getApiMethods();
1810
+ const methodMap = new Map(allMethods.map(m => [`${m.apiClass}.${m.methodName}`, m]));
1811
+ const results = oramaResults
1812
+ .map(doc => methodMap.get(doc.id))
1813
+ .filter((m) => m !== undefined);
1814
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
1815
+ initializeScriptsDirectory(scriptsDirectory);
1816
+ let cachedScripts = [];
1817
+ try {
1818
+ if (existsSync(scriptsDirectory)) {
1819
+ cachedScripts = readdirSync(scriptsDirectory)
1820
+ .filter(f => f.endsWith('.ts'))
1821
+ .sort();
1822
+ }
1823
+ }
1824
+ catch {
1825
+ // Ignore errors
1826
+ }
1827
+ // Build relevant scripts array from search results
1828
+ const relevantScripts = scriptResults.map(doc => ({
1829
+ filename: doc.methodName + '.ts',
1830
+ filePath: doc.filePath,
1831
+ description: doc.description,
1832
+ apiClasses: doc.apiClass !== 'CachedScript' ? [doc.apiClass] : [],
1833
+ }));
1834
+ const hasMore = offset + results.length < totalMethodCount;
1835
+ let summary = `Found ${results.length} method(s) for resource "${resourceType}"`;
1836
+ if (action)
1837
+ summary += `, action "${action}"`;
1838
+ if (scope !== 'all')
1839
+ summary += `, scope "${scope}"`;
1840
+ if (exclude) {
1841
+ if (exclude.actions && exclude.actions.length > 0) {
1842
+ summary += `, excluding actions: [${exclude.actions.join(', ')}]`;
1843
+ }
1844
+ if (exclude.apiClasses && exclude.apiClasses.length > 0) {
1845
+ summary += `, excluding API classes: [${exclude.apiClasses.join(', ')}]`;
1846
+ }
1847
+ }
1848
+ summary += ` (search: ${searchTime.toFixed(2)}ms)`;
1849
+ if (offset > 0 || hasMore) {
1850
+ summary += ` | Page: ${Math.floor(offset / limit) + 1}, showing ${offset + 1}-${offset + results.length} of ${totalMethodCount}`;
1851
+ }
1852
+ summary += `\n\n`;
1853
+ // ========== RELEVANT CACHED SCRIPTS (shown FIRST) ==========
1854
+ if (relevantScripts.length > 0) {
1855
+ summary += `RELEVANT CACHED SCRIPTS (${relevantScripts.length} matching "${resourceType}"):\n`;
1856
+ relevantScripts.forEach((script, i) => {
1857
+ summary += ` ${i + 1}. ${script.filename}\n`;
1858
+ summary += ` ${script.description}\n`;
1859
+ if (script.apiClasses.length > 0) {
1860
+ summary += ` APIs: ${script.apiClasses.join(', ')}\n`;
405
1861
  }
1862
+ summary += ` Path: ${script.filePath}\n`;
1863
+ summary += ` Run: npx tsx ${script.filePath}\n\n`;
1864
+ });
1865
+ }
1866
+ // ========== FACETS ==========
1867
+ if (Object.keys(facets.apiClass).length > 0) {
1868
+ summary += `FACETS (refine your search):\n`;
1869
+ summary += ` API Classes: ${Object.entries(facets.apiClass).map(([k, v]) => `${k}(${v})`).join(', ')}\n`;
1870
+ summary += ` Actions: ${Object.entries(facets.action).map(([k, v]) => `${k}(${v})`).join(', ')}\n`;
1871
+ summary += ` Scopes: ${Object.entries(facets.scope).map(([k, v]) => `${k}(${v})`).join(', ')}\n\n`;
1872
+ }
1873
+ // ========== API METHODS ==========
1874
+ summary += `API METHODS:\n\n`;
1875
+ results.forEach((method, i) => {
1876
+ summary += `${i + 1}. ${method.apiClass}.${method.methodName}\n`;
1877
+ if (method.inputSchema.required.length > 0) {
1878
+ const params = method.inputSchema.required.map(r => `${r}: "${method.inputSchema.properties[r]?.type || 'string'}"`).join(', ');
1879
+ summary += ` method_args: { ${params} }\n`;
406
1880
  }
407
- // 5. Match scope
408
- const hasNamespaced = lowerMethod.includes('namespaced');
409
- const hasForAllNamespaces = lowerMethod.includes('forallnamespaces');
410
- if (scope === 'namespaced' && !hasNamespaced) {
411
- return false;
1881
+ else {
1882
+ summary += ` method_args: {} (empty object - required)\n`;
412
1883
  }
413
- if (scope === 'cluster' && hasNamespaced && !hasForAllNamespaces) {
414
- return false;
1884
+ const isList = method.methodName.startsWith('list');
1885
+ if (isList) {
1886
+ summary += ` return_values: response.items (array of ${method.resourceType})\n`;
415
1887
  }
416
- // scope === 'all' means no filtering
417
- return true;
418
- });
419
- // Sort: prefer exact resource matches, then by method name alphabetically
420
- const sorted = filtered.sort((a, b) => {
421
- const aExact = a.resourceType.toLowerCase() === lowerResourceType;
422
- const bExact = b.resourceType.toLowerCase() === lowerResourceType;
423
- if (aExact && !bExact)
424
- return -1;
425
- if (!aExact && bExact)
426
- return 1;
427
- // Both exact or both partial: sort alphabetically
428
- return a.methodName.localeCompare(b.methodName);
1888
+ else {
1889
+ summary += ` return_values: response (${method.resourceType} object)\n`;
1890
+ }
1891
+ summary += `\n`;
429
1892
  });
430
- return sorted.slice(0, limit);
1893
+ if (results.length === 0) {
1894
+ summary += `No methods found. Try:\n`;
1895
+ summary += `- Different resourceType (e.g., "Pod", "Deployment", "Service")\n`;
1896
+ summary += `- Omit action to see all available methods\n`;
1897
+ summary += `- Use scope: "all" to see both namespaced and cluster methods\n`;
1898
+ }
1899
+ summary += `Write scripts to: ${scriptsDirectory}/<name>.ts\n`;
1900
+ summary += `Run with: npx tsx ${scriptsDirectory}/<name>.ts\n`;
1901
+ summary += `For type definitions: use mode: "types" with types: ["V1Pod"]\n\n`;
1902
+ const usage = 'USAGE:\n' +
1903
+ '- All methods require object parameter: await api.method({ param: value })\n' +
1904
+ '- List operations return: response.items (array)\n' +
1905
+ '- Single resource operations return: response (object)\n' +
1906
+ `- Write scripts to: ${scriptsDirectory}/<yourscript>.ts\n` +
1907
+ `- Import: import * as k8s from '@kubernetes/client-node';\n` +
1908
+ `- Run: npx tsx ${scriptsDirectory}/<yourscript>.ts`;
1909
+ return {
1910
+ mode: 'methods',
1911
+ summary,
1912
+ tools: results,
1913
+ totalMatches: totalMethodCount,
1914
+ usage,
1915
+ paths: {
1916
+ scriptsDirectory,
1917
+ },
1918
+ cachedScripts,
1919
+ relevantScripts,
1920
+ facets,
1921
+ searchTime,
1922
+ pagination: {
1923
+ offset,
1924
+ limit,
1925
+ hasMore,
1926
+ },
1927
+ };
431
1928
  }
432
- export const searchToolsTool = {
433
- name: 'kubernetes.searchTools',
434
- description: 'Find Kubernetes API methods by resource type and action. ' +
435
- 'Returns API methods from @kubernetes/client-node that you can use directly in your scripts. ' +
436
- 'Parameters: resourceType (e.g., "Pod", "Deployment"), action (optional: list, read, create, delete, patch, replace), scope (namespaced/cluster/all), exclude (optional: filter out by actions and/or apiClasses). ' +
437
- 'Available actions: list (list resources), read (get single resource), create (create resource), delete (delete resource), patch (update resource), replace (replace resource), connect (exec/logs/proxy), get, watch. ' +
438
- 'COMMON SEARCH PATTERNS: (1) Pod logs: use resourceType "Log" or "PodLog" (NOT "Pod" with action "connect"). Example: { resourceType: "Log" } returns CoreV1Api.readNamespacedPodLog. ' +
439
- '(2) Pod exec/attach: use { resourceType: "Pod", action: "connect" } returns connectGetNamespacedPodExec, connectPostNamespacedPodAttach. ' +
440
- '(3) Pod eviction (drain nodes): use { resourceType: "Eviction" } or { resourceType: "PodEviction" } returns CoreV1Api.createNamespacedPodEviction. ' +
441
- '(4) Binding pods to nodes: use { resourceType: "Binding" } or { resourceType: "PodBinding" } returns CoreV1Api.createNamespacedPodBinding. ' +
442
- '(5) Service account tokens: use { resourceType: "ServiceAccountToken" } returns CoreV1Api.createNamespacedServiceAccountToken. ' +
443
- '(6) Cluster health: use { resourceType: "ComponentStatus" } returns CoreV1Api.listComponentStatus. ' +
444
- '(7) Status subresources: use full resource name like { resourceType: "DeploymentStatus" }. ' +
445
- '(8) Scale subresources: use full resource name like { resourceType: "DeploymentScale" }. ' +
446
- 'TIP: Use the exclude parameter to get more precise results by filtering out unwanted methods. ' +
447
- 'Exclude examples: (1) Only action: { actions: ["delete"] } excludes all delete methods. ' +
448
- '(2) Multiple actions: { actions: ["delete", "create"] } excludes both delete and create methods. ' +
449
- '(3) By API class: { apiClasses: ["CoreV1Api"] } excludes all CoreV1Api methods. ' +
450
- '(4) Both action and apiClass (AND logic): { actions: ["delete"], apiClasses: ["CoreV1Api"] } excludes only delete methods from CoreV1Api, keeping other CoreV1Api methods and delete methods from other API classes. ' +
451
- 'Exclude is especially useful when searching broad resource types (e.g., "Pod" returns methods from CoreV1Api, AutoscalingV1Api, PolicyV1Api). ' +
452
- 'CUSTOM SCRIPTS: Before writing new scripts, check scripts/cache/ directory for existing implementations. ' +
453
- 'When creating custom scripts in scripts/cache/, follow Kubernetes library naming pattern to make them easily discoverable: ' +
454
- 'use action-scope-resource format (e.g., "list-namespaced-pod", "read-namespaced-pod-log", "create-namespaced-deployment"). ' +
455
- 'Example: For listing etcd pods in kube-system, name it "list-namespaced-etcd-pods.ts" NOT "get-etcd-pods.ts". ' +
456
- 'This naming convention makes scripts searchable and helps avoid duplicates.',
457
- schema: SearchToolsInputSchema,
458
- async execute(input) {
459
- const { resourceType, action, scope = 'all', exclude, limit = 10 } = input;
460
- const methods = extractKubernetesApiMethods();
461
- const results = matchMethods(resourceType, action, scope, exclude, methods, limit);
462
- // Define paths early so they can be used in summary
463
- const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
464
- // Initialize scripts directory with node_modules symlink
465
- initializeScriptsDirectory(scriptsDirectory);
466
- // List existing cached scripts
467
- let cachedScripts = [];
1929
+ /**
1930
+ * Execute script search mode
1931
+ */
1932
+ async function executeScriptMode(input) {
1933
+ const { searchTerm, limit = 10, offset = 0 } = input;
1934
+ const db = await searchToolsService.getOramaDb();
1935
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
1936
+ initializeScriptsDirectory(scriptsDirectory);
1937
+ let scripts = [];
1938
+ let totalMatches = 0;
1939
+ if (searchTerm) {
1940
+ // Search for scripts matching the term
1941
+ const searchParams = {
1942
+ term: searchTerm,
1943
+ properties: ['resourceType', 'methodName', 'description', 'searchTokens'],
1944
+ boost: {
1945
+ resourceType: 3,
1946
+ searchTokens: 2.5,
1947
+ methodName: 2,
1948
+ description: 1,
1949
+ },
1950
+ tolerance: 1,
1951
+ limit: 100, // Get all matches for filtering
1952
+ };
1953
+ const searchResult = await search(db, searchParams);
1954
+ // Filter to only scripts
1955
+ const scriptHits = searchResult.hits
1956
+ .filter(hit => hit.document.documentType === 'script')
1957
+ .sort((a, b) => (b.score || 0) - (a.score || 0));
1958
+ totalMatches = scriptHits.length;
1959
+ // Filter out scripts that no longer exist on disk, and clean up stale index entries
1960
+ const validScriptHits = [];
1961
+ for (const hit of scriptHits) {
1962
+ if (existsSync(hit.document.filePath)) {
1963
+ validScriptHits.push(hit);
1964
+ }
1965
+ else {
1966
+ // Clean up stale index entry
1967
+ try {
1968
+ await remove(db, hit.document.id);
1969
+ logger.debug(`Orama: Removed stale script ${hit.document.methodName} from index`);
1970
+ }
1971
+ catch {
1972
+ // Ignore removal errors
1973
+ }
1974
+ }
1975
+ }
1976
+ totalMatches = validScriptHits.length;
1977
+ scripts = validScriptHits
1978
+ .slice(offset, offset + limit)
1979
+ .map(hit => ({
1980
+ filename: hit.document.methodName + '.ts',
1981
+ filePath: hit.document.filePath,
1982
+ description: hit.document.description,
1983
+ apiClasses: hit.document.apiClass !== 'CachedScript' ? [hit.document.apiClass] : [],
1984
+ }));
1985
+ }
1986
+ else {
1987
+ // List all scripts
468
1988
  try {
469
1989
  if (existsSync(scriptsDirectory)) {
470
- cachedScripts = readdirSync(scriptsDirectory)
1990
+ const allScripts = readdirSync(scriptsDirectory)
471
1991
  .filter(f => f.endsWith('.ts'))
472
1992
  .sort();
1993
+ totalMatches = allScripts.length;
1994
+ scripts = allScripts
1995
+ .slice(offset, offset + limit)
1996
+ .map(filename => {
1997
+ const filePath = join(scriptsDirectory, filename);
1998
+ const parsed = parseScriptFile(filePath);
1999
+ return {
2000
+ filename,
2001
+ filePath,
2002
+ description: parsed?.description || `Script: ${filename.replace(/\.ts$/, '')}`,
2003
+ apiClasses: parsed?.apiClasses || [],
2004
+ };
2005
+ });
473
2006
  }
474
2007
  }
475
2008
  catch {
476
- // Ignore errors if directory doesn't exist or can't be read
477
- }
478
- // Structured output - clear and unambiguous
479
- let summary = `Found ${results.length} method(s) for resource "${resourceType}"`;
480
- if (action)
481
- summary += `, action "${action}"`;
482
- if (scope !== 'all')
483
- summary += `, scope "${scope}"`;
484
- if (exclude) {
485
- if (exclude.actions && exclude.actions.length > 0) {
486
- summary += `, excluding actions: [${exclude.actions.join(', ')}]`;
487
- }
488
- if (exclude.apiClasses && exclude.apiClasses.length > 0) {
489
- summary += `, excluding API classes: [${exclude.apiClasses.join(', ')}]`;
490
- }
2009
+ // Ignore errors
491
2010
  }
492
- summary += '\n\n';
493
- // Show existing cached scripts if any
494
- if (cachedScripts.length > 0) {
495
- summary += `📝 EXISTING CACHED SCRIPTS (${cachedScripts.length}):\n`;
496
- cachedScripts.forEach(script => {
497
- summary += ` - ${script}\n`;
498
- });
499
- summary += ` (Location: ${scriptsDirectory})\n\n`;
500
- }
501
- summary += `Write scripts to: ${scriptsDirectory}/<name>.ts and run with: npx tsx ${scriptsDirectory}/<name>.ts\n`;
502
- summary += `IMPORTANT: Check existing cached scripts above before creating new ones to avoid duplicates\n`;
503
- summary += `Custom script naming convention: Use k8s library pattern (e.g., list-namespaced-pod, read-namespaced-pod-log)\n`;
504
- summary += `For detailed type definitions: use kubernetes.getTypeDefinition tool\n\n`;
505
- results.forEach((method, i) => {
506
- summary += `${i + 1}. ${method.apiClass}.${method.methodName}\n`;
507
- // Method arguments
508
- if (method.inputSchema.required.length > 0) {
509
- const params = method.inputSchema.required.map(r => `${r}: "${method.inputSchema.properties[r]?.type || 'string'}"`).join(', ');
510
- summary += ` method_args: { ${params} }\n`;
511
- }
512
- else {
513
- summary += ` method_args: {} (empty object - required)\n`;
514
- }
515
- // Return values
516
- const isList = method.methodName.startsWith('list');
517
- if (isList) {
518
- summary += ` return_values: response.items (array of ${method.resourceType})\n`;
519
- }
520
- else {
521
- summary += ` return_values: response (${method.resourceType} object)\n`;
522
- }
523
- // Include inline type definitions if available (brief overview)
524
- if (method.typeDefinitions && method.typeDefinitions.output) {
525
- const lines = method.typeDefinitions.output.split('\n');
526
- const typeName = lines[0]?.trim() || 'unknown';
527
- // Extract key properties (just first 2-3)
528
- const propertyLines = lines.slice(1, 4).filter(l => l.trim() && !l.includes('}'));
529
- summary += ` return_types: ${typeName}\n`;
530
- if (propertyLines.length > 0) {
531
- summary += ` key properties: ${propertyLines.map(l => l.trim()).join(', ')}\n`;
532
- }
533
- summary += ` (use kubernetes.getTypeDefinition for complete type details)\n`;
2011
+ }
2012
+ const hasMore = offset + scripts.length < totalMatches;
2013
+ let summary = searchTerm
2014
+ ? `CACHED SCRIPTS (${totalMatches} matching "${searchTerm}")`
2015
+ : `CACHED SCRIPTS (${totalMatches} total)`;
2016
+ if (offset > 0 || hasMore) {
2017
+ summary += ` | Page ${Math.floor(offset / limit) + 1}, showing ${offset + 1}-${offset + scripts.length} of ${totalMatches}`;
2018
+ }
2019
+ summary += `\n\n`;
2020
+ if (scripts.length > 0) {
2021
+ scripts.forEach((script, i) => {
2022
+ summary += `${i + 1}. ${script.filename}\n`;
2023
+ summary += ` ${script.description}\n`;
2024
+ if (script.apiClasses.length > 0) {
2025
+ summary += ` APIs: ${script.apiClasses.join(', ')}\n`;
534
2026
  }
535
- summary += `\n`;
2027
+ summary += ` Path: ${script.filePath}\n`;
2028
+ summary += ` Run: npx tsx ${script.filePath}\n\n`;
536
2029
  });
537
- if (results.length === 0) {
538
- summary += `No methods found. Try:\n`;
539
- summary += `- Different resourceType (e.g., "Pod", "Deployment", "Service")\n`;
540
- summary += `- Omit action to see all available methods\n`;
541
- summary += `- Use scope: "all" to see both namespaced and cluster methods\n`;
542
- }
543
- const usage = 'USAGE:\n' +
544
- '- All methods require object parameter: await api.method({ param: value })\n' +
545
- '- No required params: await api.method({})\n' +
546
- '- List operations return: response.items (array)\n' +
547
- '- Single resource operations return: response (object)\n' +
548
- `- Write scripts to: ${scriptsDirectory}/<yourscript>.ts\n` +
549
- `- IMPORTANT: Import using package name: import * as k8s from '@kubernetes/client-node';\n` +
550
- `- Run with: npx tsx ${scriptsDirectory}/<yourscript>.ts\n` +
551
- `- The @kubernetes/client-node package is available (installed with this MCP server)`;
2030
+ }
2031
+ else {
2032
+ summary += `No scripts found.`;
2033
+ if (searchTerm) {
2034
+ summary += ` Try a different search term or omit searchTerm to list all scripts.\n`;
2035
+ }
2036
+ else {
2037
+ summary += ` Scripts directory: ${scriptsDirectory}\n`;
2038
+ }
2039
+ }
2040
+ summary += `\nScripts directory: ${scriptsDirectory}\n`;
2041
+ return {
2042
+ mode: 'scripts',
2043
+ summary,
2044
+ scripts,
2045
+ totalMatches,
2046
+ paths: {
2047
+ scriptsDirectory,
2048
+ },
2049
+ pagination: {
2050
+ offset,
2051
+ limit,
2052
+ hasMore,
2053
+ },
2054
+ };
2055
+ }
2056
+ /**
2057
+ * Group metrics by semantic category for better discoverability
2058
+ */
2059
+ function groupMetricsByCategory(metrics) {
2060
+ const categories = {
2061
+ 'status & lifecycle': [],
2062
+ 'cpu & compute': [],
2063
+ 'memory': [],
2064
+ 'network': [],
2065
+ 'storage': [],
2066
+ 'other': [],
2067
+ };
2068
+ for (const m of metrics) {
2069
+ const name = m.name.toLowerCase();
2070
+ if (name.includes('status') || name.includes('phase') || name.includes('ready') || name.includes('restart')) {
2071
+ categories['status & lifecycle'].push(m);
2072
+ }
2073
+ else if (name.includes('cpu') || name.includes('throttl')) {
2074
+ categories['cpu & compute'].push(m);
2075
+ }
2076
+ else if (name.includes('memory') || name.includes('mem_') || name.includes('_mem')) {
2077
+ categories['memory'].push(m);
2078
+ }
2079
+ else if (name.includes('network') || name.includes('receive') || name.includes('transmit') || name.includes('_rx_') || name.includes('_tx_')) {
2080
+ categories['network'].push(m);
2081
+ }
2082
+ else if (name.includes('storage') || name.includes('disk') || name.includes('volume') || name.includes('fs_')) {
2083
+ categories['storage'].push(m);
2084
+ }
2085
+ else {
2086
+ categories['other'].push(m);
2087
+ }
2088
+ }
2089
+ // Remove empty categories
2090
+ return Object.fromEntries(Object.entries(categories).filter(([, v]) => v.length > 0));
2091
+ }
2092
+ /**
2093
+ * Execute metrics mode - search for actual Prometheus metrics from the cluster
2094
+ */
2095
+ async function executeMetricsMode(searchPattern, limit, offset) {
2096
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
2097
+ initializeScriptsDirectory(scriptsDirectory);
2098
+ const indexingStatus = searchToolsService.getMetricsIndexingStatus();
2099
+ if (indexingStatus === 'unavailable') {
552
2100
  return {
553
- summary,
554
- tools: results,
555
- totalMatches: results.length,
556
- usage,
557
- paths: {
558
- scriptsDirectory,
559
- },
560
- cachedScripts,
2101
+ mode: 'prometheus',
2102
+ category: 'metrics',
2103
+ summary: 'Prometheus metrics indexing unavailable. Ensure PROMETHEUS_URL is configured.',
2104
+ metrics: [],
2105
+ totalMatches: 0,
2106
+ indexingStatus,
2107
+ paths: { scriptsDirectory },
2108
+ pagination: { offset: 0, limit, hasMore: false },
2109
+ };
2110
+ }
2111
+ const db = await searchToolsService.getOramaDb();
2112
+ // Search Orama for prometheus-metric documents
2113
+ const searchResults = await search(db, {
2114
+ term: searchPattern || '',
2115
+ properties: ['methodName', 'description', 'searchTokens'],
2116
+ limit: 10000, // Get all matching, we'll paginate manually
2117
+ });
2118
+ // Filter to only prometheus-metric documents
2119
+ const metricHits = searchResults.hits.filter(hit => hit.document.documentType === 'prometheus-metric');
2120
+ const totalMatches = metricHits.length;
2121
+ // Apply pagination
2122
+ const paginatedHits = metricHits.slice(offset, offset + limit);
2123
+ // Map to output format
2124
+ const metrics = paginatedHits.map(hit => ({
2125
+ name: hit.document.methodName,
2126
+ type: String(hit.document.metricType || 'unknown'),
2127
+ description: hit.document.description,
2128
+ }));
2129
+ // Build summary with semantic grouping
2130
+ let summary = `PROMETHEUS METRICS`;
2131
+ if (searchPattern)
2132
+ summary += ` matching "${searchPattern}"`;
2133
+ summary += `\n\nFound ${totalMatches} metric(s)`;
2134
+ if (indexingStatus === 'in_progress') {
2135
+ summary += ` (indexing in progress, results may be incomplete)`;
2136
+ }
2137
+ summary += `\n\n`;
2138
+ // Group metrics by category
2139
+ const grouped = groupMetricsByCategory(metrics);
2140
+ for (const [category, categoryMetrics] of Object.entries(grouped)) {
2141
+ summary += `${category.toUpperCase()}:\n`;
2142
+ for (const m of categoryMetrics) {
2143
+ summary += ` ${m.name} (${m.type})\n`;
2144
+ summary += ` ${m.description}\n\n`;
2145
+ }
2146
+ }
2147
+ // Add prominent usage hints
2148
+ summary += `${'='.repeat(60)}\n`;
2149
+ summary += `NEXT STEPS:\n\n`;
2150
+ summary += `1. GET LABELS for a metric (to see what you can filter on):\n`;
2151
+ summary += ` prom.labelNames(['{__name__="${metrics[0]?.name || 'metric_name'}"}'])\n`;
2152
+ summary += ` → Returns: ["namespace", "pod", "phase", ...]\n\n`;
2153
+ summary += `2. QUERY a metric:\n`;
2154
+ summary += ` prom.instantQuery('${metrics[0]?.name || 'metric_name'}{namespace="default"}')\n`;
2155
+ summary += `${'='.repeat(60)}\n`;
2156
+ return {
2157
+ mode: 'prometheus',
2158
+ category: 'metrics',
2159
+ summary,
2160
+ metrics,
2161
+ totalMatches,
2162
+ indexingStatus,
2163
+ paths: { scriptsDirectory },
2164
+ pagination: {
2165
+ offset,
2166
+ limit,
2167
+ hasMore: offset + metrics.length < totalMatches,
2168
+ },
2169
+ };
2170
+ }
2171
+ /**
2172
+ * Execute prometheus mode - search for prometheus-query library methods
2173
+ */
2174
+ async function executePrometheusMode(input) {
2175
+ const { category = 'all', methodPattern, limit = 10, offset = 0 } = input;
2176
+ // Handle metrics category - search indexed cluster metrics
2177
+ if (category === 'metrics') {
2178
+ return executeMetricsMode(methodPattern, limit, offset);
2179
+ }
2180
+ const scriptsDirectory = join(os.homedir(), '.prodisco', 'scripts', 'cache');
2181
+ initializeScriptsDirectory(scriptsDirectory);
2182
+ // Get all prometheus methods
2183
+ let methods = getPrometheusMethods();
2184
+ // Filter by category
2185
+ if (category !== 'all') {
2186
+ methods = methods.filter(m => m.category === category);
2187
+ }
2188
+ // Filter by method pattern
2189
+ if (methodPattern) {
2190
+ const pattern = methodPattern.toLowerCase();
2191
+ methods = methods.filter(m => m.methodName.toLowerCase().includes(pattern) ||
2192
+ m.description.toLowerCase().includes(pattern));
2193
+ }
2194
+ const totalMatches = methods.length;
2195
+ // Apply pagination
2196
+ const paginatedMethods = methods.slice(offset, offset + limit);
2197
+ const hasMore = offset + paginatedMethods.length < totalMatches;
2198
+ // Build facets
2199
+ const facets = {
2200
+ library: {},
2201
+ category: {},
2202
+ };
2203
+ for (const m of methods) {
2204
+ facets.library[m.library] = (facets.library[m.library] || 0) + 1;
2205
+ facets.category[m.category] = (facets.category[m.category] || 0) + 1;
2206
+ }
2207
+ // Library info
2208
+ const libraries = {
2209
+ 'prometheus-query': { installed: true, version: '^3.3.2' },
2210
+ };
2211
+ // Check if PROMETHEUS_URL is configured
2212
+ const prometheusUrl = process.env.PROMETHEUS_URL;
2213
+ // Build summary
2214
+ let summary = `PROMETHEUS METHODS`;
2215
+ if (category !== 'all')
2216
+ summary += ` (category: ${category})`;
2217
+ if (methodPattern)
2218
+ summary += ` (pattern: "${methodPattern}")`;
2219
+ summary += `\n\nFound ${totalMatches} method(s)`;
2220
+ if (offset > 0 || hasMore) {
2221
+ summary += ` | Page ${Math.floor(offset / limit) + 1}, showing ${offset + 1}-${offset + paginatedMethods.length} of ${totalMatches}`;
2222
+ }
2223
+ summary += `\n\n`;
2224
+ // Show PROMETHEUS_URL status
2225
+ if (!prometheusUrl) {
2226
+ summary += `⚠️ PROMETHEUS_URL not configured - prometheus-query methods require this environment variable\n`;
2227
+ summary += ` Set via: PROMETHEUS_URL="http://prometheus:9090"\n\n`;
2228
+ }
2229
+ else {
2230
+ summary += `✓ PROMETHEUS_URL: ${prometheusUrl}\n\n`;
2231
+ }
2232
+ // Show facets
2233
+ if (Object.keys(facets.category).length > 0) {
2234
+ summary += `FACETS:\n`;
2235
+ summary += ` Categories: ${Object.entries(facets.category).map(([k, v]) => `${k}(${v})`).join(', ')}\n\n`;
2236
+ }
2237
+ // Show methods
2238
+ summary += `METHODS:\n\n`;
2239
+ paginatedMethods.forEach((method, i) => {
2240
+ const className = method.className ? `${method.className}.` : '';
2241
+ summary += `${i + 1}. ${method.library}: ${className}${method.methodName}\n`;
2242
+ summary += ` Category: ${method.category}\n`;
2243
+ summary += ` ${method.description}\n`;
2244
+ if (method.parameters.length > 0) {
2245
+ const params = method.parameters.map(p => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', ');
2246
+ summary += ` Params: (${params})\n`;
2247
+ }
2248
+ summary += ` Returns: ${method.returnType}\n\n`;
2249
+ });
2250
+ if (paginatedMethods.length === 0) {
2251
+ summary += `No methods found. Try:\n`;
2252
+ summary += `- Different category filter\n`;
2253
+ summary += `- Different methodPattern\n`;
2254
+ }
2255
+ summary += `\nWrite scripts to: ${scriptsDirectory}/<name>.ts\n`;
2256
+ summary += `Run with: npx tsx ${scriptsDirectory}/<name>.ts\n`;
2257
+ // Add tip about metrics category
2258
+ if (prometheusUrl) {
2259
+ summary += `\n💡 TIP: Use category: "metrics" to discover actual metrics from your cluster.\n`;
2260
+ summary += ` Example: { mode: "prometheus", category: "metrics", methodPattern: "pod" }\n`;
2261
+ }
2262
+ const usage = 'USAGE:\n' +
2263
+ '- import { PrometheusDriver } from \'prometheus-query\';\n' +
2264
+ `- Write scripts to: ${scriptsDirectory}/<yourscript>.ts\n` +
2265
+ `- Run: npx tsx ${scriptsDirectory}/<yourscript>.ts`;
2266
+ // If PROMETHEUS_URL is not set, return error result
2267
+ if (!prometheusUrl) {
2268
+ return {
2269
+ mode: 'prometheus',
2270
+ error: 'PROMETHEUS_URL_NOT_CONFIGURED',
2271
+ message: 'The PROMETHEUS_URL environment variable is not set. prometheus-query methods require this to connect to a Prometheus server.',
2272
+ example: 'PROMETHEUS_URL="http://prometheus:9090" npx tsx script.ts',
2273
+ methods: paginatedMethods,
2274
+ totalMatches,
2275
+ libraries,
2276
+ paths: { scriptsDirectory },
2277
+ facets,
2278
+ pagination: { offset, limit, hasMore },
561
2279
  };
2280
+ }
2281
+ return {
2282
+ mode: 'prometheus',
2283
+ summary,
2284
+ methods: paginatedMethods,
2285
+ totalMatches,
2286
+ libraries,
2287
+ usage,
2288
+ paths: { scriptsDirectory },
2289
+ facets,
2290
+ pagination: { offset, limit, hasMore },
2291
+ };
2292
+ }
2293
+ // ============================================================================
2294
+ // Warmup Export
2295
+ // ============================================================================
2296
+ /**
2297
+ * Pre-warm the Orama search index during server startup.
2298
+ * This avoids the indexing delay on the first search request.
2299
+ */
2300
+ export async function warmupSearchIndex() {
2301
+ await searchToolsService.initialize();
2302
+ }
2303
+ /**
2304
+ * Shutdown the search tools service. Call this during graceful shutdown.
2305
+ */
2306
+ export async function shutdownSearchIndex() {
2307
+ await searchToolsService.shutdown();
2308
+ }
2309
+ // ============================================================================
2310
+ // Main Tool Export
2311
+ // ============================================================================
2312
+ export const searchToolsTool = {
2313
+ name: 'kubernetes.searchTools',
2314
+ description: 'Find Kubernetes API methods, get type definitions, or search cached scripts. ' +
2315
+ 'MODES: ' +
2316
+ '• methods (default): Search for API methods by resource type. Also shows relevant cached scripts first. ' +
2317
+ 'Params: resourceType (required), action, scope, exclude, limit, offset. ' +
2318
+ 'Example: { resourceType: "Pod", action: "list" } ' +
2319
+ '• types: Get TypeScript type definitions with path navigation. ' +
2320
+ 'Params: types (required), depth. ' +
2321
+ 'Example: { mode: "types", types: ["V1Pod", "V1Deployment.spec.template.spec"] } ' +
2322
+ '• scripts: Search or list cached scripts. ' +
2323
+ 'Params: searchTerm (optional), limit, offset. ' +
2324
+ 'Example: { mode: "scripts", searchTerm: "pod" } ' +
2325
+ '• prometheus: Search Prometheus API methods or discover cluster metrics. ' +
2326
+ 'Params: category (query/metadata/alerts/metrics), methodPattern (optional), limit, offset. ' +
2327
+ 'Example: { mode: "prometheus", category: "query" } ' +
2328
+ 'Use category: "metrics" with methodPattern to discover metrics (e.g., { mode: "prometheus", category: "metrics", methodPattern: "gpu" }). ' +
2329
+ 'Actions: list, read, create, delete, patch, replace, connect, get, watch. ' +
2330
+ 'Scopes: namespaced, cluster, all. ' +
2331
+ 'Docs: https://github.com/harche/ProDisco/blob/main/docs/search-tools.md',
2332
+ schema: SearchToolsInputSchema,
2333
+ async execute(input) {
2334
+ const { mode = 'methods' } = input;
2335
+ if (mode === 'types') {
2336
+ return executeTypeMode(input);
2337
+ }
2338
+ else if (mode === 'scripts') {
2339
+ return executeScriptMode(input);
2340
+ }
2341
+ else if (mode === 'prometheus') {
2342
+ return executePrometheusMode(input);
2343
+ }
2344
+ else {
2345
+ return executeMethodMode(input);
2346
+ }
562
2347
  },
563
2348
  };
564
2349
  //# sourceMappingURL=searchTools.js.map