@bytebase/dbhub 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,12 +32,11 @@
32
32
 
33
33
  DBHub is a Minimal Database MCP Server implementing the Model Context Protocol (MCP) server interface. This lightweight gateway allows MCP-compatible clients to connect to and explore different databases:
34
34
 
35
- - **Minimal Design**: Just two general MCP tools (execute_sql, search_objects) for token-efficient operations, plus support for custom tools
36
- - **Multi-Database Support**: Single interface for PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite
35
+ - **Token Efficient**: Just two general MCP tools (execute_sql, search_objects) to minimize context window usage, plus support for custom tools
36
+ - **Multi-Database**: Single interface for PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite
37
37
  - **Secure Access**: Read-only mode, SSH tunneling, and SSL/TLS encryption support
38
38
  - **Multiple Connections**: Connect to multiple databases simultaneously with TOML configuration
39
39
  - **Production-Ready**: Row limiting, lock timeout control, and connection pooling
40
- - **MCP Native**: Full implementation of Model Context Protocol with comprehensive tools
41
40
 
42
41
  ## Supported Databases
43
42
 
@@ -72,13 +71,13 @@ docker run --rm --init \
72
71
  **NPM:**
73
72
 
74
73
  ```bash
75
- npx @bytebase/dbhub --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
74
+ npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
76
75
  ```
77
76
 
78
77
  **Demo Mode:**
79
78
 
80
79
  ```bash
81
- npx @bytebase/dbhub --transport http --port 8080 --demo
80
+ npx @bytebase/dbhub@latest --transport http --port 8080 --demo
82
81
  ```
83
82
 
