@bytebase/dbhub 1.0.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,61 +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
- **Claude Code Plugin:**
88
-
89
- For Claude Code, the [DBHub plugin](https://dbhub.ai/claude-code-plugin) registers the MCP server with one install command — Claude Code prompts for your connection string and stores it in secure storage — and adds `/dbhub:setup` and `/dbhub:explore` skills. Read-only by design, like the MCP Bundle.
90
-
91
- **Demo Mode:**
92
-
93
- ```bash
94
- npx @bytebase/dbhub@latest --transport http --port 8080 --demo
95
- ```
96
-
97
- **Restrict to loopback (recommended for production):**
98
-
99
- ```bash
100
- npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
101
- ```
102
-
103
- > 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.
104
- >
105
- > 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`.
106
- >
107
- > DBHub serves both MCP protocol eras on the same `/mcp` endpoint: the stateless **2026-07-28** revision (cacheable `tools/list`, no session state — safe behind round-robin load balancers) and the 2025-era Streamable HTTP protocol for older clients. 2026-era clients send the standard `Mcp-Method` / `Mcp-Name` headers on every request, so a fronting gateway or WAF can route and rate-limit `execute_sql` separately from `search_objects` without parsing JSON bodies.
108
-
109
- See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
110
-
111
- ### Multi-Database Setup
67
+ Also available as:
112
68
 
113
- 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)
114
72
 
115
- 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.
116
74
 
117
75
  ## Development
118
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
+ };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  BUILTIN_TOOL_EXECUTE_SQL,
4
+ BUILTIN_TOOL_EXPLAIN_SQL,
5
+ BUILTIN_TOOL_HEALTH_CHECK,
4
6
  BUILTIN_TOOL_SEARCH_OBJECTS,
5
7
  ConnectorManager,
6
8
  classifyConnectionError,
@@ -15,13 +17,14 @@ import {
15
17
  resolveSourceConfigs,
16
18
  resolveTomlConfigPath,
17
19
  resolveTransport
18
- } from "./chunk-MQR6VX5F.js";
20
+ } from "./chunk-GSSLHJB6.js";
19
21
  import {
20
22
  allowedKeywords,
23
+ getFirstKeyword,
24
+ hasEscapeHatchFunction,
21
25
  isReadOnlySQL,
22
- sqlServerDynamicSqlPattern,
23
- sqlServerPassThroughPattern
24
- } from "./chunk-JO7YQPBQ.js";
26
+ sqlServerDynamicSqlPattern
27
+ } from "./chunk-EPOKWSFC.js";
25
28
  import {
26
29
  loadConnectors
27
30
  } from "./chunk-WVVMH6FJ.js";
@@ -114,7 +117,8 @@ var dmlPattern = /\b(?:insert|update|delete|merge|replace\s+(?:(?:low_priority|d
114
117
  var grantRevokePattern = /\b(?:grant|revoke)\b/i;
115
118
  function isAdminStatement(stripped, connectorType) {
116
119
  if (grantRevokePattern.test(stripped)) return true;
117
- return connectorType === "sqlserver" && (sqlServerDynamicSqlPattern.test(stripped) || sqlServerPassThroughPattern.test(stripped));
120
+ if (hasEscapeHatchFunction(stripped, connectorType)) return true;
121
+ return connectorType === "sqlserver" && sqlServerDynamicSqlPattern.test(stripped);
118
122
  }
119
123
  function policyFromReadonly(readonly) {
120
124
  const write = readonly === true ? "deny" : "allow";
@@ -206,10 +210,22 @@ function getClientIdentifier(ctx) {
206
210
  return "stdio";
207
211
  }
208
212
 
213
+ // src/utils/normalize-id.ts
214
+ function normalizeSourceId(id) {
215
+ return id.replace(/[^a-zA-Z0-9]/g, "_");
216
+ }
217
+
209
218
  // src/utils/tool-handler-helpers.ts
210
219
  function getEffectiveSourceId(sourceId) {
211
220
  return sourceId || "default";
212
221
  }
222
+ function resolveTrackedToolName(sourceId, baseName) {
223
+ const isSingleSource = ConnectorManager.getAvailableSourceIds().length === 1;
224
+ if (isSingleSource) {
225
+ return baseName;
226
+ }
227
+ return `${baseName}_${normalizeSourceId(getEffectiveSourceId(sourceId))}`;
228
+ }
213
229
  function createReadonlyViolationMessage(toolName, sourceId, connectorType) {
214
230
  return `Tool '${toolName}' cannot execute in readonly mode for source '${sourceId}'. Only read-only SQL operations are allowed: ${allowedKeywords[connectorType]?.join(", ") || "none"}`;
215
231
  }
@@ -785,15 +801,151 @@ function createSearchDatabaseObjectsToolHandler(sourceId) {
785
801
  };
786
802
  }
787
803
 
788
- // src/utils/tool-metadata.ts
804
+ // src/tools/explain-sql.ts
789
805
  import { z as z3 } from "zod";
806
+ var explainSqlSchema = {
807
+ sql: z3.string().describe("Single SQL statement to explain (no trailing semicolon-separated statements)")
808
+ };
809
+ var explainSqlInputSchema = z3.object(explainSqlSchema);
810
+ function buildExplainStatement(connectorType, sql) {
811
+ if (connectorType === "sqlite") {
812
+ return `EXPLAIN QUERY PLAN ${sql}`;
813
+ }
814
+ return `EXPLAIN ${sql}`;
815
+ }
816
+ function validateExplainInput(sql, connectorType) {
817
+ const statements = splitSQLStatements(sql, connectorType);
818
+ if (statements.length !== 1) {
819
+ return "explain_sql only supports a single SQL statement";
820
+ }
821
+ const firstWord = getFirstKeyword(statements[0], connectorType);
822
+ if (firstWord === "explain") {
823
+ return "explain_sql input must not itself start with EXPLAIN";
824
+ }
825
+ if (firstWord === "analyze") {
826
+ return "explain_sql does not support ANALYZE (it must never execute the statement)";
827
+ }
828
+ const cleaned = stripCommentsAndStrings(statements[0], connectorType).trim();
829
+ const leadingOptions = cleaned.match(/^\(([\s\S]*?)\)/)?.[1];
830
+ if (leadingOptions && /\banalyze\b/i.test(leadingOptions)) {
831
+ return "explain_sql does not support ANALYZE (it must never execute the statement)";
832
+ }
833
+ return null;
834
+ }
835
+ function createExplainSqlToolHandler(sourceId) {
836
+ return async (args, extra) => {
837
+ const { sql } = args;
838
+ const startTime = Date.now();
839
+ const effectiveSourceId = getEffectiveSourceId(sourceId);
840
+ let success = true;
841
+ let errorMessage;
842
+ let result;
843
+ try {
844
+ await ConnectorManager.ensureConnected(sourceId);
845
+ const connector = ConnectorManager.getCurrentConnector(sourceId);
846
+ const validationError = validateExplainInput(sql, connector.id);
847
+ if (validationError) {
848
+ success = false;
849
+ errorMessage = validationError;
850
+ return createToolErrorResponse(errorMessage, "INVALID_INPUT");
851
+ }
852
+ const explainStatement = buildExplainStatement(connector.id, sql);
853
+ result = await connector.executeSQL(explainStatement, { readonly: true });
854
+ const responseData = {
855
+ rows: result.rows,
856
+ count: result.rowCount,
857
+ source_id: effectiveSourceId,
858
+ ...result.messages && result.messages.length > 0 ? { messages: result.messages } : {}
859
+ };
860
+ return createToolSuccessResponse(responseData);
861
+ } catch (error) {
862
+ success = false;
863
+ errorMessage = error.message;
864
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
865
+ if (classified) return classified;
866
+ return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
867
+ } finally {
868
+ trackToolRequest(
869
+ {
870
+ sourceId: effectiveSourceId,
871
+ toolName: resolveTrackedToolName(sourceId, "explain_sql"),
872
+ sql
873
+ },
874
+ startTime,
875
+ extra,
876
+ success,
877
+ errorMessage
878
+ );
879
+ }
880
+ };
881
+ }
790
882
 
791
- // src/utils/normalize-id.ts
792
- function normalizeSourceId(id) {
793
- return id.replace(/[^a-zA-Z0-9]/g, "_");
883
+ // src/tools/health-check.ts
884
+ import { z as z4 } from "zod";
885
+ var healthCheckSchema = {};
886
+ var healthCheckInputSchema = z4.object(healthCheckSchema);
887
+ function createHealthCheckToolHandler(sourceId) {
888
+ return async (_args, extra) => {
889
+ const startTime = Date.now();
890
+ const effectiveSourceId = getEffectiveSourceId(sourceId);
891
+ let success = true;
892
+ let errorMessage;
893
+ try {
894
+ await ConnectorManager.ensureConnected(sourceId);
895
+ const connector = ConnectorManager.getCurrentConnector(sourceId);
896
+ if (!connector.getHealthCheck) {
897
+ success = false;
898
+ errorMessage = `health_check is not supported for '${connector.id}' sources yet`;
899
+ return createToolErrorResponse(errorMessage, "UNSUPPORTED");
900
+ }
901
+ const health = await connector.getHealthCheck();
902
+ return createToolSuccessResponse({
903
+ source_id: effectiveSourceId,
904
+ ...health.connections ? {
905
+ connections: {
906
+ total: health.connections.total,
907
+ active: health.connections.active,
908
+ idle: health.connections.idle,
909
+ idle_in_transaction: health.connections.idleInTransaction,
910
+ ...health.connections.idleInTransactionAborted !== void 0 ? { idle_in_transaction_aborted: health.connections.idleInTransactionAborted } : {},
911
+ max_connections: health.connections.maxConnections,
912
+ longest_idle_in_transaction_seconds: health.connections.longestIdleInTransactionSeconds,
913
+ longest_active_query_seconds: health.connections.longestActiveQuerySeconds
914
+ }
915
+ } : {},
916
+ ...health.bufferCache ? {
917
+ buffer_cache: {
918
+ hit_ratio_pct: health.bufferCache.hitRatioPct,
919
+ blocks_hit: health.bufferCache.blocksHit,
920
+ blocks_read: health.bufferCache.blocksRead
921
+ }
922
+ } : {},
923
+ ...health.notes && health.notes.length > 0 ? { notes: health.notes } : {}
924
+ });
925
+ } catch (error) {
926
+ success = false;
927
+ errorMessage = error.message;
928
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
929
+ if (classified) return classified;
930
+ return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
931
+ } finally {
932
+ trackToolRequest(
933
+ {
934
+ sourceId: effectiveSourceId,
935
+ toolName: effectiveSourceId === "default" ? "health_check" : `health_check_${effectiveSourceId}`,
936
+ sql: ""
937
+ },
938
+ startTime,
939
+ extra,
940
+ success,
941
+ errorMessage
942
+ );
943
+ }
944
+ };
794
945
  }
795
946
 
796
947
  // src/utils/tool-metadata.ts
948
+ import { z as z5 } from "zod";
797
949
  function buildSourceDescriptionPrefix(description) {
798
950
  const trimmed = description?.trim() ?? "";
799
951
  if (!trimmed) return "";
@@ -803,17 +955,17 @@ function zodToParameters(schema) {
803
955
  const parameters = [];
804
956
  for (const [key, zodType] of Object.entries(schema)) {
805
957
  const description = zodType.description || "";
806
- const required = !(zodType instanceof z3.ZodOptional);
958
+ const required = !(zodType instanceof z5.ZodOptional);
807
959
  let type = "string";
808
- if (zodType instanceof z3.ZodString) {
960
+ if (zodType instanceof z5.ZodString) {
809
961
  type = "string";
810
- } else if (zodType instanceof z3.ZodNumber) {
962
+ } else if (zodType instanceof z5.ZodNumber) {
811
963
  type = "number";
812
- } else if (zodType instanceof z3.ZodBoolean) {
964
+ } else if (zodType instanceof z5.ZodBoolean) {
813
965
  type = "boolean";
814
- } else if (zodType instanceof z3.ZodArray) {
966
+ } else if (zodType instanceof z5.ZodArray) {
815
967
  type = "array";
816
- } else if (zodType instanceof z3.ZodObject) {
968
+ } else if (zodType instanceof z5.ZodObject) {
817
969
  type = "object";
818
970
  }
819
971
  parameters.push({
@@ -825,20 +977,28 @@ function zodToParameters(schema) {
825
977
  }
826
978
  return parameters;
827
979
  }
828
- function getExecuteSqlMetadata(sourceId) {
980
+ function resolveBuiltinToolNaming(sourceId, baseName, titleLabel) {
829
981
  const sourceIds = ConnectorManager.getAvailableSourceIds();
830
982
  const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
831
983
  const dbType = sourceConfig.type;
832
984
  const isSingleSource = sourceIds.length === 1;
985
+ const name = isSingleSource ? baseName : `${baseName}_${normalizeSourceId(sourceId)}`;
986
+ const title = isSingleSource ? `${titleLabel} (${dbType})` : `${titleLabel} on ${sourceId} (${dbType})`;
987
+ const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description);
988
+ return { dbType, isSingleSource, name, title, userDescPrefix };
989
+ }
990
+ function getExecuteSqlMetadata(sourceId) {
991
+ const { dbType, isSingleSource, name, title, userDescPrefix } = resolveBuiltinToolNaming(
992
+ sourceId,
993
+ "execute_sql",
994
+ "Execute SQL"
995
+ );
833
996
  const registry = getToolRegistry();
834
997
  const toolConfig = registry.getBuiltinToolConfig(BUILTIN_TOOL_EXECUTE_SQL, sourceId);
835
998
  const executeOptions = {
836
999
  readonly: toolConfig?.readonly,
837
1000
  maxRows: toolConfig?.max_rows
838
1001
  };
839
- const toolName = isSingleSource ? "execute_sql" : `execute_sql_${normalizeSourceId(sourceId)}`;
840
- const title = isSingleSource ? `Execute SQL (${dbType})` : `Execute SQL on ${sourceId} (${dbType})`;
841
- const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description);
842
1002
  const readonlyNote = executeOptions.readonly ? " [READ-ONLY MODE]" : "";
843
1003
  const maxRowsNote = executeOptions.maxRows ? ` (limited to ${executeOptions.maxRows} rows)` : "";
844
1004
  const description = isSingleSource ? `${userDescPrefix}Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` : `${userDescPrefix}Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`;
@@ -855,25 +1015,62 @@ function getExecuteSqlMetadata(sourceId) {
855
1015
  openWorldHint: false
856
1016
  };
857
1017
  return {
858
- name: toolName,
1018
+ name,
859
1019
  description,
860
1020
  schema: executeSqlSchema,
861
1021
  annotations
862
1022
  };
863
1023
  }
864
1024
  function getSearchObjectsMetadata(sourceId) {
865
- const sourceIds = ConnectorManager.getAvailableSourceIds();
866
- const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
867
- const dbType = sourceConfig.type;
868
- const isSingleSource = sourceIds.length === 1;
869
- const toolName = isSingleSource ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`;
870
- const title = isSingleSource ? `Search Database Objects (${dbType})` : `Search Database Objects on ${sourceId} (${dbType})`;
871
- const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description);
1025
+ const { dbType, isSingleSource, name, title, userDescPrefix } = resolveBuiltinToolNaming(
1026
+ sourceId,
1027
+ "search_objects",
1028
+ "Search Database Objects"
1029
+ );
872
1030
  const description = isSingleSource ? `${userDescPrefix}Search and list database objects on the ${dbType} database` : `${userDescPrefix}Search and list database objects on the '${sourceId}' ${dbType} database`;
