diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index d4859cedf8..33b4374558 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -149,6 +149,10 @@ async function getMysqlPool(config: ConnectionConfig): Promise layer.type === "ssh" && layer.enabled !== false && !!layer.host) ?? false; +} + function firstProxyLayer(config: ConnectionConfig): ProxyLayer | undefined { return config.transport_layers?.find((layer): layer is ProxyLayer => layer.type === "proxy" && layer.enabled !== false && !!layer.host); } @@ -502,6 +506,17 @@ async function rqliteRequest(config: ConnectionConfig, endpoint: "/db/query" | " } export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise { + if (hasActiveSshLayer(config)) { + const result = await withTimeout( + bridgeDataRequest("/data/execute-query", { + connection_name: config.name, + database: config.database || "", + sql, + }), + resolveTimeoutMs(options), + ); + return convertBridgeQueryResult(result, options); + } if (config.db_type === "mongodb") { const find = parseMongoFindCommand(sql); if (find) { @@ -561,7 +576,7 @@ export async function listTables(config: ConnectionConfig, schema?: string): Pro const result = await query(config, `SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`); return result.rows.map((r) => ({ name: String(r.name || ""), type: String(r.type || "table") })); } - if (!isDirectQueryType(config.db_type)) { + if (hasActiveSshLayer(config) || !isDirectQueryType(config.db_type)) { const tables = await bridgeDataRequest("/data/list-tables", { connection_name: config.name, database: config.database || "", @@ -594,7 +609,7 @@ export async function describeTable(config: ConnectionConfig, table: string, sch comment: null, })); } - if (!isDirectQueryType(config.db_type)) { + if (hasActiveSshLayer(config) || !isDirectQueryType(config.db_type)) { const columns = await bridgeDataRequest("/data/describe-table", { connection_name: config.name, database: config.database || "", diff --git a/packages/node-core/tests/sqlite-direct.test.ts b/packages/node-core/tests/sqlite-direct.test.ts index 3de74043a0..a9a7967732 100644 --- a/packages/node-core/tests/sqlite-direct.test.ts +++ b/packages/node-core/tests/sqlite-direct.test.ts @@ -21,6 +21,30 @@ function sqliteConfig(path: string): ConnectionConfig { }; } +function mysqlSshConfig(): ConnectionConfig { + return { + id: "mysql-ssh-test", + name: "mysql-over-ssh", + db_type: "mysql", + host: "10.0.0.10", + port: 3306, + username: "root", + password: "secret", + ssl: false, + transport_layers: [ + { + type: "ssh", + id: "bastion", + enabled: true, + host: "bastion.example.com", + port: 22, + user: "root", + password: "ssh-secret", + }, + ], + }; +} + test("queries SQLite connections without the DBX bridge", async () => { const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-")); const path = join(dir, "app.db"); @@ -85,3 +109,19 @@ test("lists and describes SQLite tables without the DBX bridge", async () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("routes SSH transport layer connections through the DBX bridge", async () => { + const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-bridge-home-")); + const originalHome = process.env.HOME; + process.env.HOME = dir; + + try { + await assert.rejects(() => executeQuery(mysqlSshConfig(), "select 1"), /DBX desktop app is not running/); + await assert.rejects(() => listTables(mysqlSshConfig()), /DBX desktop app is not running/); + await assert.rejects(() => describeTable(mysqlSshConfig(), "users"), /DBX desktop app is not running/); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(dir, { recursive: true, force: true }); + } +});