UNPKG

@bytebase/dbhub

Version:

Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite

602 lines (600 loc) • 19.4 kB
import { MissingDatabaseError, getMySQLFamilyHealthCheck, isClientSideTimeout, isTiDBVersion, parseQueryResultSets, requireDatabaseInDSN, withReadOnlyTransaction } from "./chunk-6SZKW6GD.js"; import "./chunk-FU2ZJE4E.js"; import { quoteIdentifier } from "./chunk-X2EC47W4.js"; import { SQLRowLimiter, closeQuietly } from "./chunk-YIGJHEU6.js"; import "./chunk-ABTVMHD6.js"; import { ConnectorRegistry, SafeURL, obfuscateDSNPassword, splitSQLStatements } from "./chunk-IXC4NQGC.js"; // src/connectors/mysql/index.ts import mysql from "mysql2/promise"; var MySQLDSNParser = class { async parse(dsn, config) { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; const timezone = config?.timezone; const charset = config?.charset; const collation = config?.collation; if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); throw new Error( `Invalid MySQL DSN format. Provided: ${obfuscatedDSN} Expected: ${expectedFormat}` ); } try { const url = new SafeURL(dsn); const database = url.pathname ? url.pathname.substring(1) : ""; requireDatabaseInDSN(database, dsn, "MySQL"); const config2 = { host: url.hostname, port: url.port ? parseInt(url.port) : 3306, database, user: url.username, password: url.password, multipleStatements: true, // Enable native multi-statement support supportBigNumbers: true // Return BIGINT as string when value exceeds Number.MAX_SAFE_INTEGER }; url.forEachSearchParam((value, key) => { if (key === "sslmode") { if (value === "disable") { config2.ssl = void 0; } else if (value === "require") { config2.ssl = { rejectUnauthorized: false }; } else { config2.ssl = {}; } } }); if (connectionTimeoutSeconds !== void 0) { config2.connectTimeout = connectionTimeoutSeconds * 1e3; } if (timezone !== void 0) { config2.timezone = timezone; } const charsetOrCollation = collation ?? charset; if (charsetOrCollation !== void 0) { config2.charset = charsetOrCollation; } if (url.password && url.password.includes("X-Amz-Credential")) { config2.authPlugins = { mysql_clear_password: () => () => { return Buffer.from(url.password + "\0"); } }; if (config2.ssl === void 0) { config2.ssl = { rejectUnauthorized: false }; } } return config2; } catch (error) { if (error instanceof MissingDatabaseError) { throw error; } throw new Error( `Failed to parse MySQL DSN: ${error instanceof Error ? error.message : String(error)}` ); } } getSampleDSN() { return "mysql://root:password@localhost:3306/mysql?sslmode=require"; } isValidDSN(dsn) { try { return dsn.startsWith("mysql://"); } catch (error) { return false; } } }; var _MySQLConnector = class _MySQLConnector { constructor() { this.id = "mysql"; this.name = "MySQL"; this.dsnParser = new MySQLDSNParser(); this.pool = null; // Source ID is set by ConnectorManager after cloning this.sourceId = "default"; // TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY` // unless tidb_enable_noop_functions is on. Detected once at connect time. this.supportsReadOnlyTransaction = true; } getId() { return this.sourceId; } clone() { return new _MySQLConnector(); } async connect(dsn, initScript, config) { try { const connectionOptions = await this.dsnParser.parse(dsn, config); this.pool = mysql.createPool(connectionOptions); if (config?.queryTimeoutSeconds !== void 0) { this.queryTimeoutMs = config.queryTimeoutSeconds * 1e3; } const [rows] = await this.pool.query("SELECT VERSION() AS version"); this.supportsReadOnlyTransaction = !isTiDBVersion(rows[0]?.version); } catch (err) { if (this.pool) { const pool = this.pool; this.pool = null; await closeQuietly(() => pool.end()); } console.error("Failed to connect to MySQL database:", err); throw err; } } async disconnect() { if (this.pool) { await this.pool.end(); this.pool = null; } } async getSchemas() { if (!this.pool) { throw new Error("Not connected to database"); } try { const [rows] = await this.pool.query(` SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') ORDER BY SCHEMA_NAME `); return rows.map((row) => row.SCHEMA_NAME); } catch (error) { console.error("Error getting schemas:", error); throw error; } } async getTables(schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE TABLE_SCHEMA = ?" : "WHERE TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema] : []; const [rows] = await this.pool.query( ` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES ${schemaClause} AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME `, queryParams ); return rows.map((row) => row.TABLE_NAME); } catch (error) { console.error("Error getting tables:", error); throw error; } } async getViews(schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE TABLE_SCHEMA = ?" : "WHERE TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema] : []; const [rows] = await this.pool.query( ` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES ${schemaClause} AND TABLE_TYPE = 'VIEW' ORDER BY TABLE_NAME `, queryParams ); return rows.map((row) => row.TABLE_NAME); } catch (error) { console.error("Error getting views:", error); throw error; } } async tableExists(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE TABLE_SCHEMA = ?" : "WHERE TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema, tableName] : [tableName]; const [rows] = await this.pool.query( ` SELECT COUNT(*) AS COUNT FROM INFORMATION_SCHEMA.TABLES ${schemaClause} AND TABLE_NAME = ? `, queryParams ); return rows[0].COUNT > 0; } catch (error) { console.error("Error checking if table exists:", error); throw error; } } async getTableIndexes(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "TABLE_SCHEMA = ?" : "TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema, tableName] : [tableName]; const [indexRows] = await this.pool.query( ` SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX FROM INFORMATION_SCHEMA.STATISTICS WHERE ${schemaClause} AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX `, queryParams ); const indexMap = /* @__PURE__ */ new Map(); for (const row of indexRows) { const indexName = row.INDEX_NAME; const columnName = row.COLUMN_NAME; const isUnique = row.NON_UNIQUE === 0; const isPrimary = indexName === "PRIMARY"; if (!indexMap.has(indexName)) { indexMap.set(indexName, { columns: [], is_unique: isUnique, is_primary: isPrimary }); } const indexInfo = indexMap.get(indexName); indexInfo.columns.push(columnName); } const results = []; indexMap.forEach((indexInfo, indexName) => { results.push({ index_name: indexName, column_names: indexInfo.columns, is_unique: indexInfo.is_unique, is_primary: indexInfo.is_primary }); }); return results; } catch (error) { console.error("Error getting table indexes:", error); throw error; } } async getTableSchema(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE TABLE_SCHEMA = ?" : "WHERE TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema, tableName] : [tableName]; const [rows] = await this.pool.query( ` SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type, IS_NULLABLE as is_nullable, COLUMN_DEFAULT as column_default, COLUMN_COMMENT as description FROM INFORMATION_SCHEMA.COLUMNS ${schemaClause} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION `, queryParams ); return rows.map((row) => ({ ...row, description: row.description || null })); } catch (error) { console.error("Error getting table schema:", error); throw error; } } async getTableComment(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE TABLE_SCHEMA = ?" : "WHERE TABLE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema, tableName] : [tableName]; const [rows] = await this.pool.query( ` SELECT TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES ${schemaClause} AND TABLE_NAME = ? `, queryParams ); if (rows.length > 0) { return rows[0].TABLE_COMMENT || null; } return null; } catch (error) { return null; } } async getHealthCheck() { if (!this.pool) { throw new Error("Not connected to database"); } const pool = this.pool; return getMySQLFamilyHealthCheck(async (sql) => { const [rows] = await pool.query(sql); return rows; }); } async getStoredProcedures(schema, routineType) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE ROUTINE_SCHEMA = ?" : "WHERE ROUTINE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema] : []; let typeFilter = ""; if (routineType === "function") { typeFilter = " AND ROUTINE_TYPE = 'FUNCTION'"; } else if (routineType === "procedure") { typeFilter = " AND ROUTINE_TYPE = 'PROCEDURE'"; } const [rows] = await this.pool.query( ` SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES ${schemaClause}${typeFilter} ORDER BY ROUTINE_NAME `, queryParams ); return rows.map((row) => row.ROUTINE_NAME); } catch (error) { console.error("Error getting stored procedures:", error); throw error; } } async getStoredProcedureDetail(procedureName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } try { const schemaClause = schema ? "WHERE r.ROUTINE_SCHEMA = ?" : "WHERE r.ROUTINE_SCHEMA = DATABASE()"; const queryParams = schema ? [schema, procedureName] : [procedureName]; const [rows] = await this.pool.query( ` SELECT r.ROUTINE_NAME AS procedure_name, CASE WHEN r.ROUTINE_TYPE = 'PROCEDURE' THEN 'procedure' ELSE 'function' END AS procedure_type, LOWER(r.ROUTINE_TYPE) AS routine_type, r.ROUTINE_DEFINITION, r.DTD_IDENTIFIER AS return_type, ( SELECT GROUP_CONCAT( CONCAT(p.PARAMETER_NAME, ' ', p.PARAMETER_MODE, ' ', p.DATA_TYPE) ORDER BY p.ORDINAL_POSITION SEPARATOR ', ' ) FROM INFORMATION_SCHEMA.PARAMETERS p WHERE p.SPECIFIC_SCHEMA = r.ROUTINE_SCHEMA AND p.SPECIFIC_NAME = r.ROUTINE_NAME AND p.PARAMETER_NAME IS NOT NULL ) AS parameter_list FROM INFORMATION_SCHEMA.ROUTINES r ${schemaClause} AND r.ROUTINE_NAME = ? `, queryParams ); if (rows.length === 0) { const schemaName = schema || "current schema"; throw new Error(`Stored procedure '${procedureName}' not found in ${schemaName}`); } const procedure = rows[0]; let definition = procedure.ROUTINE_DEFINITION; try { const schemaValue = schema || await this.getCurrentSchema(); const quotedSchema = quoteIdentifier(schemaValue, "mysql"); const quotedProcName = quoteIdentifier(procedureName, "mysql"); if (procedure.procedure_type === "procedure") { try { const [defRows] = await this.pool.query(` SHOW CREATE PROCEDURE ${quotedSchema}.${quotedProcName} `); if (defRows && defRows.length > 0) { definition = defRows[0]["Create Procedure"]; } } catch (err) { console.error(`Error getting procedure definition with SHOW CREATE: ${err}`); } } else { try { const [defRows] = await this.pool.query(` SHOW CREATE FUNCTION ${quotedSchema}.${quotedProcName} `); if (defRows && defRows.length > 0) { definition = defRows[0]["Create Function"]; } } catch (innerErr) { console.error(`Error getting function definition with SHOW CREATE: ${innerErr}`); } } if (!definition) { const [bodyRows] = await this.pool.query( ` SELECT ROUTINE_DEFINITION, ROUTINE_BODY FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ? `, [schemaValue, procedureName] ); if (bodyRows && bodyRows.length > 0) { if (bodyRows[0].ROUTINE_DEFINITION) { definition = bodyRows[0].ROUTINE_DEFINITION; } else if (bodyRows[0].ROUTINE_BODY) { definition = bodyRows[0].ROUTINE_BODY; } } } } catch (error) { console.error(`Error getting procedure/function details: ${error}`); } return { procedure_name: procedure.procedure_name, procedure_type: procedure.procedure_type, language: "sql", // MySQL procedures are generally in SQL parameter_list: procedure.parameter_list || "", return_type: procedure.routine_type === "function" ? procedure.return_type : void 0, definition: definition || void 0 }; } catch (error) { console.error("Error getting stored procedure detail:", error); throw error; } } // Helper method to get current schema (database) name async getCurrentSchema() { const [rows] = await this.pool.query("SELECT DATABASE() AS DB"); return rows[0].DB; } /** * Default search scope = the database named in the DSN. DATABASE() returns * null when the connection was opened without a database, in which case * callers fall back to the full server-wide schema list. */ async getDefaultSchema() { if (!this.pool) { throw new Error("Not connected to database"); } const [rows] = await this.pool.query("SELECT DATABASE() AS DB"); return rows[0]?.DB ?? null; } async executeSQL(sql, options, parameters) { if (!this.pool) { throw new Error("Not connected to database"); } const conn = await this.pool.getConnection(); const threadId = conn.threadId; let isConnectionPoisoned = false; try { return await withReadOnlyTransaction( conn, options.readonly, this.supportsReadOnlyTransaction, async () => { const statements = splitSQLStatements(sql, "mysql"); let processedSQL = sql; let probes = []; if (options.maxRows) { const rewrites = statements.map( (statement) => SQLRowLimiter.applyMaxRowsWithTruncationProbe(statement, options.maxRows, "mysql") ); probes = rewrites.map((rewrite) => rewrite.probeApplied); processedSQL = rewrites.map((rewrite) => rewrite.sql).join("; "); if (sql.trim().endsWith(";")) { processedSQL += ";"; } } let results; if (parameters && parameters.length > 0) { results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters); } else { results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }); } const [firstResult] = results; const resultSets = parseQueryResultSets(firstResult, statements); if (resultSets.length === probes.length) { resultSets.forEach( (set, index) => SQLRowLimiter.flagTruncation(set, options.maxRows, probes[index]) ); } return { resultSets }; } ); } catch (error) { if (isClientSideTimeout(error)) { isConnectionPoisoned = true; await this.killQuery(threadId); } throw error; } finally { if (isConnectionPoisoned) { conn.destroy(); } else { conn.release(); } } } /** * Best-effort server-side kill for a query abandoned by mysql2's client-side * timeout. Uses a separate connection: the original connection's command * queue is stuck behind the abandoned statement (see isClientSideTimeout) * and cannot itself be used to send KILL QUERY. * * Bounded by its own short timeout, independent of the user's (possibly * long or unset) query_timeout — KILL QUERY is metadata-only and should * return almost immediately on a healthy server, so cleanup must not stall * indefinitely if it doesn't. */ async killQuery(threadId) { if (!this.pool) return; let killer; let killerPoisoned = false; try { killer = await this.pool.getConnection(); await killer.query({ sql: `KILL QUERY ${threadId}`, timeout: _MySQLConnector.KILL_QUERY_TIMEOUT_MS }); } catch (error) { killerPoisoned = isClientSideTimeout(error); } finally { if (killer) { if (killerPoisoned) { killer.destroy(); } else { killer.release(); } } } } }; // Bounds the KILL QUERY cleanup call issued after a client-side query // timeout; see killQuery. _MySQLConnector.KILL_QUERY_TIMEOUT_MS = 5e3; var MySQLConnector = _MySQLConnector; var mysqlConnector = new MySQLConnector(); ConnectorRegistry.register(mysqlConnector); export { MySQLConnector };