@berthojoris/mcp-mysql-server 1.16.3 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ForecastingTools = void 0;
7
+ const connection_1 = __importDefault(require("../db/connection"));
8
+ const config_1 = require("../config/config");
9
+ class ForecastingTools {
10
+ constructor(security) {
11
+ this.db = connection_1.default.getInstance();
12
+ this.security = security;
13
+ }
14
+ validateDatabaseAccess(requestedDatabase) {
15
+ const connectedDatabase = config_1.dbConfig.database;
16
+ if (!connectedDatabase) {
17
+ return {
18
+ valid: false,
19
+ database: "",
20
+ error: "No database configured. Please specify a database in your connection settings.",
21
+ };
22
+ }
23
+ if (requestedDatabase && requestedDatabase !== connectedDatabase) {
24
+ return {
25
+ valid: false,
26
+ database: "",
27
+ error: `Access denied: You are connected to '${connectedDatabase}' but requested '${requestedDatabase}'. Cross-database access is not permitted.`,
28
+ };
29
+ }
30
+ return { valid: true, database: connectedDatabase };
31
+ }
32
+ extractExplainNodes(explainJson) {
33
+ const nodes = [];
34
+ const visit = (obj) => {
35
+ if (!obj || typeof obj !== "object")
36
+ return;
37
+ if (obj.table && typeof obj.table === "object") {
38
+ const t = obj.table;
39
+ nodes.push({
40
+ table_name: t.table_name,
41
+ access_type: t.access_type,
42
+ key: t.key,
43
+ rows_examined_per_scan: t.rows_examined_per_scan,
44
+ rows_produced_per_join: t.rows_produced_per_join,
45
+ filtered: t.filtered,
46
+ });
47
+ }
48
+ for (const v of Object.values(obj)) {
49
+ if (Array.isArray(v))
50
+ v.forEach(visit);
51
+ else if (v && typeof v === "object")
52
+ visit(v);
53
+ }
54
+ };
55
+ visit(explainJson);
56
+ return nodes;
57
+ }
58
+ /**
59
+ * Predict how query cost/scan volume might change under table growth assumptions.
60
+ * This is heuristic-based and uses EXPLAIN FORMAT=JSON estimates.
61
+ */
62
+ async predictQueryPerformance(params) {
63
+ try {
64
+ const query = params.query;
65
+ if (!query || typeof query !== "string") {
66
+ return { status: "error", error: "query is required" };
67
+ }
68
+ if (!this.security.isReadOnlyQuery(query)) {
69
+ return {
70
+ status: "error",
71
+ error: "Only read-only queries (SELECT/SHOW/DESCRIBE/EXPLAIN) are supported for prediction.",
72
+ };
73
+ }
74
+ const growth = params.row_growth_multiplier ?? 2;
75
+ if (!Number.isFinite(growth) || growth <= 0) {
76
+ return { status: "error", error: "row_growth_multiplier must be > 0" };
77
+ }
78
+ const explainRows = await this.db.query(`EXPLAIN FORMAT=JSON ${query}`);
79
+ let explainJson = null;
80
+ let queryCost = null;
81
+ if (explainRows[0] && explainRows[0].EXPLAIN) {
82
+ try {
83
+ explainJson = JSON.parse(explainRows[0].EXPLAIN);
84
+ const qc = explainJson?.query_block?.cost_info?.query_cost;
85
+ queryCost = qc !== undefined ? parseFloat(String(qc)) : null;
86
+ }
87
+ catch {
88
+ explainJson = explainRows;
89
+ }
90
+ }
91
+ const nodes = explainJson ? this.extractExplainNodes(explainJson) : [];
92
+ const perTable = params.per_table_row_growth || {};
93
+ const tablePredictions = nodes
94
+ .filter((n) => !!n.table_name)
95
+ .map((n) => {
96
+ const t = n.table_name;
97
+ const factor = typeof perTable[t] === "number" && perTable[t] > 0
98
+ ? perTable[t]
99
+ : growth;
100
+ const baseRows = n.rows_examined_per_scan ?? n.rows_produced_per_join ?? null;
101
+ const predictedRows = typeof baseRows === "number" ? Math.round(baseRows * factor) : null;
102
+ return {
103
+ table_name: t,
104
+ access_type: n.access_type,
105
+ key: n.key,
106
+ base_rows_estimate: baseRows,
107
+ growth_factor: factor,
108
+ predicted_rows_estimate: predictedRows,
109
+ };
110
+ });
111
+ const avgFactor = tablePredictions.length > 0
112
+ ? tablePredictions.reduce((acc, t) => acc + t.growth_factor, 0) /
113
+ tablePredictions.length
114
+ : growth;
115
+ const predictedCost = typeof queryCost === "number" && Number.isFinite(queryCost)
116
+ ? parseFloat((queryCost * avgFactor).toFixed(4))
117
+ : null;
118
+ const worstScan = tablePredictions.reduce((max, t) => Math.max(max, t.predicted_rows_estimate || 0), 0);
119
+ let risk = "low";
120
+ if (worstScan > 1000000)
121
+ risk = "high";
122
+ else if (worstScan > 100000)
123
+ risk = "medium";
124
+ const recommendations = [];
125
+ for (const t of tablePredictions) {
126
+ if ((t.access_type || "").toUpperCase() === "ALL") {
127
+ recommendations.push(`Table '${t.table_name}' is using a full scan (access_type=ALL). Consider adding an index aligned with WHERE/JOIN predicates.`);
128
+ }
129
+ if (!t.key && (t.predicted_rows_estimate || 0) > 100000) {
130
+ recommendations.push(`Table '${t.table_name}' has no chosen index in EXPLAIN and predicted scan is large; review indexing and query predicates.`);
131
+ }
132
+ }
133
+ const data = {
134
+ current_estimate: {
135
+ query_cost: queryCost,
136
+ },
137
+ growth_assumptions: {
138
+ row_growth_multiplier: growth,
139
+ per_table_row_growth: perTable,
140
+ cost_scaling_model: "cost ~ linear in average growth factor (heuristic)",
141
+ },
142
+ predicted_estimate: {
143
+ query_cost: predictedCost,
144
+ },
145
+ table_estimates: tablePredictions,
146
+ risk,
147
+ recommendations,
148
+ notes: [
149
+ "This is an estimate based on MySQL EXPLAIN and simple scaling assumptions.",
150
+ "Validate with production-like data volumes and real timings.",
151
+ ],
152
+ };
153
+ if (params.include_explain_json ?? false) {
154
+ data.explain_json = explainJson;
155
+ }
156
+ return { status: "success", data };
157
+ }
158
+ catch (error) {
159
+ return { status: "error", error: error.message };
160
+ }
161
+ }
162
+ /**
163
+ * Forecast database/table growth based on current sizes and user-supplied growth rate assumptions.
164
+ */
165
+ async forecastDatabaseGrowth(params = {}) {
166
+ try {
167
+ const dbValidation = this.validateDatabaseAccess(params?.database);
168
+ if (!dbValidation.valid) {
169
+ return { status: "error", error: dbValidation.error };
170
+ }
171
+ const database = dbValidation.database;
172
+ const horizonDays = Math.min(Math.max(params.horizon_days ?? 30, 1), 3650);
173
+ let baseDailyRate = null;
174
+ if (typeof params.growth_rate_percent_per_day === "number") {
175
+ baseDailyRate = params.growth_rate_percent_per_day / 100;
176
+ }
177
+ else if (typeof params.growth_rate_percent_per_month === "number") {
178
+ const monthly = params.growth_rate_percent_per_month / 100;
179
+ // Convert to approx daily compound rate assuming 30-day month
180
+ baseDailyRate = Math.pow(1 + monthly, 1 / 30) - 1;
181
+ }
182
+ const perTableRates = params.per_table_growth_rate_percent_per_day || {};
183
+ if (baseDailyRate === null && Object.keys(perTableRates).length === 0) {
184
+ return {
185
+ status: "error",
186
+ error: "Provide growth_rate_percent_per_day or growth_rate_percent_per_month, or per_table_growth_rate_percent_per_day.",
187
+ };
188
+ }
189
+ const rows = await this.db.query(`
190
+ SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
191
+ FROM INFORMATION_SCHEMA.TABLES
192
+ WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
193
+ ORDER BY TABLE_NAME
194
+ `, [database]);
195
+ const tableForecasts = rows.map((r) => {
196
+ const table = r.TABLE_NAME;
197
+ const currentRows = typeof r.TABLE_ROWS === "number"
198
+ ? r.TABLE_ROWS
199
+ : parseInt(String(r.TABLE_ROWS || "0"), 10) || 0;
200
+ const dataBytes = typeof r.DATA_LENGTH === "number"
201
+ ? r.DATA_LENGTH
202
+ : parseInt(String(r.DATA_LENGTH || "0"), 10) || 0;
203
+ const indexBytes = typeof r.INDEX_LENGTH === "number"
204
+ ? r.INDEX_LENGTH
205
+ : parseInt(String(r.INDEX_LENGTH || "0"), 10) || 0;
206
+ const totalBytes = dataBytes + indexBytes;
207
+ const dailyRate = typeof perTableRates[table] === "number"
208
+ ? perTableRates[table] / 100
209
+ : baseDailyRate || 0;
210
+ const growthFactor = Math.pow(1 + dailyRate, horizonDays);
211
+ const forecastRows = Math.round(currentRows * growthFactor);
212
+ const forecastTotalBytes = Math.round(totalBytes * growthFactor);
213
+ return {
214
+ table_name: table,
215
+ current: {
216
+ row_estimate: currentRows,
217
+ total_size_bytes: totalBytes,
218
+ data_size_bytes: dataBytes,
219
+ index_size_bytes: indexBytes,
220
+ },
221
+ assumptions: {
222
+ daily_growth_rate_percent: dailyRate * 100,
223
+ horizon_days: horizonDays,
224
+ },
225
+ forecast: {
226
+ row_estimate: forecastRows,
227
+ total_size_bytes: forecastTotalBytes,
228
+ },
229
+ };
230
+ });
231
+ const totals = {
232
+ current_total_bytes: tableForecasts.reduce((acc, t) => acc + t.current.total_size_bytes, 0),
233
+ forecast_total_bytes: tableForecasts.reduce((acc, t) => acc + t.forecast.total_size_bytes, 0),
234
+ };
235
+ return {
236
+ status: "success",
237
+ data: {
238
+ database,
239
+ horizon_days: horizonDays,
240
+ base_growth_rate_percent_per_day: baseDailyRate === null ? null : baseDailyRate * 100,
241
+ per_table_growth_rate_percent_per_day: perTableRates,
242
+ totals,
243
+ tables: tableForecasts,
244
+ notes: [
245
+ "Forecast uses simple exponential growth from current INFORMATION_SCHEMA sizes.",
246
+ "Row counts and sizes are estimates and may be stale depending on storage engine/statistics.",
247
+ ],
248
+ },
249
+ };
250
+ }
251
+ catch (error) {
252
+ return { status: "error", error: error.message };
253
+ }
254
+ }
255
+ }
256
+ exports.ForecastingTools = ForecastingTools;
@@ -0,0 +1,50 @@
1
+ import { SecurityLayer } from "../security/securityLayer";
2
+ export declare class IndexRecommendationTools {
3
+ private db;
4
+ private security;
5
+ constructor(security: SecurityLayer);
6
+ private validateDatabaseAccess;
7
+ recommendIndexes(params?: {
8
+ database?: string;
9
+ max_query_patterns?: number;
10
+ max_recommendations?: number;
11
+ min_execution_count?: number;
12
+ min_avg_time_ms?: number;
13
+ include_unused_index_warnings?: boolean;
14
+ }): Promise<{
15
+ status: string;
16
+ data?: {
17
+ database: string;
18
+ analyzed_query_patterns: number;
19
+ recommendations: Array<{
20
+ table_name: string;
21
+ columns: string[];
22
+ proposed_index_name: string;
23
+ create_index_sql: string;
24
+ reason: string;
25
+ supporting_query_patterns: Array<{
26
+ query_pattern: string;
27
+ execution_count: number;
28
+ avg_execution_time_ms: number;
29
+ }>;
30
+ }>;
31
+ unused_index_warnings?: Array<{
32
+ table_schema: string;
33
+ table_name: string;
34
+ index_name: string;
35
+ note: string;
36
+ }>;
37
+ notes: string[];
38
+ };
39
+ error?: string;
40
+ }>;
41
+ private bumpCounts;
42
+ private pickIndexColumns;
43
+ private buildReasonString;
44
+ private makeIndexName;
45
+ private isCoveredByExistingIndex;
46
+ private getExistingIndexMap;
47
+ private getTopSelectDigests;
48
+ private getUnusedIndexes;
49
+ private parseQueryPattern;
50
+ }