@bytebase/dbhub 0.24.0 → 1.1.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,7 +32,7 @@
32
32
 
33
33
  DBHub is a zero-dependency, token efficient 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
- - **Local Development First**: Zero dependency, token efficient with just two MCP tools to maximize context window
35
+ - **Local Development First**: Zero dependency, token efficient with a minimal set of MCP tools to maximize context window
36
36
  - **Multi-Database**: PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite through a single interface
37
37
  - **Multi-Connection**: Connect to multiple databases simultaneously with TOML configuration
38
38
  - **Guardrails**: Read-only mode, row limiting, and query timeout to prevent runaway operations
@@ -48,6 +48,8 @@ DBHub implements MCP tools for database operations:
48
48
 
49
49
  - **[execute_sql](https://dbhub.ai/tools/execute-sql)**: Execute SQL queries with transaction support and safety controls
50
50
  - **[search_objects](https://dbhub.ai/tools/search-objects)**: Search and explore database schemas, tables, columns, indexes, and procedures with progressive disclosure
51
+ - **[explain_sql](https://dbhub.ai/tools/explain-sql)** (opt-in): Show a query's execution plan without running it
52
+ - **[health_check](https://dbhub.ai/tools/health-check)** (opt-in, PostgreSQL/MySQL/MariaDB/SQL Server for now): Report connection pool state and buffer cache hit ratio
51
53
  - **[Custom Tools](https://dbhub.ai/tools/custom-tools)**: Define reusable, parameterized SQL operations in your `dbhub.toml` configuration file
52
54
 
53
55
  ## Workbench
@@ -58,55 +60,17 @@ DBHub includes a [built-in web interface](https://dbhub.ai/workbench/overview) f
58
60
 
59
61
  ## Installation
60
62
 
61
- See the full [Installation Guide](https://dbhub.ai/installation) for detailed instructions.
62
-
63
- ### Quick Start
64
-
65
- **Docker:**
66
-
67
- ```bash
68
- docker run --rm --init \
69
- --name dbhub \
70
- --publish 8080:8080 \
71
- bytebase/dbhub \
72
- --transport http \
73
- --port 8080 \
74
- --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
75
- ```
76
-
77
- **NPM:** (requires Node.js >= 22.5.0)
78
-
79
63
  ```bash
80
64
  npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
81
65
  ```
82
66
 
83
- **MCP Bundle (one-click install):**
84
-
85
- Download `dbhub-<version>.mcpb` from the [latest release](https://github.com/bytebase/dbhub/releases/latest) and install it in any [MCPB-compatible client](https://github.com/modelcontextprotocol/mcpb) — Claude Desktop (double-click, or drag into Settings → Extensions), Claude Code, or MCP for Windows — then enter your database connection string. The bundle runs locally over stdio, is **read-only by design** (writes are rejected and the database session is set to read-only at the engine level), and needs no remote endpoint or OAuth setup — ideal for giving non-technical teammates curated, read-only database access. Pair it with a least-privilege, read-only database account. See the [MCP Bundle guide](https://dbhub.ai/mcpb) for details and for packaging your own bundle.
86
-
87
- **Demo Mode:**
88
-
89
- ```bash
90
- npx @bytebase/dbhub@latest --transport http --port 8080 --demo
91
- ```
92
-
93
- **Restrict to loopback (recommended for production):**
94
-
95
- ```bash
96
- npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
97
- ```
98
-
99
- > The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients.
100
- >
101
- > The HTTP transport also has built-in DNS-rebinding protection: it only accepts requests whose `Host` is loopback, this machine's own hostname/IPs, or a name you allow via [`--allowed-hosts`](https://dbhub.ai/config/command-line#allowed-hosts). If a client behind a reverse proxy or custom DNS name gets a `403`, add that hostname with `--allowed-hosts`.
102
-
103
- See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
104
-
105
- ### Multi-Database Setup
67
+ Also available as:
106
68
 
107
- Connect to multiple databases simultaneously using TOML configuration files. Perfect for managing production, staging, and development databases from a single DBHub instance.
69
+ - [Docker image](https://dbhub.ai/installation#docker)
70
+ - [MCP Bundle](https://dbhub.ai/mcpb) (one-click install, read-only)
71
+ - [Claude Code plugin](https://dbhub.ai/claude-code-plugin)
108
72
 
109
- See [Multi-Database Configuration](https://dbhub.ai/config/toml) for complete setup instructions.
73
+ See the [Installation Guide](https://dbhub.ai/installation) for all options, [Command-Line Options](https://dbhub.ai/config/command-line) for parameters, and [Multi-Database Configuration](https://dbhub.ai/config/toml) for connecting several databases at once.
110
74
 
111
75
  ## Development
112
76
 
@@ -52,6 +52,22 @@ var sqlServerPassThroughPattern = new RegExp(
52
52
  `\\b(?:${sqlServerPassThroughKeywords.join("|")})\\s*\\(`,
53
53
  "i"
54
54
  );
55
+ var escapeHatchFunctionKeywords = {
56
+ mysql: ["load_file", "get_lock", "release_lock", "release_all_locks"],
57
+ mariadb: ["load_file", "get_lock", "release_lock", "release_all_locks"],
58
+ postgres: ["pg_read_file", "pg_read_binary_file", "pg_ls_dir"],
59
+ sqlserver: sqlServerPassThroughKeywords
60
+ };
61
+ var escapeHatchFunctionPatterns = Object.fromEntries(
62
+ Object.entries(escapeHatchFunctionKeywords).map(([type, keywords]) => [
63
+ type,
64
+ new RegExp(`\\b(?:${keywords.join("|")})\\s*\\(`, "i")
65
+ ])
66
+ );
67
+ function hasEscapeHatchFunction(strippedSQL, connectorType) {
68
+ const pattern = escapeHatchFunctionPatterns[connectorType];
69
+ return pattern !== void 0 && pattern.test(strippedSQL);
70
+ }
55
71
  var mutatingPatternSqlServer = new RegExp(
56
72
  `\\b(?:${[...mutatingKeywords, ...sqlServerDynamicSqlKeywords].join("|")})\\b`,
57
73
  "i"
@@ -78,6 +94,10 @@ function isReadOnlySQL(sql, connectorType) {
78
94
  connectorType
79
95
  );
80
96
  }
97
+ function getFirstKeyword(sql, connectorType) {
98
+ const cleaned = stripCommentsAndStrings(sql, connectorType).trim();
99
+ return cleaned.match(/\S+/)?.[0]?.toLowerCase() ?? "";
100
+ }
81
101
  function checkReadOnly(cleanedSQL, connectorType) {
82
102
  if (!cleanedSQL) {
83
103
  return false;
@@ -87,7 +107,7 @@ function checkReadOnly(cleanedSQL, connectorType) {
87
107
  if (!keywordList.includes(firstWord)) {
88
108
  return false;
89
109
  }
90
- if (connectorType === "sqlserver" && sqlServerPassThroughPattern.test(cleanedSQL)) {
110
+ if (hasEscapeHatchFunction(cleanedSQL, connectorType)) {
91
111
  return false;
92
112
  }
93
113
  if (firstWord === "with") {
@@ -126,5 +146,7 @@ export {
126
146
  sqlServerDynamicSqlPattern,
127
147
  sqlServerPassThroughKeywords,
128
148
  sqlServerPassThroughPattern,
129
- isReadOnlySQL
149
+ hasEscapeHatchFunction,
150
+ isReadOnlySQL,
151
+ getFirstKeyword
130
152
  };
@@ -0,0 +1,16 @@
1
+ // src/connectors/health-check-utils.ts
2
+ function toNullableNumber(value) {
3
+ return value === null || value === void 0 ? null : Number(value);
4
+ }
5
+ function computeHitRatioPct(logicalReads, physicalReads) {
6
+ if (!Number.isFinite(logicalReads) || !Number.isFinite(physicalReads) || logicalReads === 0) {
7
+ return null;
8
+ }
9
+ const pct = Math.round((logicalReads - physicalReads) / logicalReads * 1e4) / 100;
10
+ return Math.min(100, Math.max(0, pct));
11
+ }
12
+
13
+ export {
14
+ toNullableNumber,
15
+ computeHitRatioPct
16
+ };
@@ -13,10 +13,17 @@ import {
13
13
  // src/tools/builtin-tools.ts
14
14
  var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
15
15
  var BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects";
16
+ var BUILTIN_TOOL_EXPLAIN_SQL = "explain_sql";
17
+ var BUILTIN_TOOL_HEALTH_CHECK = "health_check";
16
18
  var BUILTIN_TOOLS = [
17
19
  BUILTIN_TOOL_EXECUTE_SQL,
18
20
  BUILTIN_TOOL_SEARCH_OBJECTS
19
21
  ];
22
+ var ALL_BUILTIN_TOOL_NAMES = [
23
+ ...BUILTIN_TOOLS,
24
+ BUILTIN_TOOL_EXPLAIN_SQL,
25
+ BUILTIN_TOOL_HEALTH_CHECK
26
+ ];
20
27
 
21
28
  // src/utils/ssh-tunnel.ts
22
29
  import { Client } from "ssh2";
@@ -1130,7 +1137,7 @@ function validateToolsConfig(tools, sources, configPath) {
1130
1137
  `Configuration file ${configPath}: tool '${tool.name}' references unknown source '${tool.source}'`
1131
1138
  );
1132
1139
  }
1133
- const isBuiltin = BUILTIN_TOOLS.includes(tool.name);
1140
+ const isBuiltin = ALL_BUILTIN_TOOL_NAMES.includes(tool.name);
1134
1141
  const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL;
1135
1142
  if (isBuiltin) {
1136
1143
  if (tool.description || tool.statement || tool.parameters) {
@@ -2278,7 +2285,7 @@ var ToolRegistry = class {
2278
2285
  * Check if a tool name is a built-in tool
2279
2286
  */
2280
2287
  isBuiltinTool(toolName) {
2281
- return BUILTIN_TOOLS.includes(toolName);
2288
+ return ALL_BUILTIN_TOOL_NAMES.includes(toolName);
2282
2289
  }
2283
2290
  /**
2284
2291
  * Validate a custom tool parameter definition
@@ -2350,10 +2357,10 @@ var ToolRegistry = class {
2350
2357
  `Tool '${toolConfig.name}' references unknown source '${toolConfig.source}'. Available sources: ${availableSources.join(", ")}`
2351
2358
  );
2352
2359
  }
2353
- for (const builtinName of BUILTIN_TOOLS) {
2360
+ for (const builtinName of ALL_BUILTIN_TOOL_NAMES) {
2354
2361
  if (toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`)) {
2355
2362
  throw new Error(
2356
- `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${BUILTIN_TOOLS.join(", ")}`
2363
+ `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${ALL_BUILTIN_TOOL_NAMES.join(", ")}`
2357
2364
  );
2358
2365
  }
2359
2366
  }
@@ -2488,6 +2495,8 @@ export {
2488
2495
  resolveSourceConfigs,
2489
2496
  BUILTIN_TOOL_EXECUTE_SQL,
2490
2497
  BUILTIN_TOOL_SEARCH_OBJECTS,
2498
+ BUILTIN_TOOL_EXPLAIN_SQL,
2499
+ BUILTIN_TOOL_HEALTH_CHECK,
2491
2500
  loadTomlConfig,
2492
2501
  resolveTomlConfigPath,
2493
2502
  classifyConnectionError,
@@ -0,0 +1,190 @@
1
+ import {
2
+ computeHitRatioPct,
3
+ toNullableNumber
4
+ } from "./chunk-FU2ZJE4E.js";
5
+ import {
6
+ obfuscateDSNPassword
7
+ } from "./chunk-JEZZN2YZ.js";
8
+
9
+ // src/connectors/mysql-family-health-check.ts
10
+ async function getMySQLFamilyHealthCheck(query) {
11
+ const [processlistRows, maxConnRows, bufferPoolRows, trxOutcome] = await Promise.all([
12
+ query(`
13
+ SELECT
14
+ COUNT(*) AS total,
15
+ SUM(CASE WHEN COMMAND != 'Sleep' THEN 1 ELSE 0 END) AS active,
16
+ SUM(CASE WHEN COMMAND = 'Sleep' THEN 1 ELSE 0 END) AS idle,
17
+ MAX(CASE WHEN COMMAND != 'Sleep' THEN TIME ELSE NULL END) AS longest_active_query_seconds
18
+ FROM information_schema.PROCESSLIST
19
+ WHERE ID <> CONNECTION_ID()
20
+ `),
21
+ query(`SHOW VARIABLES LIKE 'max_connections'`),
22
+ query(`
23
+ SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads')
24
+ `),
25
+ // Joining PROCESSLIST against INNODB_TRX to detect idle-in-transaction
26
+ // sessions requires the PROCESS privilege - unlike a plain PROCESSLIST
27
+ // select, which silently narrows to the caller's own session instead of
28
+ // erroring. Run concurrently with the other queries above, but resolve
29
+ // rather than reject on failure so it degrades instead of failing the
30
+ // whole health check.
31
+ query(`
32
+ SELECT
33
+ COUNT(*) AS idle_in_transaction,
34
+ MAX(TIMESTAMPDIFF(SECOND, t.trx_started, NOW())) AS longest_idle_in_transaction_seconds
35
+ FROM information_schema.INNODB_TRX t
36
+ JOIN information_schema.PROCESSLIST p ON p.ID = t.trx_mysql_thread_id
37
+ WHERE p.COMMAND = 'Sleep'
38
+ `).then(
39
+ (rows) => ({ ok: true, rows }),
40
+ () => ({ ok: false })
41
+ )
42
+ ]);
43
+ const notes = [];
44
+ let idleInTransaction = 0;
45
+ let longestIdleInTransactionSeconds = null;
46
+ if (trxOutcome.ok) {
47
+ idleInTransaction = Number(trxOutcome.rows[0].idle_in_transaction);
48
+ longestIdleInTransactionSeconds = toNullableNumber(trxOutcome.rows[0].longest_idle_in_transaction_seconds);
49
+ } else {
50
+ notes.push(
51
+ "Connected user lacks the PROCESS privilege: PROCESSLIST visibility is restricted to the connecting user's own sessions, and this diagnostic session is excluded from the count, so connection counts and active-query duration may under-report (down to 0). Idle-in-transaction detection is unavailable."
52
+ );
53
+ }
54
+ const proc = processlistRows[0];
55
+ const bufferPoolStats = Object.fromEntries(bufferPoolRows.map((row) => [row.Variable_name, row.Value]));
56
+ const readRequests = Number(bufferPoolStats.Innodb_buffer_pool_read_requests ?? 0);
57
+ const reads = Number(bufferPoolStats.Innodb_buffer_pool_reads ?? 0);
58
+ return {
59
+ connections: {
60
+ total: Number(proc.total),
61
+ active: Number(proc.active),
62
+ idle: Number(proc.idle),
63
+ idleInTransaction,
64
+ // MySQL/MariaDB's InnoDB has no equivalent of Postgres's "idle in
65
+ // transaction (aborted)" state - a failed statement doesn't leave the
66
+ // session's transaction in a distinct aborted-but-open state the way
67
+ // Postgres does - so idleInTransactionAborted is left undefined.
68
+ maxConnections: maxConnRows.length > 0 ? Number(maxConnRows[0].Value) : null,
69
+ longestIdleInTransactionSeconds,
70
+ longestActiveQuerySeconds: toNullableNumber(proc.longest_active_query_seconds)
71
+ },
72
+ bufferCache: {
73
+ hitRatioPct: computeHitRatioPct(readRequests, reads),
74
+ blocksHit: readRequests - reads,
75
+ blocksRead: reads
76
+ },
77
+ ...notes.length > 0 ? { notes } : {}
78
+ };
79
+ }
80
+
81
+ // src/utils/dsn-database.ts
82
+ var MissingDatabaseError = class extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = "MissingDatabaseError";
86
+ }
87
+ };
88
+ function requireDatabaseInDSN(database, dsn, label) {
89
+ if (database) {
90
+ return;
91
+ }
92
+ throw new MissingDatabaseError(
93
+ `${label} DSN must name a database.
94
+ Provided: ${obfuscateDSNPassword(dsn)}
95
+ Add the database to the DSN, e.g. ...:3306/mydb
96
+ To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
97
+ );
98
+ }
99
+
100
+ // src/utils/multi-statement-result-parser.ts
101
+ function isMetadataObject(element) {
102
+ if (!element || typeof element !== "object" || Array.isArray(element)) {
103
+ return false;
104
+ }
105
+ return "affectedRows" in element || "insertId" in element || "fieldCount" in element || "warningStatus" in element;
106
+ }
107
+ function isMultiStatementResult(results) {
108
+ if (!Array.isArray(results) || results.length === 0) {
109
+ return false;
110
+ }
111
+ const firstElement = results[0];
112
+ return isMetadataObject(firstElement) || Array.isArray(firstElement);
113
+ }
114
+ function extractRowsFromMultiStatement(results) {
115
+ if (!Array.isArray(results)) {
116
+ return [];
117
+ }
118
+ const allRows = [];
119
+ for (const result of results) {
120
+ if (Array.isArray(result)) {
121
+ allRows.push(...result);
122
+ }
123
+ }
124
+ return allRows;
125
+ }
126
+ function extractAffectedRows(results) {
127
+ if (isMetadataObject(results)) {
128
+ return results.affectedRows || 0;
129
+ }
130
+ if (!Array.isArray(results)) {
131
+ return 0;
132
+ }
133
+ if (isMultiStatementResult(results)) {
134
+ let totalAffected = 0;
135
+ for (const result of results) {
136
+ if (isMetadataObject(result)) {
137
+ totalAffected += result.affectedRows || 0;
138
+ } else if (Array.isArray(result)) {
139
+ totalAffected += result.length;
140
+ }
141
+ }
142
+ return totalAffected;
143
+ }
144
+ return results.length;
145
+ }
146
+ function parseQueryResults(results) {
147
+ if (!Array.isArray(results)) {
148
+ return [];
149
+ }
150
+ if (isMultiStatementResult(results)) {
151
+ return extractRowsFromMultiStatement(results);
152
+ }
153
+ return results;
154
+ }
155
+
156
+ // src/utils/readonly-transaction.ts
157
+ async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
158
+ if (!readonly) {
159
+ return execute();
160
+ }
161
+ try {
162
+ await conn.query(
163
+ supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
164
+ );
165
+ const result = await execute();
166
+ await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
167
+ return result;
168
+ } catch (error) {
169
+ try {
170
+ await conn.query("ROLLBACK");
171
+ } catch {
172
+ }
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ // src/utils/server-flavor.ts
178
+ function isTiDBVersion(version) {
179
+ return typeof version === "string" && /tidb/i.test(version);
180
+ }
181
+
182
+ export {
183
+ getMySQLFamilyHealthCheck,
184
+ MissingDatabaseError,
185
+ requireDatabaseInDSN,
186
+ extractAffectedRows,
187
+ parseQueryResults,
188
+ withReadOnlyTransaction,
189
+ isTiDBVersion
190
+ };
@@ -66,7 +66,8 @@ var SQLRowLimiter = class {
66
66
  const trimmed = sql.trim();
67
67
  const hasSemicolon = trimmed.endsWith(";");
68
68
  const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
69
- return `${sqlWithoutSemicolon} LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
69
+ return `${sqlWithoutSemicolon}
70
+ LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
70
71
  }
71
72
  }
72
73
  /**
@@ -107,7 +108,8 @@ var SQLRowLimiter = class {
107
108
  const trimmed = sql.trim();
108
109
  const hasSemicolon = trimmed.endsWith(";");
109
110
  const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
110
- return `SELECT * FROM (${sqlWithoutSemicolon}) AS subq LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
111
+ return `SELECT * FROM (${sqlWithoutSemicolon}
112
+ ) AS subq LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
111
113
  }
112
114
  return this.applyLimitToQuery(sql, maxRows);
113
115
  }