1031
+ return { name, description, title };
1032
+ }
1033
+ function getExplainSqlMetadata(sourceId) {
1034
+ const { dbType, isSingleSource, name, title, userDescPrefix } = resolveBuiltinToolNaming(
1035
+ sourceId,
1036
+ "explain_sql",
1037
+ "Explain Query Plan"
1038
+ );
1039
+ const description = isSingleSource ? `${userDescPrefix}Show the execution plan for a SQL statement on the ${dbType} database without running it. Always read-only and safe, independent of the source's read/write mode.` : `${userDescPrefix}Show the execution plan for a SQL statement on the '${sourceId}' ${dbType} database without running it. Always read-only and safe, independent of the source's read/write mode.`;
1040
+ const annotations = {
1041
+ title,
1042
+ readOnlyHint: true,
1043
+ destructiveHint: false,
1044
+ idempotentHint: true,
1045
+ openWorldHint: false
1046
+ };
1047
+ return {
1048
+ name,
1049
+ description,
1050
+ schema: explainSqlSchema,
1051
+ annotations
1052
+ };
1053
+ }
1054
+ function getHealthCheckMetadata(sourceId) {
1055
+ const { dbType, isSingleSource, name, title, userDescPrefix } = resolveBuiltinToolNaming(
1056
+ sourceId,
1057
+ "health_check",
1058
+ "Health Check"
1059
+ );
1060
+ const description = isSingleSource ? `${userDescPrefix}Report operational health metrics (connection pool state, buffer cache hit ratio) for the ${dbType} database. Read-only, independent of the source's read/write mode.` : `${userDescPrefix}Report operational health metrics (connection pool state, buffer cache hit ratio) for the '${sourceId}' ${dbType} database. Read-only, independent of the source's read/write mode.`;
1061
+ const annotations = {
1062
+ title,
1063
+ readOnlyHint: true,
1064
+ destructiveHint: false,
1065
+ idempotentHint: false,
1066
+ // Metrics change from call to call, unlike explain_sql's plan output
1067
+ openWorldHint: false
1068
+ };
873
1069
  return {
874
- name: toolName,
1070
+ name,
875
1071
  description,
876
- title
1072
+ schema: healthCheckSchema,
1073
+ annotations
877
1074
  };
878
1075
  }
