@bytebase/dbhub
Version:
Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite
602 lines (600 loc) • 19.4 kB
JavaScript
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
};