UNPKG

@bytebase/dbhub

Version:

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

831 lines (825 loc) • 27.1 kB
import { CANCEL_QUERY_TIMEOUT_MS, clientQueryTimeoutMs, isClientSideTimeout } from "./chunk-5XSBOK3W.js"; import { toNullableNumber } from "./chunk-FU2ZJE4E.js"; import { quoteIdentifier } from "./chunk-X2EC47W4.js"; import { SQLRowLimiter, closeQuietly } from "./chunk-X5OCFRNL.js"; import "./chunk-55X2VY5D.js"; import { ConnectorRegistry, SafeURL, obfuscateDSNPassword, splitSQLStatements } from "./chunk-IXC4NQGC.js"; // src/connectors/postgres/index.ts import pg3 from "pg"; // src/connectors/postgres/failed-to-read-certificate.ts var FailedToReadCertificate = class extends Error { constructor(message) { super(message); this.name = "FailedToReadCertificate"; } }; // src/connectors/postgres/type-parsers.ts import pg from "pg"; var VERBATIM_DATE_TIME_OIDS = /* @__PURE__ */ new Set([ 1082, // date 1114, // timestamp without time zone 1184, // timestamp with time zone 1182, // date[] 1115, // timestamp[] 1185 // timestamptz[] ]); var passthrough = (value) => value; var postgresTypeParsers = { getTypeParser(oid, format) { if (VERBATIM_DATE_TIME_OIDS.has(oid) && (format === void 0 || format === "text")) { return passthrough; } return pg.types.getTypeParser(oid, format); } }; // src/connectors/postgres/ssl-pems.ts import fs from "fs"; import os from "os"; import path from "path"; import pg2 from "pg"; var CLIENT_CERT_SSL_MODES = ["require", "verify-ca", "verify-full"]; var SUPPORTED_SSL_MODES = ["disable", ...CLIENT_CERT_SSL_MODES]; var SSL_ROOT_CERT_LABEL = "SSL root certificate"; var SSL_CLIENT_CERT_LABEL = "SSL client certificate"; var SSL_CLIENT_KEY_LABEL = "SSL client key"; function resolvePemPath(filePath) { return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath; } function failedToRead(label, resolved, err) { return new FailedToReadCertificate( `Failed to read ${label} at '${resolved}': ${err instanceof Error ? err.message : String(err)}` ); } async function readPemFile(filePath, label) { const resolved = resolvePemPath(filePath); try { return await fs.promises.readFile(resolved, "utf-8"); } catch (err) { throw failedToRead(label, resolved, err); } } function readPemFileSync(filePath, label) { const resolved = resolvePemPath(filePath); try { return fs.readFileSync(resolved, "utf-8"); } catch (err) { throw failedToRead(label, resolved, err); } } function isEncryptedPemKey(pem) { return pem.includes("ENCRYPTED PRIVATE KEY") || pem.includes("Proc-Type: 4,ENCRYPTED"); } function encryptedKeyMessage(keyPath) { return `SSL client key at '${keyPath}' is encrypted; encrypted private keys are not supported. Decrypt it first, e.g. 'openssl pkey -in client.key -out client-plain.key'`; } function loadPems(paths) { const pems = {}; if (paths.sslrootcert !== void 0) { pems.ca = readPemFileSync(paths.sslrootcert, SSL_ROOT_CERT_LABEL); } if (paths.sslcert !== void 0 && paths.sslkey !== void 0) { const key = readPemFileSync(paths.sslkey, SSL_CLIENT_KEY_LABEL); if (isEncryptedPemKey(key)) { throw new FailedToReadCertificate(encryptedKeyMessage(paths.sslkey)); } pems.key = key; pems.cert = readPemFileSync(paths.sslcert, SSL_CLIENT_CERT_LABEL); } return pems; } function rotatingPemClient(paths) { return class RotatingPemClient extends pg2.Client { connect(callback) { let pems; try { pems = loadPems(paths); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); if (callback) { callback(error); return; } return Promise.reject(error); } Object.assign(this.ssl, pems); return callback ? super.connect(callback) : super.connect(); } }; } // src/connectors/postgres/index.ts var { Pool } = pg3; var PostgresDSNParser = class { async parse(dsn, config) { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; const queryTimeoutSeconds = config?.queryTimeoutSeconds; const poolMaxConnections = config?.poolMaxConnections; if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); throw new Error( `Invalid PostgreSQL DSN format. Provided: ${obfuscatedDSN} Expected: ${expectedFormat}` ); } try { const url = new SafeURL(dsn); const poolConfig = { host: url.hostname, port: url.port ? parseInt(url.port) : 5432, database: url.pathname ? url.pathname.substring(1) : "", // Remove leading '/' if exists user: url.username, password: config?.password ?? url.password }; let sslmode; let sslrootcert; let sslcert; let sslkey; url.forEachSearchParam((value, key) => { if (key === "sslmode") { sslmode = value; } else if (key === "sslrootcert") { sslrootcert = value; } else if (key === "sslcert") { sslcert = value; } else if (key === "sslkey") { sslkey = value; } }); if (sslmode !== void 0 && !SUPPORTED_SSL_MODES.includes(sslmode)) { throw new Error( `Unsupported sslmode '${sslmode}'. Valid values: ${SUPPORTED_SSL_MODES.join(", ")}` ); } if (sslrootcert !== void 0 && sslmode !== "verify-ca" && sslmode !== "verify-full") { throw new FailedToReadCertificate( `sslrootcert requires sslmode 'verify-ca' or 'verify-full' (got '${sslmode ?? "not set"}'). Use sslmode=verify-ca to verify the server certificate against it, or remove sslrootcert.` ); } if (sslcert === void 0 !== (sslkey === void 0)) { throw new FailedToReadCertificate( "sslcert and sslkey must be set together for client certificate authentication" ); } if (sslcert !== void 0 && !CLIENT_CERT_SSL_MODES.includes(sslmode ?? "")) { throw new FailedToReadCertificate( `sslcert/sslkey require sslmode to be one of ${CLIENT_CERT_SSL_MODES.join(", ")} (got '${sslmode ?? "not set"}')` ); } if (sslmode === "disable") { poolConfig.ssl = false; } else if (sslmode === "require") { poolConfig.ssl = { rejectUnauthorized: false }; } else if (sslmode === "verify-ca" || sslmode === "verify-full") { const sslConfig = { rejectUnauthorized: true }; if (sslmode === "verify-ca") { sslConfig.checkServerIdentity = () => void 0; } if (sslrootcert) { sslConfig.ca = await readPemFile(sslrootcert, SSL_ROOT_CERT_LABEL); } poolConfig.ssl = sslConfig; } if (sslcert !== void 0 && sslkey !== void 0) { const key = await readPemFile(sslkey, SSL_CLIENT_KEY_LABEL); if (isEncryptedPemKey(key)) { throw new FailedToReadCertificate(encryptedKeyMessage(sslkey)); } Object.assign(poolConfig.ssl, { cert: await readPemFile(sslcert, SSL_CLIENT_CERT_LABEL), key }); } if (sslrootcert !== void 0 || sslcert !== void 0) { poolConfig.Client = rotatingPemClient({ sslrootcert, sslcert, sslkey }); } if (connectionTimeoutSeconds !== void 0) { poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1e3; } if (queryTimeoutSeconds !== void 0) { const queryTimeoutMs = queryTimeoutSeconds * 1e3; poolConfig.statement_timeout = queryTimeoutMs; poolConfig.query_timeout = clientQueryTimeoutMs(queryTimeoutMs); } if (poolMaxConnections !== void 0) { poolConfig.max = poolMaxConnections; } return poolConfig; } catch (error) { if (error instanceof FailedToReadCertificate) { throw error; } const originalError = error instanceof Error ? error : new Error(String(error)); throw new Error( `Failed to parse PostgreSQL DSN: ${originalError.message}`, { cause: originalError } ); } } getSampleDSN() { return "postgres://postgres:password@localhost:5432/postgres?sslmode=require"; } isValidDSN(dsn) { try { return dsn.startsWith("postgres://") || dsn.startsWith("postgresql://"); } catch (error) { return false; } } }; var PostgresConnector = class _PostgresConnector { constructor() { this.id = "postgres"; this.name = "PostgreSQL"; this.dsnParser = new PostgresDSNParser(); this.pool = null; // Source ID is set by ConnectorManager after cloning this.sourceId = "default"; // Default schema for discovery methods (first entry from search_path, or "public") this.defaultSchema = "public"; } getId() { return this.sourceId; } clone() { return new _PostgresConnector(); } async connect(dsn, initScript, config) { this.defaultSchema = "public"; try { const poolConfig = await this.dsnParser.parse(dsn, config); poolConfig.types = postgresTypeParsers; if (config?.readonly) { poolConfig.options = (poolConfig.options || "") + " -c default_transaction_read_only=on"; } if (config?.searchPath) { const schemas = config.searchPath.split(",").map((s) => s.trim()).filter((s) => s.length > 0); if (schemas.length > 0) { this.defaultSchema = schemas[0]; const quotedSchemas = schemas.map((s) => quoteIdentifier(s, "postgres")); const optionsValue = quotedSchemas.join(",").replace(/\\/g, "\\\\").replace(/ /g, "\\ "); poolConfig.options = (poolConfig.options || "") + ` -c search_path=${optionsValue}`; } } this.pool = new Pool(poolConfig); this.pool.on("error", (err) => { console.error( `PostgreSQL pool (source "${this.sourceId}"): idle connection dropped, will reconnect on next query:`, err.message ); }); const client = await this.pool.connect(); client.release(); } catch (err) { if (this.pool) { const pool = this.pool; this.pool = null; await closeQuietly(() => pool.end()); } console.error("Failed to connect to PostgreSQL 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"); } const client = await this.pool.connect(); try { const result = await client.query(` SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') ORDER BY schema_name `); return result.rows.map((row) => row.schema_name); } finally { client.release(); } } async getTables(schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type IN ('BASE TABLE', 'FOREIGN') ORDER BY table_name `, [schemaToUse] ); return result.rows.map((row) => row.table_name); } finally { client.release(); } } async getViews(schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'VIEW' ORDER BY table_name `, [schemaToUse] ); return result.rows.map((row) => row.table_name); } finally { client.release(); } } async tableExists(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2 ) `, [schemaToUse, tableName] ); return result.rows[0].exists; } finally { client.release(); } } async getTableIndexes(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT i.relname as index_name, array_agg(a.attname::text) as column_names, ix.indisunique as is_unique, ix.indisprimary as is_primary FROM pg_class t, pg_class i, pg_index ix, pg_attribute a, pg_namespace ns WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) AND t.relkind IN ('r','p') AND t.relname = $1 AND ns.oid = t.relnamespace AND ns.nspname = $2 GROUP BY i.relname, ix.indisunique, ix.indisprimary ORDER BY i.relname `, [tableName, schemaToUse] ); return result.rows.map((row) => ({ index_name: row.index_name, column_names: row.column_names, is_unique: row.is_unique, is_primary: row.is_primary })); } finally { client.release(); } } async getTableSchema(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, pgd.description FROM information_schema.columns c LEFT JOIN pg_catalog.pg_namespace nsp ON nsp.nspname = c.table_schema LEFT JOIN pg_catalog.pg_class cls ON cls.relnamespace = nsp.oid AND cls.relname = c.table_name LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = cls.oid AND pgd.objsubid = c.ordinal_position WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position `, [schemaToUse, tableName] ); return result.rows; } finally { client.release(); } } async getTableRowCount(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT c.reltuples::bigint as count FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = $1 AND n.nspname = $2 AND c.relkind IN ('r','p','m','f') `, [tableName, schemaToUse] ); if (result.rows.length > 0) { const count = Number(result.rows[0].count); return count >= 0 ? count : null; } return null; } finally { client.release(); } } async getTableComment(tableName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT obj_description(c.oid) as table_comment FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = $1 AND n.nspname = $2 AND c.relkind IN ('r','p','m','f','v') `, [tableName, schemaToUse] ); if (result.rows.length > 0) { return result.rows[0].table_comment || null; } return null; } finally { client.release(); } } async getHealthCheck() { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const [connectionsResult, maxConnectionsResult, bufferCacheResult] = await Promise.all([ client.query(` SELECT count(*) AS total, count(*) FILTER (WHERE state = 'active') AS active, count(*) FILTER (WHERE state = 'idle') AS idle, count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction, count(*) FILTER (WHERE state = 'idle in transaction (aborted)') AS idle_in_transaction_aborted, max(EXTRACT(EPOCH FROM (now() - state_change))) FILTER (WHERE state = 'idle in transaction') AS longest_idle_in_transaction_seconds, max(EXTRACT(EPOCH FROM (now() - query_start))) FILTER (WHERE state = 'active') AS longest_active_query_seconds FROM pg_stat_activity WHERE pid <> pg_backend_pid() `), client.query(`SHOW max_connections`), client.query( ` SELECT blks_hit, blks_read, CASE WHEN (blks_hit + blks_read) = 0 THEN NULL ELSE round(100.0 * blks_hit / (blks_hit + blks_read), 2) END AS hit_ratio_pct FROM pg_stat_database WHERE datname = current_database() ` ) ]); const conn = connectionsResult.rows[0]; const cache = bufferCacheResult.rows[0]; return { connections: { total: Number(conn.total), active: Number(conn.active), idle: Number(conn.idle), idleInTransaction: Number(conn.idle_in_transaction), idleInTransactionAborted: Number(conn.idle_in_transaction_aborted), maxConnections: Number(maxConnectionsResult.rows[0].max_connections), longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds), longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds) }, bufferCache: { hitRatioPct: toNullableNumber(cache.hit_ratio_pct), blocksHit: Number(cache.blks_hit), blocksRead: Number(cache.blks_read) } }; } finally { client.release(); } } async getStoredProcedures(schema, routineType) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const params = [schemaToUse]; let typeFilter = ""; if (routineType === "function") { typeFilter = " AND routine_type = 'FUNCTION'"; } else if (routineType === "procedure") { typeFilter = " AND routine_type = 'PROCEDURE'"; } const result = await client.query( ` SELECT routine_name FROM information_schema.routines WHERE routine_schema = $1${typeFilter} ORDER BY routine_name `, params ); return result.rows.map((row) => row.routine_name); } finally { client.release(); } } async getStoredProcedureDetail(procedureName, schema) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); try { const schemaToUse = schema || this.defaultSchema; const result = await client.query( ` SELECT routine_name as procedure_name, routine_type, CASE WHEN routine_type = 'PROCEDURE' THEN 'procedure' ELSE 'function' END as procedure_type, external_language as language, data_type as return_type, routine_definition as definition, ( SELECT string_agg( parameter_name || ' ' || parameter_mode || ' ' || data_type, ', ' ) FROM information_schema.parameters WHERE specific_schema = $1 AND specific_name = $2 AND parameter_name IS NOT NULL ) as parameter_list FROM information_schema.routines WHERE routine_schema = $1 AND routine_name = $2 `, [schemaToUse, procedureName] ); if (result.rows.length === 0) { throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`); } const procedure = result.rows[0]; let definition = procedure.definition; try { const oidResult = await client.query( ` SELECT p.oid, p.prosrc FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.proname = $1 AND n.nspname = $2 `, [procedureName, schemaToUse] ); if (oidResult.rows.length > 0) { if (!definition) { const oid = oidResult.rows[0].oid; const defResult = await client.query(`SELECT pg_get_functiondef($1)`, [oid]); if (defResult.rows.length > 0) { definition = defResult.rows[0].pg_get_functiondef; } else { definition = oidResult.rows[0].prosrc; } } } } catch (err) { console.error(`Error getting procedure definition: ${err}`); } return { procedure_name: procedure.procedure_name, procedure_type: procedure.procedure_type, language: procedure.language || "sql", parameter_list: procedure.parameter_list || "", return_type: procedure.return_type !== "void" ? procedure.return_type : void 0, definition: definition || void 0 }; } finally { client.release(); } } async executeSQL(sql, options, parameters) { if (!this.pool) { throw new Error("Not connected to database"); } const client = await this.pool.connect(); const backendPid = client.processID; let isClientDiscarded = false; try { const statements = splitSQLStatements(sql, "postgres"); if (statements.length === 1) { const { sql: processedStatement, probeApplied } = SQLRowLimiter.applyMaxRowsWithTruncationProbe( statements[0], options.maxRows, "postgres" ); if (options.readonly) { await client.query("BEGIN READ ONLY"); try { const result2 = parameters && parameters.length > 0 ? await client.query(processedStatement, parameters) : await client.query(processedStatement); await client.query("COMMIT"); const resultSet2 = { sql: statements[0], rows: result2.rows, rowCount: result2.rowCount ?? result2.rows.length }; SQLRowLimiter.flagTruncation(resultSet2, options.maxRows, probeApplied); return { resultSets: [resultSet2] }; } catch (error) { await this.rollbackQuietly(client, error); throw error; } } let result; if (parameters && parameters.length > 0) { result = await client.query(processedStatement, parameters); } else { result = await client.query(processedStatement); } const resultSet = { sql: statements[0], rows: result.rows, rowCount: result.rowCount ?? result.rows.length }; SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied); return { resultSets: [resultSet] }; } else { if (parameters && parameters.length > 0) { throw new Error("Parameters are not supported for multi-statement queries in PostgreSQL"); } const resultSets = []; await client.query(options.readonly ? "BEGIN READ ONLY" : "BEGIN"); try { for (let statement of statements) { const { sql: processedStatement, probeApplied } = SQLRowLimiter.applyMaxRowsWithTruncationProbe( statement, options.maxRows, "postgres" ); const result = await client.query(processedStatement); const resultSet = { sql: statement, rows: result.rows ?? [], rowCount: result.rowCount ?? result.rows?.length ?? 0 }; SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied); resultSets.push(resultSet); } await client.query("COMMIT"); } catch (error) { await this.rollbackQuietly(client, error); throw error; } return { resultSets }; } } catch (error) { if (isClientSideTimeout(error)) { isClientDiscarded = true; client.release(true); await this.cancelBackend(backendPid); } throw error; } finally { if (!isClientDiscarded) { client.release(); } } } /** * Best-effort rollback after a failed statement, so a failed ROLLBACK (e.g. * a dropped connection) cannot mask the original error. Skipped for a * client-side timeout: the client is still busy with the abandoned * statement, so the ROLLBACK would queue behind it (see isClientSideTimeout). * executeSQL discards that client instead. */ async rollbackQuietly(client, error) { if (isClientSideTimeout(error)) { return; } try { await client.query("ROLLBACK"); } catch { } } /** * Best-effort server-side cancel for a statement abandoned by the * client-side timeout, sent over a separate pooled connection. Closing the * abandoned client's socket is not enough: PostgreSQL only notices a * vanished client when it next writes to it, so a statement that is * sleeping or computing would run to completion. * * Bounded by its own short timeout (CANCEL_QUERY_TIMEOUT_MS), independent * of the user's query_timeout. pool.query discards its client on error, so * a cancel that itself times out does not leak a stuck client. */ async cancelBackend(backendPid) { if (!this.pool || backendPid === null) return; try { const cancel = { text: "SELECT pg_cancel_backend($1)", values: [backendPid], query_timeout: CANCEL_QUERY_TIMEOUT_MS }; await this.pool.query(cancel); } catch { } } }; var postgresConnector = new PostgresConnector(); ConnectorRegistry.register(postgresConnector); export { PostgresConnector };