879
1076
  function customParamsToToolParams(params) {
@@ -947,6 +1144,16 @@ function buildSearchObjectsTool(sourceId) {
947
1144
  // search_objects is always readonly
948
1145
  };
949
1146
  }
1147
+ function buildExplainSqlTool(sourceId) {
1148
+ const explainMetadata = getExplainSqlMetadata(sourceId);
1149
+ return {
1150
+ name: explainMetadata.name,
1151
+ description: explainMetadata.description,
1152
+ parameters: zodToParameters(explainMetadata.schema),
1153
+ readonly: true
1154
+ // explain_sql is always readonly
1155
+ };
1156
+ }
950
1157
  function buildCustomTool(toolConfig) {
951
1158
  return {
952
1159
  name: toolConfig.name,
@@ -965,6 +1172,8 @@ function getToolsForSource(sourceId) {
965
1172
  return buildExecuteSqlTool(sourceId, toolConfig);
966
1173
  } else if (toolConfig.name === "search_objects") {
967
1174
  return buildSearchObjectsTool(sourceId);
1175
+ } else if (toolConfig.name === "explain_sql") {
1176
+ return buildExplainSqlTool(sourceId);
968
1177
  } else {
969
1178
  return buildCustomTool(toolConfig);
970
1179
  }
@@ -972,7 +1181,7 @@ function getToolsForSource(sourceId) {
972
1181
  }
973
1182
 
974
1183
  // src/tools/custom-tool-handler.ts
975
- import { z as z4 } from "zod";
1184
+ import { z as z6 } from "zod";
976
1185
  function buildZodSchemaFromParameters(parameters) {
977
1186
  if (!parameters || parameters.length === 0) {
978
1187
  return {};
@@ -982,26 +1191,26 @@ function buildZodSchemaFromParameters(parameters) {
982
1191
  let fieldSchema;
983
1192
  switch (param.type) {
984
1193
  case "string":
985
- fieldSchema = z4.string().describe(param.description);
1194
+ fieldSchema = z6.string().describe(param.description);
986
1195
  break;
987
1196
  case "integer":
988
- fieldSchema = z4.number().int().describe(param.description);
1197
+ fieldSchema = z6.number().int().describe(param.description);
989
1198
  break;
990
1199
  case "float":
991
- fieldSchema = z4.number().describe(param.description);
1200
+ fieldSchema = z6.number().describe(param.description);
992
1201
  break;
993
1202
  case "boolean":
994
- fieldSchema = z4.boolean().describe(param.description);
1203
+ fieldSchema = z6.boolean().describe(param.description);
995
1204
  break;
996
1205
  case "array":
997
- fieldSchema = z4.array(z4.unknown()).describe(param.description);
1206
+ fieldSchema = z6.array(z6.unknown()).describe(param.description);
998
1207
  break;
999
1208
  default:
1000
1209
  throw new Error(`Unsupported parameter type: ${param.type}`);
1001
1210
  }
1002
1211
  if (param.allowed_values && param.allowed_values.length > 0) {
1003
1212
  if (param.type === "string") {
1004
- fieldSchema = z4.enum(param.allowed_values).describe(param.description);
1213
+ fieldSchema = z6.enum(param.allowed_values).describe(param.description);
1005
1214
  } else {
1006
1215
  fieldSchema = fieldSchema.refine(
1007
1216
  (val) => param.allowed_values.includes(val),
@@ -1022,7 +1231,7 @@ var customToolInputSchemas = /* @__PURE__ */ new WeakMap();
1022
1231
  function getCustomToolInputSchema(toolConfig) {
1023
1232
  let schema = customToolInputSchemas.get(toolConfig);
1024
1233
  if (!schema) {
1025
- schema = z4.object(buildZodSchemaFromParameters(toolConfig.parameters));
1234
+ schema = z6.object(buildZodSchemaFromParameters(toolConfig.parameters));
1026
1235
  customToolInputSchemas.set(toolConfig, schema);
1027
1236
  }
1028
1237
  return schema;
@@ -1068,7 +1277,7 @@ function createCustomToolHandler(toolConfig) {
1068
1277
  errorMessage = error.message;
1069
1278
  const classified = tryClassifyConnectionError(error, toolConfig.source, toolConfig.source);
1070
1279
  if (classified) return classified;
1071
- if (error instanceof z4.ZodError) {
1280
+ if (error instanceof z6.ZodError) {
1072
1281
  const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
1073
1282
  errorMessage = `Parameter validation failed: ${issues}`;
1074
1283
  } else {
@@ -1108,6 +1317,10 @@ function registerTools(server) {
1108
1317
  registerExecuteSqlTool(server, sourceId);
1109
1318
  } else if (toolConfig.name === BUILTIN_TOOL_SEARCH_OBJECTS) {
1110
1319
  registerSearchObjectsTool(server, sourceId);
1320
+ } else if (toolConfig.name === BUILTIN_TOOL_EXPLAIN_SQL) {
1321
+ registerExplainSqlTool(server, sourceId);
1322
+ } else if (toolConfig.name === BUILTIN_TOOL_HEALTH_CHECK) {
1323
+ registerHealthCheckTool(server, sourceId);
1111
1324
  } else {
1112
1325
  registerCustomTool(server, sourceId, toolConfig);
1113
1326
  }
@@ -1144,6 +1357,30 @@ function registerSearchObjectsTool(server, sourceId) {
1144
1357
  createSearchDatabaseObjectsToolHandler(sourceId)
1145
1358
  );
1146
1359
  }
1360
+ function registerExplainSqlTool(server, sourceId) {
1361
+ const metadata = getExplainSqlMetadata(sourceId);
1362
+ server.registerTool(
1363
+ metadata.name,
1364
+ {
1365
+ description: metadata.description,
1366
+ inputSchema: explainSqlInputSchema,
1367
+ annotations: metadata.annotations
1368
+ },
1369
+ createExplainSqlToolHandler(sourceId)
1370
+ );
1371
+ }
1372
+ function registerHealthCheckTool(server, sourceId) {
1373
+ const metadata = getHealthCheckMetadata(sourceId);
1374
+ server.registerTool(
1375
+ metadata.name,
1376
+ {
1377
+ description: metadata.description,
1378
+ inputSchema: healthCheckInputSchema,
1379
+ annotations: metadata.annotations
1380
+ },
1381
+ createHealthCheckToolHandler(sourceId)
1382
+ );
1383
+ }
1147
1384
  function registerCustomTool(server, sourceId, toolConfig) {
1148
1385
  const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
1149
1386
  const dbType = sourceConfig.type;
@@ -1639,7 +1876,7 @@ See documentation for more details on configuring database connections.
1639
1876
  const sources = sourceConfigsData.sources;
1640
1877
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1641
1878
  await connectorManager.connectWithSources(sources);
1642
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZU6ZFNQ2.js");
1879
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-JYRRNC3E.js");
1643
1880
  initializeToolRegistry2({
1644
1881
  sources: sourceConfigsData.sources,
1645
1882
  tools: sourceConfigsData.tools
@@ -1780,11 +2017,11 @@ See documentation for more details on configuring database connections.
1780
2017
 
1781
2018
  // src/index.ts
1782
2019
  var connectorModules = [
1783
- { load: () => import("./postgres-YAYHDXVD.js"), name: "PostgreSQL", driver: "pg" },
1784
- { load: () => import("./sqlserver-COKW7RIK.js"), name: "SQL Server", driver: "mssql" },
2020
+ { load: () => import("./postgres-5ZOFYV6X.js"), name: "PostgreSQL", driver: "pg" },
2021
+ { load: () => import("./sqlserver-PSAZRH7G.js"), name: "SQL Server", driver: "mssql" },
1785
2022
  { load: () => import("./sqlite-5M7SDAQZ.js"), name: "SQLite", driver: "node:sqlite" },
1786
- { load: () => import("./mysql-CDUKJMNN.js"), name: "MySQL", driver: "mysql2" },
1787
- { load: () => import("./mariadb-72C2BNCG.js"), name: "MariaDB", driver: "mariadb" }
2023
+ { load: () => import("./mysql-265M3KBD.js"), name: "MySQL", driver: "mysql2" },
2024
+ { load: () => import("./mariadb-DUDFBY5W.js"), name: "MariaDB", driver: "mariadb" }
1788
2025
  ];
1789
2026
  loadConnectors(connectorModules).then(() => main()).catch((error) => {
1790
2027
  console.error("Fatal error:", error);
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  MissingDatabaseError,
3
3
  extractAffectedRows,
4
+ getMySQLFamilyHealthCheck,
4
5
  isTiDBVersion,
5
6
  parseQueryResults,
6
7
  requireDatabaseInDSN,
7
8
  withReadOnlyTransaction
8
- } from "./chunk-DUCNPMNW.js";
9
+ } from "./chunk-NIILHXTG.js";
10
+ import "./chunk-FU2ZJE4E.js";
9
11
  import {
10
12
  quoteIdentifier
11
13
  } from "./chunk-JFWX35TB.js";
@@ -338,6 +340,13 @@ var MariaDBConnector = class _MariaDBConnector {
338
340
  return null;
339
341
  }
340
342
  }
343
+ async getHealthCheck() {
344
+ if (!this.pool) {
345
+ throw new Error("Not connected to database");
346
+ }
347
+ const pool = this.pool;
348
+ return getMySQLFamilyHealthCheck((sql) => pool.query(sql));
349
+ }
341
350
  async getStoredProcedures(schema, routineType) {
342
351
  if (!this.pool) {
343
352
  throw new Error("Not connected to database");
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  MissingDatabaseError,
3
3
  extractAffectedRows,
4
+ getMySQLFamilyHealthCheck,
4
5
  isTiDBVersion,
5
6
  parseQueryResults,
6
7
  requireDatabaseInDSN,
7
8
  withReadOnlyTransaction
8
- } from "./chunk-DUCNPMNW.js";
9
+ } from "./chunk-NIILHXTG.js";
10
+ import "./chunk-FU2ZJE4E.js";
9
11
  import {
10
12
  quoteIdentifier
11
13
  } from "./chunk-JFWX35TB.js";
@@ -343,6 +345,16 @@ var MySQLConnector = class _MySQLConnector {
343
345
  return null;
344
346
  }
345
347
  }
348
+ async getHealthCheck() {
349
+ if (!this.pool) {
350
+ throw new Error("Not connected to database");
351
+ }
352
+ const pool = this.pool;
353
+ return getMySQLFamilyHealthCheck(async (sql) => {
354
+ const [rows] = await pool.query(sql);
355
+ return rows;
356
+ });
357
+ }
346
358
  async getStoredProcedures(schema, routineType) {
347
359
  if (!this.pool) {
348
360
  throw new Error("Not connected to database");
@@ -1,3 +1,6 @@
1
+ import {
2
+ toNullableNumber
3
+ } from "./chunk-FU2ZJE4E.js";
1
4
  import {
2
5
  quoteIdentifier
3
6
  } from "./chunk-JFWX35TB.js";
@@ -383,6 +386,64 @@ var PostgresConnector = class _PostgresConnector {
383
386
  client.release();
384
387
  }
385
388
  }
389
+ async getHealthCheck() {
390
+ if (!this.pool) {
391
+ throw new Error("Not connected to database");
392
+ }
393
+ const client = await this.pool.connect();
394
+ try {
395
+ const [connectionsResult, maxConnectionsResult, bufferCacheResult] = await Promise.all([
396
+ client.query(`
397
+ SELECT
398
+ count(*) AS total,
399
+ count(*) FILTER (WHERE state = 'active') AS active,
400
+ count(*) FILTER (WHERE state = 'idle') AS idle,
401
+ count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
402
+ count(*) FILTER (WHERE state = 'idle in transaction (aborted)') AS idle_in_transaction_aborted,
403
+ max(EXTRACT(EPOCH FROM (now() - state_change)))
404
+ FILTER (WHERE state = 'idle in transaction') AS longest_idle_in_transaction_seconds,
405
+ max(EXTRACT(EPOCH FROM (now() - query_start)))
406
+ FILTER (WHERE state = 'active') AS longest_active_query_seconds
407
+ FROM pg_stat_activity
408
+ WHERE pid <> pg_backend_pid()
409
+ `),
410
+ client.query(`SHOW max_connections`),
411
+ client.query(
412
+ `
413
+ SELECT
414
+ blks_hit,
415
+ blks_read,
416
+ CASE WHEN (blks_hit + blks_read) = 0 THEN NULL
417
+ ELSE round(100.0 * blks_hit / (blks_hit + blks_read), 2)
418
+ END AS hit_ratio_pct
419
+ FROM pg_stat_database
420
+ WHERE datname = current_database()
421
+ `
422
+ )
423
+ ]);
424
+ const conn = connectionsResult.rows[0];
425
+ const cache = bufferCacheResult.rows[0];
426
+ return {
427
+ connections: {
428
+ total: Number(conn.total),
429
+ active: Number(conn.active),
430
+ idle: Number(conn.idle),
431
+ idleInTransaction: Number(conn.idle_in_transaction),
432
+ idleInTransactionAborted: Number(conn.idle_in_transaction_aborted),
433
+ maxConnections: Number(maxConnectionsResult.rows[0].max_connections),
434
+ longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds),
435
+ longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds)
436
+ },
437
+ bufferCache: {
438
+ hitRatioPct: toNullableNumber(cache.hit_ratio_pct),
439
+ blocksHit: Number(cache.blks_hit),
440
+ blocksRead: Number(cache.blks_read)
441
+ }
442
+ };
443
+ } finally {
444
+ client.release();
445
+ }
446
+ }
386
447
  async getStoredProcedures(schema, routineType) {
387
448
  if (!this.pool) {
388
449
  throw new Error("Not connected to database");
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-MQR6VX5F.js";
5
+ } from "./chunk-GSSLHJB6.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
7
  import "./chunk-JEZZN2YZ.js";
8
8
  export {
@@ -3,10 +3,14 @@ import {
3
3
  sqlServerDynamicSqlPattern,
4
4
  sqlServerPassThroughKeywords,
5
5
  sqlServerPassThroughPattern
6
- } from "./chunk-JO7YQPBQ.js";
6
+ } from "./chunk-EPOKWSFC.js";
7
7
  import {
8
8
  isDriverNotInstalled
9
9
  } from "./chunk-WVVMH6FJ.js";
10
+ import {
11
+ computeHitRatioPct,
12
+ toNullableNumber
13
+ } from "./chunk-FU2ZJE4E.js";
10
14
  import {
11
15
  SQLRowLimiter,
12
16
  closeQuietly
@@ -372,6 +376,81 @@ var _SQLServerConnector = class _SQLServerConnector {
372
376
  return null;
373
377
  }
374
378
  }
379
+ async getHealthCheck() {
380
+ if (!this.connection) {
381
+ throw new Error("Not connected to SQL Server database");
382
+ }
383
+ const notes = [];
384
+ const result = {};
385
+ try {
386
+ const [connResult, maxConnResult] = await Promise.all([
387
+ this.connection.request().query(`
388
+ SELECT
389
+ COUNT(*) AS total,
390
+ SUM(CASE WHEN r.session_id IS NOT NULL THEN 1 ELSE 0 END) AS active,
391
+ SUM(CASE WHEN r.session_id IS NULL THEN 1 ELSE 0 END) AS idle,
392
+ SUM(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
393
+ MAX(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL
394
+ THEN DATEDIFF(SECOND, s.last_request_end_time, SYSDATETIME()) ELSE NULL END) AS longest_idle_in_transaction_seconds,
395
+ MAX(CASE WHEN r.session_id IS NOT NULL
396
+ THEN DATEDIFF(SECOND, r.start_time, SYSDATETIME()) ELSE NULL END) AS longest_active_query_seconds
397
+ FROM sys.dm_exec_sessions s
398
+ LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
399
+ LEFT JOIN (SELECT DISTINCT session_id FROM sys.dm_tran_session_transactions) t ON t.session_id = s.session_id
400
+ WHERE s.is_user_process = 1 AND s.session_id <> @@SPID
401
+ `),
402
+ this.connection.request().query(`
403
+ SELECT CAST(value_in_use AS INT) AS max_connections
404
+ FROM sys.configurations
405
+ WHERE name = 'user connections'
406
+ `)
407
+ ]);
408
+ const conn = connResult.recordset[0];
409
+ const maxConnections = maxConnResult.recordset.length > 0 && maxConnResult.recordset[0].max_connections > 0 ? maxConnResult.recordset[0].max_connections : null;
410
+ result.connections = {
411
+ total: conn.total ?? 0,
412
+ active: conn.active ?? 0,
413
+ idle: conn.idle ?? 0,
414
+ idleInTransaction: conn.idle_in_transaction ?? 0,
415
+ // SQL Server has no equivalent of Postgres's "idle in transaction
416
+ // (aborted)" state - a failed statement doesn't leave the session's
417
+ // transaction in a distinct aborted-but-open state the way Postgres does.
418
+ maxConnections,
419
+ longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds),
420
+ longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds)
421
+ };
422
+ } catch {
423
+ notes.push(
424
+ "Connection pool metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
425
+ );
426
+ }
427
+ try {
428
+ const bufferResult = await this.connection.request().query(`
429
+ SELECT counter_name, CAST(cntr_value AS BIGINT) AS cntr_value
430
+ FROM sys.dm_os_performance_counters
431
+ WHERE object_name LIKE '%Buffer Manager%'
432
+ AND counter_name IN ('Page lookups/sec', 'Page reads/sec')
433
+ `);
434
+ const counters = Object.fromEntries(
435
+ bufferResult.recordset.map((row) => [row.counter_name.trim(), Number(row.cntr_value)])
436
+ );
437
+ const pageLookups = counters["Page lookups/sec"] ?? 0;
438
+ const pageReads = counters["Page reads/sec"] ?? 0;
439
+ result.bufferCache = {
440
+ hitRatioPct: computeHitRatioPct(pageLookups, pageReads),
441
+ blocksHit: pageLookups - pageReads,
442
+ blocksRead: pageReads
443
+ };
444
+ } catch {
445
+ notes.push(
446
+ "Buffer cache metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
447
+ );
448
+ }
449
+ if (notes.length > 0) {
450
+ result.notes = notes;
451
+ }
452
+ return result;
453
+ }
375
454
  async getStoredProcedures(schema, routineType) {
376
455
  if (!this.connection) {
377
456
  throw new Error("Not connected to SQL Server database");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.bytebase/dbhub",
5
5
  "description": "Minimal, token-efficient Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
6
6
  "repository": {
@@ -1,113 +0,0 @@
1
- import {
2
- obfuscateDSNPassword
3
- } from "./chunk-JEZZN2YZ.js";
4
-
5
- // src/utils/dsn-database.ts
6
- var MissingDatabaseError = class extends Error {
7
- constructor(message) {
8
- super(message);
9
- this.name = "MissingDatabaseError";
10
- }
11
- };
12
- function requireDatabaseInDSN(database, dsn, label) {
13
- if (database) {
14
- return;
15
- }
16
- throw new MissingDatabaseError(
17
- `${label} DSN must name a database.
18
- Provided: ${obfuscateDSNPassword(dsn)}
19
- Add the database to the DSN, e.g. ...:3306/mydb
20
- To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
21
- );
22
- }
23
-
24
- // src/utils/multi-statement-result-parser.ts
25
- function isMetadataObject(element) {
26
- if (!element || typeof element !== "object" || Array.isArray(element)) {
27
- return false;
28
- }
29
- return "affectedRows" in element || "insertId" in element || "fieldCount" in element || "warningStatus" in element;
30
- }
31
- function isMultiStatementResult(results) {
32
- if (!Array.isArray(results) || results.length === 0) {
33
- return false;
34
- }
35
- const firstElement = results[0];
36
- return isMetadataObject(firstElement) || Array.isArray(firstElement);
37
- }
38
- function extractRowsFromMultiStatement(results) {
39
- if (!Array.isArray(results)) {
40
- return [];
41
- }
42
- const allRows = [];
43
- for (const result of results) {
44
- if (Array.isArray(result)) {
45
- allRows.push(...result);
46
- }
47
- }
48
- return allRows;
49
- }
50
- function extractAffectedRows(results) {
51
- if (isMetadataObject(results)) {
52
- return results.affectedRows || 0;
53
- }
54
- if (!Array.isArray(results)) {
55
- return 0;
56
- }
57
- if (isMultiStatementResult(results)) {
58
- let totalAffected = 0;
59
- for (const result of results) {
60
- if (isMetadataObject(result)) {
61
- totalAffected += result.affectedRows || 0;
62
- } else if (Array.isArray(result)) {
63
- totalAffected += result.length;
64
- }
65
- }
66
- return totalAffected;
67
- }
68
- return results.length;
69
- }
70
- function parseQueryResults(results) {
71
- if (!Array.isArray(results)) {
72
- return [];
73
- }
74
- if (isMultiStatementResult(results)) {
75
- return extractRowsFromMultiStatement(results);
76
- }
77
- return results;
78
- }
79
-
80
- // src/utils/readonly-transaction.ts
81
- async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
82
- if (!readonly) {
83
- return execute();
84
- }
85
- try {
86
- await conn.query(
87
- supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
88
- );
89
- const result = await execute();
90
- await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
91
- return result;
92
- } catch (error) {
93
- try {
94
- await conn.query("ROLLBACK");
95
- } catch {
96
- }
97
- throw error;
98
- }
99
- }
100
-
101
- // src/utils/server-flavor.ts
102
- function isTiDBVersion(version) {
103
- return typeof version === "string" && /tidb/i.test(version);
104
- }
105
-
106
- export {
107
- MissingDatabaseError,
108
- requireDatabaseInDSN,
109
- extractAffectedRows,
110
- parseQueryResults,
111
- withReadOnlyTransaction,
112
- isTiDBVersion
113
- };