@ecubelabs/mysql-mcp 0.0.0-stage → 0.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.
@@ -0,0 +1,461 @@
1
+ import { performance } from 'perf_hooks';
2
+ import * as mysql2 from 'mysql2/promise';
3
+ import { mcpConfig as config, describeConfiguredTarget, SSH_ENABLED, MYSQL_PROFILE, PROFILE_LABEL, CODE_BRANCH, IS_WRITE_FORBIDDEN_PROFILE, MYSQL_DISABLE_READ_ONLY_TRANSACTIONS, MYSQL_DEFAULT_TIMEOUT_SECONDS, MYSQL_MAX_TIMEOUT_SECONDS, MYSQL_CATALOG_TIMEOUT_SECONDS, MAX_RESPONSE_ROWS, } from './../config/index.js';
4
+ import { isDDLAllowedForSchema, isInsertAllowedForSchema, isUpdateAllowedForSchema, isDeleteAllowedForSchema, } from './permissions.js';
5
+ import { extractReferencedSchemas, findFileExport, findUnsupportedStatementType, getQueryTypes, isUnparseableIntrospection, extractQualifiers, } from './utils.js';
6
+ import { isQueryTimeoutError, parseExplainPayload, renderTimeoutDiagnostic } from './diagnose.js';
7
+ import { log } from './../utils/index.js';
8
+ import { ensureTunnel, getTunnelEndpoint, getTunnelFatalError, describeTunnel } from './../ssh/tunnel.js';
9
+ let poolPromise;
10
+ const READ_LIMITS = {
11
+ maxExecutionTimeMs: MYSQL_DEFAULT_TIMEOUT_SECONDS * 1_000,
12
+ selectLimit: MAX_RESPONSE_ROWS + 1,
13
+ };
14
+ const CATALOG_LIMITS = {
15
+ maxExecutionTimeMs: MYSQL_CATALOG_TIMEOUT_SECONDS * 1_000,
16
+ selectLimit: null,
17
+ };
18
+ const SESSION_LIMITS = Symbol.for('mysql-mcp.sessionLimits');
19
+ function limitCarrier(connection) {
20
+ const inner = connection?.connection;
21
+ return (inner ?? connection);
22
+ }
23
+ function limitsStatement(limits) {
24
+ const selectLimit = limits.selectLimit === null ? 'DEFAULT' : String(limits.selectLimit);
25
+ return (`SET SESSION max_execution_time = ${limits.maxExecutionTimeMs}, ` + `SESSION sql_select_limit = ${selectLimit}`);
26
+ }
27
+ function sameLimits(a, b) {
28
+ return a !== undefined && a.maxExecutionTimeMs === b.maxExecutionTimeMs && a.selectLimit === b.selectLimit;
29
+ }
30
+ async function applySessionLimits(connection, desired) {
31
+ const carrier = limitCarrier(connection);
32
+ if (sameLimits(carrier[SESSION_LIMITS], desired)) {
33
+ return 0;
34
+ }
35
+ try {
36
+ await connection.query(limitsStatement(desired));
37
+ carrier[SESSION_LIMITS] = desired;
38
+ }
39
+ catch (error) {
40
+ delete carrier[SESSION_LIMITS];
41
+ log('error', 'Failed to apply session limits; continuing without them:', error);
42
+ }
43
+ return 1;
44
+ }
45
+ const getPool = () => {
46
+ if (!poolPromise) {
47
+ poolPromise = (async () => {
48
+ const endpoint = await ensureTunnel();
49
+ const mysqlConfig = endpoint
50
+ ? (() => {
51
+ const { socketPath: _ignored, ...rest } = config.mysql;
52
+ return { ...rest, host: endpoint.host, port: endpoint.port };
53
+ })()
54
+ : config.mysql;
55
+ const pool = mysql2.createPool(mysqlConfig);
56
+ pool.on('connection', (connection) => {
57
+ const carrier = limitCarrier(connection);
58
+ const raw = connection;
59
+ carrier[SESSION_LIMITS] = READ_LIMITS;
60
+ raw.query(limitsStatement(READ_LIMITS), (error) => {
61
+ if (!error) {
62
+ return;
63
+ }
64
+ log('error', 'Failed to prime session limits on a new connection:', error);
65
+ delete carrier[SESSION_LIMITS];
66
+ });
67
+ });
68
+ log('info', `MySQL pool created successfully (profile: ${PROFILE_LABEL}` +
69
+ (endpoint
70
+ ? `, via ${endpoint.reused ? 'reused' : 'new'} tunnel ${endpoint.host}:${endpoint.port}`
71
+ : '') +
72
+ ')');
73
+ return pool;
74
+ })();
75
+ poolPromise.catch((error) => {
76
+ log('error', 'Error creating MySQL pool:', error);
77
+ poolPromise = undefined;
78
+ });
79
+ }
80
+ return poolPromise;
81
+ };
82
+ function describeQueryError(error) {
83
+ if (!(error instanceof Error)) {
84
+ return String(error);
85
+ }
86
+ const { code, errno, sqlState, sql } = error;
87
+ if (sql !== undefined || sqlState !== undefined) {
88
+ return { code, errno, sqlState };
89
+ }
90
+ return error.message;
91
+ }
92
+ function assertTunnelHealthy() {
93
+ const fatal = getTunnelFatalError();
94
+ if (fatal) {
95
+ throw fatal;
96
+ }
97
+ }
98
+ function describeConnection() {
99
+ const endpoint = getTunnelEndpoint();
100
+ if (endpoint) {
101
+ return `${endpoint.host}:${endpoint.port} ` + `(${endpoint.reused ? 'reused' : 'new'} ssh tunnel)`;
102
+ }
103
+ const target = describeConfiguredTarget();
104
+ return SSH_ENABLED ? `${target} (via ssh tunnel, not yet established)` : target;
105
+ }
106
+ function profileBanner() {
107
+ const parts = [`profile: ${PROFILE_LABEL}`];
108
+ parts.push(IS_WRITE_FORBIDDEN_PROFILE ? 'READ-ONLY (enforced)' : 'read-only');
109
+ const db = config.mysql.database || 'multi-db';
110
+ parts.push(`database: ${db}`);
111
+ if (CODE_BRANCH) {
112
+ parts.push(`code: ${CODE_BRANCH} branch`);
113
+ }
114
+ const tunnel = describeTunnel();
115
+ if (tunnel) {
116
+ parts.push(tunnel);
117
+ }
118
+ return `[${parts.join(' | ')}]`;
119
+ }
120
+ async function executeQuery(sql, params = []) {
121
+ let connection;
122
+ try {
123
+ assertTunnelHealthy();
124
+ const pool = await getPool();
125
+ connection = await pool.getConnection();
126
+ await applySessionLimits(connection, CATALOG_LIMITS);
127
+ const result = await connection.query(sql, params);
128
+ return (Array.isArray(result) ? result[0] : result);
129
+ }
130
+ catch (error) {
131
+ log('error', 'Error executing query:', error);
132
+ throw error;
133
+ }
134
+ finally {
135
+ if (connection) {
136
+ try {
137
+ await applySessionLimits(connection, READ_LIMITS);
138
+ }
139
+ catch (restoreError) {
140
+ log('error', 'Error restoring session limits:', restoreError);
141
+ }
142
+ connection.release();
143
+ log('error', 'Connection released');
144
+ }
145
+ }
146
+ }
147
+ async function executeWriteQuery(sql) {
148
+ let connection;
149
+ if (IS_WRITE_FORBIDDEN_PROFILE) {
150
+ log('error', `Refusing write operation: profile "${MYSQL_PROFILE}" is read-only by policy.`);
151
+ return {
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: `${profileBanner()} Error: write operations are disabled by policy for profile "${MYSQL_PROFILE}". ` +
156
+ `This cannot be enabled through configuration.`,
157
+ },
158
+ ],
159
+ isError: true,
160
+ };
161
+ }
162
+ try {
163
+ assertTunnelHealthy();
164
+ const pool = await getPool();
165
+ connection = await pool.getConnection();
166
+ log('error', 'Write connection acquired');
167
+ const schema = (extractReferencedSchemas(sql) ?? [null]).map((name) => name ?? 'default').join(', ');
168
+ await connection.beginTransaction();
169
+ try {
170
+ const startTime = performance.now();
171
+ const result = await connection.query(sql);
172
+ const endTime = performance.now();
173
+ const duration = endTime - startTime;
174
+ const response = Array.isArray(result) ? result[0] : result;
175
+ await connection.commit();
176
+ let responseText;
177
+ const queryTypes = await getQueryTypes(sql);
178
+ const isUpdateOperation = queryTypes.some((type) => ['update'].includes(type));
179
+ const isInsertOperation = queryTypes.some((type) => ['insert'].includes(type));
180
+ const isDeleteOperation = queryTypes.some((type) => ['delete'].includes(type));
181
+ const isDDLOperation = queryTypes.some((type) => ['create', 'alter', 'drop', 'truncate'].includes(type));
182
+ if (isInsertOperation) {
183
+ const resultHeader = response;
184
+ responseText = `Insert successful on schema '${schema}'. Affected rows: ${resultHeader.affectedRows}, Last insert ID: ${resultHeader.insertId}`;
185
+ }
186
+ else if (isUpdateOperation) {
187
+ const resultHeader = response;
188
+ responseText = `Update successful on schema '${schema}'. Affected rows: ${resultHeader.affectedRows}, Changed rows: ${resultHeader.changedRows || 0}`;
189
+ }
190
+ else if (isDeleteOperation) {
191
+ const resultHeader = response;
192
+ responseText = `Delete successful on schema '${schema}'. Affected rows: ${resultHeader.affectedRows}`;
193
+ }
194
+ else if (isDDLOperation) {
195
+ responseText = `DDL operation successful on schema '${schema}'.`;
196
+ }
197
+ else {
198
+ responseText = JSON.stringify(response, null, 2);
199
+ }
200
+ return {
201
+ content: [
202
+ {
203
+ type: 'text',
204
+ text: responseText,
205
+ },
206
+ {
207
+ type: 'text',
208
+ text: `Query execution time: ${duration.toFixed(2)} ms`,
209
+ },
210
+ ],
211
+ isError: false,
212
+ };
213
+ }
214
+ catch (error) {
215
+ log('error', 'Error executing write query:', describeQueryError(error));
216
+ await connection.rollback();
217
+ return {
218
+ content: [
219
+ {
220
+ type: 'text',
221
+ text: `Error executing write operation: ${error instanceof Error ? error.message : String(error)}`,
222
+ },
223
+ ],
224
+ isError: true,
225
+ };
226
+ }
227
+ }
228
+ catch (error) {
229
+ log('error', 'Error in write operation transaction:', describeQueryError(error));
230
+ return {
231
+ content: [
232
+ {
233
+ type: 'text',
234
+ text: `Database connection error: ${error instanceof Error ? error.message : String(error)}`,
235
+ },
236
+ ],
237
+ isError: true,
238
+ };
239
+ }
240
+ finally {
241
+ if (connection) {
242
+ connection.release();
243
+ log('error', 'Write connection released');
244
+ }
245
+ }
246
+ }
247
+ let diagnosticsSource = null;
248
+ function setQueryDiagnosticsSource(source) {
249
+ diagnosticsSource = source;
250
+ }
251
+ function clampTimeoutSeconds(requested) {
252
+ let value;
253
+ if (typeof requested === 'number') {
254
+ value = requested;
255
+ }
256
+ else if (typeof requested === 'string') {
257
+ const trimmed = requested.trim();
258
+ if (trimmed === '') {
259
+ return MYSQL_DEFAULT_TIMEOUT_SECONDS;
260
+ }
261
+ value = Number(trimmed);
262
+ }
263
+ else {
264
+ return MYSQL_DEFAULT_TIMEOUT_SECONDS;
265
+ }
266
+ if (!Number.isFinite(value)) {
267
+ return MYSQL_DEFAULT_TIMEOUT_SECONDS;
268
+ }
269
+ return Math.min(Math.max(Math.floor(value), 1), MYSQL_MAX_TIMEOUT_SECONDS);
270
+ }
271
+ async function diagnoseTimeout(connection, sql, timeoutSeconds) {
272
+ let plan = null;
273
+ try {
274
+ const explained = await connection.query(`EXPLAIN FORMAT=JSON ${sql}`);
275
+ plan = parseExplainPayload(Array.isArray(explained) ? explained[0] : explained);
276
+ }
277
+ catch (error) {
278
+ log('error', 'Could not EXPLAIN the timed-out query:', describeQueryError(error));
279
+ }
280
+ const qualifiers = extractQualifiers(sql);
281
+ const source = diagnosticsSource;
282
+ return renderTimeoutDiagnostic({
283
+ timeoutSeconds,
284
+ maxTimeoutSeconds: MYSQL_MAX_TIMEOUT_SECONDS,
285
+ plan,
286
+ qualifiers,
287
+ lookupIndexes: (name) => {
288
+ if (!source) {
289
+ return null;
290
+ }
291
+ const resolved = qualifiers.get(name.toLowerCase());
292
+ return source.indexFacts(resolved ?? { schema: null, table: name });
293
+ },
294
+ });
295
+ }
296
+ async function executeReadOnlyQuery(sql, options = {}) {
297
+ let connection;
298
+ try {
299
+ assertTunnelHealthy();
300
+ const bypassesQueryTypeChecks = isUnparseableIntrospection(sql);
301
+ let queryTypes = [];
302
+ let isUpdateOperation = false;
303
+ let isInsertOperation = false;
304
+ let isDeleteOperation = false;
305
+ let isDDLOperation = false;
306
+ if (!bypassesQueryTypeChecks) {
307
+ queryTypes = await getQueryTypes(sql);
308
+ const unsupported = findUnsupportedStatementType(queryTypes);
309
+ if (unsupported) {
310
+ const statement = unsupported.toUpperCase();
311
+ log('error', `Refusing ${statement} statement: not a supported statement type.`);
312
+ return {
313
+ content: [
314
+ {
315
+ type: 'text',
316
+ text: `Error: ${statement} statements are not allowed. This server runs SELECT, SHOW, ` +
317
+ `DESCRIBE and EXPLAIN, plus INSERT, UPDATE, DELETE and DDL where the profile's write ` +
318
+ `permissions allow them. Qualify table names with their schema instead of USE.`,
319
+ },
320
+ ],
321
+ isError: true,
322
+ };
323
+ }
324
+ const fileExport = findFileExport(sql);
325
+ if (fileExport) {
326
+ log('error', `Refusing SELECT ... ${fileExport}: it writes a file on the database server.`);
327
+ return {
328
+ content: [
329
+ {
330
+ type: 'text',
331
+ text: `Error: SELECT ... ${fileExport} is not allowed because it writes a file on the ` +
332
+ `database server. Run the SELECT without it; rows come back in this response.`,
333
+ },
334
+ ],
335
+ isError: true,
336
+ };
337
+ }
338
+ isUpdateOperation = queryTypes.some((type) => ['update'].includes(type));
339
+ isInsertOperation = queryTypes.some((type) => ['insert'].includes(type));
340
+ isDeleteOperation = queryTypes.some((type) => ['delete'].includes(type));
341
+ isDDLOperation = queryTypes.some((type) => ['create', 'alter', 'drop', 'truncate'].includes(type));
342
+ }
343
+ const writeChecks = [
344
+ { enabled: isInsertOperation, label: 'INSERT', allowed: isInsertAllowedForSchema },
345
+ { enabled: isUpdateOperation, label: 'UPDATE', allowed: isUpdateAllowedForSchema },
346
+ { enabled: isDeleteOperation, label: 'DELETE', allowed: isDeleteAllowedForSchema },
347
+ { enabled: isDDLOperation, label: 'DDL', allowed: isDDLAllowedForSchema },
348
+ ].filter((check) => check.enabled);
349
+ if (writeChecks.length > 0) {
350
+ const schemas = extractReferencedSchemas(sql);
351
+ for (const check of writeChecks) {
352
+ const deniedIndex = schemas ? schemas.findIndex((name) => !check.allowed(name)) : 0;
353
+ if (deniedIndex === -1) {
354
+ continue;
355
+ }
356
+ const target = schemas ? (schemas[deniedIndex] ?? 'default') : 'unknown';
357
+ const setting = `SCHEMA_${check.label}_PERMISSIONS`;
358
+ log('error', `${check.label} operations are not allowed for schema '${target}'. Configure ${setting}.`);
359
+ return {
360
+ content: [
361
+ {
362
+ type: 'text',
363
+ text: `Error: ${check.label} operations are not allowed for schema '${target}'. Ask the administrator to update ${setting}.`,
364
+ },
365
+ ],
366
+ isError: true,
367
+ };
368
+ }
369
+ return executeWriteQuery(sql);
370
+ }
371
+ const pool = await getPool();
372
+ connection = await pool.getConnection();
373
+ log('error', 'Read-only connection acquired');
374
+ const timeoutSeconds = clampTimeoutSeconds(options.timeoutSeconds);
375
+ let roundTrips = await applySessionLimits(connection, {
376
+ maxExecutionTimeMs: timeoutSeconds * 1_000,
377
+ selectLimit: MAX_RESPONSE_ROWS + 1,
378
+ });
379
+ if (!MYSQL_DISABLE_READ_ONLY_TRANSACTIONS) {
380
+ await connection.query('START TRANSACTION READ ONLY');
381
+ }
382
+ else {
383
+ log('info', 'Read-only transactions disabled via MYSQL_DISABLE_READ_ONLY_TRANSACTIONS=true');
384
+ await connection.beginTransaction();
385
+ }
386
+ roundTrips += 1;
387
+ try {
388
+ const startTime = performance.now();
389
+ const result = await connection.query(sql);
390
+ const endTime = performance.now();
391
+ const duration = endTime - startTime;
392
+ roundTrips += 1;
393
+ let rows = Array.isArray(result) ? result[0] : result;
394
+ await connection.rollback();
395
+ roundTrips += 1;
396
+ log('info', `Read query completed in ${roundTrips} DB round trips`);
397
+ let truncated = false;
398
+ if (Array.isArray(rows) && rows.length > MAX_RESPONSE_ROWS) {
399
+ truncated = true;
400
+ rows = rows.slice(0, MAX_RESPONSE_ROWS);
401
+ }
402
+ return {
403
+ content: [
404
+ {
405
+ type: 'text',
406
+ text: JSON.stringify(rows, null, 2),
407
+ },
408
+ ...(truncated
409
+ ? [
410
+ {
411
+ type: 'text',
412
+ text: `[TRUNCATED] Only the first ${MAX_RESPONSE_ROWS.toLocaleString('en-US')} rows are shown; ` +
413
+ `the query matched more. This is NOT the complete result - do not count, sum, or ` +
414
+ `conclude anything about totals from it. Add a narrower WHERE, an aggregate ` +
415
+ `(COUNT/SUM/GROUP BY), or paginate with ORDER BY + LIMIT/OFFSET. Raising the cap ` +
416
+ `is an operator change (MYSQL_MAX_RESPONSE_ROWS), not something to retry around.`,
417
+ },
418
+ ]
419
+ : []),
420
+ {
421
+ type: 'text',
422
+ text: `Query execution time: ${duration.toFixed(2)} ms`,
423
+ },
424
+ ],
425
+ isError: false,
426
+ };
427
+ }
428
+ catch (error) {
429
+ log('error', 'Error executing read-only query:', describeQueryError(error));
430
+ await connection.rollback();
431
+ if (isQueryTimeoutError(error)) {
432
+ const diagnostic = await diagnoseTimeout(connection, sql, timeoutSeconds);
433
+ log('info', `Read query cancelled at ${timeoutSeconds}s after ${roundTrips + 3} DB round trips (including one EXPLAIN)`);
434
+ return {
435
+ content: [{ type: 'text', text: diagnostic }],
436
+ isError: true,
437
+ };
438
+ }
439
+ throw error;
440
+ }
441
+ }
442
+ catch (error) {
443
+ log('error', 'Error in read-only query transaction:', describeQueryError(error));
444
+ try {
445
+ if (connection) {
446
+ await connection.rollback();
447
+ }
448
+ }
449
+ catch (cleanupError) {
450
+ log('error', 'Error during cleanup:', cleanupError);
451
+ }
452
+ throw error;
453
+ }
454
+ finally {
455
+ if (connection) {
456
+ connection.release();
457
+ log('error', 'Read-only connection released');
458
+ }
459
+ }
460
+ }
461
+ export { profileBanner, describeConnection, executeQuery, getPool, executeWriteQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, describeQueryError, poolPromise, };
@@ -0,0 +1,26 @@
1
+ import { ALLOW_DELETE_OPERATION, ALLOW_DDL_OPERATION, ALLOW_INSERT_OPERATION, ALLOW_UPDATE_OPERATION, SCHEMA_DELETE_PERMISSIONS, SCHEMA_DDL_PERMISSIONS, SCHEMA_INSERT_PERMISSIONS, SCHEMA_UPDATE_PERMISSIONS, } from '../config/index.js';
2
+ function isInsertAllowedForSchema(schema) {
3
+ if (!schema) {
4
+ return ALLOW_INSERT_OPERATION;
5
+ }
6
+ return schema in SCHEMA_INSERT_PERMISSIONS ? SCHEMA_INSERT_PERMISSIONS[schema] : ALLOW_INSERT_OPERATION;
7
+ }
8
+ function isUpdateAllowedForSchema(schema) {
9
+ if (!schema) {
10
+ return ALLOW_UPDATE_OPERATION;
11
+ }
12
+ return schema in SCHEMA_UPDATE_PERMISSIONS ? SCHEMA_UPDATE_PERMISSIONS[schema] : ALLOW_UPDATE_OPERATION;
13
+ }
14
+ function isDeleteAllowedForSchema(schema) {
15
+ if (!schema) {
16
+ return ALLOW_DELETE_OPERATION;
17
+ }
18
+ return schema in SCHEMA_DELETE_PERMISSIONS ? SCHEMA_DELETE_PERMISSIONS[schema] : ALLOW_DELETE_OPERATION;
19
+ }
20
+ function isDDLAllowedForSchema(schema) {
21
+ if (!schema) {
22
+ return ALLOW_DDL_OPERATION;
23
+ }
24
+ return schema in SCHEMA_DDL_PERMISSIONS ? SCHEMA_DDL_PERMISSIONS[schema] : ALLOW_DDL_OPERATION;
25
+ }
26
+ export { isInsertAllowedForSchema, isUpdateAllowedForSchema, isDeleteAllowedForSchema, isDDLAllowedForSchema };