mcp-database-inspector 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/dist/database/connection.d.ts +13 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +155 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/database/manager.d.ts +28 -0
- package/dist/database/manager.d.ts.map +1 -0
- package/dist/database/manager.js +621 -0
- package/dist/database/manager.js.map +1 -0
- package/dist/database/postgres-connection.d.ts +10 -0
- package/dist/database/postgres-connection.d.ts.map +1 -0
- package/dist/database/postgres-connection.js +113 -0
- package/dist/database/postgres-connection.js.map +1 -0
- package/dist/database/types.d.ts +84 -0
- package/dist/database/types.d.ts.map +1 -0
- package/dist/database/types.js +6 -0
- package/dist/database/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +186 -0
- package/dist/server.js.map +1 -0
- package/dist/test-defaults.d.ts +2 -0
- package/dist/test-defaults.d.ts.map +1 -0
- package/dist/test-defaults.js +57 -0
- package/dist/test-defaults.js.map +1 -0
- package/dist/tools/analyze-query.d.ts +27 -0
- package/dist/tools/analyze-query.d.ts.map +1 -0
- package/dist/tools/analyze-query.js +71 -0
- package/dist/tools/analyze-query.js.map +1 -0
- package/dist/tools/execute-query.d.ts +33 -0
- package/dist/tools/execute-query.d.ts.map +1 -0
- package/dist/tools/execute-query.js +57 -0
- package/dist/tools/execute-query.js.map +1 -0
- package/dist/tools/get-foreign-keys.d.ts +38 -0
- package/dist/tools/get-foreign-keys.d.ts.map +1 -0
- package/dist/tools/get-foreign-keys.js +391 -0
- package/dist/tools/get-foreign-keys.js.map +1 -0
- package/dist/tools/get-indexes.d.ts +38 -0
- package/dist/tools/get-indexes.d.ts.map +1 -0
- package/dist/tools/get-indexes.js +472 -0
- package/dist/tools/get-indexes.js.map +1 -0
- package/dist/tools/information-schema-query.d.ts +33 -0
- package/dist/tools/information-schema-query.d.ts.map +1 -0
- package/dist/tools/information-schema-query.js +76 -0
- package/dist/tools/information-schema-query.js.map +1 -0
- package/dist/tools/inspect-table.d.ts +38 -0
- package/dist/tools/inspect-table.d.ts.map +1 -0
- package/dist/tools/inspect-table.js +351 -0
- package/dist/tools/inspect-table.js.map +1 -0
- package/dist/tools/list-databases.d.ts +14 -0
- package/dist/tools/list-databases.d.ts.map +1 -0
- package/dist/tools/list-databases.js +83 -0
- package/dist/tools/list-databases.js.map +1 -0
- package/dist/tools/list-tables.d.ts +19 -0
- package/dist/tools/list-tables.d.ts.map +1 -0
- package/dist/tools/list-tables.js +130 -0
- package/dist/tools/list-tables.js.map +1 -0
- package/dist/utils/errors.d.ts +32 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +98 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +28 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +132 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/validators/input-validator.d.ts +76 -0
- package/dist/validators/input-validator.d.ts.map +1 -0
- package/dist/validators/input-validator.js +295 -0
- package/dist/validators/input-validator.js.map +1 -0
- package/dist/validators/query-validator.d.ts +19 -0
- package/dist/validators/query-validator.d.ts.map +1 -0
- package/dist/validators/query-validator.js +229 -0
- package/dist/validators/query-validator.js.map +1 -0
- package/enhanced_sql_prompt.md +324 -0
- package/examples/claude-config.json +23 -0
- package/examples/roo-config.json +16 -0
- package/package.json +42 -0
- package/src/database/connection.ts +165 -0
- package/src/database/manager.ts +682 -0
- package/src/database/postgres-connection.ts +123 -0
- package/src/database/types.ts +93 -0
- package/src/index.ts +136 -0
- package/src/server.ts +254 -0
- package/src/test-defaults.ts +63 -0
- package/src/tools/analyze-query.test.ts +100 -0
- package/src/tools/analyze-query.ts +112 -0
- package/src/tools/execute-query.ts +91 -0
- package/src/tools/get-foreign-keys.test.ts +51 -0
- package/src/tools/get-foreign-keys.ts +488 -0
- package/src/tools/get-indexes.test.ts +51 -0
- package/src/tools/get-indexes.ts +570 -0
- package/src/tools/information-schema-query.ts +125 -0
- package/src/tools/inspect-table.test.ts +59 -0
- package/src/tools/inspect-table.ts +440 -0
- package/src/tools/list-databases.ts +119 -0
- package/src/tools/list-tables.ts +181 -0
- package/src/utils/errors.ts +103 -0
- package/src/utils/logger.ts +158 -0
- package/src/validators/input-validator.ts +318 -0
- package/src/validators/query-validator.ts +267 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DatabaseManager } from '../database/manager.js';
|
|
3
|
+
import { InputValidator } from '../validators/input-validator.js';
|
|
4
|
+
import { Logger } from '../utils/logger.js';
|
|
5
|
+
import { ToolError } from '../utils/errors.js';
|
|
6
|
+
|
|
7
|
+
// Tool schema
|
|
8
|
+
const GetIndexesArgsSchema = z.object({
|
|
9
|
+
database: z.string().min(1, 'Database name is required'),
|
|
10
|
+
table: z.string().min(1, 'Table name is required').optional(),
|
|
11
|
+
tables: z.array(z.string().min(1)).optional()
|
|
12
|
+
}).superRefine((data, ctx) => {
|
|
13
|
+
const hasTable = typeof data.table === 'string' && data.table.length > 0;
|
|
14
|
+
const hasTables = Array.isArray(data.tables) && data.tables.length > 0;
|
|
15
|
+
if (hasTable && hasTables) {
|
|
16
|
+
ctx.addIssue({
|
|
17
|
+
code: z.ZodIssueCode.custom,
|
|
18
|
+
message: "Provide either 'table' or 'tables', not both"
|
|
19
|
+
});
|
|
20
|
+
} else if (!hasTable && !hasTables) {
|
|
21
|
+
ctx.addIssue({
|
|
22
|
+
code: z.ZodIssueCode.custom,
|
|
23
|
+
message: "Either 'table' or non-empty 'tables' must be provided"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tool: get_indexes
|
|
30
|
+
* Get detailed index information for one or more tables.
|
|
31
|
+
*
|
|
32
|
+
* Supports both single-table and multi-table inspection:
|
|
33
|
+
* - Provide either `table` (string) for a single table, or `tables` (string[]) for multiple tables.
|
|
34
|
+
* - If `tables` is provided, returns a mapping of table names to their index analysis.
|
|
35
|
+
* - Do not provide both `table` and `tables` at the same time.
|
|
36
|
+
*/
|
|
37
|
+
export interface GetIndexesTool {
|
|
38
|
+
name: 'get_indexes';
|
|
39
|
+
description: 'Get detailed index information for one or more tables. Supports multi-table inspection via the tables: string[] parameter.';
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: 'object';
|
|
42
|
+
properties: {
|
|
43
|
+
database: {
|
|
44
|
+
type: 'string';
|
|
45
|
+
description: 'Name of the database containing the table(s)';
|
|
46
|
+
};
|
|
47
|
+
table: {
|
|
48
|
+
type: 'string';
|
|
49
|
+
description: 'Name of the table to get indexes for (single-table mode)';
|
|
50
|
+
};
|
|
51
|
+
tables: {
|
|
52
|
+
type: 'array';
|
|
53
|
+
items: { type: 'string' };
|
|
54
|
+
description: 'Array of table names to get indexes for (multi-table mode)';
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
required: ['database'];
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const getIndexesToolDefinition: GetIndexesTool = {
|
|
62
|
+
name: 'get_indexes',
|
|
63
|
+
description: 'Get detailed index information for one or more tables. Supports multi-table inspection via the tables: string[] parameter.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
database: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'Name of the database containing the table(s)'
|
|
70
|
+
},
|
|
71
|
+
table: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'Name of the table to get indexes for (single-table mode)'
|
|
74
|
+
},
|
|
75
|
+
tables: {
|
|
76
|
+
type: 'array',
|
|
77
|
+
items: { type: 'string' },
|
|
78
|
+
description: 'Array of table names to get indexes for (multi-table mode)'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
required: ['database']
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export async function handleGetIndexes(
|
|
86
|
+
args: unknown,
|
|
87
|
+
dbManager: DatabaseManager
|
|
88
|
+
): Promise<any> {
|
|
89
|
+
try {
|
|
90
|
+
Logger.info('Executing get_indexes tool');
|
|
91
|
+
|
|
92
|
+
// Validate arguments
|
|
93
|
+
const validationResult = GetIndexesArgsSchema.safeParse(args);
|
|
94
|
+
if (!validationResult.success) {
|
|
95
|
+
Logger.warn('Invalid arguments for get_indexes', validationResult.error);
|
|
96
|
+
throw new ToolError(
|
|
97
|
+
`Invalid arguments: ${validationResult.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
|
|
98
|
+
'get_indexes'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { database, table, tables } = validationResult.data;
|
|
103
|
+
|
|
104
|
+
// Sanitize database input
|
|
105
|
+
const sanitizedDatabase = InputValidator.sanitizeString(database);
|
|
106
|
+
const dbNameValidation = InputValidator.validateDatabaseName(sanitizedDatabase);
|
|
107
|
+
if (!dbNameValidation.isValid) {
|
|
108
|
+
throw new ToolError(
|
|
109
|
+
`Invalid database name: ${dbNameValidation.error}`,
|
|
110
|
+
'get_indexes'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Multi-table mode
|
|
115
|
+
if (Array.isArray(tables) && tables.length > 0) {
|
|
116
|
+
Logger.info(`Processing indexes for multiple tables: ${tables.join(', ')}`);
|
|
117
|
+
const results: Record<string, any> = {};
|
|
118
|
+
for (const tbl of tables) {
|
|
119
|
+
const sanitizedTable = InputValidator.sanitizeString(tbl);
|
|
120
|
+
const tableNameValidation = InputValidator.validateTableName(sanitizedTable);
|
|
121
|
+
if (!tableNameValidation.isValid) {
|
|
122
|
+
Logger.warn(`Invalid table name for table '${tbl}': ${tableNameValidation.error}`);
|
|
123
|
+
results[tbl] = {
|
|
124
|
+
error: `Invalid table name: ${tableNameValidation.error}`
|
|
125
|
+
};
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
Logger.info(`Getting indexes for table: ${sanitizedDatabase}.${sanitizedTable}`);
|
|
130
|
+
Logger.time(`get_indexes_execution_${sanitizedTable}`);
|
|
131
|
+
const indexes = await dbManager.getIndexes(sanitizedDatabase, sanitizedTable);
|
|
132
|
+
Logger.timeEnd(`get_indexes_execution_${sanitizedTable}`);
|
|
133
|
+
Logger.info(`Found ${indexes.length} index entries for table: ${sanitizedDatabase}.${sanitizedTable}`);
|
|
134
|
+
|
|
135
|
+
if (indexes.length === 0) {
|
|
136
|
+
results[tbl] = {
|
|
137
|
+
database: sanitizedDatabase,
|
|
138
|
+
table: sanitizedTable,
|
|
139
|
+
indexes: [],
|
|
140
|
+
statistics: {
|
|
141
|
+
totalIndexes: 0,
|
|
142
|
+
uniqueIndexes: 0,
|
|
143
|
+
primaryKeyIndex: false,
|
|
144
|
+
compositeIndexes: 0,
|
|
145
|
+
singleColumnIndexes: 0,
|
|
146
|
+
totalIndexedColumns: 0
|
|
147
|
+
},
|
|
148
|
+
analysis: {
|
|
149
|
+
indexCoverage: 'none',
|
|
150
|
+
recommendations: ['Table has no indexes. Consider adding appropriate indexes for query performance.'],
|
|
151
|
+
potentialIssues: ['No indexes found - all queries will require full table scans']
|
|
152
|
+
},
|
|
153
|
+
summary: {
|
|
154
|
+
hasIndexes: false,
|
|
155
|
+
message: `No indexes found for table '${sanitizedTable}' in database '${sanitizedDatabase}'`
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const groupedIndexes = groupIndexesByName(indexes);
|
|
162
|
+
const analysis = analyzeIndexes(groupedIndexes);
|
|
163
|
+
|
|
164
|
+
results[tbl] = {
|
|
165
|
+
database: sanitizedDatabase,
|
|
166
|
+
table: sanitizedTable,
|
|
167
|
+
indexes: groupedIndexes.map(idx => ({
|
|
168
|
+
name: idx.name,
|
|
169
|
+
type: idx.type,
|
|
170
|
+
unique: idx.unique,
|
|
171
|
+
columns: idx.columns.map((col: any) => ({
|
|
172
|
+
name: col.name,
|
|
173
|
+
cardinality: col.cardinality,
|
|
174
|
+
subPart: col.subPart,
|
|
175
|
+
nullable: col.nullable,
|
|
176
|
+
selectivity: col.cardinality && analysis.statistics.estimatedTableSize ?
|
|
177
|
+
(col.cardinality / analysis.statistics.estimatedTableSize).toFixed(4) : null
|
|
178
|
+
})),
|
|
179
|
+
isComposite: idx.columns.length > 1,
|
|
180
|
+
isPrimary: idx.isPrimary,
|
|
181
|
+
purpose: determinePurpose(idx)
|
|
182
|
+
})),
|
|
183
|
+
statistics: analysis.statistics,
|
|
184
|
+
indexAnalysis: analysis.analysis,
|
|
185
|
+
performance: analyzeIndexPerformance(groupedIndexes),
|
|
186
|
+
coverage: analyzeIndexCoverage(groupedIndexes),
|
|
187
|
+
recommendations: generateIndexRecommendations(groupedIndexes, analysis),
|
|
188
|
+
summary: {
|
|
189
|
+
hasIndexes: groupedIndexes.length > 0,
|
|
190
|
+
message: `Found ${groupedIndexes.length} index(es) covering ${analysis.statistics.totalIndexedColumns} column(s) in table '${sanitizedTable}'`
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
} catch (err) {
|
|
194
|
+
Logger.error(`Error processing table '${tbl}' in get_indexes tool`, err);
|
|
195
|
+
results[tbl] = {
|
|
196
|
+
error: err instanceof Error ? err.message : String(err)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
content: [{
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: JSON.stringify(results, null, 2)
|
|
204
|
+
}]
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Single-table mode (backward compatible)
|
|
209
|
+
if (typeof table !== 'string' || table.length === 0) {
|
|
210
|
+
throw new ToolError(
|
|
211
|
+
"Missing required parameter: 'table'",
|
|
212
|
+
'get_indexes'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const sanitizedTable = InputValidator.sanitizeString(table);
|
|
216
|
+
const tableNameValidation = InputValidator.validateTableName(sanitizedTable);
|
|
217
|
+
if (!tableNameValidation.isValid) {
|
|
218
|
+
throw new ToolError(
|
|
219
|
+
`Invalid table name: ${tableNameValidation.error}`,
|
|
220
|
+
'get_indexes'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
Logger.info(`Getting indexes for table: ${sanitizedDatabase}.${sanitizedTable}`);
|
|
225
|
+
Logger.time('get_indexes_execution');
|
|
226
|
+
|
|
227
|
+
const indexes = await dbManager.getIndexes(sanitizedDatabase, sanitizedTable);
|
|
228
|
+
|
|
229
|
+
Logger.timeEnd('get_indexes_execution');
|
|
230
|
+
Logger.info(`Found ${indexes.length} index entries for table: ${sanitizedDatabase}.${sanitizedTable}`);
|
|
231
|
+
|
|
232
|
+
if (indexes.length === 0) {
|
|
233
|
+
const response = {
|
|
234
|
+
database: sanitizedDatabase,
|
|
235
|
+
table: sanitizedTable,
|
|
236
|
+
indexes: [],
|
|
237
|
+
statistics: {
|
|
238
|
+
totalIndexes: 0,
|
|
239
|
+
uniqueIndexes: 0,
|
|
240
|
+
primaryKeyIndex: false,
|
|
241
|
+
compositeIndexes: 0,
|
|
242
|
+
singleColumnIndexes: 0,
|
|
243
|
+
totalIndexedColumns: 0
|
|
244
|
+
},
|
|
245
|
+
analysis: {
|
|
246
|
+
indexCoverage: 'none',
|
|
247
|
+
recommendations: ['Table has no indexes. Consider adding appropriate indexes for query performance.'],
|
|
248
|
+
potentialIssues: ['No indexes found - all queries will require full table scans']
|
|
249
|
+
},
|
|
250
|
+
summary: {
|
|
251
|
+
hasIndexes: false,
|
|
252
|
+
message: `No indexes found for table '${sanitizedTable}' in database '${sanitizedDatabase}'`
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
content: [{
|
|
258
|
+
type: 'text',
|
|
259
|
+
text: JSON.stringify(response, null, 2)
|
|
260
|
+
}]
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Group indexes by name and analyze
|
|
265
|
+
const groupedIndexes = groupIndexesByName(indexes);
|
|
266
|
+
const analysis = analyzeIndexes(groupedIndexes);
|
|
267
|
+
|
|
268
|
+
const response = {
|
|
269
|
+
database: sanitizedDatabase,
|
|
270
|
+
table: sanitizedTable,
|
|
271
|
+
indexes: groupedIndexes.map(idx => ({
|
|
272
|
+
name: idx.name,
|
|
273
|
+
type: idx.type,
|
|
274
|
+
unique: idx.unique,
|
|
275
|
+
columns: idx.columns.map((col: any) => ({
|
|
276
|
+
name: col.name,
|
|
277
|
+
cardinality: col.cardinality,
|
|
278
|
+
subPart: col.subPart,
|
|
279
|
+
nullable: col.nullable,
|
|
280
|
+
selectivity: col.cardinality && analysis.statistics.estimatedTableSize ?
|
|
281
|
+
(col.cardinality / analysis.statistics.estimatedTableSize).toFixed(4) : null
|
|
282
|
+
})),
|
|
283
|
+
isComposite: idx.columns.length > 1,
|
|
284
|
+
isPrimary: idx.isPrimary,
|
|
285
|
+
purpose: determinePurpose(idx)
|
|
286
|
+
})),
|
|
287
|
+
statistics: analysis.statistics,
|
|
288
|
+
indexAnalysis: analysis.analysis,
|
|
289
|
+
performance: analyzeIndexPerformance(groupedIndexes),
|
|
290
|
+
coverage: analyzeIndexCoverage(groupedIndexes),
|
|
291
|
+
recommendations: generateIndexRecommendations(groupedIndexes, analysis),
|
|
292
|
+
summary: {
|
|
293
|
+
hasIndexes: groupedIndexes.length > 0,
|
|
294
|
+
message: `Found ${groupedIndexes.length} index(es) covering ${analysis.statistics.totalIndexedColumns} column(s) in table '${sanitizedTable}'`
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
Logger.debug('get_indexes completed successfully', {
|
|
299
|
+
database: sanitizedDatabase,
|
|
300
|
+
table: sanitizedTable,
|
|
301
|
+
indexCount: groupedIndexes.length,
|
|
302
|
+
totalIndexedColumns: analysis.statistics.totalIndexedColumns
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
content: [{
|
|
307
|
+
type: 'text',
|
|
308
|
+
text: JSON.stringify(response, null, 2)
|
|
309
|
+
}]
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Add table context to error logs
|
|
314
|
+
let tableContext: string | undefined;
|
|
315
|
+
let argTables: string[] | undefined;
|
|
316
|
+
let argTable: string | undefined;
|
|
317
|
+
if (args && typeof args === 'object') {
|
|
318
|
+
// @ts-ignore
|
|
319
|
+
argTables = Array.isArray(args.tables) ? args.tables : undefined;
|
|
320
|
+
// @ts-ignore
|
|
321
|
+
argTable = typeof args.table === 'string' ? args.table : undefined;
|
|
322
|
+
}
|
|
323
|
+
if (Array.isArray(argTables) && argTables.length > 0) {
|
|
324
|
+
tableContext = `tables: [${argTables.join(', ')}]`;
|
|
325
|
+
} else if (typeof argTable === 'string' && argTable.length > 0) {
|
|
326
|
+
tableContext = `table: ${argTable}`;
|
|
327
|
+
} else {
|
|
328
|
+
tableContext = 'no table(s) specified';
|
|
329
|
+
}
|
|
330
|
+
Logger.error(`Error in get_indexes tool (${tableContext})`, error);
|
|
331
|
+
|
|
332
|
+
if (error instanceof ToolError) {
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
throw new ToolError(
|
|
337
|
+
`Failed to get indexes (${tableContext}): ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
338
|
+
'get_indexes',
|
|
339
|
+
error instanceof Error ? error : undefined
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Helper functions
|
|
345
|
+
function groupIndexesByName(indexes: any[]): any[] {
|
|
346
|
+
const indexGroups: Record<string, any> = {};
|
|
347
|
+
|
|
348
|
+
indexes.forEach(idx => {
|
|
349
|
+
if (!indexGroups[idx.indexName]) {
|
|
350
|
+
indexGroups[idx.indexName] = {
|
|
351
|
+
name: idx.indexName,
|
|
352
|
+
type: idx.indexType,
|
|
353
|
+
unique: !idx.nonUnique,
|
|
354
|
+
columns: []
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
indexGroups[idx.indexName].columns.push({
|
|
358
|
+
name: idx.columnName,
|
|
359
|
+
cardinality: idx.cardinality,
|
|
360
|
+
subPart: idx.subPart,
|
|
361
|
+
nullable: idx.nullable
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return Object.values(indexGroups);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function analyzeIndexes(indexes: any[]): any {
|
|
369
|
+
const uniqueIndexes = indexes.filter(idx => idx.unique).length;
|
|
370
|
+
const primaryIndex = indexes.find(idx => idx.isPrimary);
|
|
371
|
+
const compositeIndexes = indexes.filter(idx => idx.columns.length > 1).length;
|
|
372
|
+
const singleColumnIndexes = indexes.filter(idx => idx.columns.length === 1).length;
|
|
373
|
+
|
|
374
|
+
// Get all indexed columns (unique)
|
|
375
|
+
const indexedColumns = new Set();
|
|
376
|
+
indexes.forEach(idx => {
|
|
377
|
+
idx.columns.forEach((col: any) => indexedColumns.add(col.name));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Estimate table size from cardinality
|
|
381
|
+
let estimatedTableSize = 0;
|
|
382
|
+
if (primaryIndex && primaryIndex.columns.length > 0) {
|
|
383
|
+
estimatedTableSize = primaryIndex.columns[0].cardinality || 0;
|
|
384
|
+
} else {
|
|
385
|
+
// Use highest cardinality from unique indexes
|
|
386
|
+
const maxCardinality = Math.max(...indexes
|
|
387
|
+
.filter(idx => idx.unique)
|
|
388
|
+
.flatMap(idx => idx.columns.map((col: any) => col.cardinality || 0))
|
|
389
|
+
);
|
|
390
|
+
estimatedTableSize = maxCardinality > 0 ? maxCardinality : 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
statistics: {
|
|
395
|
+
totalIndexes: indexes.length,
|
|
396
|
+
uniqueIndexes,
|
|
397
|
+
primaryKeyIndex: !!primaryIndex,
|
|
398
|
+
compositeIndexes,
|
|
399
|
+
singleColumnIndexes,
|
|
400
|
+
totalIndexedColumns: indexedColumns.size,
|
|
401
|
+
estimatedTableSize,
|
|
402
|
+
averageIndexCardinality: indexes.length > 0 ?
|
|
403
|
+
indexes.reduce((sum, idx) => sum + (idx.columns[0]?.cardinality || 0), 0) / indexes.length : 0
|
|
404
|
+
},
|
|
405
|
+
analysis: {
|
|
406
|
+
hasGoodCoverage: indexedColumns.size >= Math.min(5, indexes.length * 2), // Heuristic
|
|
407
|
+
hasPrimaryKey: !!primaryIndex,
|
|
408
|
+
hasUniqueConstraints: uniqueIndexes > (primaryIndex ? 1 : 0),
|
|
409
|
+
indexDistribution: {
|
|
410
|
+
simple: singleColumnIndexes,
|
|
411
|
+
composite: compositeIndexes
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function analyzeIndexPerformance(indexes: any[]): any {
|
|
418
|
+
const performance = {
|
|
419
|
+
highSelectivity: [] as any[],
|
|
420
|
+
lowSelectivity: [] as any[],
|
|
421
|
+
potentiallyRedundant: [] as any[],
|
|
422
|
+
wellDesigned: [] as any[]
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
indexes.forEach(idx => {
|
|
426
|
+
const firstColumn = idx.columns[0];
|
|
427
|
+
if (!firstColumn) return;
|
|
428
|
+
|
|
429
|
+
const cardinality = firstColumn.cardinality || 0;
|
|
430
|
+
const isLowCardinality = cardinality < 10; // Very low selectivity
|
|
431
|
+
const isHighCardinality = cardinality > 1000; // Good selectivity
|
|
432
|
+
|
|
433
|
+
if (isLowCardinality && !idx.unique && !idx.isPrimary) {
|
|
434
|
+
performance.lowSelectivity.push({
|
|
435
|
+
name: idx.name,
|
|
436
|
+
cardinality,
|
|
437
|
+
reason: 'Low cardinality may not provide good query performance'
|
|
438
|
+
});
|
|
439
|
+
} else if (isHighCardinality) {
|
|
440
|
+
performance.highSelectivity.push({
|
|
441
|
+
name: idx.name,
|
|
442
|
+
cardinality,
|
|
443
|
+
reason: 'High selectivity should provide good query performance'
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Check for well-designed composite indexes (decreasing cardinality)
|
|
448
|
+
if (idx.columns.length > 1) {
|
|
449
|
+
const cardinalities = idx.columns.map((col: any) => col.cardinality || 0);
|
|
450
|
+
const isWellOrdered = cardinalities.every((card: number, i: number) =>
|
|
451
|
+
i === 0 || card <= cardinalities[i - 1]
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (isWellOrdered) {
|
|
455
|
+
performance.wellDesigned.push({
|
|
456
|
+
name: idx.name,
|
|
457
|
+
columns: idx.columns.map((col: any) => `${col.name}(${col.cardinality})`).join(', '),
|
|
458
|
+
reason: 'Columns ordered by decreasing selectivity'
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Look for potentially redundant indexes
|
|
465
|
+
indexes.forEach((idx1, i) => {
|
|
466
|
+
indexes.slice(i + 1).forEach(idx2 => {
|
|
467
|
+
const columns1 = idx1.columns.map((col: any) => col.name);
|
|
468
|
+
const columns2 = idx2.columns.map((col: any) => col.name);
|
|
469
|
+
|
|
470
|
+
// Check if one index is a prefix of another
|
|
471
|
+
if (columns1.length <= columns2.length) {
|
|
472
|
+
const isPrefix = columns1.every((col: string, idx: number) => col === columns2[idx]);
|
|
473
|
+
if (isPrefix && !idx1.unique && !idx2.unique) {
|
|
474
|
+
performance.potentiallyRedundant.push({
|
|
475
|
+
redundantIndex: idx1.name,
|
|
476
|
+
supersetIndex: idx2.name,
|
|
477
|
+
reason: `Index '${idx1.name}' may be redundant as '${idx2.name}' covers the same columns and more`
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return performance;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function analyzeIndexCoverage(indexes: any[]): any {
|
|
488
|
+
const coverage = {
|
|
489
|
+
primaryKey: false,
|
|
490
|
+
foreignKeys: [] as string[], // Would need FK info to populate
|
|
491
|
+
commonPatterns: {
|
|
492
|
+
hasTimestampIndex: false,
|
|
493
|
+
hasStatusIndex: false,
|
|
494
|
+
hasNameIndex: false
|
|
495
|
+
},
|
|
496
|
+
columnTypes: {
|
|
497
|
+
numeric: 0,
|
|
498
|
+
string: 0,
|
|
499
|
+
datetime: 0,
|
|
500
|
+
other: 0
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const allColumns = indexes.flatMap(idx => idx.columns.map((col: any) => col.name.toLowerCase()));
|
|
505
|
+
|
|
506
|
+
coverage.primaryKey = indexes.some(idx => idx.isPrimary);
|
|
507
|
+
coverage.commonPatterns.hasTimestampIndex = allColumns.some(col =>
|
|
508
|
+
col.includes('created') || col.includes('updated') || col.includes('timestamp')
|
|
509
|
+
);
|
|
510
|
+
coverage.commonPatterns.hasStatusIndex = allColumns.some(col =>
|
|
511
|
+
col.includes('status') || col.includes('state') || col.includes('active')
|
|
512
|
+
);
|
|
513
|
+
coverage.commonPatterns.hasNameIndex = allColumns.some(col =>
|
|
514
|
+
col.includes('name') || col.includes('title') || col.includes('description')
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return coverage;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function generateIndexRecommendations(indexes: any[], analysis: any): string[] {
|
|
521
|
+
const recommendations: string[] = [];
|
|
522
|
+
|
|
523
|
+
if (!analysis.analysis.hasPrimaryKey) {
|
|
524
|
+
recommendations.push('Add a primary key index for better performance and data integrity');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (analysis.statistics.totalIndexes === 0) {
|
|
528
|
+
recommendations.push('Table has no indexes. Consider adding indexes on frequently queried columns');
|
|
529
|
+
} else if (analysis.statistics.totalIndexes > 10) {
|
|
530
|
+
recommendations.push('Table has many indexes. Consider removing unused indexes to improve write performance');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (analysis.statistics.compositeIndexes === 0 && analysis.statistics.totalIndexes > 3) {
|
|
534
|
+
recommendations.push('Consider creating composite indexes for queries filtering on multiple columns');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Check for missing indexes on commonly queried patterns
|
|
538
|
+
const allColumns = indexes.flatMap(idx => idx.columns.map((col: any) => col.name.toLowerCase()));
|
|
539
|
+
|
|
540
|
+
if (!allColumns.some(col => col.includes('created') || col.includes('timestamp'))) {
|
|
541
|
+
recommendations.push('Consider adding an index on timestamp/created_at columns for temporal queries');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (indexes.filter(idx => !idx.unique).length > 6) {
|
|
545
|
+
recommendations.push('Many non-unique indexes detected. Review if all are necessary for your query patterns');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return recommendations;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function determinePurpose(index: any): string {
|
|
552
|
+
if (index.isPrimary) {
|
|
553
|
+
return 'primary_key';
|
|
554
|
+
} else if (index.unique) {
|
|
555
|
+
return 'unique_constraint';
|
|
556
|
+
} else if (index.columns.length > 1) {
|
|
557
|
+
return 'composite_query_optimization';
|
|
558
|
+
} else {
|
|
559
|
+
const columnName = index.columns[0]?.name.toLowerCase() || '';
|
|
560
|
+
if (columnName.includes('foreign') || columnName.endsWith('_id')) {
|
|
561
|
+
return 'foreign_key_optimization';
|
|
562
|
+
} else if (columnName.includes('created') || columnName.includes('updated')) {
|
|
563
|
+
return 'temporal_queries';
|
|
564
|
+
} else if (columnName.includes('status') || columnName.includes('state')) {
|
|
565
|
+
return 'filtering_optimization';
|
|
566
|
+
} else {
|
|
567
|
+
return 'query_optimization';
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Tool: informationSchemaQuery
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DatabaseManager } from '../database/manager.js';
|
|
4
|
+
import { InputValidator } from '../validators/input-validator.js';
|
|
5
|
+
import { Logger } from '../utils/logger.js';
|
|
6
|
+
import { ToolError } from '../utils/errors.js';
|
|
7
|
+
|
|
8
|
+
// Allowed tables for INFORMATION_SCHEMA
|
|
9
|
+
const ALLOWED_TABLES = ['COLUMNS', 'TABLES', 'ROUTINES'] as const;
|
|
10
|
+
type AllowedTable = typeof ALLOWED_TABLES[number];
|
|
11
|
+
|
|
12
|
+
// Zod schema for arguments
|
|
13
|
+
const InformationSchemaQueryArgsSchema = z.object({
|
|
14
|
+
database: z.string().min(1, 'Database name is required'),
|
|
15
|
+
table: z.enum(ALLOWED_TABLES),
|
|
16
|
+
filters: z.record(z.string(), z.string()).optional(), // key-value pairs for WHERE
|
|
17
|
+
limit: z.number().int().min(1).max(1000).optional()
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export interface InformationSchemaQueryTool {
|
|
21
|
+
name: 'information_schema_query';
|
|
22
|
+
description: 'Query INFORMATION_SCHEMA tables (COLUMNS, TABLES, ROUTINES) with filters and limits. Only safe, parameterized queries are allowed.';
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object';
|
|
25
|
+
properties: {
|
|
26
|
+
database: { type: 'string'; description: 'Database name' };
|
|
27
|
+
table: { type: 'string'; enum: typeof ALLOWED_TABLES; description: 'INFORMATION_SCHEMA table to query' };
|
|
28
|
+
filters?: { type: 'object'; description: 'Key-value filters for WHERE clause' };
|
|
29
|
+
limit?: { type: 'number'; description: 'Maximum number of rows to return (default 100)' };
|
|
30
|
+
};
|
|
31
|
+
required: ['database', 'table'];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const informationSchemaQueryToolDefinition: InformationSchemaQueryTool = {
|
|
36
|
+
name: 'information_schema_query',
|
|
37
|
+
description: 'Query INFORMATION_SCHEMA tables (COLUMNS, TABLES, ROUTINES) with filters and limits. Only safe, parameterized queries are allowed.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
database: { type: 'string', description: 'Database name' },
|
|
42
|
+
table: { type: 'string', enum: ALLOWED_TABLES, description: 'INFORMATION_SCHEMA table to query' },
|
|
43
|
+
filters: { type: 'object', description: 'Key-value filters for WHERE clause' },
|
|
44
|
+
limit: { type: 'number', description: 'Maximum number of rows to return (default 100)' }
|
|
45
|
+
},
|
|
46
|
+
required: ['database', 'table']
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function handleInformationSchemaQuery(
|
|
51
|
+
args: unknown,
|
|
52
|
+
dbManager: DatabaseManager
|
|
53
|
+
): Promise<any> {
|
|
54
|
+
try {
|
|
55
|
+
Logger.info('Executing information_schema_query tool');
|
|
56
|
+
const validationResult = InformationSchemaQueryArgsSchema.safeParse(args);
|
|
57
|
+
if (!validationResult.success) {
|
|
58
|
+
Logger.warn('Invalid arguments for information_schema_query', validationResult.error);
|
|
59
|
+
throw new ToolError(
|
|
60
|
+
`Invalid arguments: ${validationResult.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
|
|
61
|
+
'information_schema_query'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const { database, table, filters, limit } = validationResult.data;
|
|
65
|
+
|
|
66
|
+
// Sanitize and validate database name
|
|
67
|
+
const sanitizedDatabase = InputValidator.sanitizeString(database);
|
|
68
|
+
const dbNameValidation = InputValidator.validateDatabaseName(sanitizedDatabase);
|
|
69
|
+
if (!dbNameValidation.isValid) {
|
|
70
|
+
throw new ToolError(
|
|
71
|
+
`Invalid database name: ${dbNameValidation.error}`,
|
|
72
|
+
'information_schema_query'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only allow specific INFORMATION_SCHEMA tables
|
|
77
|
+
if (!ALLOWED_TABLES.includes(table)) {
|
|
78
|
+
throw new ToolError(
|
|
79
|
+
`Table '${table}' is not allowed. Only ${ALLOWED_TABLES.join(', ')} are permitted.`,
|
|
80
|
+
'information_schema_query'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Validate filters: only allow string keys/values, and only safe columns
|
|
85
|
+
let safeFilters: Record<string, string> = {};
|
|
86
|
+
if (filters) {
|
|
87
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
88
|
+
if (!/^[A-Z_]+$/.test(key)) {
|
|
89
|
+
throw new ToolError(
|
|
90
|
+
`Invalid filter key: ${key}. Only uppercase letters and underscores allowed.`,
|
|
91
|
+
'information_schema_query'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
safeFilters[key] = InputValidator.sanitizeString(value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Limit
|
|
99
|
+
const safeLimit = limit && limit > 0 && limit <= 1000 ? limit : 100;
|
|
100
|
+
|
|
101
|
+
Logger.info(`Querying INFORMATION_SCHEMA.${table} for database ${sanitizedDatabase} with filters: ${JSON.stringify(safeFilters)} and limit ${safeLimit}`);
|
|
102
|
+
|
|
103
|
+
const result = await dbManager.queryInformationSchema(
|
|
104
|
+
sanitizedDatabase,
|
|
105
|
+
table,
|
|
106
|
+
safeFilters,
|
|
107
|
+
safeLimit
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{
|
|
112
|
+
type: 'text',
|
|
113
|
+
text: JSON.stringify(result.rows, null, 2)
|
|
114
|
+
}]
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
Logger.error('Error in information_schema_query tool', error);
|
|
118
|
+
if (error instanceof ToolError) throw error;
|
|
119
|
+
throw new ToolError(
|
|
120
|
+
`Failed to query INFORMATION_SCHEMA: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
121
|
+
'information_schema_query',
|
|
122
|
+
error instanceof Error ? error : undefined
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|