@bytebase/dbhub
Version:
Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite
2,863 lines • 101 kB
JavaScript
import {
isDriverNotInstalled
} from "./chunk-WVVMH6FJ.js";
import {
MAX_QUERY_TIMEOUT_SECONDS
} from "./chunk-5XSBOK3W.js";
import {
ConnectorRegistry,
SafeURL,
getDatabaseTypeFromDSN,
getDefaultPortForType,
obfuscateDSNPassword,
parseConnectionInfoFromDSN,
stripCommentsAndStrings
} from "./chunk-IXC4NQGC.js";
// src/tools/builtin-tools.ts
var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
var BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects";
var BUILTIN_TOOL_EXPLAIN_SQL = "explain_sql";
var BUILTIN_TOOL_HEALTH_CHECK = "health_check";
var BUILTIN_TOOLS = [
BUILTIN_TOOL_EXECUTE_SQL,
BUILTIN_TOOL_SEARCH_OBJECTS
];
var ALL_BUILTIN_TOOL_NAMES = [
...BUILTIN_TOOLS,
BUILTIN_TOOL_EXPLAIN_SQL,
BUILTIN_TOOL_HEALTH_CHECK
];
// src/utils/ssh-tunnel.ts
import ssh2, { Client } from "ssh2";
import { existsSync, readFileSync as readFileSync2 } from "fs";
import { createServer } from "net";
// src/utils/ssh-config-parser.ts
import { readFileSync, realpathSync, statSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import SSHConfig from "ssh-config";
function getDefaultSSHConfigPath() {
return join(homedir(), ".ssh", "config");
}
var DEFAULT_SSH_KEYS = [
"~/.ssh/id_rsa",
"~/.ssh/id_ed25519",
"~/.ssh/id_ecdsa",
"~/.ssh/id_dsa"
];
function expandTilde(filePath) {
if (filePath.startsWith("~/")) {
return join(homedir(), filePath.substring(2));
}
return filePath;
}
function resolveSymlink(filePath) {
const expandedPath = expandTilde(filePath);
try {
return realpathSync(expandedPath);
} catch {
return expandedPath;
}
}
function isFile(filePath) {
try {
const stat = statSync(filePath);
return stat.isFile();
} catch {
return false;
}
}
function findDefaultSSHKey() {
for (const keyPath of DEFAULT_SSH_KEYS) {
const resolvedPath = resolveSymlink(keyPath);
if (isFile(resolvedPath)) {
return resolvedPath;
}
}
return void 0;
}
function parseSSHConfig(hostAlias, configPath, options = {}) {
const { requireUser = true } = options;
const sshConfigPath = resolveSymlink(configPath);
if (!isFile(sshConfigPath)) {
return null;
}
try {
const configContent = readFileSync(sshConfigPath, "utf8");
const config = SSHConfig.parse(configContent);
const hostConfig = config.compute(hostAlias);
if (!hostConfig || !hostConfig.HostName && !hostConfig.User && !hostConfig.Port && !hostConfig.IdentityFile && !hostConfig.ProxyJump) {
return null;
}
const sshConfig = {};
if (hostConfig.HostName) {
sshConfig.host = hostConfig.HostName;
} else {
sshConfig.host = hostAlias;
}
if (hostConfig.Port) {
sshConfig.port = parseInt(hostConfig.Port, 10);
}
if (hostConfig.User) {
sshConfig.username = hostConfig.User;
}
if (hostConfig.IdentityFile) {
const identityFile = Array.isArray(hostConfig.IdentityFile) ? hostConfig.IdentityFile[0] : hostConfig.IdentityFile;
const resolvedPath = resolveSymlink(identityFile);
if (isFile(resolvedPath)) {
sshConfig.privateKey = resolvedPath;
}
}
if (!sshConfig.privateKey) {
const defaultKey = findDefaultSSHKey();
if (defaultKey) {
sshConfig.privateKey = defaultKey;
}
}
if (sshConfig.privateKey) {
sshConfig.privateKeyDiscovered = true;
}
if (hostConfig.ProxyJump) {
sshConfig.proxyJump = hostConfig.ProxyJump;
}
if (hostConfig.ProxyCommand) {
console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
}
if (!sshConfig.host || requireUser && !sshConfig.username) {
return null;
}
return requireUser ? sshConfig : sshConfig;
} catch (error) {
console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
function looksLikeSSHAlias(host) {
if (host.includes(".")) {
return false;
}
if (/^[\d:]+$/.test(host)) {
return false;
}
if (/^[0-9a-fA-F:]+$/.test(host) && host.includes(":")) {
return false;
}
return true;
}
function validatePort(port, jumpHostStr) {
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port number in "${jumpHostStr}": port must be between 1 and 65535`);
}
}
function parseJumpHost(jumpHostStr) {
let username;
let host;
let port = 22;
let remaining = jumpHostStr.trim();
if (!remaining) {
throw new Error("Jump host string cannot be empty");
}
const atIndex = remaining.indexOf("@");
if (atIndex !== -1) {
const extractedUsername = remaining.substring(0, atIndex).trim();
if (extractedUsername) {
username = extractedUsername;
}
remaining = remaining.substring(atIndex + 1);
}
if (remaining.startsWith("[")) {
const closeBracket = remaining.indexOf("]");
if (closeBracket !== -1) {
host = remaining.substring(1, closeBracket);
const afterBracket = remaining.substring(closeBracket + 1);
if (afterBracket.startsWith(":")) {
const parsedPort = parseInt(afterBracket.substring(1), 10);
validatePort(parsedPort, jumpHostStr);
port = parsedPort;
}
} else {
throw new Error(`Invalid ProxyJump host "${jumpHostStr}": missing closing bracket in IPv6 address`);
}
} else {
const lastColon = remaining.lastIndexOf(":");
if (lastColon !== -1) {
const potentialPort = remaining.substring(lastColon + 1);
if (/^\d+$/.test(potentialPort)) {
host = remaining.substring(0, lastColon);
const parsedPort = parseInt(potentialPort, 10);
validatePort(parsedPort, jumpHostStr);
port = parsedPort;
} else {
host = remaining;
}
} else {
host = remaining;
}
}
if (!host) {
throw new Error(`Invalid jump host format: "${jumpHostStr}" - host cannot be empty`);
}
return { host, port, username };
}
function parseJumpHosts(proxyJump) {
if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
return [];
}
return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
}
function resolveJumpHosts(proxyJump, configPath, visited = /* @__PURE__ */ new Set()) {
if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
return [];
}
const resolved = [];
for (const token of proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0)) {
const hop = parseJumpHost(token);
if (!looksLikeSSHAlias(hop.host)) {
resolved.push(hop);
continue;
}
if (visited.has(hop.host)) {
throw new Error(`Cycle detected in SSH ProxyJump chain at alias "${hop.host}"`);
}
const aliasConfig = parseSSHConfig(hop.host, configPath, { requireUser: false });
if (!aliasConfig) {
resolved.push(hop);
continue;
}
if (aliasConfig.proxyJump) {
resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host)));
}
resolved.push({
host: aliasConfig.host,
// An explicit `:port` on the token wins; otherwise use the alias's Port (default 22).
port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22,
// An explicit `user@` on the token wins; otherwise the alias's User.
username: hop.username ?? aliasConfig.username,
privateKey: aliasConfig.privateKey,
passphrase: aliasConfig.passphrase
});
}
return resolved;
}
function tokenHasExplicitPort(token) {
const atIndex = token.indexOf("@");
const hostPart = atIndex !== -1 ? token.slice(atIndex + 1) : token;
return hostPart.startsWith("[") ? /\]:\d+$/.test(hostPart) : /:\d+$/.test(hostPart);
}
// src/utils/ssh-tunnel.ts
var SSHTunnel = class {
constructor() {
this.sshClients = [];
// All SSH clients in the chain
this.localServer = null;
this.tunnelInfo = null;
this.isConnected = false;
}
/**
* Establish an SSH tunnel, optionally through jump hosts (ProxyJump).
* @param config SSH connection configuration
* @param options Tunnel options including target host and port
* @returns Promise resolving to tunnel information including local port
*/
async establish(config, options) {
if (this.isConnected) {
throw new Error("SSH tunnel is already established");
}
this.isConnected = true;
try {
const jumpHosts = config.resolvedJumpHosts ?? (config.proxyJump ? parseJumpHosts(config.proxyJump) : []);
const privateKeyBuffer = config.privateKey ? this.loadPrivateKey(config.privateKey) : void 0;
const agent = this.resolveAgent(config.agent);
if (!config.password && !privateKeyBuffer && !agent) {
throw new Error("Either password, privateKey, or an SSH agent (agent or SSH_AUTH_SOCK) must be provided for SSH authentication");
}
const finalClient = await this.establishChain(jumpHosts, config, privateKeyBuffer, agent);
return await this.createLocalTunnel(finalClient, options);
} catch (error) {
this.cleanup();
throw error;
}
}
/**
* Pick the SSH agent socket: an explicitly configured one wins over the ambient
* SSH_AUTH_SOCK. A configured socket that does not exist is an error; a stale
* SSH_AUTH_SOCK is ignored, since the user never asked DBHub to use it.
*/
resolveAgent(configuredAgent) {
if (configuredAgent) {
if (!this.agentSocketExists(configuredAgent)) {
throw new Error(`SSH agent socket not found: ${configuredAgent}`);
}
return configuredAgent;
}
const ambientAgent = process.env.SSH_AUTH_SOCK;
if (!ambientAgent) {
return void 0;
}
if (!this.agentSocketExists(ambientAgent)) {
console.warn(`Ignoring SSH_AUTH_SOCK: no SSH agent socket at ${ambientAgent}`);
return void 0;
}
return ambientAgent;
}
/**
* Windows agents are named pipes or Pageant rather than files, so only Unix
* socket paths are checked.
*/
agentSocketExists(agent) {
return process.platform === "win32" || existsSync(agent);
}
/**
* Why ssh2 would refuse this key, mirroring the checks its connect() runs before
* trying any auth method, or undefined when the key is usable.
*/
unusableKeyReason(privateKey, passphrase) {
const parsed = ssh2.utils.parseKey(privateKey, passphrase);
if (parsed instanceof Error) {
return parsed.message;
}
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key.getPrivatePEM() === null) {
return "not a private key";
}
return void 0;
}
/**
* Load an SSH private key, supporting both a file path (with symlink resolution)
* and base64-encoded key content.
*/
loadPrivateKey(key) {
try {
const resolvedKeyPath = resolveSymlink(key);
return readFileSync2(resolvedKeyPath);
} catch {
try {
const decoded = Buffer.from(key, "base64");
const text = decoded.toString("utf8");
if (text.includes("PRIVATE KEY")) {
return decoded;
}
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
} catch (decodeError) {
if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
throw decodeError;
}
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
}
}
}
/**
* Establish a chain of SSH connections through jump hosts.
* @returns The final SSH client connected to the target host
*/
async establishChain(jumpHosts, targetConfig, privateKey, agent) {
let previousStream;
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHost = jumpHosts[i];
const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
const hopAuth = {
password: targetConfig.password,
privateKey: jumpHost.privateKey ? this.loadPrivateKey(jumpHost.privateKey) : privateKey,
passphrase: jumpHost.passphrase ?? targetConfig.passphrase,
privateKeyDiscovered: jumpHost.privateKey ? true : targetConfig.privateKeyDiscovered,
agent
};
let client = null;
let forwardStream;
try {
client = await this.connectToHost(
{
host: jumpHost.host,
port: jumpHost.port,
username: jumpHost.username || targetConfig.username
},
hopAuth,
previousStream,
`jump host ${i + 1}`,
targetConfig.keepaliveInterval,
targetConfig.keepaliveCountMax
);
console.error(` \u2192 Forwarding through ${jumpHost.host}:${jumpHost.port} to ${nextHost.host}:${nextHost.port}`);
forwardStream = await this.forwardTo(client, nextHost.host, nextHost.port);
} catch (error) {
if (client) {
try {
client.end();
} catch {
}
}
throw error;
}
this.sshClients.push(client);
previousStream = forwardStream;
}
const finalClient = await this.connectToHost(
{
host: targetConfig.host,
port: targetConfig.port || 22,
username: targetConfig.username
},
{
password: targetConfig.password,
privateKey,
passphrase: targetConfig.passphrase,
privateKeyDiscovered: targetConfig.privateKeyDiscovered,
agent
},
previousStream,
jumpHosts.length > 0 ? "target host" : void 0,
targetConfig.keepaliveInterval,
targetConfig.keepaliveCountMax
);
this.sshClients.push(finalClient);
return finalClient;
}
/**
* Connect to a single SSH host.
*/
connectToHost(hostInfo, auth, sock, label, keepaliveInterval, keepaliveCountMax) {
return new Promise((resolve, reject) => {
const client = new Client();
const sshConfig = {
host: hostInfo.host,
port: hostInfo.port,
username: hostInfo.username
};
const { password, privateKey, passphrase, agent } = auth;
if (password) {
sshConfig.password = password;
}
if (privateKey) {
const canSkip = auth.privateKeyDiscovered && Boolean(password || agent);
const unusableReason = canSkip ? this.unusableKeyReason(privateKey, passphrase) : void 0;
if (unusableReason) {
const desc = label || `${hostInfo.host}:${hostInfo.port}`;
console.warn(
`Skipping unusable SSH private key from ~/.ssh/config for ${desc} (${unusableReason}).`
);
} else {
sshConfig.privateKey = privateKey;
if (passphrase) {
sshConfig.passphrase = passphrase;
}
}
}
if (agent) {
sshConfig.agent = agent;
}
if (sock) {
sshConfig.sock = sock;
}
if (keepaliveInterval !== void 0) {
if (Number.isNaN(keepaliveInterval) || keepaliveInterval < 0) {
const desc = label || `${hostInfo.host}:${hostInfo.port}`;
console.warn(
`Invalid SSH keepaliveInterval (${keepaliveInterval}) for ${desc}; keepalive configuration will be ignored.`
);
} else if (keepaliveInterval > 0) {
sshConfig.keepaliveInterval = keepaliveInterval * 1e3;
sshConfig.keepaliveCountMax = keepaliveCountMax ?? 3;
}
}
const onError = (err) => {
client.removeListener("ready", onReady);
client.destroy();
reject(new Error(`SSH connection error${label ? ` (${label})` : ""}: ${err.message}`));
};
const onReady = () => {
client.removeListener("error", onError);
const desc = label || `${hostInfo.host}:${hostInfo.port}`;
console.error(`SSH connection established: ${desc}`);
resolve(client);
};
client.on("error", onError);
client.on("ready", onReady);
client.connect(sshConfig);
});
}
/**
* Forward a connection through an SSH client to a target host.
*/
forwardTo(client, targetHost, targetPort) {
return new Promise((resolve, reject) => {
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
if (err) {
reject(new Error(`SSH forward error: ${err.message}`));
return;
}
resolve(stream);
});
});
}
/**
* Create the local server that tunnels connections to the database.
*/
createLocalTunnel(sshClient, options) {
return new Promise((resolve, reject) => {
let settled = false;
this.localServer = createServer((localSocket) => {
sshClient.forwardOut(
"127.0.0.1",
0,
options.targetHost,
options.targetPort,
(err, stream) => {
if (err) {
console.error("SSH forward error:", err);
localSocket.end();
return;
}
localSocket.pipe(stream).pipe(localSocket);
stream.on("error", (err2) => {
console.error("SSH stream error:", err2);
localSocket.end();
});
localSocket.on("error", (err2) => {
console.error("Local socket error:", err2);
stream.end();
});
}
);
});
this.localServer.on("error", (err) => {
if (!settled) {
settled = true;
reject(new Error(`Local server error: ${err.message}`));
} else {
console.error("Local server error after tunnel established:", err);
this.cleanup();
}
});
const localPort = options.localPort || 0;
this.localServer.listen(localPort, "127.0.0.1", () => {
const address = this.localServer.address();
if (!address || typeof address === "string") {
if (!settled) {
settled = true;
reject(new Error("Failed to get local server address"));
}
return;
}
this.tunnelInfo = {
localPort: address.port,
targetHost: options.targetHost,
targetPort: options.targetPort
};
console.error(`SSH tunnel established: localhost:${address.port} \u2192 ${options.targetHost}:${options.targetPort}`);
settled = true;
resolve(this.tunnelInfo);
});
});
}
/**
* Close the SSH tunnel and clean up resources
*/
async close() {
if (!this.isConnected) {
return;
}
return new Promise((resolve) => {
this.cleanup();
console.error("SSH tunnel closed");
resolve();
});
}
/**
* Clean up resources. Closes all SSH clients in reverse order (innermost first).
*/
cleanup() {
if (this.localServer) {
this.localServer.close();
this.localServer = null;
}
for (let i = this.sshClients.length - 1; i >= 0; i--) {
try {
this.sshClients[i].end();
} catch {
}
}
this.sshClients = [];
this.tunnelInfo = null;
this.isConnected = false;
}
/**
* Get current tunnel information
*/
getTunnelInfo() {
return this.tunnelInfo;
}
/**
* Check if tunnel is connected
*/
getIsConnected() {
return this.isConnected;
}
};
// src/config/toml-loader.ts
import fs2 from "fs";
import path2 from "path";
import { homedir as homedir2 } from "os";
import toml from "@iarna/toml";
// src/config/env.ts
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
var __filename = fileURLToPath(import.meta.url);
var __dirname = path.dirname(__filename);
function parseCommandLineArgs() {
const args = process.argv.slice(2);
const parsedManually = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const parts = arg.substring(2).split("=");
const key = parts[0];
if (key === "readonly") {
console.error("\nERROR: --readonly flag is no longer supported.");
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
console.error(" [[sources]]");
console.error(' id = "default"');
console.error(' dsn = "..."\n');
console.error(" [[tools]]");
console.error(' name = "execute_sql"');
console.error(' source = "default"');
console.error(" readonly = true\n");
console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
process.exit(1);
}
if (key === "max-rows") {
console.error("\nERROR: --max-rows flag is no longer supported.");
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
console.error(" [[sources]]");
console.error(' id = "default"');
console.error(' dsn = "..."\n');
console.error(" [[tools]]");
console.error(' name = "execute_sql"');
console.error(' source = "default"');
console.error(" max_rows = 1000\n");
console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
process.exit(1);
}
const value = parts.length > 1 ? parts.slice(1).join("=") : void 0;
if (value) {
parsedManually[key] = value;
} else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
parsedManually[key] = args[i + 1];
i++;
} else {
parsedManually[key] = "true";
}
}
}
return parsedManually;
}
function findEnvFile() {
const isDevelopment = process.env.NODE_ENV === "development" || process.argv[1]?.includes("tsx");
const envFileNames = isDevelopment ? [".env.local", ".env"] : [".env"];
const envPaths = [];
for (const fileName of envFileNames) {
envPaths.push(
path.join(process.cwd(), fileName),
// Current working directory
path.join(__dirname, "..", "..", fileName)
// Two levels up (src/config -> src -> root)
);
}
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
return envPath;
}
}
return null;
}
function loadEnvFiles() {
const envPath = findEnvFile();
if (envPath) {
dotenv.config({ path: envPath });
if (process.env.READONLY !== void 0) {
console.error("\nERROR: READONLY environment variable is no longer supported.");
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
console.error(" [[sources]]");
console.error(' id = "default"');
console.error(' dsn = "..."\n');
console.error(" [[tools]]");
console.error(' name = "execute_sql"');
console.error(' source = "default"');
console.error(" readonly = true\n");
console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
process.exit(1);
}
if (process.env.MAX_ROWS !== void 0) {
console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
console.error(" [[sources]]");
console.error(' id = "default"');
console.error(' dsn = "..."\n');
console.error(" [[tools]]");
console.error(' name = "execute_sql"');
console.error(' source = "default"');
console.error(" max_rows = 1000\n");
console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
process.exit(1);
}
return path.basename(envPath);
}
return null;
}
function isDemoMode() {
const args = parseCommandLineArgs();
return args.demo === "true";
}
function buildDSNFromEnvParams() {
const dbType = process.env.DB_TYPE;
const dbHost = process.env.DB_HOST;
const dbUser = process.env.DB_USER;
const dbPassword = process.env.DB_PASSWORD;
const dbName = process.env.DB_NAME;
const dbPort = process.env.DB_PORT;
if (dbType?.toLowerCase() === "sqlite") {
if (!dbName) {
return null;
}
} else {
if (!dbType || !dbHost || !dbUser || !dbPassword || !dbName) {
return null;
}
}
const supportedTypes = ["postgres", "postgresql", "mysql", "mariadb", "sqlserver", "sqlite", "oracle"];
if (!supportedTypes.includes(dbType.toLowerCase())) {
throw new Error(`Unsupported DB_TYPE: ${dbType}. Supported types: ${supportedTypes.join(", ")}`);
}
let port = dbPort;
if (!port) {
switch (dbType.toLowerCase()) {
case "postgres":
case "postgresql":
port = "5432";
break;
case "mysql":
case "mariadb":
port = "3306";
break;
case "sqlserver":
port = "1433";
break;
case "oracle":
port = "1521";
break;
case "sqlite":
return {
dsn: `sqlite:///${dbName}`,
source: "individual environment variables"
};
default:
throw new Error(`Unknown database type for port determination: ${dbType}`);
}
}
const user = dbUser;
const password = dbPassword;
const dbNameStr = dbName;
const encodedUser = encodeURIComponent(user);
const encodedPassword = encodeURIComponent(password);
const encodedDbName = encodeURIComponent(dbNameStr);
const protocol = dbType.toLowerCase() === "postgresql" ? "postgres" : dbType.toLowerCase();
const dsn = `${protocol}://${encodedUser}:${encodedPassword}@${dbHost}:${port}/${encodedDbName}`;
return {
dsn,
source: "individual environment variables"
};
}
function resolveDSN() {
const args = parseCommandLineArgs();
if (isDemoMode()) {
return {
dsn: "sqlite:///:memory:",
source: "demo mode",
isDemo: true
};
}
if (args.dsn) {
return { dsn: args.dsn, source: "command line argument" };
}
if (process.env.DSN) {
return { dsn: process.env.DSN, source: "environment variable" };
}
const envParamsResult = buildDSNFromEnvParams();
if (envParamsResult) {
return envParamsResult;
}
const loadedEnvFile = loadEnvFiles();
if (loadedEnvFile && process.env.DSN) {
return { dsn: process.env.DSN, source: `${loadedEnvFile} file` };
}
if (loadedEnvFile) {
const envFileParamsResult = buildDSNFromEnvParams();
if (envFileParamsResult) {
return {
dsn: envFileParamsResult.dsn,
source: `${loadedEnvFile} file (individual parameters)`
};
}
}
return null;
}
function resolveTransport() {
const args = parseCommandLineArgs();
if (args.transport) {
const type = args.transport === "http" ? "http" : "stdio";
return { type, source: "command line argument" };
}
if (process.env.TRANSPORT) {
const type = process.env.TRANSPORT === "http" ? "http" : "stdio";
return { type, source: "environment variable" };
}
return { type: "stdio", source: "default" };
}
function resolvePort() {
const args = parseCommandLineArgs();
if (args.port) {
const port = parseInt(args.port, 10);
return { port, source: "command line argument" };
}
if (process.env.PORT) {
const port = parseInt(process.env.PORT, 10);
return { port, source: "environment variable" };
}
return { port: 8080, source: "default" };
}
function requireFlagValue(flag, args, example) {
const fail = () => {
console.error(`ERROR: --${flag} requires a value (e.g., --${flag}=${example}).`);
process.exit(1);
};
const rawArgs = process.argv.slice(2);
for (let i = 0; i < rawArgs.length; i++) {
const token = rawArgs[i];
if (token === `--${flag}`) {
const next = rawArgs[i + 1];
if (!next || next.startsWith("--")) fail();
} else if (token === `--${flag}=`) {
fail();
}
}
if (args[flag] === void 0) return void 0;
const value = args[flag].trim();
if (!value) fail();
return value;
}
function resolveHost() {
const args = parseCommandLineArgs();
const cliHost = requireFlagValue("host", args, "127.0.0.1");
if (cliHost !== void 0) {
return { host: cliHost, source: "command line argument" };
}
const envHost = process.env.DBHUB_HOST?.trim();
if (envHost) {
return { host: envHost, source: "environment variable" };
}
return { host: "0.0.0.0", source: "default" };
}
function resolveAllowedHosts() {
const args = parseCommandLineArgs();
const cliValue = requireFlagValue("allowed-hosts", args, "db.internal,app.example.com");
if (cliValue !== void 0) {
return { hosts: splitHostList(cliValue), source: "command line argument" };
}
const envValue = process.env.DBHUB_ALLOWED_HOSTS?.trim();
if (envValue) {
return { hosts: splitHostList(envValue), source: "environment variable" };
}
return { hosts: [], source: "default" };
}
function splitHostList(value) {
return value.split(",").map((h) => h.trim()).filter((h) => h.length > 0);
}
function resolveAuthTokens() {
const args = parseCommandLineArgs();
const cliValue = requireFlagValue("auth-token", args, "secret1,secret2");
if (cliValue !== void 0) {
return { tokens: splitTokenList(cliValue), source: "command line argument" };
}
const envValue = process.env.DBHUB_AUTH_TOKEN?.trim();
if (envValue) {
return { tokens: splitTokenList(envValue), source: "environment variable" };
}
return { tokens: [], source: "default" };
}
function splitTokenList(value) {
return value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
}
function redactDSN(dsn) {
return obfuscateDSNPassword(dsn);
}
function resolveId() {
const args = parseCommandLineArgs();
if (args.id) {
return { id: args.id, source: "command line argument" };
}
if (process.env.ID) {
return { id: process.env.ID, source: "environment variable" };
}
return null;
}
function resolveSSHConfig() {
const args = parseCommandLineArgs();
const hasSSHArgs = args["ssh-host"] || process.env.SSH_HOST;
if (!hasSSHArgs) {
return null;
}
let config = {};
let sources = [];
let sshConfigHost;
if (args["ssh-host"]) {
sshConfigHost = args["ssh-host"];
config.host = args["ssh-host"];
sources.push("ssh-host from command line");
} else if (process.env.SSH_HOST) {
sshConfigHost = process.env.SSH_HOST;
config.host = process.env.SSH_HOST;
sources.push("SSH_HOST from environment");
}
if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
const sshConfigPath = getDefaultSSHConfigPath();
console.error(`Attempting to parse SSH config for host '${sshConfigHost}' from: ${sshConfigPath}`);
const sshConfigData = parseSSHConfig(sshConfigHost, sshConfigPath);
if (sshConfigData) {
config = { ...sshConfigData };
sources.push(`SSH config for host '${sshConfigHost}'`);
}
}
if (args["ssh-port"]) {
config.port = parseInt(args["ssh-port"], 10);
sources.push("ssh-port from command line");
} else if (process.env.SSH_PORT) {
config.port = parseInt(process.env.SSH_PORT, 10);
sources.push("SSH_PORT from environment");
}
if (args["ssh-user"]) {
config.username = args["ssh-user"];
sources.push("ssh-user from command line");
} else if (process.env.SSH_USER) {
config.username = process.env.SSH_USER;
sources.push("SSH_USER from environment");
}
if (args["ssh-password"]) {
config.password = args["ssh-password"];
sources.push("ssh-password from command line");
} else if (process.env.SSH_PASSWORD) {
config.password = process.env.SSH_PASSWORD;
sources.push("SSH_PASSWORD from environment");
}
if (args["ssh-key"]) {
config.privateKey = args["ssh-key"];
config.privateKeyDiscovered = false;
if (config.privateKey.startsWith("~/")) {
config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
}
sources.push("ssh-key from command line");
} else if (process.env.SSH_KEY) {
config.privateKey = process.env.SSH_KEY;
config.privateKeyDiscovered = false;
if (config.privateKey.startsWith("~/")) {
config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
}
sources.push("SSH_KEY from environment");
}
if (args["ssh-passphrase"]) {
config.passphrase = args["ssh-passphrase"];
sources.push("ssh-passphrase from command line");
} else if (process.env.SSH_PASSPHRASE) {
config.passphrase = process.env.SSH_PASSPHRASE;
sources.push("SSH_PASSPHRASE from environment");
}
const cliAgent = requireFlagValue("ssh-agent", args, "~/.ssh/agent.sock");
if (cliAgent) {
config.agent = cliAgent;
if (config.agent.startsWith("~/")) {
config.agent = path.join(process.env.HOME || "", config.agent.substring(2));
}
sources.push("ssh-agent from command line");
}
if (args["ssh-proxy-jump"]) {
config.proxyJump = args["ssh-proxy-jump"];
sources.push("ssh-proxy-jump from command line");
} else if (process.env.SSH_PROXY_JUMP) {
config.proxyJump = process.env.SSH_PROXY_JUMP;
sources.push("SSH_PROXY_JUMP from environment");
}
const parseNonNegativeInteger = (value, name) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`Invalid value for ${name}: "${value}". Expected a non-negative integer.`);
}
return parsed;
};
if (args["ssh-keepalive-interval"]) {
config.keepaliveInterval = parseNonNegativeInteger(args["ssh-keepalive-interval"], "ssh-keepalive-interval");
sources.push("ssh-keepalive-interval from command line");
} else if (process.env.SSH_KEEPALIVE_INTERVAL) {
config.keepaliveInterval = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_INTERVAL, "SSH_KEEPALIVE_INTERVAL");
sources.push("SSH_KEEPALIVE_INTERVAL from environment");
}
if (args["ssh-keepalive-count-max"]) {
config.keepaliveCountMax = parseNonNegativeInteger(args["ssh-keepalive-count-max"], "ssh-keepalive-count-max");
sources.push("ssh-keepalive-count-max from command line");
} else if (process.env.SSH_KEEPALIVE_COUNT_MAX) {
config.keepaliveCountMax = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_COUNT_MAX, "SSH_KEEPALIVE_COUNT_MAX");
sources.push("SSH_KEEPALIVE_COUNT_MAX from environment");
}
if (!config.host || !config.username) {
throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
}
if (!config.password && !config.privateKey && !config.agent && !process.env.SSH_AUTH_SOCK) {
throw new Error("SSH tunnel configuration requires either --ssh-password or --ssh-key (or an SSH agent via --ssh-agent or SSH_AUTH_SOCK) for authentication");
}
return {
config,
source: sources.join(", ")
};
}
async function resolveSourceConfigs() {
if (parseCommandLineArgs().config) {
loadEnvFiles();
}
if (!isDemoMode()) {
const tomlConfig = loadTomlConfig();
if (tomlConfig) {
const idData = resolveId();
if (idData) {
throw new Error(
"The --id flag cannot be used with TOML configuration. TOML config defines source IDs directly. Either remove the --id flag or use command-line DSN configuration instead."
);
}
if (parseCommandLineArgs().dsn) {
throw new Error(
`The --dsn flag cannot be used with TOML configuration (${tomlConfig.source}). TOML config defines database sources directly and supports multiple databases, while a DSN configures a single database. Either remove the --dsn flag or drop the --config flag.`
);
}
return tomlConfig;
}
}
const dsnResult = resolveDSN();
if (dsnResult) {
let dsnUrl;
try {
dsnUrl = new SafeURL(dsnResult.dsn);
} catch (error) {
throw new Error(
`Invalid DSN format: ${redactDSN(dsnResult.dsn)}. Expected format: protocol://[user[:password]@]host[:port]/database`
);
}
const protocol = dsnUrl.protocol.replace(":", "");
let dbType;
if (protocol === "postgresql" || protocol === "postgres") {
dbType = "postgres";
} else if (protocol === "mysql") {
dbType = "mysql";
} else if (protocol === "mariadb") {
dbType = "mariadb";
} else if (protocol === "sqlserver") {
dbType = "sqlserver";
} else if (protocol === "sqlite") {
dbType = "sqlite";
} else if (protocol === "oracle") {
dbType = "oracle";
} else {
throw new Error(`Unsupported database type in DSN: ${protocol}`);
}
const idData = resolveId();
const sourceId = idData?.id || "default";
const source = {
id: sourceId,
type: dbType,
dsn: dsnResult.dsn
};
const connectionInfo = parseConnectionInfoFromDSN(dsnResult.dsn);
if (connectionInfo) {
if (connectionInfo.host) {
source.host = connectionInfo.host;
}
if (connectionInfo.port !== void 0) {
source.port = connectionInfo.port;
}
if (connectionInfo.database) {
source.database = connectionInfo.database;
}
if (connectionInfo.user) {
source.user = connectionInfo.user;
}
}
const sshResult = resolveSSHConfig();
if (sshResult) {
source.ssh_host = sshResult.config.host;
source.ssh_port = sshResult.config.port;
source.ssh_user = sshResult.config.username;
source.ssh_password = sshResult.config.password;
source.ssh_key = sshResult.config.privateKey;
source.ssh_key_discovered = sshResult.config.privateKeyDiscovered;
source.ssh_passphrase = sshResult.config.passphrase;
source.ssh_agent = sshResult.config.agent;
source.ssh_keepalive_interval = sshResult.config.keepaliveInterval;
source.ssh_keepalive_count_max = sshResult.config.keepaliveCountMax;
}
if (dsnResult.isDemo) {
const { getSqliteInMemorySetupSql } = await import("./demo-loader-PSMTLZ2T.js");
source.init_script = getSqliteInMemorySetupSql();
}
return {
sources: [source],
tools: [],
source: dsnResult.isDemo ? "demo mode" : dsnResult.source
};
}
return null;
}
// src/config/toml-loader.ts
function loadTomlConfig() {
const configPath = resolveTomlConfigPath();
if (!configPath) {
return null;
}
try {
const fileContent = fs2.readFileSync(configPath, "utf-8");
const rawToml = toml.parse(fileContent);
const parsedToml = interpolateEnvVars(rawToml);
if (!Array.isArray(parsedToml.sources)) {
throw new Error(
`Configuration file ${configPath}: must contain a [[sources]] array. Use [[sources]] syntax for array of tables in TOML.`
);
}
const sources = processSourceConfigs(parsedToml.sources, configPath);
validateTomlConfig({ ...parsedToml, sources }, configPath);
return {
sources,
tools: parsedToml.tools,
source: path2.basename(configPath)
};
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to load TOML configuration from ${configPath}: ${error.message}`
);
}
throw error;
}
}
function resolveTomlConfigPath() {
const args = parseCommandLineArgs();
const configValue = requireFlagValue("config", args, "./dbhub.toml");
if (configValue) {
const configPath = expandHomeDir(configValue);
if (!fs2.existsSync(configPath)) {
throw new Error(
`Configuration file specified by --config flag not found: ${configPath}`
);
}
return configPath;
}
return null;
}
function validateTomlConfig(config, configPath) {
if (!config.sources) {
throw new Error(
`Configuration file ${configPath} must contain a [[sources]] array. Example:
[[sources]]
id = "my_db"
dsn = "postgres://..."`
);
}
if (config.sources.length === 0) {
throw new Error(
`Configuration file ${configPath}: sources array cannot be empty. Please define at least one source with [[sources]].`
);
}
const ids = /* @__PURE__ */ new Set();
const duplicates = [];
for (const source of config.sources) {
if (!source.id) {
throw new Error(
`Configuration file ${configPath}: each source must have an 'id' field. Example: [[sources]]
id = "my_db"`
);
}
if (ids.has(source.id)) {
duplicates.push(source.id);
} else {
ids.add(source.id);
}
}
if (duplicates.length > 0) {
throw new Error(
`Configuration file ${configPath}: duplicate source IDs found: ${duplicates.join(", ")}. Each source must have a unique 'id' field.`
);
}
for (const source of config.sources) {
validateSourceConfig(source, configPath);
}
if (config.tools) {
validateToolsConfig(config.tools, config.sources, configPath);
}
}
function validateToolsConfig(tools, sources, configPath) {
const toolSourcePairs = /* @__PURE__ */ new Set();
for (const tool of tools) {
if (!tool.name) {
throw new Error(
`Configuration file ${configPath}: all tools must have a 'name' field`
);
}
if (!tool.source) {
throw new Error(
`Configuration file ${configPath}: tool '${tool.name}' must have a 'source' field`
);
}
const pairKey = `${tool.name}:${tool.source}`;
if (toolSourcePairs.has(pairKey)) {
throw new Error(
`Configuration file ${configPath}: duplicate tool configuration found for '${tool.name}' on source '${tool.source}'`
);
}
toolSourcePairs.add(pairKey);
if (!sources.some((s) => s.id === tool.source)) {
throw new Error(
`Configuration file ${configPath}: tool '${tool.name}' references unknown source '${tool.source}'`
);
}
const isBuiltin = ALL_BUILTIN_TOOL_NAMES.includes(tool.name);
const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL;
if (isBuiltin) {
if (tool.description || tool.statement || tool.parameters) {
throw new Error(
`Configuration file ${configPath}: built-in tool '${tool.name}' cannot have description, statement, or parameters fields`
);
}
if (!isExecuteSql && (tool.readonly !== void 0 || tool.max_rows !== void 0)) {
throw new Error(
`Configuration file ${configPath}: tool '${tool.name}' cannot have readonly or max_rows fields (these are only valid for ${BUILTIN_TOOL_EXECUTE_SQL} tool)`
);
}
} else {
if (!tool.description || !tool.statement) {
throw new Error(
`Configuration file ${configPath}: custom tool '${tool.name}' must have 'description' and 'statement' fields`
);
}
}
if (tool.max_rows !== void 0) {
if (typeof tool.max_rows !== "number" || tool.max_rows <= 0) {
throw new Error(
`Configuration file ${configPath}: tool '${tool.name}' has invalid max_rows. Must be a positive integer.`
);
}
}
if (tool.readonly !== void 0 && typeof tool.readonly !== "boolean") {
throw new Error(
`Configuration file ${configPath}: tool '${tool.name}' has invalid readonly. Must be a boolean (true or false).`
);
}
}
}
function getRawDSNQueryParam(dsn, key) {
const queryStart = dsn.indexOf("?");
if (queryStart === -1) {
return null;
}
if (key === "sslmode" && dsn.startsWith("sqlserver://")) {
return new URLSearchParams(dsn.substring(queryStart + 1)).get(key);
}
for (const pair of dsn.substring(queryStart + 1).split("&")) {
if (pair === "") {
continue;
}
const eq = pair.indexOf("=");
const rawKey = eq === -1 ? pair : pair.substring(0, eq);
if (rawKey === key) {
const rawValue = eq === -1 ? "" : pair.substring(eq + 1);
try {
return decodeURIComponent(rawValue);
} catch {
return rawValue;
}
}
}
return null;
}
var CLIENT_CERT_SSL_MODES = ["require", "verify-ca", "verify-full"];
function validateReadableFile(source, field, filePath, configPath) {
const expandedPath = expandHomeDir(filePath);
let stats;
try {
stats = fs2.statSync(expandedPath);
} catch {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' ${field} file not found or not accessible: '${expandedPath}'`
);
}
if (!stats.isFile()) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' ${field} path is not a regular file: '${expandedPath}'`
);
}
try {
fs2.accessSync(expandedPath, fs2.constants.R_OK);
} catch {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' ${field} file is not readable: '${expandedPath}'`
);
}
}
function validateDSNFieldConflicts(source, configPath) {
const conflict = (field, fieldValue, dsnValue) => {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has conflicting ${field}: the DSN specifies '${dsnValue}' but the ${field} field is '${fieldValue}'. Set ${field} in only one place, or make the two values match.`
);
};
const info = parseConnectionInfoFromDSN(source.dsn);
if (source.type && info?.type && source.type !== info.type) {
conflict("type", source.type, info.type);
}
if (info?.type === "sqlite") {
return;
}
let url;
try {
url = new SafeURL(source.dsn);
} catch {
return;
}
if (info) {
if (source.host && info.host && source.host.toLowerCase() !== info.host.toLowerCase()) {
conflict("host", source.host, info.host);
}
if (source.port !== void 0 && info.port !== void 0 && source.port !== info.port) {
conflict("port", String(source.port), String(info.port));
}
if (source.database && info.database && source.database !== info.database) {
conflict("database", source.database, info.database);
}
if (source.database && !info.database) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has a 'database' field but the DSN names no database. The field is ignored at connection time \u2014 add the database to the DSN, or use individual connection parameters instead of a DSN.`
);
}
if (source.user && info.user && source.user !== info.user) {
conflict("user", source.user, info.user);
}
}
if (source.password && source.password !== url.password) {
if (!url.password) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has a 'password' field but the DSN has no password. The field is ignored at connection time \u2014 add the password to the DSN, or use individual connection parameters instead of a DSN.`
);
}
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has a 'password' field that conflicts with the password in the DSN. Set the password in only one place.`
);
}
const dsnSslmode = getRawDSNQueryParam(source.dsn, "sslmode");
if (source.sslmode && dsnSslmode !== null && dsnSslmode !== source.sslmode) {
if (source.type === "sqlserver") {
throw new Error("Conflicting SQL Server sslmode. Set sslmode in only one place, or make the two values match.");
}
conflict("sslmode", source.sslmode, dsnSslmode);
}
const dsnSslrootcert = getRawDSNQueryParam(source.dsn, "sslrootcert");
if (source.sslrootcert && dsnSslrootcert !== null && expandHomeDir(source.sslrootcert) !== expandHomeDir(dsnSslrootcert)) {
conflict("sslrootcert", expandHomeDir(source.sslrootcert), expandHomeDir(dsnSslrootcert));
}
for (const field of ["sslcert", "sslkey"]) {
const fieldValue = source[field];
const dsnValue = getRawDSNQueryParam(source.dsn, field);
if (fieldValue && dsnValue !== null && expandHomeDir(fieldValue) !== expandHomeDir(dsnValue)) {
conflict(field, expandHomeDir(fieldValue), expandHomeDir(dsnValue));
}
}
const dsnInstanceName = getRawDSNQueryParam(source.dsn, "instanceName");
if (source.instanceName && dsnInstanceName !== null && dsnInstanceName !== source.instanceName) {
conflict("instanceName", source.instanceName, dsnInstanceName);
}
const dsnAuthentication = getRawDSNQueryParam(source.dsn, "authentication");
if (source.authentication && dsnAuthentication !== null && dsnAuthentication !== source.authentication) {
conflict("authentication", source.authentication, dsnAuthentication);
}
const dsnDomain = getRawDSNQueryParam(source.dsn, "domain");
if (source.domain && dsnDomain !== null && dsnDomain !== source.domain) {
conflict("domain", source.domain, dsnDomain);
}
}
function validateSourceConfig(source, configPath) {
const hasConnectionParams = source.type && (source.type === "sqlite" ? source.database : source.host);
if (!source.dsn && !hasConnectionParams) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' must have either:
- 'dsn' field (e.g., dsn = "postgres://user:pass@host:5432/dbname")
- OR connection parameters (type, host, database, user, password)
- For SQLite: type = "sqlite" and database path`
);
}
if (source.type) {
const validTypes = ["postgres", "mysql", "mariadb", "sqlserver", "sqlite", "oracle"];
if (!validTypes.includes(source.type)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid type '${source.type}'. Valid types: ${validTypes.join(", ")}`
);
}
}
if (source.aws_iam_auth !== void 0 && typeof source.aws_iam_auth !== "boolean") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid aws_iam_auth. Must be a boolean (true or false).`
);
}
if (source.aws_region !== void 0) {
if (typeof source.aws_region !== "string" || source.aws_region.trim().length === 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid aws_region. Must be a non-empty string (e.g., "eu-west-1").`
);
}
}
if (source.aws_profile !== void 0) {
if (typeof source.aws_profile !== "string" || source.aws_profile.trim().length === 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid aws_profile. Must be a non-empty string.`
);
}
if (source.aws_iam_auth !== true) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' aws_profile requires aws_iam_auth = true.`
);
}
}
if (source.aws_iam_auth === true) {
const validIamTypes = ["postgres", "mysql", "mariadb"];
if (!source.type || !validIamTypes.includes(source.type)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled, but this is only supported for postgres, mysql, and mariadb sources.`
);
}
if (!source.aws_region) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled but aws_region is not specified.`
);
}
}
if (source.connection_timeout !== void 0) {
if (typeof source.connection_timeout !== "number" || source.connection_timeout <= 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid connection_timeout. Must be a positive number (in seconds).`
);
}
}
if (source.query_timeout !== void 0) {
if (!Number.isFinite(source.query_timeout) || source.query_timeout <= 0 || source.query_timeout > MAX_QUERY_TIMEOUT_SECONDS) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid query_timeout. Must be a positive number of seconds, at most ${MAX_QUERY_TIMEOUT_SECONDS}.`
);
}
}
if (source.pool_max_connections !== void 0) {
if (source.type !== "postgres") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'pool_max_connections' but it is only supported for PostgreSQL sources.`
);
}
if (!Number.isInteger(source.pool_max_connections) || source.pool_max_connections < 1 || source.pool_max_connections > 1e3) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid pool_max_connections. Must be an integer between 1 and 1000.`
);
}
}
if (source.ssh_port !== void 0) {
if (typeof source.ssh_port !== "number" || source.ssh_port <= 0 || source.ssh_port > 65535) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid ssh_port. Must be between 1 and 65535.`
);
}
}
if (source.ssh_agent !== void 0) {
if (typeof source.ssh_agent !== "string" || source.ssh_agent.trim() === "") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid ssh_agent. Must be a path to an SSH agent socket.`
);
}
}
if (source.sslmode !== void 0) {
if (source.type === "sqlite") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslmode but SQLite does not support SSL. Remove the sslmode field for SQLite sources.`
);
}
const validSslModes = ["disable", "require", "verify-ca", "verify-full"];
if (!validSslModes.includes(source.sslmode)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid sslmode '${source.sslmode}'. Valid values: ${validSslModes.join(", ")}`
);
}
const verifyModesByType = {
postgres: ["verify-ca", "verify-full"],
sqlserver: ["verify-full"],
oracle: ["verify-full"]
};
if ((source.sslmode === "verify-ca" || source.sslmode === "verify-full") && !(verifyModesByType[source.type] ?? []).includes(source.sslmode)) {
const supported = ["disable", "require", ...verifyModesByType[source.type] ?? []];
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslmode '${source.sslmode}' which is not supported for ${source.type}. Valid values for ${source.type}: ${supported.join(", ")}`
);
}
}
if (source.dsn) {
validateDSNFieldConflicts(source, configPath);
}
if (source.sslrootcert !== void 0) {
if (source.type !== "postgres") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslrootcert but it is only supported for PostgreSQL.`
);
}
if (source.sslmode !== "verify-ca" && source.sslmode !== "verify-full") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslrootcert but sslmode is '${source.sslmode ?? "not set"}'. sslrootcert requires sslmode 'verify-ca' or 'verify-full'`
);
}
validateReadableFile(source, "sslrootcert", source.sslrootcert, configPath);
}
if (source.sslcert !== void 0 || source.sslkey !== void 0) {
if (source.type !== "postgres") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslcert/sslkey but they are only supported for PostgreSQL.`
);
}
if (source.sslcert === void 0 || source.sslkey === void 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has ${source.sslcert !== void 0 ? "sslcert" : "sslkey"} without ${source.sslcert !== void 0 ? "sslkey" : "sslcert"}. sslcert and sslkey must be set together for client certificate authentication`
);
}
if (!CLIENT_CERT_SSL_MODES.includes(source.sslmode ?? "")) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslcert/sslkey but sslmode is '${source.sslmode ?? "not set"}'. sslcert/sslkey require sslmode 'require', 'verify-ca' or 'verify-full'`
);
}
validateReadableFile(source, "sslcert", source.sslcert, configPath);
validateReadableFile(source, "sslkey", source.sslkey, configPath);
}
if (source.authentication !== void 0) {
if (source.type !== "sqlserver") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has authentication but it is only supported for SQL Server.`
);
}
const validAuthMethods = ["ntlm", "azure-active-directory-access-token"];
if (!validAuthMethods.includes(source.authentication)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid authentication '${source.authentication}'. Valid values: ${validAuthMethods.join(", ")}`
);
}
if (source.authentication === "ntlm" && !source.domain) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' uses NTLM authentication but 'domain' is not specified.`
);
}
}
if (source.domain !== void 0) {
if (source.type !== "sqlserver") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has domain but it is only supported for SQL Server.`
);
}
if (source.authentication === void 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has domain but authentication is not set. Add authentication = "ntlm" to use Windows domain authentication.`
);
}
if (source.authentication !== "ntlm") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has domain but authentication is set to '${source.authentication}'. Domain is only valid with authentication = "ntlm".`
);
}
}
if (source.search_path !== void 0) {
if (source.type !== "postgres") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'search_path' but it is only supported for PostgreSQL sources.`
);
}
if (typeof source.search_path !== "string" || source.search_path.trim().length === 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid search_path. Must be a non-empty string of comma-separated schema names (e.g., "myschema,public").`
);
}
}
if (source.timezone !== void 0) {
if (source.type !== "mysql" && source.type !== "mariadb") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'timezone' but it is only supported for MySQL and MariaDB sources.`
);
}
if (typeof source.timezone !== "string" || !/^(?:local|Z|[+-]\d\d:\d\d)$/.test(source.timezone)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid timezone '${source.timezone}'. Must be "local", "Z" (UTC), or an offset like "+09:00".`
);
}
}
if (source.charset !== void 0) {
if (source.type !== "mysql" && source.type !== "mariadb") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'charset' but it is only supported for MySQL and MariaDB sources.`
);
}
if (typeof source.charset !== "string" || source.charset.trim() === "") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid charset '${source.charset}'. Must be a non-empty string naming a character set (e.g. "utf8mb4").`
);
}
}
if (source.collation !== void 0) {
if (source.type !== "mysql" && source.type !== "mariadb") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'collation' but it is only supported for MySQL and MariaDB sources.`
);
}
if (typeof source.collation !== "string" || source.collation.trim() === "") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid collation '${source.collation}'. Must be a non-empty string naming a collation (e.g. "utf8mb4_0900_ai_ci").`
);
}
}
if (source.readonly !== void 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'readonly' field, but readonly must be configured per-tool, not per-source. Move 'readonly' to [[tools]] configuration instead.`
);
}
if (source.max_rows !== void 0) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has 'max_rows' field, but max_rows must be configured per-tool, not per-source. Move 'max_rows' to [[tools]] configuration instead.`
);
}
}
function resolveSqliteDatabasePath(database, configPath) {
if (database === ":memory:") {
return database;
}
const expanded = expandHomeDir(database);
const absolute = path2.isAbsolute(expanded) ? expanded : path2.resolve(path2.dirname(configPath), expanded);
return absolute.replace(/\\/g, "/");
}
function buildSqliteDSN(database) {
if (database === ":memory:") {
return "sqlite:///:memory:";
}
return `sqlite://${database.startsWith("/") ? database : `/${database}`}`;
}
function processSourceConfigs(sources, configPath) {
return sources.map((source) => {
const processed = { ...source };
if (processed.ssh_key) {
processed.ssh_key = expandHomeDir(processed.ssh_key);
}
delete processed.ssh_key_discovered;
if (typeof processed.ssh_agent === "string" && processed.ssh_agent) {
processed.ssh_agent = expandHomeDir(processed.ssh_agent);
}
for (const field of ["sslrootcert", "sslcert", "sslkey"]) {
if (processed[field]) {
processed[field] = expandHomeDir(processed[field]);
}
}
if (processed.type === "sqlite" && processed.database) {
processed.database = resolveSqliteDatabasePath(processed.database, configPath);
}
if (processed.dsn && processed.dsn.startsWith("sqlite:///~")) {
processed.dsn = `sqlite:///${expandHomeDir(processed.dsn.substring(11))}`;
}
if (processed.dsn) {
const connectionInfo = parseConnectionInfoFromDSN(processed.dsn);
if (connectionInfo) {
if (!processed.type && connectionInfo.type) {
processed.type = connectionInfo.type;
}
if (!processed.host && connectionInfo.host) {
processed.host = connectionInfo.host;
}
if (processed.port === void 0 && connectionInfo.port !== void 0) {
processed.port = connectionInfo.port;
}
if (!processed.database && connectionInfo.database) {
processed.database = connectionInfo.database;
}
if (!processed.user && connectionInfo.user) {
processed.user = connectionInfo.user;
}
}
try {
const url = new SafeURL(processed.dsn);
const dsnSslmode = url.getSearchParam("sslmode");
if (processed.sslmode === void 0 && dsnSslmode) {
processed.sslmode = dsnSslmode;
}
for (const field of ["sslrootcert", "sslcert", "sslkey"]) {
const dsnValue = url.getSearchParam(field);
if (!processed[field] && dsnValue) {
processed[field] = dsnValue;
}
}
} catch {
}
}
return processed;
});
}
var ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
function interpolateEnvVars(value) {
if (typeof value === "string") {
return value.replace(ENV_VAR_PATTERN, (match, varName) => {
const envValue = process.env[varName];
return envValue !== void 0 ? envValue : match;
});
}
if (Array.isArray(value)) {
return value.map((item) => interpolateEnvVars(item));
}
if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
const result = {};
for (const [key, val] of Object.entries(value)) {
result[key] = interpolateEnvVars(val);
}
return result;
}
return value;
}
function expandHomeDir(filePath) {
if (filePath.startsWith("~/")) {
return path2.join(homedir2(), filePath.substring(2));
}
return filePath;
}
function mergeSourceFieldsIntoDSN(dsn, source) {
if (source.type === "sqlite") {
return dsn;
}
try {
new SafeURL(dsn);
} catch {
return dsn;
}
const hasParam = (key) => getRawDSNQueryParam(dsn, key) !== null;
const additions = [];
if (source.type === "sqlserver") {
if (source.instanceName && !hasParam("instanceName")) {
additions.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
}
if (source.authentication && !hasParam("authentication")) {
additions.push(`authentication=${encodeURIComponent(source.authentication)}`);
}
if (source.domain && !hasParam("domain")) {
additions.push(`domain=${encodeURIComponent(source.domain)}`);
}
}
if (source.sslmode && !hasParam("sslmode")) {
additions.push(`sslmode=${source.sslmode}`);
}
additions.push(...postgresSslFileParams(source).filter(({ key }) => !hasParam(key)).map(({ param }) => param));
if (additions.length === 0) {
return dsn;
}
let separator;
if (!dsn.includes("?")) {
separator = "?";
} else if (dsn.endsWith("?") || dsn.endsWith("&")) {
separator = "";
} else {
separator = "&";
}
return `${dsn}${separator}${additions.join("&")}`;
}
function postgresSslFileParams(source) {
if (source.type !== "postgres") {
return [];
}
const params = [];
const push = (key) => {
params.push({ key, param: `${key}=${encodeURIComponent(expandHomeDir(source[key]))}` });
};
if (source.sslrootcert && (source.sslmode === "verify-ca" || source.sslmode === "verify-full")) {
push("sslrootcert");
}
if (source.sslcert && source.sslkey && CLIENT_CERT_SSL_MODES.includes(source.sslmode ?? "")) {
push("sslcert");
push("sslkey");
}
return params;
}
function buildDSNFromSource(source) {
if (source.dsn) {
return mergeSourceFieldsIntoDSN(source.dsn, source);
}
if (!source.type) {
throw new Error(
`Source '${source.id}': 'type' field is required when 'dsn' is not provided`
);
}
if (source.type === "sqlite") {
if (!source.database) {
throw new Error(
`Source '${source.id}': 'database' field is required for SQLite`
);
}
return buildSqliteDSN(source.database);
}
const isAwsIamPasswordless = source.aws_iam_auth === true && ["postgres", "mysql", "mariadb"].includes(source.type);
const passwordRequired = source.authentication !== "azure-active-directory-access-token" && !isAwsIamPasswordless;
if (!source.host || !source.user || !source.database) {
throw new Error(
`Source '${source.id}': missing required connection parameters. Required: type, host, user, database`
);
}
if (passwordRequired && !source.password) {
throw new Error(
`Source '${source.id}': password is required. (Password is optional for azure-active-directory-access-token authentication or when aws_iam_auth=true)`
);
}
const port = source.port || getDefaultPortForType(source.type);
if (!port) {
throw new Error(`Source '${source.id}': unable to determine port`);
}
const encodedUser = encodeURIComponent(source.user);
const encodedPassword = source.password ? encodeURIComponent(source.password) : "";
const encodedDatabase = encodeURIComponent(source.database);
let dsn = `${source.type}://${encodedUser}:${encodedPassword}@${source.host}:${port}/${encodedDatabase}`;
const queryParams = [];
if (source.type === "sqlserver") {
if (source.instanceName) {
queryParams.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
}
if (source.authentication) {
queryParams.push(`authentication=${encodeURIComponent(source.authentication)}`);
}
if (source.domain) {
queryParams.push(`domain=${encodeURIComponent(source.domain)}`);
}
}
if (source.sslmode && source.type !== "sqlite") {
queryParams.push(`sslmode=${source.sslmode}`);
}
queryParams.push(...postgresSslFileParams(source).map(({ param }) => param));
if (queryParams.length > 0) {
dsn += `?${queryParams.join("&")}`;
}
return dsn;
}
// src/utils/aws-rds-signer.ts
async function generateRdsAuthToken(params) {
let Signer;
try {
({ Signer } = await import("@aws-sdk/rds-signer"));
} catch (error) {
if (isDriverNotInstalled(error, "@aws-sdk/rds-signer")) {
throw new Error(
'AWS IAM authentication requires the "@aws-sdk/rds-signer" package. Install it with: pnpm add @aws-sdk/rds-signer'
);
}
throw error;
}
const signerConfig = {
hostname: params.hostname,
port: params.port,
username: params.username,
region: params.region
};
const { fromIni, fromNodeProviderChain } = await import("@aws-sdk/credential-providers");
signerConfig.credentials = params.profile ? fromIni({ profile: params.profile, ignoreCache: true }) : fromNodeProviderChain({ ignoreCache: true });
const signer = new Signer(signerConfig);
return signer.getAuthToken();
}
// src/utils/error-classifier.ts
var TUNNEL_ERROR_MARKER = "__dbhubSSHTunnelError";
var NETWORK_CODES = /* @__PURE__ */ new Set([
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"EHOSTUNREACH",
"ENETUNREACH",
"ECONNRESET"
]);
var NETWORK_CODES_BY_TYPE = {
// NJS-503: "connection to host ... could not be established" (Thin mode)
oracle: /* @__PURE__ */ new Set(["NJS-503"])
};
var AUTH_CODES = {
postgres: ["28P01", "28000"],
mysql: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
mariadb: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
sqlserver: ["ELOGIN"],
// ORA-01017: invalid username/password; ORA-28000: account locked
oracle: ["ORA-01017", "ORA-28000"],
sqlite: []
// no network/auth layer
};
function unreachableMessage(sourceId) {
return `Source '${sourceId}' is unreachable. Verify the database is running and reachable (host, port, network), then retry.`;
}
function authMessage(sourceId) {
return `Authentication failed for source '${sourceId}'. Verify the credentials/access for this source are valid, then retry.`;
}
function tunnelMessage(sourceId) {
return `SSH tunnel for source '${sourceId}' failed to establish. Verify SSH host/credentials and bastion reachability, then retry.`;
}
function classifyConnectionError(error, connectorType, sourceId) {
if (!error || typeof error !== "object") {
return null;
}
const err = error;
if (err[TUNNEL_ERROR_MARKER] === true) {
return { code: "TUNNEL_FAILED", message: tunnelMessage(sourceId) };
}
const code = err.code;
if (typeof code === "string" && (NETWORK_CODES.has(code) || NETWORK_CODES_BY_TYPE[connectorType]?.has(code))) {
return { code: "SOURCE_UNREACHABLE", message: unreachableMessage(sourceId) };
}
const authCodes = AUTH_CODES[connectorType];
const errno = err.errno;
if (typeof code === "string" && authCodes.includes(code) || typeof errno === "number" && authCodes.includes(errno)) {
return { code: "AUTH_FAILED", message: authMessage(sourceId) };
}
return null;
}
// src/connectors/manager.ts
var managerInstance = null;
var AWS_IAM_TOKEN_REFRESH_MS = 14 * 60 * 1e3;
var ConnectorManager = class {
constructor() {
// Maps for multi-source support
this.connectors = /* @__PURE__ */ new Map();
this.sshTunnels = /* @__PURE__ */ new Map();
this.sourceConfigs = /* @__PURE__ */ new Map();
// Store original source configs
this.sourceIds = [];
// Ordered list of source IDs (first is default)
this.iamRefreshTimers = /* @__PURE__ */ new Map();
this.pendingIamRefreshes = /* @__PURE__ */ new Map();
// In-flight refresh per source
this.isDisconnecting = false;
// Lazy connection support
this.lazySources = /* @__PURE__ */ new Map();
// Sources pending lazy connection
this.pendingConnections = /* @__PURE__ */ new Map();
// Prevent race conditions
// A socket timeout does not cancel a credential helper. Share its in-flight
// attempt across retries of the same config, but never across changed profiles.
this.pendingIamTokens = /* @__PURE__ */ new WeakMap();
if (!managerInstance) {
managerInstance = this;
}
}
/**
* Initialize and connect to multiple databases using source configurations
* This is the new multi-source connection method
*/
async connectWithSources(sources) {
if (sources.length === 0) {
throw new Error("No sources provided");
}
const eagerSources = sources.filter((s) => !s.lazy);
const lazySources = sources.filter((s) => s.lazy);
if (eagerSources.length > 0) {
console.error(`Connecting to ${eagerSources.length} database source(s)...`);
}
for (const source of eagerSources) {
await this.connectSource(source);
}
for (const source of lazySources) {
this.registerLazySource(source);
}
}
/**
* Register a lazy source without establishing connection
* Connection will be established on first use via ensureConnected()
*/
registerLazySource(source) {
const sourceId = source.id;
const dsn = buildDSNFromSource(source);
console.error(` - ${sourceId}: ${redactDSN(dsn)} (lazy, will connect on first use)`);
this.lazySources.set(sourceId, source);
this.sourceConfigs.set(sourceId, source);
this.sourceIds.push(sourceId);
}
/**
* Ensure a source is connected (handles lazy connection on demand)
* Safe to call multiple times - uses promise-based deduplication so concurrent calls share the same connection attempt
*/
async ensureConnected(sourceId) {
const id = sourceId || this.sourceIds[0];
if (this.connectors.has(id)) {
return;
}
const lazySource = this.lazySources.get(id);
if (!lazySource) {
if (sourceId) {
throw new Error(
`Source '${sourceId}' not found. Available sources: ${this.getAvailableSourceIds().join(", ")}`
);
} else {
throw new Error("No sources configured. Call connectWithSources() first.");
}
}
const pending = this.pendingConnections.get(id);
if (pending) {
return pending;
}
const connectionPromise = (async () => {
try {
console.error(`Lazy connecting to source '${id}'...`);
await this.connectSource(lazySource);
this.lazySources.delete(id);
} finally {
this.pendingConnections.delete(id);
}
})();
this.pendingConnections.set(id, connectionPromise);
return connectionPromise;
}
/**
* Static method to ensure a source is connected (for tool handlers)
*/
static async ensureConnected(sourceId) {
if (!managerInstance) {
throw new Error("ConnectorManager not initialized");
}
return managerInstance.ensureConnected(sourceId);
}
/**
* Connect to a single source (helper for connectWithSources)
*/
async connectSource(source) {
const sourceId = source.id;
const config = {};
const dsn = await this.buildConnectionDSN(source, config);
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
let actualDSN = dsn;
let tunnel;
if (source.ssh_host) {
const sshConfigPath = getDefaultSSHConfigPath();
let resolvedSSHConfig = null;
if (looksLikeSSHAlias(source.ssh_host)) {
console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
}
const username = source.ssh_user || resolvedSSHConfig?.username;
const proxyJump = source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump;
let resolvedJumpHosts;
try {
resolvedJumpHosts = proxyJump ? resolveJumpHosts(proxyJump, sshConfigPath) : void 0;
} catch (error) {
if (error && typeof error === "object") {
error[TUNNEL_ERROR_MARKER] = true;
}
throw error;
}
const sshConfig = {
host: resolvedSSHConfig?.host || source.ssh_host,
port: source.ssh_port || resolvedSSHConfig?.port || 22,
username: username || "",
password: source.ssh_password,
privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
privateKeyDiscovered: source.ssh_key ? source.ssh_key_discovered : resolvedSSHConfig?.privateKeyDiscovered,
passphrase: source.ssh_passphrase,
agent: source.ssh_agent,
proxyJump,
resolvedJumpHosts,
keepaliveInterval: source.ssh_keepalive_interval,
keepaliveCountMax: source.ssh_keepalive_count_max
};
if (!username) {
throw new Error(
`Source '${sourceId}': SSH tunnel requires ssh_user (or a matching Host entry in ~/.ssh/config with User)`
);
}
if (!sshConfig.password && !sshConfig.privateKey && !sshConfig.agent && !process.env.SSH_AUTH_SOCK) {
throw new Error(
`Source '${sourceId}': SSH tunnel requires either ssh_password or ssh_key (or a matching Host entry in ~/.ssh/config with IdentityFile, or an SSH agent via ssh_agent or SSH_AUTH_SOCK)`
);
}
const url = new URL(dsn);
const targetHost = url.hostname;
const targetPort = parseInt(url.port) || this.getDefaultPort(dsn);
tunnel = new SSHTunnel();
let tunnelInfo;
try {
tunnelInfo = await tunnel.establish(sshConfig, {
targetHost,
targetPort
});
} catch (error) {
if (error && typeof error === "object") {
error[TUNNEL_ERROR_MARKER] = true;
}
throw error;
}
url.hostname = "127.0.0.1";
url.port = tunnelInfo.localPort.toString();
actualDSN = url.toString();
this.sshTunnels.set(sourceId, tunnel);
console.error(
` SSH tunnel established through localhost:${tunnelInfo.localPort}`
);
}
let connector;
try {
const connectorPrototype = ConnectorRegistry.getConnectorForDSN(actualDSN);
if (!connectorPrototype) {
throw new Error(
`Source '${sourceId}': No connector found for DSN: ${actualDSN}`
);
}
connector = connectorPrototype.clone();
connector.sourceId = sourceId;
if (source.connection_timeout !== void 0) {
config.connectionTimeoutSeconds = source.connection_timeout;
}
if (source.query_timeout !== void 0 && connector.id !== "sqlite") {
config.queryTimeoutSeconds = source.query_timeout;
}
if (source.pool_max_connections !== void 0) {
config.poolMaxConnections = source.pool_max_connections;
}
if (source.search_path) {
config.searchPath = source.search_path;
}
if (source.timezone) {
config.timezone = source.timezone;
}
if (source.charset) {
config.charset = source.charset;
}
if (source.collation) {
config.collation = source.collation;
}
await connector.connect(actualDSN, source.init_script, config);
} catch (error) {
if (tunnel) {
this.sshTunnels.delete(sourceId);
try {
await tunnel.close();
} catch (closeError) {
console.error(`Error closing SSH tunnel for source '${sourceId}':`, closeError);
}
}
throw error;
}
this.connectors.set(sourceId, connector);
if (!this.sourceIds.includes(sourceId)) {
this.sourceIds.push(sourceId);
}
this.sourceConfigs.set(sourceId, source);
this.scheduleIamRefresh(source);
}
/**
* Add a single source without touching the others. Eager sources connect now;
* lazy ones are registered and connect on first use. Used by the TOML hot reload
* to apply only the entries that changed.
*/
async addSource(source) {
if (this.sourceIds.includes(source.id)) {
throw new Error(`Source '${source.id}' already exists`);
}
if (source.lazy) {
this.registerLazySource(source);
} else {
await this.connectSource(source);
}
}
/**
* Disconnect and forget a single source, leaving every other source's pool and
* tunnel untouched. Resolves silently for an unknown id.
*/
async removeSource(sourceId) {
const pending = this.pendingConnections.get(sourceId);
if (pending) {
try {
await pending;
} catch {
}
}
const refresh = this.pendingIamRefreshes.get(sourceId);
if (refresh) {
await refresh;
}
const timer = this.iamRefreshTimers.get(sourceId);
if (timer) {
clearTimeout(timer);
this.iamRefreshTimers.delete(sourceId);
}
const connector = this.connectors.get(sourceId);
this.connectors.delete(sourceId);
if (connector) {
try {
await connector.disconnect();
console.error(`Disconnected from source '${sourceId}'`);
} catch (error) {
console.error(`Error disconnecting from source '${sourceId}':`, error);
}
}
const tunnel = this.sshTunnels.get(sourceId);
this.sshTunnels.delete(sourceId);
if (tunnel) {
try {
await tunnel.close();
} catch (error) {
console.error(`Error closing SSH tunnel for source '${sourceId}':`, error);
}
}
this.sourceConfigs.delete(sourceId);
this.lazySources.delete(sourceId);
this.pendingConnections.delete(sourceId);
this.sourceIds = this.sourceIds.filter((id) => id !== sourceId);
}
/**
* Reorder known sources to match `orderedIds` (the first entry is the default
* source). Unknown ids are ignored; known ids missing from the list keep their
* relative order after the listed ones.
*/
reorderSources(orderedIds) {
const known = new Set(this.sourceIds);
const ordered = orderedIds.filter((id) => known.has(id));
const listed = new Set(ordered);
this.sourceIds = [...ordered, ...this.sourceIds.filter((id) => !listed.has(id))];
}
/**
* Close all database connections
*/
async disconnect() {
this.isDisconnecting = true;
for (const timer of this.iamRefreshTimers.values()) {
clearTimeout(timer);
}
this.iamRefreshTimers.clear();
for (const [sourceId, connector] of this.connectors.entries()) {
try {
await connector.disconnect();
console.error(`Disconnected from source '${sourceId || "(default)"}'`);
} catch (error) {
console.error(`Error disconnecting from source '${sourceId}':`, error);
}
}
for (const [sourceId, tunnel] of this.sshTunnels.entries()) {
try {
await tunnel.close();
} catch (error) {
console.error(`Error closing SSH tunnel for source '${sourceId}':`, error);
}
}
this.connectors.clear();
this.sshTunnels.clear();
this.sourceConfigs.clear();
this.lazySources.clear();
this.pendingConnections.clear();
this.pendingIamRefreshes.clear();
this.sourceIds = [];
this.isDisconnecting = false;
}
/**
* Get a connector by source ID
* If sourceId is not provided, returns the default (first) connector
*/
getConnector(sourceId) {
const id = sourceId || this.sourceIds[0];
const connector = this.connectors.get(id);
if (!connector) {
if (sourceId) {
throw new Error(
`Source '${sourceId}' not found. Available sources: ${this.getAvailableSourceIds().join(", ")}`
);
} else {
throw new Error("No sources connected. Call connectWithSources() first.");
}
}
return connector;
}
/**
* Get all available connector types
*/
static getAvailableConnectors() {
return ConnectorRegistry.getAvailableConnectors();
}
/**
* Get sample DSNs for all available connectors
*/
static getAllSampleDSNs() {
return ConnectorRegistry.getAllSampleDSNs();
}
/**
* Get the current active connector instance
* This is used by resource and tool handlers
* @param sourceId - Optional source ID. If not provided, returns default (first) connector
*/
static getCurrentConnector(sourceId) {
if (!managerInstance) {
throw new Error("ConnectorManager not initialized");
}
return managerInstance.getConnector(sourceId);
}
/**
* Get all available source IDs
*/
getSourceIds() {
return [...this.sourceIds];
}
/**
* Source IDs that can actually serve a request: connected, or registered for
* lazy (re)connection on first use. Used for error messages so a source that
* is neither is not reported as available.
*/
getAvailableSourceIds() {
return this.sourceIds.filter((id) => this.connectors.has(id) || this.lazySources.has(id));
}
/** Get all available source IDs */
static getAvailableSourceIds() {
if (!managerInstance) {
throw new Error("ConnectorManager not initialized");
}
return managerInstance.getSourceIds();
}
/**
* Get source configuration by ID
* @param sourceId - Source ID. If not provided, returns default (first) source config
*/
getSourceConfig(sourceId) {
if (this.sourceIds.length === 0) {
return null;
}
const id = sourceId || this.sourceIds[0];
return this.sourceConfigs.get(id) || null;
}
/**
* Get all source configurations
*/
getAllSourceConfigs() {
return this.sourceIds.map((id) => this.sourceConfigs.get(id));
}
/**
* Get source configuration by ID (static method for external access)
*/
static getSourceConfig(sourceId) {
if (!managerInstance) {
throw new Error("ConnectorManager not initialized");
}
return managerInstance.getSourceConfig(sourceId);
}
/**
* Get all source configurations (static method for external access)
*/
static getAllSourceConfigs() {
if (!managerInstance) {
throw new Error("ConnectorManager not initialized");
}
return managerInstance.getAllSourceConfigs();
}
/**
* Get default port for a database based on DSN protocol
*/
getDefaultPort(dsn) {
const type = getDatabaseTypeFromDSN(dsn);
if (!type) {
return 0;
}
return getDefaultPortForType(type) ?? 0;
}
scheduleIamRefresh(source) {
if (this.isDisconnecting) {
return;
}
const sourceId = source.id;
const existingTimer = this.iamRefreshTimers.get(sourceId);
if (existingTimer) {
clearTimeout(existingTimer);
this.iamRefreshTimers.delete(sourceId);
}
if (!source.aws_iam_auth || source.type === "postgres") {
return;
}
const timer = setTimeout(() => {
if (this.isDisconnecting) {
return;
}
const run = (async () => {
try {
await this.refreshIamSourceConnection(source);
} catch (error) {
console.error(
`Error refreshing AWS IAM auth token for source '${sourceId}':`,
error
);
} finally {
this.pendingIamRefreshes.delete(sourceId);
if (!this.isDisconnecting && this.ownsSource(source) && this.connectors.has(sourceId)) {
this.scheduleIamRefresh(source);
}
}
})();
this.pendingIamRefreshes.set(sourceId, run);
}, AWS_IAM_TOKEN_REFRESH_MS);
timer.unref?.();
this.iamRefreshTimers.set(sourceId, timer);
}
async refreshIamSourceConnection(source) {
const sourceId = source.id;
if (this.isDisconnecting || !source.aws_iam_auth || !this.connectors.has(sourceId)) {
return;
}
console.error(`Refreshing AWS IAM auth connection for source '${sourceId}'...`);
const existingConnector = this.connectors.get(sourceId);
if (existingConnector) {
await existingConnector.disconnect();
this.connectors.delete(sourceId);
}
const existingTunnel = this.sshTunnels.get(sourceId);
if (existingTunnel) {
await existingTunnel.close();
this.sshTunnels.delete(sourceId);
}
if (this.isDisconnecting || !this.ownsSource(source)) {
return;
}
try {
await this.connectSource(source);
} catch (error) {
if (!this.isDisconnecting && this.ownsSource(source)) {
this.lazySources.set(sourceId, source);
}
throw error;
}
}
/**
* True while `source` is the config object registered under its id. Every
* registration path stores the same object, so identity tells an in-flight
* operation whether its source was removed or replaced underneath it.
*/
ownsSource(source) {
return this.sourceConfigs.get(source.id) === source;
}
/**
* Build a connection DSN, optionally replacing password with
* an AWS RDS IAM auth token when aws_iam_auth is enabled.
*/
async buildConnectionDSN(source, config = {}) {
const dsn = buildDSNFromSource(source);
if (!source.aws_iam_auth) {
return dsn;
}
const supportedIamTypes = ["postgres", "mysql", "mariadb"];
if (!source.type || !supportedIamTypes.includes(source.type)) {
throw new Error(
`Source '${source.id}': aws_iam_auth is only supported for postgres, mysql, and mariadb`
);
}
if (!source.aws_region) {
throw new Error(
`Source '${source.id}': aws_region is required when aws_iam_auth is enabled`
);
}
const parsed = new SafeURL(dsn);
const hostname = parsed.hostname;
const username = source.user || parsed.username;
const defaultPort = getDefaultPortForType(source.type);
const port = parsed.port ? parseInt(parsed.port) : defaultPort;
if (!hostname || !username || !port) {
throw new Error(
`Source '${source.id}': unable to resolve host, username, or port for AWS IAM authentication`
);
}
const password = () => {
let pending = this.pendingIamTokens.get(source);
if (!pending) {
pending = generateRdsAuthToken({
hostname,
port,
username,
region: source.aws_region,
profile: source.aws_profile
}).finally(() => {
this.pendingIamTokens.delete(source);
});
this.pendingIamTokens.set(source, pending);
}
return pending;
};
if (source.type === "postgres") {
config.password = password;
}
const token = source.type === "postgres" ? "" : await password();
const queryParams = new Map(parsed.searchParams);
const currentSslMode = queryParams.get("sslmode");
if (currentSslMode !== "verify-ca" && currentSslMode !== "verify-full") {
queryParams.set("sslmode", "require");
}
const protocol = parsed.protocol.endsWith(":") ? parsed.protocol.slice(0, -1) : parsed.protocol;
const encodedUser = encodeURIComponent(username);
const encodedToken = encodeURIComponent(token);
const path3 = parsed.pathname || "/";
const query = Array.from(queryParams.entries()).map(
([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
).join("&");
return `${protocol}://${encodedUser}:${encodedToken}@${hostname}:${port}${path3}${query ? `?${query}` : ""}`;
}
};
// src/utils/parameter-mapper.ts
var PARAMETER_STYLES = {
postgres: "numbered",
// $1, $2, $3
mysql: "positional",
// ?, ?, ?
mariadb: "positional",
// ?, ?, ?
sqlserver: "named",
// @p1, @p2, @p3
sqlite: "positional",
// ?, ?, ?
oracle: "colon"
// :1, :2, :3
};
function detectParameterStyle(statement, connectorType) {
const cleanedSQL = stripCommentsAndStrings(statement, connectorType);
if (/\$\d+/.test(cleanedSQL)) {
return "numbered";
}
if (/@p\d+/.test(cleanedSQL)) {
return "named";
}
if (/(?<!:):\d+/.test(cleanedSQL)) {
return "colon";
}
if (/\?/.test(cleanedSQL)) {
return "positional";
}
return "none";
}
function validateParameterStyle(statement, connectorType) {
const detectedStyle = detectParameterStyle(statement, connectorType);
const expectedStyle = PARAMETER_STYLES[connectorType];
if (detectedStyle === "none") {
return;
}
if (detectedStyle !== expectedStyle) {
const examples = {
numbered: "$1, $2, $3",
positional: "?, ?, ?",
named: "@p1, @p2, @p3",
colon: ":1, :2, :3"
};
throw new Error(
`Invalid parameter syntax for ${connectorType}. Expected ${expectedStyle} style (${examples[expectedStyle]}), but found ${detectedStyle} style in statement.`
);
}
}
function countParameters(statement, connectorType) {
const style = detectParameterStyle(statement, connectorType);
const cleanedSQL = stripCommentsAndStrings(statement, connectorType);
switch (style) {
case "numbered":
return countIndexedParameters(cleanedSQL, /\$\d+/g, "$", "numbered");
case "named":
return countIndexedParameters(cleanedSQL, /@p\d+/g, "@p", "named");
case "colon":
return countIndexedParameters(cleanedSQL, /(?<!:):\d+/g, ":", "colon-numbered");
case "positional":
return (cleanedSQL.match(/\?/g) || []).length;
default:
return 0;
}
}
function countIndexedParameters(cleanedSQL, pattern, sigil, styleName) {
const matches = cleanedSQL.match(pattern);
if (!matches) return 0;
const indices = new Set(matches.map((m) => parseInt(m.slice(sigil.length), 10)));
if (indices.has(0)) {
throw new Error(
`Invalid ${styleName} parameter ${sigil}0. Parameters must be sequential starting from ${sigil}1.`
);
}
const maxIndex = Math.max(...indices);
for (let i = 1; i <= maxIndex; i++) {
if (!indices.has(i)) {
const found = Array.from(indices).sort((a, b) => a - b).map((n) => `${sigil}${n}`).join(", ");
throw new Error(
`Non-sequential ${styleName} parameters detected. Found placeholders: ${found}. Parameters must be sequential starting from ${sigil}1 (missing ${sigil}${i}).`
);
}
}
return maxIndex;
}
function validateParameters(statement, parameters, connectorType) {
validateParameterStyle(statement, connectorType);
const paramCount = countParameters(statement, connectorType);
const definedCount = parameters?.length || 0;
if (paramCount !== definedCount) {
throw new Error(
`Parameter count mismatch: SQL statement has ${paramCount} parameter(s), but ${definedCount} parameter(s) defined in tool configuration.`
);
}
}
function mapArgumentsToArray(parameters, args) {
if (!parameters || parameters.length === 0) {
return [];
}
return parameters.map((param) => {
const value = args[param.name];
if (value !== void 0) {
return value;
}
if (param.default !== void 0) {
return param.default;
}
if (param.required !== false) {
throw new Error(
`Required parameter '${param.name}' is missing and has no default value.`
);
}
return null;
});
}
// src/tools/registry.ts
var ToolRegistry = class {
constructor(config) {
this.toolsBySource = this.buildRegistry(config);
}
/**
* Check if a tool name is a built-in tool
*/
isBuiltinTool(toolName) {
return ALL_BUILTIN_TOOL_NAMES.includes(toolName);
}
/**
* Validate a custom tool parameter definition
*/
validateParameter(toolName, param) {
if (!param.name || param.name.trim() === "") {
throw new Error(`Tool '${toolName}' has parameter missing 'name' field`);
}
if (!param.type) {
throw new Error(
`Tool '${toolName}', parameter '${param.name}' missing 'type' field`
);
}
const validTypes = ["string", "integer", "float", "boolean", "array"];
if (!validTypes.includes(param.type)) {
throw new Error(
`Tool '${toolName}', parameter '${param.name}' has invalid type '${param.type}'. Valid types: ${validTypes.join(", ")}`
);
}
if (!param.description || param.description.trim() === "") {
throw new Error(
`Tool '${toolName}', parameter '${param.name}' missing 'description' field`
);
}
if (param.allowed_values) {
if (!Array.isArray(param.allowed_values)) {
throw new Error(
`Tool '${toolName}', parameter '${param.name}': allowed_values must be an array`
);
}
if (param.allowed_values.length === 0) {
throw new Error(
`Tool '${toolName}', parameter '${param.name}': allowed_values cannot be empty`
);
}
}
if (param.default !== void 0 && param.allowed_values) {
if (!param.allowed_values.includes(param.default)) {
throw new Error(
`Tool '${toolName}', parameter '${param.name}': default value '${param.default}' is not in allowed_values: ${param.allowed_values.join(", ")}`
);
}
}
}
/**
* Validate a custom tool configuration
*/
validateCustomTool(toolConfig, availableSources) {
if (!toolConfig.name || toolConfig.name.trim() === "") {
throw new Error("Tool definition missing required field: name");
}
if (!toolConfig.description || toolConfig.description.trim() === "") {
throw new Error(
`Tool '${toolConfig.name}' missing required field: description`
);
}
if (!toolConfig.source || toolConfig.source.trim() === "") {
throw new Error(
`Tool '${toolConfig.name}' missing required field: source`
);
}
if (!toolConfig.statement || toolConfig.statement.trim() === "") {
throw new Error(
`Tool '${toolConfig.name}' missing required field: statement`
);
}
if (!availableSources.includes(toolConfig.source)) {
throw new Error(
`Tool '${toolConfig.name}' references unknown source '${toolConfig.source}'. Available sources: ${availableSources.join(", ")}`
);
}
for (const builtinName of ALL_BUILTIN_TOOL_NAMES) {
if (toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`)) {
throw new Error(
`Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${ALL_BUILTIN_TOOL_NAMES.join(", ")}`
);
}
}
const sourceConfig = ConnectorManager.getSourceConfig(toolConfig.source);
const connectorType = sourceConfig.type;
try {
validateParameters(
toolConfig.statement,
toolConfig.parameters,
connectorType
);
} catch (error) {
throw new Error(
`Tool '${toolConfig.name}' validation failed: ${error.message}`
);
}
if (toolConfig.parameters) {
for (const param of toolConfig.parameters) {
this.validateParameter(toolConfig.name, param);
}
}
}
/**
* Build the internal registry mapping sources to their enabled tools
*/
buildRegistry(config) {
const registry = /* @__PURE__ */ new Map();
const availableSources = config.sources.map((s) => s.id);
const customToolNames = /* @__PURE__ */ new Set();
for (const tool of config.tools || []) {
if (!this.isBuiltinTool(tool.name)) {
this.validateCustomTool(tool, availableSources);
if (customToolNames.has(tool.name)) {
throw new Error(
`Duplicate tool name '${tool.name}'. Tool names must be unique.`
);
}
customToolNames.add(tool.name);
}
const existing = registry.get(tool.source) || [];
existing.push(tool);
registry.set(tool.source, existing);
}
for (const source of config.sources) {
if (!registry.has(source.id)) {
const defaultTools = BUILTIN_TOOLS.map((name) => {
if (name === "execute_sql") {
return { name: "execute_sql", source: source.id };
} else {
return { name: "search_objects", source: source.id };
}
});
registry.set(source.id, defaultTools);
}
}
return registry;
}
/**
* Get all enabled tool configs for a specific source
*/
getEnabledToolConfigs(sourceId) {
return this.toolsBySource.get(sourceId) || [];
}
/**
* Get built-in tool configuration for a specific source
* Returns undefined if tool is not enabled or not a built-in
*/
getBuiltinToolConfig(toolName, sourceId) {
if (!this.isBuiltinTool(toolName)) {
return void 0;
}
const tools = this.getEnabledToolConfigs(sourceId);
return tools.find((t) => t.name === toolName);
}
/**
* Get all unique tools across all sources (for tools/list response)
* Returns the union of all enabled tools
*/
getAllTools() {
const seen = /* @__PURE__ */ new Set();
const result = [];
for (const tools of this.toolsBySource.values()) {
for (const tool of tools) {
if (!seen.has(tool.name)) {
seen.add(tool.name);
result.push(tool);
}
}
}
return result;
}
/**
* Get all custom tools (non-builtin) across all sources
*/
getCustomTools() {
return this.getAllTools().filter((tool) => !this.isBuiltinTool(tool.name));
}
/**
* Get all built-in tool names that are enabled across any source
*/
getEnabledBuiltinToolNames() {
const enabledBuiltins = /* @__PURE__ */ new Set();
for (const tools of this.toolsBySource.values()) {
for (const tool of tools) {
if (this.isBuiltinTool(tool.name)) {
enabledBuiltins.add(tool.name);
}
}
}
return Array.from(enabledBuiltins);
}
};
var globalRegistry = null;
function initializeToolRegistry(config) {
globalRegistry = new ToolRegistry(config);
}
function getToolRegistry() {
if (!globalRegistry) {
throw new Error(
"Tool registry not initialized. Call initializeToolRegistry first."
);
}
return globalRegistry;
}
export {
isDemoMode,
resolveTransport,
resolvePort,
resolveHost,
resolveAllowedHosts,
resolveAuthTokens,
resolveSourceConfigs,
BUILTIN_TOOL_EXECUTE_SQL,
BUILTIN_TOOL_SEARCH_OBJECTS,
BUILTIN_TOOL_EXPLAIN_SQL,
BUILTIN_TOOL_HEALTH_CHECK,
loadTomlConfig,
resolveTomlConfigPath,
classifyConnectionError,
ConnectorManager,
mapArgumentsToArray,
ToolRegistry,
initializeToolRegistry,
getToolRegistry
};