84
83
  See [Server Options](https://dbhub.ai/config/server-options) for all available parameters.
@@ -91,31 +90,18 @@ See [Multi-Database Configuration](https://dbhub.ai/config/multi-database) for c
91
90
 
92
91
  ## Development
93
92
 
94
- 1. Install dependencies:
95
-
96
- ```bash
97
- pnpm install
98
- ```
99
-
100
- 1. Run in development mode:
101
-
102
- ```bash
103
- pnpm dev
104
- ```
105
-
106
- 1. Build for production:
107
- ```bash
108
- pnpm build
109
- pnpm start --transport stdio --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
110
- ```
111
-
112
- ### Testing
93
+ ```bash
94
+ # Install dependencies
95
+ pnpm install
113
96
 
114
- See [TESTING.md](.claude/skills/testing/SKILL.md).
97
+ # Run in development mode
98
+ pnpm dev
115
99
 
116
- ### Debug
100
+ # Build and run for production
101
+ pnpm build && pnpm start --transport stdio --dsn "postgres://user:password@localhost:5432/dbname"
102
+ ```
117
103
 
118
- See [Debug](https://dbhub.ai/config/debug).
104
+ See [Testing](.claude/skills/testing/SKILL.md) and [Debug](https://dbhub.ai/config/debug).
119
105
 
120
106
  ## Contributors
121
107
 
@@ -62,161 +62,11 @@ var ConnectorRegistry = _ConnectorRegistry;
62
62
 
63
63
  // src/utils/ssh-tunnel.ts
64
64
  import { Client } from "ssh2";
65
- import { readFileSync } from "fs";
65
+ import { readFileSync as readFileSync2 } from "fs";
66
66
  import { createServer } from "net";
67
- var SSHTunnel = class {
68
- constructor() {
69
- this.sshClient = null;
70
- this.localServer = null;
71
- this.tunnelInfo = null;
72
- this.isConnected = false;
73
- }
74
- /**
75
- * Establish an SSH tunnel
76
- * @param config SSH connection configuration
77
- * @param options Tunnel options including target host and port
78
- * @returns Promise resolving to tunnel information including local port
79
- */
80
- async establish(config, options) {
81
- if (this.isConnected) {
82
- throw new Error("SSH tunnel is already established");
83
- }
84
- return new Promise((resolve, reject) => {
85
- this.sshClient = new Client();
86
- const sshConfig = {
87
- host: config.host,
88
- port: config.port || 22,
89
- username: config.username
90
- };
91
- if (config.password) {
92
- sshConfig.password = config.password;
93
- } else if (config.privateKey) {
94
- try {
95
- const privateKey = readFileSync(config.privateKey);
96
- sshConfig.privateKey = privateKey;
97
- if (config.passphrase) {
98
- sshConfig.passphrase = config.passphrase;
99
- }
100
- } catch (error) {
101
- reject(new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`));
102
- return;
103
- }
104
- } else {
105
- reject(new Error("Either password or privateKey must be provided for SSH authentication"));
106
- return;
107
- }
108
- this.sshClient.on("error", (err) => {
109
- this.cleanup();
110
- reject(new Error(`SSH connection error: ${err.message}`));
111
- });
112
- this.sshClient.on("ready", () => {
113
- console.error("SSH connection established");
114
- this.localServer = createServer((localSocket) => {
115
- this.sshClient.forwardOut(
116
- "127.0.0.1",
117
- 0,
118
- options.targetHost,
119
- options.targetPort,
120
- (err, stream) => {
121
- if (err) {
122
- console.error("SSH forward error:", err);
123
- localSocket.end();
124
- return;
125
- }
126
- localSocket.pipe(stream).pipe(localSocket);
127
- stream.on("error", (err2) => {
128
- console.error("SSH stream error:", err2);
129
- localSocket.end();
130
- });
131
- localSocket.on("error", (err2) => {
132
- console.error("Local socket error:", err2);
133
- stream.end();
134
- });
135
- }
136
- );
137
- });
138
- const localPort = options.localPort || 0;
139
- this.localServer.listen(localPort, "127.0.0.1", () => {
140
- const address = this.localServer.address();
141
- if (!address || typeof address === "string") {
142
- this.cleanup();
143
- reject(new Error("Failed to get local server address"));
144
- return;
145
- }
146
- this.tunnelInfo = {
147
- localPort: address.port,
148
- targetHost: options.targetHost,
149
- targetPort: options.targetPort
150
- };
151
- this.isConnected = true;
152
- console.error(`SSH tunnel established: localhost:${address.port} -> ${options.targetHost}:${options.targetPort}`);
153
- resolve(this.tunnelInfo);
154
- });
155
- this.localServer.on("error", (err) => {
156
- this.cleanup();
157
- reject(new Error(`Local server error: ${err.message}`));
158
- });
159
- });
160
- this.sshClient.connect(sshConfig);
161
- });
162
- }
163
- /**
164
- * Close the SSH tunnel and clean up resources
165
- */
166
- async close() {
167
- if (!this.isConnected) {
168
- return;
169
- }
170
- return new Promise((resolve) => {
171
- this.cleanup();
172
- this.isConnected = false;
173
- console.error("SSH tunnel closed");
174
- resolve();
175
- });
176
- }
177
- /**
178
- * Clean up resources
179
- */
180
- cleanup() {
181
- if (this.localServer) {
182
- this.localServer.close();
183
- this.localServer = null;
184
- }
185
- if (this.sshClient) {
186
- this.sshClient.end();
187
- this.sshClient = null;
188
- }
189
- this.tunnelInfo = null;
190
- }
191
- /**
192
- * Get current tunnel information
193
- */
194
- getTunnelInfo() {
195
- return this.tunnelInfo;
196
- }
197
- /**
198
- * Check if tunnel is connected
199
- */
200
- getIsConnected() {
201
- return this.isConnected;
202
- }
203
- };
204
-
205
- // src/config/toml-loader.ts
206
- import fs2 from "fs";
207
- import path2 from "path";
208
- import { homedir as homedir3 } from "os";
209
- import toml from "@iarna/toml";
210
-
211
- // src/config/env.ts
212
- import dotenv from "dotenv";
213
- import path from "path";
214
- import fs from "fs";
215
- import { fileURLToPath } from "url";
216
- import { homedir as homedir2 } from "os";
217
67
 
218
68
  // src/utils/ssh-config-parser.ts
219
- import { readFileSync as readFileSync2, existsSync } from "fs";
69
+ import { readFileSync, realpathSync, statSync } from "fs";
220
70
  import { homedir } from "os";
221
71
  import { join } from "path";
222
72
  import SSHConfig from "ssh-config";
@@ -232,28 +82,38 @@ function expandTilde(filePath) {
232
82
  }
233
83
  return filePath;
234
84
  }
235
- function fileExists(filePath) {
85
+ function resolveSymlink(filePath) {
86
+ const expandedPath = expandTilde(filePath);
236
87
  try {
237
- return existsSync(expandTilde(filePath));
88
+ return realpathSync(expandedPath);
89
+ } catch {
90
+ return expandedPath;
91
+ }
92
+ }
93
+ function isFile(filePath) {
94
+ try {
95
+ const stat = statSync(filePath);
96
+ return stat.isFile();
238
97
  } catch {
239
98
  return false;
240
99
  }
241
100
  }
242
101
  function findDefaultSSHKey() {
243
102
  for (const keyPath of DEFAULT_SSH_KEYS) {
244
- if (fileExists(keyPath)) {
245
- return expandTilde(keyPath);
103
+ const resolvedPath = resolveSymlink(keyPath);
104
+ if (isFile(resolvedPath)) {
105
+ return resolvedPath;
246
106
  }
247
107
  }
248
108
  return void 0;
249
109
  }
250
110
  function parseSSHConfig(hostAlias, configPath) {
251
- const sshConfigPath = configPath;
252
- if (!existsSync(sshConfigPath)) {
111
+ const sshConfigPath = resolveSymlink(configPath);
112
+ if (!isFile(sshConfigPath)) {
253
113
  return null;
254
114
  }
255
115
  try {
256
- const configContent = readFileSync2(sshConfigPath, "utf8");
116
+ const configContent = readFileSync(sshConfigPath, "utf8");
257
117
  const config = SSHConfig.parse(configContent);
258
118
  const hostConfig = config.compute(hostAlias);
259
119
  if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
@@ -273,9 +133,9 @@ function parseSSHConfig(hostAlias, configPath) {
273
133
  }
274
134
  if (hostConfig.IdentityFile) {
275
135
  const identityFile = Array.isArray(hostConfig.IdentityFile) ? hostConfig.IdentityFile[0] : hostConfig.IdentityFile;
276
- const expandedPath = expandTilde(identityFile);
277
- if (fileExists(expandedPath)) {
278
- sshConfig.privateKey = expandedPath;
136
+ const resolvedPath = resolveSymlink(identityFile);
137
+ if (isFile(resolvedPath)) {
138
+ sshConfig.privateKey = resolvedPath;
279
139
  }
280
140
  }
281
141
  if (!sshConfig.privateKey) {
@@ -284,8 +144,11 @@ function parseSSHConfig(hostAlias, configPath) {
284
144
  sshConfig.privateKey = defaultKey;
285
145
  }
286
146
  }
287
- if (hostConfig.ProxyJump || hostConfig.ProxyCommand) {
288
- console.error("Warning: ProxyJump/ProxyCommand in SSH config is not yet supported by DBHub");
147
+ if (hostConfig.ProxyJump) {
148
+ sshConfig.proxyJump = hostConfig.ProxyJump;
149
+ }
150
+ if (hostConfig.ProxyCommand) {
151
+ console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
289
152
  }
290
153
  if (!sshConfig.host || !sshConfig.username) {
291
154
  return null;
@@ -308,6 +171,332 @@ function looksLikeSSHAlias(host) {
308
171
  }
309
172
  return true;
310
173
  }
174
+ function validatePort(port, jumpHostStr) {
175
+ if (isNaN(port) || port <= 0 || port > 65535) {
176
+ throw new Error(`Invalid port number in "${jumpHostStr}": port must be between 1 and 65535`);
177
+ }
178
+ }
179
+ function parseJumpHost(jumpHostStr) {
180
+ let username;
181
+ let host;
182
+ let port = 22;
183
+ let remaining = jumpHostStr.trim();
184
+ if (!remaining) {
185
+ throw new Error("Jump host string cannot be empty");
186
+ }
187
+ const atIndex = remaining.indexOf("@");
188
+ if (atIndex !== -1) {
189
+ const extractedUsername = remaining.substring(0, atIndex).trim();
190
+ if (extractedUsername) {
191
+ username = extractedUsername;
192
+ }
193
+ remaining = remaining.substring(atIndex + 1);
194
+ }
195
+ if (remaining.startsWith("[")) {
196
+ const closeBracket = remaining.indexOf("]");
197
+ if (closeBracket !== -1) {
198
+ host = remaining.substring(1, closeBracket);
199
+ const afterBracket = remaining.substring(closeBracket + 1);
200
+ if (afterBracket.startsWith(":")) {
201
+ const parsedPort = parseInt(afterBracket.substring(1), 10);
202
+ validatePort(parsedPort, jumpHostStr);
203
+ port = parsedPort;
204
+ }
205
+ } else {
206
+ throw new Error(`Invalid ProxyJump host "${jumpHostStr}": missing closing bracket in IPv6 address`);
207
+ }
208
+ } else {
209
+ const lastColon = remaining.lastIndexOf(":");
210
+ if (lastColon !== -1) {
211
+ const potentialPort = remaining.substring(lastColon + 1);
212
+ if (/^\d+$/.test(potentialPort)) {
213
+ host = remaining.substring(0, lastColon);
214
+ const parsedPort = parseInt(potentialPort, 10);
215
+ validatePort(parsedPort, jumpHostStr);
216
+ port = parsedPort;
217
+ } else {
218
+ host = remaining;
219
+ }
220
+ } else {
221
+ host = remaining;
222
+ }
223
+ }
224
+ if (!host) {
225
+ throw new Error(`Invalid jump host format: "${jumpHostStr}" - host cannot be empty`);
226
+ }
227
+ return { host, port, username };
228
+ }
229
+ function parseJumpHosts(proxyJump) {
230
+ if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
231
+ return [];
232
+ }
233
+ return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
234
+ }
235
+
236
+ // src/utils/ssh-tunnel.ts
237
+ var SSHTunnel = class {
238
+ constructor() {
239
+ this.sshClients = [];
240
+ // All SSH clients in the chain
241
+ this.localServer = null;
242
+ this.tunnelInfo = null;
243
+ this.isConnected = false;
244
+ }
245
+ /**
246
+ * Establish an SSH tunnel, optionally through jump hosts (ProxyJump).
247
+ * @param config SSH connection configuration
248
+ * @param options Tunnel options including target host and port
249
+ * @returns Promise resolving to tunnel information including local port
250
+ */
251
+ async establish(config, options) {
252
+ if (this.isConnected) {
253
+ throw new Error("SSH tunnel is already established");
254
+ }
255
+ this.isConnected = true;
256
+ try {
257
+ const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
258
+ let privateKeyBuffer;
259
+ if (config.privateKey) {
260
+ try {
261
+ const resolvedKeyPath = resolveSymlink(config.privateKey);
262
+ privateKeyBuffer = readFileSync2(resolvedKeyPath);
263
+ } catch (error) {
264
+ throw new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`);
265
+ }
266
+ }
267
+ if (!config.password && !privateKeyBuffer) {
268
+ throw new Error("Either password or privateKey must be provided for SSH authentication");
269
+ }
270
+ const finalClient = await this.establishChain(jumpHosts, config, privateKeyBuffer);
271
+ return await this.createLocalTunnel(finalClient, options);
272
+ } catch (error) {
273
+ this.cleanup();
274
+ throw error;
275
+ }
276
+ }
277
+ /**
278
+ * Establish a chain of SSH connections through jump hosts.
279
+ * @returns The final SSH client connected to the target host
280
+ */
281
+ async establishChain(jumpHosts, targetConfig, privateKey) {
282
+ let previousStream;
283
+ for (let i = 0; i < jumpHosts.length; i++) {
284
+ const jumpHost = jumpHosts[i];
285
+ const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
286
+ let client = null;
287
+ let forwardStream;
288
+ try {
289
+ client = await this.connectToHost(
290
+ {
291
+ host: jumpHost.host,
292
+ port: jumpHost.port,
293
+ username: jumpHost.username || targetConfig.username
294
+ },
295
+ targetConfig.password,
296
+ privateKey,
297
+ targetConfig.passphrase,
298
+ previousStream,
299
+ `jump host ${i + 1}`
300
+ );
301
+ console.error(` \u2192 Forwarding through ${jumpHost.host}:${jumpHost.port} to ${nextHost.host}:${nextHost.port}`);
302
+ forwardStream = await this.forwardTo(client, nextHost.host, nextHost.port);
303
+ } catch (error) {
304
+ if (client) {
305
+ try {
306
+ client.end();
307
+ } catch {
308
+ }
309
+ }
310
+ throw error;
311
+ }
312
+ this.sshClients.push(client);
313
+ previousStream = forwardStream;
314
+ }
315
+ const finalClient = await this.connectToHost(
316
+ {
317
+ host: targetConfig.host,
318
+ port: targetConfig.port || 22,
319
+ username: targetConfig.username
320
+ },
321
+ targetConfig.password,
322
+ privateKey,
323
+ targetConfig.passphrase,
324
+ previousStream,
325
+ jumpHosts.length > 0 ? "target host" : void 0
326
+ );
327
+ this.sshClients.push(finalClient);
328
+ return finalClient;
329
+ }
330
+ /**
331
+ * Connect to a single SSH host.
332
+ */
333
+ connectToHost(hostInfo, password, privateKey, passphrase, sock, label) {
334
+ return new Promise((resolve, reject) => {
335
+ const client = new Client();
336
+ const sshConfig = {
337
+ host: hostInfo.host,
338
+ port: hostInfo.port,
339
+ username: hostInfo.username
340
+ };
341
+ if (password) {
342
+ sshConfig.password = password;
343
+ }
344
+ if (privateKey) {
345
+ sshConfig.privateKey = privateKey;
346
+ if (passphrase) {
347
+ sshConfig.passphrase = passphrase;
348
+ }
349
+ }
350
+ if (sock) {
351
+ sshConfig.sock = sock;
352
+ }
353
+ const onError = (err) => {
354
+ client.removeListener("ready", onReady);
355
+ client.destroy();
356
+ reject(new Error(`SSH connection error${label ? ` (${label})` : ""}: ${err.message}`));
357
+ };
358
+ const onReady = () => {
359
+ client.removeListener("error", onError);
360
+ const desc = label || `${hostInfo.host}:${hostInfo.port}`;
361
+ console.error(`SSH connection established: ${desc}`);
362
+ resolve(client);
363
+ };
364
+ client.on("error", onError);
365
+ client.on("ready", onReady);
366
+ client.connect(sshConfig);
367
+ });
368
+ }
369
+ /**
370
+ * Forward a connection through an SSH client to a target host.
371
+ */
372
+ forwardTo(client, targetHost, targetPort) {
373
+ return new Promise((resolve, reject) => {
374
+ client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
375
+ if (err) {
376
+ reject(new Error(`SSH forward error: ${err.message}`));
377
+ return;
378
+ }
379
+ resolve(stream);
380
+ });
381
+ });
382
+ }
383
+ /**
384
+ * Create the local server that tunnels connections to the database.
385
+ */
386
+ createLocalTunnel(sshClient, options) {
387
+ return new Promise((resolve, reject) => {
388
+ let settled = false;
389
+ this.localServer = createServer((localSocket) => {
390
+ sshClient.forwardOut(
391
+ "127.0.0.1",
392
+ 0,
393
+ options.targetHost,
394
+ options.targetPort,
395
+ (err, stream) => {
396
+ if (err) {
397
+ console.error("SSH forward error:", err);
398
+ localSocket.end();
399
+ return;
400
+ }
401
+ localSocket.pipe(stream).pipe(localSocket);
402
+ stream.on("error", (err2) => {
403
+ console.error("SSH stream error:", err2);
404
+ localSocket.end();
405
+ });
406
+ localSocket.on("error", (err2) => {
407
+ console.error("Local socket error:", err2);
408
+ stream.end();
409
+ });
410
+ }
411
+ );
412
+ });
413
+ this.localServer.on("error", (err) => {
414
+ if (!settled) {
415
+ settled = true;
416
+ reject(new Error(`Local server error: ${err.message}`));
417
+ } else {
418
+ console.error("Local server error after tunnel established:", err);
419
+ this.cleanup();
420
+ }
421
+ });
422
+ const localPort = options.localPort || 0;
423
+ this.localServer.listen(localPort, "127.0.0.1", () => {
424
+ const address = this.localServer.address();
425
+ if (!address || typeof address === "string") {
426
+ if (!settled) {
427
+ settled = true;
428
+ reject(new Error("Failed to get local server address"));
429
+ }
430
+ return;
431
+ }
432
+ this.tunnelInfo = {
433
+ localPort: address.port,
434
+ targetHost: options.targetHost,
435
+ targetPort: options.targetPort
436
+ };
437
+ console.error(`SSH tunnel established: localhost:${address.port} \u2192 ${options.targetHost}:${options.targetPort}`);
438
+ settled = true;
439
+ resolve(this.tunnelInfo);
440
+ });
441
+ });
442
+ }
443
+ /**
444
+ * Close the SSH tunnel and clean up resources
445
+ */
446
+ async close() {
447
+ if (!this.isConnected) {
448
+ return;
449
+ }
450
+ return new Promise((resolve) => {
451
+ this.cleanup();
452
+ console.error("SSH tunnel closed");
453
+ resolve();
454
+ });
455
+ }
456
+ /**
457
+ * Clean up resources. Closes all SSH clients in reverse order (innermost first).
458
+ */
459
+ cleanup() {
460
+ if (this.localServer) {
461
+ this.localServer.close();
462
+ this.localServer = null;
463
+ }
464
+ for (let i = this.sshClients.length - 1; i >= 0; i--) {
465
+ try {
466
+ this.sshClients[i].end();
467
+ } catch {
468
+ }
469
+ }
470
+ this.sshClients = [];
471
+ this.tunnelInfo = null;
472
+ this.isConnected = false;
473
+ }
474
+ /**
475
+ * Get current tunnel information
476
+ */
477
+ getTunnelInfo() {
478
+ return this.tunnelInfo;
479
+ }
480
+ /**
481
+ * Check if tunnel is connected
482
+ */
483
+ getIsConnected() {
484
+ return this.isConnected;
485
+ }
486
+ };
487
+
488
+ // src/config/toml-loader.ts
489
+ import fs2 from "fs";
490
+ import path2 from "path";
491
+ import { homedir as homedir3 } from "os";
492
+ import toml from "@iarna/toml";
493
+
494
+ // src/config/env.ts
495
+ import dotenv from "dotenv";
496
+ import path from "path";
497
+ import fs from "fs";
498
+ import { fileURLToPath } from "url";
499
+ import { homedir as homedir2 } from "os";
311
500
 
312
501
  // src/utils/safe-url.ts
313
502
  var SafeURL = class {
@@ -815,6 +1004,13 @@ function resolveSSHConfig() {
815
1004
  config.passphrase = process.env.SSH_PASSPHRASE;
816
1005
  sources.push("SSH_PASSPHRASE from environment");
817
1006
  }
1007
+ if (args["ssh-proxy-jump"]) {
1008
+ config.proxyJump = args["ssh-proxy-jump"];
1009
+ sources.push("ssh-proxy-jump from command line");
1010
+ } else if (process.env.SSH_PROXY_JUMP) {
1011
+ config.proxyJump = process.env.SSH_PROXY_JUMP;
1012
+ sources.push("SSH_PROXY_JUMP from environment");
1013
+ }
818
1014
  if (!config.host || !config.username) {
819
1015
  throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
820
1016
  }
@@ -917,8 +1113,13 @@ function loadTomlConfig() {
917
1113
  try {
918
1114
  const fileContent = fs2.readFileSync(configPath, "utf-8");
919
1115
  const parsedToml = toml.parse(fileContent);
920
- validateTomlConfig(parsedToml, configPath);
1116
+ if (!Array.isArray(parsedToml.sources)) {
1117
+ throw new Error(
1118
+ `Configuration file ${configPath}: must contain a [[sources]] array. Use [[sources]] syntax for array of tables in TOML.`
1119
+ );
1120
+ }
921
1121
  const sources = processSourceConfigs(parsedToml.sources, configPath);
1122
+ validateTomlConfig({ ...parsedToml, sources }, configPath);
922
1123
  return {
923
1124
  sources,
924
1125
  tools: parsedToml.tools,
@@ -960,11 +1161,6 @@ id = "my_db"
960
1161
  dsn = "postgres://..."`
961
1162
  );
962
1163
  }
963
- if (!Array.isArray(config.sources)) {
964
- throw new Error(
965
- `Configuration file ${configPath}: 'sources' must be an array. Use [[sources]] syntax for array of tables in TOML.`
966
- );
967
- }
968
1164
  if (config.sources.length === 0) {
969
1165
  throw new Error(
970
1166
  `Configuration file ${configPath}: sources array cannot be empty. Please define at least one source with [[sources]].`
@@ -1081,10 +1277,10 @@ function validateSourceConfig(source, configPath) {
1081
1277
  );
1082
1278
  }
1083
1279
  }
1084
- if (source.request_timeout !== void 0) {
1085
- if (typeof source.request_timeout !== "number" || source.request_timeout <= 0) {
1280
+ if (source.query_timeout !== void 0) {
1281
+ if (typeof source.query_timeout !== "number" || source.query_timeout <= 0) {
1086
1282
  throw new Error(
1087
- `Configuration file ${configPath}: source '${source.id}' has invalid request_timeout. Must be a positive number (in seconds).`
1283
+ `Configuration file ${configPath}: source '${source.id}' has invalid query_timeout. Must be a positive number (in seconds).`
1088
1284
  );
1089
1285
  }
1090
1286
  }
@@ -1095,6 +1291,54 @@ function validateSourceConfig(source, configPath) {
1095
1291
  );
1096
1292
  }
1097
1293
  }
1294
+ if (source.sslmode !== void 0) {
1295
+ if (source.type === "sqlite") {
1296
+ throw new Error(
1297
+ `Configuration file ${configPath}: source '${source.id}' has sslmode but SQLite does not support SSL. Remove the sslmode field for SQLite sources.`
1298
+ );
1299
+ }
1300
+ const validSslModes = ["disable", "require"];
1301
+ if (!validSslModes.includes(source.sslmode)) {
1302
+ throw new Error(
1303
+ `Configuration file ${configPath}: source '${source.id}' has invalid sslmode '${source.sslmode}'. Valid values: ${validSslModes.join(", ")}`
1304
+ );
1305
+ }
1306
+ }
1307
+ if (source.authentication !== void 0) {
1308
+ if (source.type !== "sqlserver") {
1309
+ throw new Error(
1310
+ `Configuration file ${configPath}: source '${source.id}' has authentication but it is only supported for SQL Server.`
1311
+ );
1312
+ }
1313
+ const validAuthMethods = ["ntlm", "azure-active-directory-access-token"];
1314
+ if (!validAuthMethods.includes(source.authentication)) {
1315
+ throw new Error(
1316
+ `Configuration file ${configPath}: source '${source.id}' has invalid authentication '${source.authentication}'. Valid values: ${validAuthMethods.join(", ")}`
1317
+ );
1318
+ }
1319
+ if (source.authentication === "ntlm" && !source.domain) {
1320
+ throw new Error(
1321
+ `Configuration file ${configPath}: source '${source.id}' uses NTLM authentication but 'domain' is not specified.`
1322
+ );
1323
+ }
1324
+ }
1325
+ if (source.domain !== void 0) {
1326
+ if (source.type !== "sqlserver") {
1327
+ throw new Error(
1328
+ `Configuration file ${configPath}: source '${source.id}' has domain but it is only supported for SQL Server.`
1329
+ );
1330
+ }
1331
+ if (source.authentication === void 0) {
1332
+ throw new Error(
1333
+ `Configuration file ${configPath}: source '${source.id}' has domain but authentication is not set. Add authentication = "ntlm" to use Windows domain authentication.`
1334
+ );
1335
+ }
1336
+ if (source.authentication !== "ntlm") {
1337
+ throw new Error(
1338
+ `Configuration file ${configPath}: source '${source.id}' has domain but authentication is set to '${source.authentication}'. Domain is only valid with authentication = "ntlm".`
1339
+ );
1340
+ }
1341
+ }
1098
1342
  }
1099
1343
  function processSourceConfigs(sources, configPath) {
1100
1344
  return sources.map((source) => {
@@ -1154,9 +1398,15 @@ function buildDSNFromSource(source) {
1154
1398
  }
1155
1399
  return `sqlite:///${source.database}`;
1156
1400
  }
1157
- if (!source.host || !source.user || !source.password || !source.database) {
1401
+ const passwordRequired = source.authentication !== "azure-active-directory-access-token";
1402
+ if (!source.host || !source.user || !source.database) {
1158
1403
  throw new Error(
1159
- `Source '${source.id}': missing required connection parameters. Required: type, host, user, password, database`
1404
+ `Source '${source.id}': missing required connection parameters. Required: type, host, user, database`
1405
+ );
1406
+ }
1407
+ if (passwordRequired && !source.password) {
1408
+ throw new Error(
1409
+ `Source '${source.id}': password is required. (Password is optional only for azure-active-directory-access-token authentication)`
1160
1410
  );
1161
1411
  }
1162
1412
  const port = source.port || getDefaultPortForType(source.type);
@@ -1164,11 +1414,26 @@ function buildDSNFromSource(source) {
1164
1414
  throw new Error(`Source '${source.id}': unable to determine port`);
1165
1415
  }
1166
1416
  const encodedUser = encodeURIComponent(source.user);
1167
- const encodedPassword = encodeURIComponent(source.password);
1417
+ const encodedPassword = source.password ? encodeURIComponent(source.password) : "";
1168
1418
  const encodedDatabase = encodeURIComponent(source.database);
1169
1419
  let dsn = `${source.type}://${encodedUser}:${encodedPassword}@${source.host}:${port}/${encodedDatabase}`;
1170
- if (source.type === "sqlserver" && source.instanceName) {
1171
- dsn += `?instanceName=${encodeURIComponent(source.instanceName)}`;
1420
+ const queryParams = [];
1421
+ if (source.type === "sqlserver") {
1422
+ if (source.instanceName) {
1423
+ queryParams.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
1424
+ }
1425
+ if (source.authentication) {
1426
+ queryParams.push(`authentication=${encodeURIComponent(source.authentication)}`);
1427
+ }
1428
+ if (source.domain) {
1429
+ queryParams.push(`domain=${encodeURIComponent(source.domain)}`);
1430
+ }
1431
+ }
1432
+ if (source.sslmode && source.type !== "sqlite") {
1433
+ queryParams.push(`sslmode=${source.sslmode}`);
1434
+ }
1435
+ if (queryParams.length > 0) {
1436
+ dsn += `?${queryParams.join("&")}`;
1172
1437
  }
1173
1438
  return dsn;
1174
1439
  }
@@ -1197,6 +1462,7 @@ var ConnectorManager = class {
1197
1462
  if (sources.length === 0) {
1198
1463
  throw new Error("No sources provided");
1199
1464
  }
1465
+ console.error(`Connecting to ${sources.length} database source(s)...`);
1200
1466
  for (const source of sources) {
1201
1467
  await this.connectSource(source);
1202
1468
  }
@@ -1207,6 +1473,7 @@ var ConnectorManager = class {
1207
1473
  async connectSource(source) {
1208
1474
  const sourceId = source.id;
1209
1475
  const dsn = buildDSNFromSource(source);
1476
+ console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
1210
1477
  let actualDSN = dsn;
1211
1478
  if (source.ssh_host) {
1212
1479
  if (!source.ssh_user) {
@@ -1220,7 +1487,8 @@ var ConnectorManager = class {
1220
1487
  username: source.ssh_user,
1221
1488
  password: source.ssh_password,
1222
1489
  privateKey: source.ssh_key,
1223
- passphrase: source.ssh_passphrase
1490
+ passphrase: source.ssh_passphrase,
1491
+ proxyJump: source.ssh_proxy_jump
1224
1492
  };
1225
1493
  if (!sshConfig.password && !sshConfig.privateKey) {
1226
1494
  throw new Error(
@@ -1255,8 +1523,8 @@ var ConnectorManager = class {
1255
1523
  if (source.connection_timeout !== void 0) {
1256
1524
  config.connectionTimeoutSeconds = source.connection_timeout;
1257
1525
  }
1258
- if (connector.id === "sqlserver" && source.request_timeout !== void 0) {
1259
- config.requestTimeoutSeconds = source.request_timeout;
1526
+ if (source.query_timeout !== void 0 && connector.id !== "sqlite") {
1527
+ config.queryTimeoutSeconds = source.query_timeout;
1260
1528
  }
1261
1529
  if (source.readonly !== void 0) {
1262
1530
  config.readonly = source.readonly;
@@ -1812,11 +2080,9 @@ export {
1812
2080
  isDemoMode,
1813
2081
  resolveTransport,
1814
2082
  resolvePort,
1815
- redactDSN,
1816
2083
  resolveSourceConfigs,
1817
2084
  BUILTIN_TOOL_EXECUTE_SQL,
1818
2085
  BUILTIN_TOOL_SEARCH_OBJECTS,
1819
- buildDSNFromSource,
1820
2086
  ConnectorManager,
1821
2087
  mapArgumentsToArray,
1822
2088
  ToolRegistry,
package/dist/index.js CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  ConnectorManager,
6
6
  ConnectorRegistry,
7
7
  SafeURL,
8
- buildDSNFromSource,
9
8
  getDatabaseTypeFromDSN,
10
9
  getDefaultPortForType,
11
10
  getToolRegistry,
@@ -13,12 +12,11 @@ import {
13
12
  mapArgumentsToArray,
14
13
  obfuscateDSNPassword,
15
14
  parseConnectionInfoFromDSN,
16
- redactDSN,
17
15
  resolvePort,
18
16
  resolveSourceConfigs,
19
17
  resolveTransport,
20
18
  stripCommentsAndStrings
21
- } from "./chunk-KBVJEDZF.js";
19
+ } from "./chunk-WGDSRFBW.js";
22
20
 
23
21
  // src/connectors/postgres/index.ts
24
22
  import pg from "pg";
@@ -148,6 +146,7 @@ var { Pool } = pg;
148
146
  var PostgresDSNParser = class {
149
147
  async parse(dsn, config) {
150
148
  const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
149
+ const queryTimeoutSeconds = config?.queryTimeoutSeconds;
151
150
  if (!this.isValidDSN(dsn)) {
152
151
  const obfuscatedDSN = obfuscateDSNPassword(dsn);
153
152
  const expectedFormat = this.getSampleDSN();
@@ -159,7 +158,7 @@ Expected: ${expectedFormat}`
159
158
  }
160
159
  try {
161
160
  const url = new SafeURL(dsn);
162
- const config2 = {
161
+ const poolConfig = {
163
162
  host: url.hostname,
164
163
  port: url.port ? parseInt(url.port) : 5432,
165
164
  database: url.pathname ? url.pathname.substring(1) : "",
@@ -170,18 +169,21 @@ Expected: ${expectedFormat}`
170
169
  url.forEachSearchParam((value, key) => {
171
170
  if (key === "sslmode") {
172
171
  if (value === "disable") {
173
- config2.ssl = false;
172
+ poolConfig.ssl = false;
174
173
  } else if (value === "require") {
175
- config2.ssl = { rejectUnauthorized: false };
174
+ poolConfig.ssl = { rejectUnauthorized: false };
176
175
  } else {
177
- config2.ssl = true;
176
+ poolConfig.ssl = true;
178
177
  }
179
178
  }
180
179
  });
181
180
  if (connectionTimeoutSeconds !== void 0) {
182
- config2.connectionTimeoutMillis = connectionTimeoutSeconds * 1e3;
181
+ poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1e3;
183
182
  }
184
- return config2;
183
+ if (queryTimeoutSeconds !== void 0) {
184
+ poolConfig.query_timeout = queryTimeoutSeconds * 1e3;
185
+ }
186
+ return poolConfig;
185
187
  } catch (error) {
186
188
  throw new Error(
187
189
  `Failed to parse PostgreSQL DSN: ${error instanceof Error ? error.message : String(error)}`
@@ -521,7 +523,7 @@ import { DefaultAzureCredential } from "@azure/identity";
521
523
  var SQLServerDSNParser = class {
522
524
  async parse(dsn, config) {
523
525
  const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
524
- const requestTimeoutSeconds = config?.requestTimeoutSeconds;
526
+ const queryTimeoutSeconds = config?.queryTimeoutSeconds;
525
527
  if (!this.isValidDSN(dsn)) {
526
528
  const obfuscatedDSN = obfuscateDSNPassword(dsn);
527
529
  const expectedFormat = this.getSampleDSN();
@@ -541,8 +543,16 @@ Expected: ${expectedFormat}`
541
543
  options.sslmode = value;
542
544
  } else if (key === "instanceName") {
543
545
  options.instanceName = value;
546
+ } else if (key === "domain") {
547
+ options.domain = value;
544
548
  }
545
549
  });
550
+ if (options.authentication === "ntlm" && !options.domain) {
551
+ throw new Error("NTLM authentication requires 'domain' parameter");
552
+ }
553
+ if (options.domain && options.authentication !== "ntlm") {
554
+ throw new Error("Parameter 'domain' requires 'authentication=ntlm'");
555
+ }
546
556
  if (options.sslmode) {
547
557
  if (options.sslmode === "disable") {
548
558
  options.encrypt = false;
@@ -553,8 +563,6 @@ Expected: ${expectedFormat}`
553
563
  }
554
564
  }
555
565
  const config2 = {
556
- user: url.username,
557
- password: url.password,
558
566
  server: url.hostname,
559
567
  port: url.port ? parseInt(url.port) : 1433,
560
568
  // Default SQL Server port
@@ -567,27 +575,44 @@ Expected: ${expectedFormat}`
567
575
  ...connectionTimeoutSeconds !== void 0 && {
568
576
  connectTimeout: connectionTimeoutSeconds * 1e3
569
577
  },
570
- ...requestTimeoutSeconds !== void 0 && {
571
- requestTimeout: requestTimeoutSeconds * 1e3
578
+ ...queryTimeoutSeconds !== void 0 && {
579
+ requestTimeout: queryTimeoutSeconds * 1e3
572
580
  },
573
581
  instanceName: options.instanceName
574
582
  // Add named instance support
575
583
  }
576
584
  };
577
- if (options.authentication === "azure-active-directory-access-token") {
578
- try {
579
- const credential = new DefaultAzureCredential();
580
- const token = await credential.getToken("https://database.windows.net/");
585
+ switch (options.authentication) {
586
+ case "azure-active-directory-access-token": {
587
+ try {
588
+ const credential = new DefaultAzureCredential();
589
+ const token = await credential.getToken("https://database.windows.net/");
590
+ config2.authentication = {
591
+ type: "azure-active-directory-access-token",
592
+ options: {
593
+ token: token.token
594
+ }
595
+ };
596
+ } catch (error) {
597
+ const errorMessage = error instanceof Error ? error.message : String(error);
598
+ throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
599
+ }
600
+ break;
601
+ }
602
+ case "ntlm":
581
603
  config2.authentication = {
582
- type: "azure-active-directory-access-token",
604
+ type: "ntlm",
583
605
  options: {
584
- token: token.token
606
+ domain: options.domain,
607
+ userName: url.username,
608
+ password: url.password
585
609
  }
586
610
  };
587
- } catch (error) {
588
- const errorMessage = error instanceof Error ? error.message : String(error);
589
- throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
590
- }
611
+ break;
612
+ default:
613
+ config2.user = url.username;
614
+ config2.password = url.password;
615
+ break;
591
616
  }
592
617
  return config2;
593
618
  } catch (error) {
@@ -1374,6 +1399,9 @@ var MySQLConnector = class _MySQLConnector {
1374
1399
  try {
1375
1400
  const connectionOptions = await this.dsnParser.parse(dsn, config);
1376
1401
  this.pool = mysql.createPool(connectionOptions);
1402
+ if (config?.queryTimeoutSeconds !== void 0) {
1403
+ this.queryTimeoutMs = config.queryTimeoutSeconds * 1e3;
1404
+ }
1377
1405
  const [rows] = await this.pool.query("SELECT 1");
1378
1406
  } catch (err) {
1379
1407
  console.error("Failed to connect to MySQL database:", err);
@@ -1676,7 +1704,7 @@ var MySQLConnector = class _MySQLConnector {
1676
1704
  let results;
1677
1705
  if (parameters && parameters.length > 0) {
1678
1706
  try {
1679
- results = await conn.query(processedSQL, parameters);
1707
+ results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters);
1680
1708
  } catch (error) {
1681
1709
  console.error(`[MySQL executeSQL] ERROR: ${error.message}`);
1682
1710
  console.error(`[MySQL executeSQL] SQL: ${processedSQL}`);
@@ -1684,7 +1712,7 @@ var MySQLConnector = class _MySQLConnector {
1684
1712
  throw error;
1685
1713
  }
1686
1714
  } else {
1687
- results = await conn.query(processedSQL);
1715
+ results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs });
1688
1716
  }
1689
1717
  const [firstResult] = results;
1690
1718
  const rows = parseQueryResults(firstResult);
@@ -1705,6 +1733,7 @@ import mariadb from "mariadb";
1705
1733
  var MariadbDSNParser = class {
1706
1734
  async parse(dsn, config) {
1707
1735
  const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
1736
+ const queryTimeoutSeconds = config?.queryTimeoutSeconds;
1708
1737
  if (!this.isValidDSN(dsn)) {
1709
1738
  const obfuscatedDSN = obfuscateDSNPassword(dsn);
1710
1739
  const expectedFormat = this.getSampleDSN();
@@ -1716,7 +1745,7 @@ Expected: ${expectedFormat}`
1716
1745
  }
1717
1746
  try {
1718
1747
  const url = new SafeURL(dsn);
1719
- const config2 = {
1748
+ const connectionConfig = {
1720
1749
  host: url.hostname,
1721
1750
  port: url.port ? parseInt(url.port) : 3306,
1722
1751
  database: url.pathname ? url.pathname.substring(1) : "",
@@ -1727,25 +1756,28 @@ Expected: ${expectedFormat}`
1727
1756
  // Enable native multi-statement support
1728
1757
  ...connectionTimeoutSeconds !== void 0 && {
1729
1758
  connectTimeout: connectionTimeoutSeconds * 1e3
1759
+ },
1760
+ ...queryTimeoutSeconds !== void 0 && {
1761
+ queryTimeout: queryTimeoutSeconds * 1e3
1730
1762
  }
1731
1763
  };
1732
1764
  url.forEachSearchParam((value, key) => {
1733
1765
  if (key === "sslmode") {
1734
1766
  if (value === "disable") {
1735
- config2.ssl = void 0;
1767
+ connectionConfig.ssl = void 0;
1736
1768
  } else if (value === "require") {
1737
- config2.ssl = { rejectUnauthorized: false };
1769
+ connectionConfig.ssl = { rejectUnauthorized: false };
1738
1770
  } else {
1739
- config2.ssl = {};
1771
+ connectionConfig.ssl = {};
1740
1772
  }
1741
1773
  }
1742
1774
  });
1743
1775
  if (url.password && url.password.includes("X-Amz-Credential")) {
1744
- if (config2.ssl === void 0) {
1745
- config2.ssl = { rejectUnauthorized: false };
1776
+ if (connectionConfig.ssl === void 0) {
1777
+ connectionConfig.ssl = { rejectUnauthorized: false };
1746
1778
  }
1747
1779
  }
1748
- return config2;
1780
+ return connectionConfig;
1749
1781
  } catch (error) {
1750
1782
  throw new Error(
1751
1783
  `Failed to parse MariaDB DSN: ${error instanceof Error ? error.message : String(error)}`
@@ -2244,7 +2276,7 @@ function getClientIdentifier(extra) {
2244
2276
 
2245
2277
  // src/tools/execute-sql.ts
2246
2278
  var executeSqlSchema = {
2247
- sql: z.string().describe("SQL query or multiple SQL statements to execute (separated by semicolons)")
2279
+ sql: z.string().describe("SQL to execute (multiple statements separated by ;)")
2248
2280
  };
2249
2281
  function splitSQLStatements(sql2) {
2250
2282
  return sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
@@ -2306,12 +2338,12 @@ function createExecuteSqlToolHandler(sourceId) {
2306
2338
  // src/tools/search-objects.ts
2307
2339
  import { z as z2 } from "zod";
2308
2340
  var searchDatabaseObjectsSchema = {
2309
- object_type: z2.enum(["schema", "table", "column", "procedure", "index"]).describe("Type of database object to search for"),
2310
- pattern: z2.string().optional().default("%").describe("Search pattern (SQL LIKE syntax: % for wildcard, _ for single char). Case-insensitive. Defaults to '%' (match all)."),
2311
- schema: z2.string().optional().describe("Filter results to a specific schema/database (exact match)"),
2312
- table: z2.string().optional().describe("Filter to a specific table (exact match). Requires schema parameter. Only applies to columns and indexes."),
2313
- detail_level: z2.enum(["names", "summary", "full"]).default("names").describe("Level of detail to return: names (minimal), summary (with metadata), full (complete structure)"),
2314
- limit: z2.number().int().positive().max(1e3).default(100).describe("Maximum number of results to return (default: 100, max: 1000)")
2341
+ object_type: z2.enum(["schema", "table", "column", "procedure", "index"]).describe("Object type to search"),
2342
+ pattern: z2.string().optional().default("%").describe("LIKE pattern (% = any chars, _ = one char). Default: %"),
2343
+ schema: z2.string().optional().describe("Filter to schema"),
2344
+ table: z2.string().optional().describe("Filter to table (requires schema; column/index only)"),
2345
+ detail_level: z2.enum(["names", "summary", "full"]).default("names").describe("Detail: names (minimal), summary (metadata), full (all)"),
2346
+ limit: z2.number().int().positive().max(1e3).default(100).describe("Max results (default: 100, max: 1000)")
2315
2347
  };
2316
2348
  function likePatternToRegex(pattern) {
2317
2349
  const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".");
@@ -2701,12 +2733,12 @@ function getExecuteSqlMetadata(sourceId) {
2701
2733
  const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
2702
2734
  const executeOptions = ConnectorManager.getCurrentExecuteOptions(sourceId);
2703
2735
  const dbType = sourceConfig.type;
2704
- const toolName = sourceId === "default" ? "execute_sql" : `execute_sql_${normalizeSourceId(sourceId)}`;
2705
- const isDefault = sourceIds[0] === sourceId;
2706
- const title = isDefault ? `Execute SQL (${dbType})` : `Execute SQL on ${sourceId} (${dbType})`;
2736
+ const isSingleSource = sourceIds.length === 1;
2737
+ const toolName = isSingleSource ? "execute_sql" : `execute_sql_${normalizeSourceId(sourceId)}`;
2738
+ const title = isSingleSource ? `Execute SQL (${dbType})` : `Execute SQL on ${sourceId} (${dbType})`;
2707
2739
  const readonlyNote = executeOptions.readonly ? " [READ-ONLY MODE]" : "";
2708
2740
  const maxRowsNote = executeOptions.maxRows ? ` (limited to ${executeOptions.maxRows} rows)` : "";
2709
- const description = `Execute SQL queries on the '${sourceId}' ${dbType} database${isDefault ? " (default)" : ""}${readonlyNote}${maxRowsNote}`;
2741
+ const description = isSingleSource ? `Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` : `Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`;
2710
2742
  const isReadonly = executeOptions.readonly === true;
2711
2743
  const annotations = {
2712
2744
  title,
@@ -2726,10 +2758,14 @@ function getExecuteSqlMetadata(sourceId) {
2726
2758
  annotations
2727
2759
  };
2728
2760
  }
2729
- function getSearchObjectsMetadata(sourceId, dbType, isDefault) {
2730
- const toolName = sourceId === "default" ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`;
2731
- const title = isDefault ? `Search Database Objects (${dbType})` : `Search Database Objects on ${sourceId} (${dbType})`;
2732
- const description = `Search and list database objects (schemas, tables, columns, procedures, indexes) on the '${sourceId}' ${dbType} database${isDefault ? " (default)" : ""}. Supports SQL LIKE patterns (default: '%' for all), filtering, and token-efficient progressive disclosure.`;
2761
+ function getSearchObjectsMetadata(sourceId) {
2762
+ const sourceIds = ConnectorManager.getAvailableSourceIds();
2763
+ const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
2764
+ const dbType = sourceConfig.type;
2765
+ const isSingleSource = sourceIds.length === 1;
2766
+ const toolName = isSingleSource ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`;
2767
+ const title = isSingleSource ? `Search Database Objects (${dbType})` : `Search Database Objects on ${sourceId} (${dbType})`;
2768
+ const description = isSingleSource ? `Search and list database objects (schemas, tables, columns, procedures, indexes) on the ${dbType} database` : `Search and list database objects (schemas, tables, columns, procedures, indexes) on the '${sourceId}' ${dbType} database`;
2733
2769
  return {
2734
2770
  name: toolName,
2735
2771
  description,
@@ -2757,11 +2793,7 @@ function buildExecuteSqlTool(sourceId) {
2757
2793
  };
2758
2794
  }
2759
2795
  function buildSearchObjectsTool(sourceId) {
2760
- const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
2761
- const dbType = sourceConfig.type;
2762
- const sourceIds = ConnectorManager.getAvailableSourceIds();
2763
- const isDefault = sourceIds[0] === sourceId;
2764
- const searchMetadata = getSearchObjectsMetadata(sourceId, dbType, isDefault);
2796
+ const searchMetadata = getSearchObjectsMetadata(sourceId);
2765
2797
  return {
2766
2798
  name: searchMetadata.name,
2767
2799
  description: searchMetadata.description,
@@ -2770,31 +2802,37 @@ function buildSearchObjectsTool(sourceId) {
2770
2802
  name: "object_type",
2771
2803
  type: "string",
2772
2804
  required: true,
2773
- description: "Type of database object to search for (schema, table, column, procedure, index)"
2805
+ description: "Object type to search"
2774
2806
  },
2775
2807
  {
2776
2808
  name: "pattern",
2777
2809
  type: "string",
2778
2810
  required: false,
2779
- description: "Search pattern (SQL LIKE syntax: % for wildcard, _ for single char). Case-insensitive. Defaults to '%' (match all)."
2811
+ description: "LIKE pattern (% = any chars, _ = one char). Default: %"
2780
2812
  },
2781
2813
  {
2782
2814
  name: "schema",
2783
2815
  type: "string",
2784
2816
  required: false,
2785
- description: "Filter results to a specific schema/database"
2817
+ description: "Filter to schema"
2818
+ },
2819
+ {
2820
+ name: "table",
2821
+ type: "string",
2822
+ required: false,
2823
+ description: "Filter to table (requires schema; column/index only)"
2786
2824
  },
2787
2825
  {
2788
2826
  name: "detail_level",
2789
2827
  type: "string",
2790
2828
  required: false,
2791
- description: "Level of detail to return: names (minimal), summary (with metadata), full (complete structure). Defaults to 'names'."
2829
+ description: "Detail: names (minimal), summary (metadata), full (all)"
2792
2830
  },
2793
2831
  {
2794
2832
  name: "limit",
2795
2833
  type: "integer",
2796
2834
  required: false,
2797
- description: "Maximum number of results to return (default: 100, max: 1000)"
2835
+ description: "Max results (default: 100, max: 1000)"
2798
2836
  }
2799
2837
  ]
2800
2838
  };
@@ -2940,21 +2978,18 @@ function registerTools(server) {
2940
2978
  const registry = getToolRegistry();
2941
2979
  for (const sourceId of sourceIds) {
2942
2980
  const enabledTools = registry.getEnabledToolConfigs(sourceId);
2943
- const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
2944
- const dbType = sourceConfig.type;
2945
- const isDefault = sourceIds[0] === sourceId;
2946
2981
  for (const toolConfig of enabledTools) {
2947
2982
  if (toolConfig.name === BUILTIN_TOOL_EXECUTE_SQL) {
2948
- registerExecuteSqlTool(server, sourceId, dbType);
2983
+ registerExecuteSqlTool(server, sourceId);
2949
2984
  } else if (toolConfig.name === BUILTIN_TOOL_SEARCH_OBJECTS) {
2950
- registerSearchObjectsTool(server, sourceId, dbType, isDefault);
2985
+ registerSearchObjectsTool(server, sourceId);
2951
2986
  } else {
2952
- registerCustomTool(server, toolConfig, dbType);
2987
+ registerCustomTool(server, sourceId, toolConfig);
2953
2988
  }
2954
2989
  }
2955
2990
  }
2956
2991
  }
2957
- function registerExecuteSqlTool(server, sourceId, dbType) {
2992
+ function registerExecuteSqlTool(server, sourceId) {
2958
2993
  const metadata = getExecuteSqlMetadata(sourceId);
2959
2994
  server.registerTool(
2960
2995
  metadata.name,
@@ -2966,8 +3001,8 @@ function registerExecuteSqlTool(server, sourceId, dbType) {
2966
3001
  createExecuteSqlToolHandler(sourceId)
2967
3002
  );
2968
3003
  }
2969
- function registerSearchObjectsTool(server, sourceId, dbType, isDefault) {
2970
- const metadata = getSearchObjectsMetadata(sourceId, dbType, isDefault);
3004
+ function registerSearchObjectsTool(server, sourceId) {
3005
+ const metadata = getSearchObjectsMetadata(sourceId);
2971
3006
  server.registerTool(
2972
3007
  metadata.name,
2973
3008
  {
@@ -2984,7 +3019,9 @@ function registerSearchObjectsTool(server, sourceId, dbType, isDefault) {
2984
3019
  createSearchDatabaseObjectsToolHandler(sourceId)
2985
3020
  );
2986
3021
  }
2987
- function registerCustomTool(server, toolConfig, dbType) {
3022
+ function registerCustomTool(server, sourceId, toolConfig) {
3023
+ const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
3024
+ const dbType = sourceConfig.type;
2988
3025
  const isReadOnly = isReadOnlySQL(toolConfig.statement, dbType);
2989
3026
  const zodSchema = buildZodSchemaFromParameters(toolConfig.parameters);
2990
3027
  server.registerTool(
@@ -3006,7 +3043,7 @@ function registerCustomTool(server, toolConfig, dbType) {
3006
3043
  }
3007
3044
 
3008
3045
  // src/api/sources.ts
3009
- function transformSourceConfig(source, isDefault) {
3046
+ function transformSourceConfig(source) {
3010
3047
  if (!source.type && source.dsn) {
3011
3048
  const inferredType = getDatabaseTypeFromDSN(source.dsn);
3012
3049
  if (inferredType) {
@@ -3018,8 +3055,7 @@ function transformSourceConfig(source, isDefault) {
3018
3055
  }
3019
3056
  const dataSource = {
3020
3057
  id: source.id,
3021
- type: source.type,
3022
- is_default: isDefault
3058
+ type: source.type
3023
3059
  };
3024
3060
  if (source.host) {
3025
3061
  dataSource.host = source.host;
@@ -3058,9 +3094,8 @@ function transformSourceConfig(source, isDefault) {
3058
3094
  function listSources(req, res) {
3059
3095
  try {
3060
3096
  const sourceConfigs = ConnectorManager.getAllSourceConfigs();
3061
- const sources = sourceConfigs.map((config, index) => {
3062
- const isDefault = index === 0;
3063
- return transformSourceConfig(config, isDefault);
3097
+ const sources = sourceConfigs.map((config) => {
3098
+ return transformSourceConfig(config);
3064
3099
  });
3065
3100
  res.json(sources);
3066
3101
  } catch (error) {
@@ -3074,7 +3109,6 @@ function listSources(req, res) {
3074
3109
  function getSource(req, res) {
3075
3110
  try {
3076
3111
  const sourceId = req.params.sourceId;
3077
- const sourceIds = ConnectorManager.getAvailableSourceIds();
3078
3112
  const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
3079
3113
  if (!sourceConfig) {
3080
3114
  const errorResponse = {
@@ -3084,8 +3118,7 @@ function getSource(req, res) {
3084
3118
  res.status(404).json(errorResponse);
3085
3119
  return;
3086
3120
  }
3087
- const isDefault = sourceIds[0] === sourceId;
3088
- const dataSource = transformSourceConfig(sourceConfig, isDefault);
3121
+ const dataSource = transformSourceConfig(sourceConfig);
3089
3122
  res.json(dataSource);
3090
3123
  } catch (error) {
3091
3124
  console.error(`Error getting source ${req.params.sourceId}:`, error);
@@ -3280,13 +3313,8 @@ See documentation for more details on configuring database connections.
3280
3313
  const connectorManager = new ConnectorManager();
3281
3314
  const sources = sourceConfigsData.sources;
3282
3315
  console.error(`Configuration source: ${sourceConfigsData.source}`);
3283
- console.error(`Connecting to ${sources.length} database source(s)...`);
3284
- for (const source of sources) {
3285
- const dsn = source.dsn || buildDSNFromSource(source);
3286
- console.error(` - ${source.id}: ${redactDSN(dsn)}`);
3287
- }
3288
3316
  await connectorManager.connectWithSources(sources);
3289
- const { initializeToolRegistry } = await import("./registry-AWAIN6WO.js");
3317
+ const { initializeToolRegistry } = await import("./registry-FVGT25UH.js");
3290
3318
  initializeToolRegistry({
3291
3319
  sources: sourceConfigsData.sources,
3292
3320
  tools: sourceConfigsData.tools
@@ -3380,9 +3408,9 @@ See documentation for more details on configuring database connections.
3380
3408
  console.error(" Backend API: http://localhost:8080");
3381
3409
  console.error("");
3382
3410
  } else {
3383
- console.error(`Admin console at http://0.0.0.0:${port}/`);
3411
+ console.error(`Admin console at http://localhost:${port}/`);
3384
3412
  }
3385
- console.error(`MCP server endpoint at http://0.0.0.0:${port}/mcp`);
3413
+ console.error(`MCP server endpoint at http://localhost:${port}/mcp`);
3386
3414
  });
3387
3415
  } else {
3388
3416
  const server = createServer();
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-KBVJEDZF.js";
5
+ } from "./chunk-WGDSRFBW.js";
6
6
  export {
7
7
  ToolRegistry,
8
8
  getToolRegistry,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "mcpName": "io.github.bytebase/dbhub",
5
- "description": "Minimal Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
5
+ "description": "Minimal, token-efficient Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/bytebase/dbhub.git"
@@ -38,7 +38,7 @@
38
38
  "dependencies": {
39
39
  "@azure/identity": "^4.8.0",
40
40
  "@iarna/toml": "^2.2.5",
41
- "@modelcontextprotocol/sdk": "^1.23.0",
41
+ "@modelcontextprotocol/sdk": "^1.25.1",
42
42
  "better-sqlite3": "^11.9.0",
43
43
  "dotenv": "^16.4.7",
44
44
  "express": "^4.18.2",