@bytebase/dbhub 0.22.5 → 0.23.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
@@ -80,6 +80,10 @@ docker run --rm --init \
80
80
  npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
81
81
  ```
82
82
 
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
+
83
87
  **Demo Mode:**
84
88
 
85
89
  ```bash
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  stripCommentsAndStrings
3
- } from "./chunk-C7WEAPX4.js";
3
+ } from "./chunk-JEZZN2YZ.js";
4
4
 
5
5
  // src/utils/sql-row-limiter.ts
6
6
  var SQLRowLimiter = class {
@@ -8,7 +8,7 @@ import {
8
8
  getDefaultPortForType,
9
9
  parseConnectionInfoFromDSN,
10
10
  stripCommentsAndStrings
11
- } from "./chunk-C7WEAPX4.js";
11
+ } from "./chunk-JEZZN2YZ.js";
12
12
 
13
13
  // src/tools/builtin-tools.ts
14
14
  var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
@@ -68,7 +68,8 @@ function findDefaultSSHKey() {
68
68
  }
69
69
  return void 0;
70
70
  }
71
- function parseSSHConfig(hostAlias, configPath) {
71
+ function parseSSHConfig(hostAlias, configPath, options = {}) {
72
+ const { requireUser = true } = options;
72
73
  const sshConfigPath = resolveSymlink(configPath);
73
74
  if (!isFile(sshConfigPath)) {
74
75
  return null;
@@ -77,7 +78,7 @@ function parseSSHConfig(hostAlias, configPath) {
77
78
  const configContent = readFileSync(sshConfigPath, "utf8");
78
79
  const config = SSHConfig.parse(configContent);
79
80
  const hostConfig = config.compute(hostAlias);
80
- if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
81
+ if (!hostConfig || !hostConfig.HostName && !hostConfig.User && !hostConfig.Port && !hostConfig.IdentityFile && !hostConfig.ProxyJump) {
81
82
  return null;
82
83
  }
83
84
  const sshConfig = {};
@@ -111,10 +112,10 @@ function parseSSHConfig(hostAlias, configPath) {
111
112
  if (hostConfig.ProxyCommand) {
112
113
  console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
113
114
  }
114
- if (!sshConfig.host || !sshConfig.username) {
115
+ if (!sshConfig.host || requireUser && !sshConfig.username) {
115
116
  return null;
116
117
  }
117
- return sshConfig;
118
+ return requireUser ? sshConfig : sshConfig;
118
119
  } catch (error) {
119
120
  console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
120
121
  return null;
@@ -193,6 +194,45 @@ function parseJumpHosts(proxyJump) {
193
194
  }
194
195
  return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
195
196
  }
197
+ function resolveJumpHosts(proxyJump, configPath, visited = /* @__PURE__ */ new Set()) {
198
+ if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
199
+ return [];
200
+ }
201
+ const resolved = [];
202
+ for (const token of proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0)) {
203
+ const hop = parseJumpHost(token);
204
+ if (!looksLikeSSHAlias(hop.host)) {
205
+ resolved.push(hop);
206
+ continue;
207
+ }
208
+ if (visited.has(hop.host)) {
209
+ throw new Error(`Cycle detected in SSH ProxyJump chain at alias "${hop.host}"`);
210
+ }
211
+ const aliasConfig = parseSSHConfig(hop.host, configPath, { requireUser: false });
212
+ if (!aliasConfig) {
213
+ resolved.push(hop);
214
+ continue;
215
+ }
216
+ if (aliasConfig.proxyJump) {
217
+ resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host)));
218
+ }
219
+ resolved.push({
220
+ host: aliasConfig.host,
221
+ // An explicit `:port` on the token wins; otherwise use the alias's Port (default 22).
222
+ port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22,
223
+ // An explicit `user@` on the token wins; otherwise the alias's User.
224
+ username: hop.username ?? aliasConfig.username,
225
+ privateKey: aliasConfig.privateKey,
226
+ passphrase: aliasConfig.passphrase
227
+ });
228
+ }
229
+ return resolved;
230
+ }
231
+ function tokenHasExplicitPort(token) {
232
+ const atIndex = token.indexOf("@");
233
+ const hostPart = atIndex !== -1 ? token.slice(atIndex + 1) : token;
234
+ return hostPart.startsWith("[") ? /\]:\d+$/.test(hostPart) : /:\d+$/.test(hostPart);
235
+ }
196
236
 
197
237
  // src/utils/ssh-tunnel.ts
198
238
  var SSHTunnel = class {
@@ -215,29 +255,8 @@ var SSHTunnel = class {
215
255
  }
216
256
  this.isConnected = true;
217
257
  try {
218
- const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
219
- let privateKeyBuffer;
220
- if (config.privateKey) {
221
- try {
222
- const resolvedKeyPath = resolveSymlink(config.privateKey);
223
- privateKeyBuffer = readFileSync2(resolvedKeyPath);
224
- } catch {
225
- try {
226
- const decoded = Buffer.from(config.privateKey, "base64");
227
- const text = decoded.toString("utf8");
228
- if (text.includes("PRIVATE KEY")) {
229
- privateKeyBuffer = decoded;
230
- } else {
231
- throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
232
- }
233
- } catch (decodeError) {
234
- if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
235
- throw decodeError;
236
- }
237
- throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
238
- }
239
- }
240
- }
258
+ const jumpHosts = config.resolvedJumpHosts ?? (config.proxyJump ? parseJumpHosts(config.proxyJump) : []);
259
+ const privateKeyBuffer = config.privateKey ? this.loadPrivateKey(config.privateKey) : void 0;
241
260
  if (!config.password && !privateKeyBuffer) {
242
261
  throw new Error("Either password or privateKey must be provided for SSH authentication");
243
262
  }
@@ -248,6 +267,30 @@ var SSHTunnel = class {
248
267
  throw error;
249
268
  }
250
269
  }
270
+ /**
271
+ * Load an SSH private key, supporting both a file path (with symlink resolution)
272
+ * and base64-encoded key content.
273
+ */
274
+ loadPrivateKey(key) {
275
+ try {
276
+ const resolvedKeyPath = resolveSymlink(key);
277
+ return readFileSync2(resolvedKeyPath);
278
+ } catch {
279
+ try {
280
+ const decoded = Buffer.from(key, "base64");
281
+ const text = decoded.toString("utf8");
282
+ if (text.includes("PRIVATE KEY")) {
283
+ return decoded;
284
+ }
285
+ throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
286
+ } catch (decodeError) {
287
+ if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
288
+ throw decodeError;
289
+ }
290
+ throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
291
+ }
292
+ }
293
+ }
251
294
  /**
252
295
  * Establish a chain of SSH connections through jump hosts.
253
296
  * @returns The final SSH client connected to the target host
@@ -257,6 +300,9 @@ var SSHTunnel = class {
257
300
  for (let i = 0; i < jumpHosts.length; i++) {
258
301
  const jumpHost = jumpHosts[i];
259
302
  const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
303
+ const hopPrivateKey = jumpHost.privateKey ? this.loadPrivateKey(jumpHost.privateKey) : privateKey;
304
+ const hopPassword = targetConfig.password;
305
+ const hopPassphrase = jumpHost.passphrase ?? targetConfig.passphrase;
260
306
  let client = null;
261
307
  let forwardStream;
262
308
  try {
@@ -266,9 +312,9 @@ var SSHTunnel = class {
266
312
  port: jumpHost.port,
267
313
  username: jumpHost.username || targetConfig.username
268
314
  },
269
- targetConfig.password,
270
- privateKey,
271
- targetConfig.passphrase,
315
+ hopPassword,
316
+ hopPrivateKey,
317
+ hopPassphrase,
272
318
  previousStream,
273
319
  `jump host ${i + 1}`,
274
320
  targetConfig.keepaliveInterval,
@@ -1367,6 +1413,30 @@ function validateSourceConfig(source, configPath) {
1367
1413
  );
1368
1414
  }
1369
1415
  }
1416
+ if (source.charset !== void 0) {
1417
+ if (source.type !== "mysql" && source.type !== "mariadb") {
1418
+ throw new Error(
1419
+ `Configuration file ${configPath}: source '${source.id}' has 'charset' but it is only supported for MySQL and MariaDB sources.`
1420
+ );
1421
+ }
1422
+ if (typeof source.charset !== "string" || source.charset.trim() === "") {
1423
+ throw new Error(
1424
+ `Configuration file ${configPath}: source '${source.id}' has invalid charset '${source.charset}'. Must be a non-empty string naming a character set (e.g. "utf8mb4").`
1425
+ );
1426
+ }
1427
+ }
1428
+ if (source.collation !== void 0) {
1429
+ if (source.type !== "mysql" && source.type !== "mariadb") {
1430
+ throw new Error(
1431
+ `Configuration file ${configPath}: source '${source.id}' has 'collation' but it is only supported for MySQL and MariaDB sources.`
1432
+ );
1433
+ }
1434
+ if (typeof source.collation !== "string" || source.collation.trim() === "") {
1435
+ throw new Error(
1436
+ `Configuration file ${configPath}: source '${source.id}' has invalid collation '${source.collation}'. Must be a non-empty string naming a collation (e.g. "utf8mb4_0900_ai_ci").`
1437
+ );
1438
+ }
1439
+ }
1370
1440
  if (source.readonly !== void 0) {
1371
1441
  throw new Error(
1372
1442
  `Configuration file ${configPath}: source '${source.id}' has 'readonly' field, but readonly must be configured per-tool, not per-source. Move 'readonly' to [[tools]] configuration instead.`
@@ -1735,13 +1805,23 @@ var ConnectorManager = class {
1735
1805
  console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
1736
1806
  let actualDSN = dsn;
1737
1807
  if (source.ssh_host) {
1808
+ const sshConfigPath = getDefaultSSHConfigPath();
1738
1809
  let resolvedSSHConfig = null;
1739
1810
  if (looksLikeSSHAlias(source.ssh_host)) {
1740
- const sshConfigPath = getDefaultSSHConfigPath();
1741
1811
  console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
1742
1812
  resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
1743
1813
  }
1744
1814
  const username = source.ssh_user || resolvedSSHConfig?.username;
1815
+ const proxyJump = source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump;
1816
+ let resolvedJumpHosts;
1817
+ try {
1818
+ resolvedJumpHosts = proxyJump ? resolveJumpHosts(proxyJump, sshConfigPath) : void 0;
1819
+ } catch (error) {
1820
+ if (error && typeof error === "object") {
1821
+ error[TUNNEL_ERROR_MARKER] = true;
1822
+ }
1823
+ throw error;
1824
+ }
1745
1825
  const sshConfig = {
1746
1826
  host: resolvedSSHConfig?.host || source.ssh_host,
1747
1827
  port: source.ssh_port || resolvedSSHConfig?.port || 22,
@@ -1749,7 +1829,8 @@ var ConnectorManager = class {
1749
1829
  password: source.ssh_password,
1750
1830
  privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
1751
1831
  passphrase: source.ssh_passphrase,
1752
- proxyJump: source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump,
1832
+ proxyJump,
1833
+ resolvedJumpHosts,
1753
1834
  keepaliveInterval: source.ssh_keepalive_interval,
1754
1835
  keepaliveCountMax: source.ssh_keepalive_count_max
1755
1836
  };
@@ -1802,15 +1883,18 @@ var ConnectorManager = class {
1802
1883
  if (source.query_timeout !== void 0 && connector.id !== "sqlite") {
1803
1884
  config.queryTimeoutSeconds = source.query_timeout;
1804
1885
  }
1805
- if (source.readonly !== void 0) {
1806
- config.readonly = source.readonly;
1807
- }
1808
1886
  if (source.search_path) {
1809
1887
  config.searchPath = source.search_path;
1810
1888
  }
1811
1889
  if (source.timezone) {
1812
1890
  config.timezone = source.timezone;
1813
1891
  }
1892
+ if (source.charset) {
1893
+ config.charset = source.charset;
1894
+ }
1895
+ if (source.collation) {
1896
+ config.collation = source.collation;
1897
+ }
1814
1898
  await connector.connect(actualDSN, source.init_script, config);
1815
1899
  this.connectors.set(sourceId, connector);
1816
1900
  if (!this.sourceIds.includes(sourceId)) {
@@ -267,6 +267,20 @@ function scanSingleLineComment(sql, i) {
267
267
  }
268
268
  return { type: TokenType.Comment, end: j };
269
269
  }
270
+ function scanSingleLineCommentMySQL(sql, i) {
271
+ if (sql[i] !== "-" || sql[i + 1] !== "-") {
272
+ return null;
273
+ }
274
+ const next = sql[i + 2];
275
+ if (next !== void 0 && next.charCodeAt(0) > 32 && next.charCodeAt(0) !== 127) {
276
+ return null;
277
+ }
278
+ let j = i;
279
+ while (j < sql.length && sql[j] !== "\n") {
280
+ j++;
281
+ }
282
+ return { type: TokenType.Comment, end: j };
283
+ }
270
284
  function scanMultiLineComment(sql, i) {
271
285
  if (sql[i] !== "/" || sql[i + 1] !== "*") {
272
286
  return null;
@@ -405,7 +419,7 @@ function scanTokenPostgres(sql, i) {
405
419
  return scanSingleLineComment(sql, i) ?? scanNestedMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanDollarQuotedBlock(sql, i) ?? plainToken(i);
406
420
  }
407
421
  function scanTokenMySQL(sql, i) {
408
- return scanSingleLineComment(sql, i) ?? scanMultiLineCommentMySQL(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? plainToken(i);
422
+ return scanSingleLineCommentMySQL(sql, i) ?? scanMultiLineCommentMySQL(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? plainToken(i);
409
423
  }
410
424
  function scanTokenSQLite(sql, i) {
411
425
  return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? scanBracketQuotedIdentifier(sql, i) ?? plainToken(i);
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  resolveSourceConfigs,
16
16
  resolveTomlConfigPath,
17
17
  resolveTransport
18
- } from "./chunk-IOKERPF2.js";
18
+ } from "./chunk-COEDRFSK.js";
19
19
  import {
20
20
  loadConnectors
21
21
  } from "./chunk-WVVMH6FJ.js";
@@ -29,7 +29,7 @@ import {
29
29
  parseConnectionInfoFromDSN,
30
30
  splitSQLStatements,
31
31
  stripCommentsAndStrings
32
- } from "./chunk-C7WEAPX4.js";
32
+ } from "./chunk-JEZZN2YZ.js";
33
33
 
34
34
  // src/server.ts
35
35
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -126,14 +126,25 @@ var mutatingPatternWithReplace = new RegExp(
126
126
  `\\b(?:${mutatingKeywords.join("|")}|replace\\s+(?:(?:low_priority|delayed)\\s+)?into)\\b`,
127
127
  "i"
128
128
  );
129
+ var mutatingPatternSqlServer = new RegExp(
130
+ `\\b(?:${[...mutatingKeywords, "execute", "exec", "sp_executesql", "xp_cmdshell"].join("|")})\\b`,
131
+ "i"
132
+ );
129
133
  var mutatingPatterns = {
130
134
  postgres: mutatingPattern,
131
135
  mysql: mutatingPatternWithReplace,
132
136
  mariadb: mutatingPatternWithReplace,
133
137
  sqlite: mutatingPatternWithReplace,
134
- sqlserver: mutatingPattern
138
+ sqlserver: mutatingPatternSqlServer
135
139
  };
136
140
  var selectIntoPattern = /\bselect\b[\s\S]+\binto\b/i;
141
+ var sqliteReadOnlyArgPragmas = /* @__PURE__ */ new Set([
142
+ "table_info",
143
+ "index_info",
144
+ "index_list",
145
+ "foreign_key_list"
146
+ ]);
147
+ var sqlitePragmaParenPattern = /^pragma\s+(?:[a-z0-9_]+\.)?([a-z0-9_]+)\s*\(/;
137
148
  var explainAnalyzePattern = /^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)/i;
138
149
  function isReadOnlySQL(sql, connectorType) {
139
150
  return checkReadOnly(
@@ -156,6 +167,15 @@ function checkReadOnly(cleanedSQL, connectorType) {
156
167
  return false;
157
168
  }
158
169
  }
170
+ if (firstWord === "pragma" && connectorType === "sqlite") {
171
+ if (cleanedSQL.includes("=")) {
172
+ return false;
173
+ }
174
+ const parenMatch = cleanedSQL.match(sqlitePragmaParenPattern);
175
+ if (parenMatch && !sqliteReadOnlyArgPragmas.has(parenMatch[1])) {
176
+ return false;
177
+ }
178
+ }
159
179
  if ((firstWord === "select" || firstWord === "with") && selectIntoPattern.test(cleanedSQL)) {
160
180
  return false;
161
181
  }
@@ -1656,7 +1676,7 @@ See documentation for more details on configuring database connections.
1656
1676
  const sources = sourceConfigsData.sources;
1657
1677
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1658
1678
  await connectorManager.connectWithSources(sources);
1659
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-L3ZIOUF2.js");
1679
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZME64VB2.js");
1660
1680
  initializeToolRegistry2({
1661
1681
  sources: sourceConfigsData.sources,
1662
1682
  tools: sourceConfigsData.tools
@@ -1810,11 +1830,11 @@ See documentation for more details on configuring database connections.
1810
1830
 
1811
1831
  // src/index.ts
1812
1832
  var connectorModules = [
1813
- { load: () => import("./postgres-EKVOBAH6.js"), name: "PostgreSQL", driver: "pg" },
1814
- { load: () => import("./sqlserver-5TFVBCMA.js"), name: "SQL Server", driver: "mssql" },
1815
- { load: () => import("./sqlite-2GBIF3MQ.js"), name: "SQLite", driver: "node:sqlite" },
1816
- { load: () => import("./mysql-WG6UFALX.js"), name: "MySQL", driver: "mysql2" },
1817
- { load: () => import("./mariadb-ERB4D3RP.js"), name: "MariaDB", driver: "mariadb" }
1833
+ { load: () => import("./postgres-BKBCGDZS.js"), name: "PostgreSQL", driver: "pg" },
1834
+ { load: () => import("./sqlserver-65AGCEJX.js"), name: "SQL Server", driver: "mssql" },
1835
+ { load: () => import("./sqlite-CQ4LCBB6.js"), name: "SQLite", driver: "node:sqlite" },
1836
+ { load: () => import("./mysql-MZWCPISB.js"), name: "MySQL", driver: "mysql2" },
1837
+ { load: () => import("./mariadb-SPFBL46R.js"), name: "MariaDB", driver: "mariadb" }
1818
1838
  ];
1819
1839
  loadConnectors(connectorModules).then(() => main()).catch((error) => {
1820
1840
  console.error("Fatal error:", error);
@@ -7,13 +7,13 @@ import {
7
7
  } from "./chunk-JFWX35TB.js";
8
8
  import {
9
9
  SQLRowLimiter
10
- } from "./chunk-BRXZ5ZQB.js";
10
+ } from "./chunk-BXXHWEQS.js";
11
11
  import {
12
12
  ConnectorRegistry,
13
13
  SafeURL,
14
14
  obfuscateDSNPassword,
15
15
  splitSQLStatements
16
- } from "./chunk-C7WEAPX4.js";
16
+ } from "./chunk-JEZZN2YZ.js";
17
17
 
18
18
  // src/connectors/mariadb/index.ts
19
19
  import * as mariadb from "mariadb";
@@ -50,7 +50,14 @@ Expected: ${expectedFormat}`
50
50
  // Controls how the driver interprets DATETIME values ("Z", "local", or "±HH:MM").
51
51
  ...config?.timezone !== void 0 && {
52
52
  timezone: config.timezone
53
- }
53
+ },
54
+ // Connection character set (e.g. "utf8mb4") / collation (e.g.
55
+ // "utf8mb4_general_ci"). The mariadb driver exposes both as distinct
56
+ // options, but a configured collation is authoritative (it implies its
57
+ // character set) and the driver ignores `collation` when `charset` is also
58
+ // passed. So forward the collation when present, otherwise the charset
59
+ // (which uses that character set's default collation).
60
+ ...config?.collation !== void 0 ? { collation: config.collation } : config?.charset !== void 0 ? { charset: config.charset } : {}
54
61
  };
55
62
  url.forEachSearchParam((value, key) => {
56
63
  if (key === "sslmode") {
@@ -466,6 +473,9 @@ var MariaDBConnector = class _MariaDBConnector {
466
473
  }
467
474
  const conn = await this.pool.getConnection();
468
475
  try {
476
+ if (options.readonly) {
477
+ await conn.query("START TRANSACTION READ ONLY");
478
+ }
469
479
  let processedSQL = sql;
470
480
  if (options.maxRows) {
471
481
  const statements = splitSQLStatements(sql, "mariadb");
@@ -492,8 +502,17 @@ var MariaDBConnector = class _MariaDBConnector {
492
502
  }
493
503
  const rows = parseQueryResults(results);
494
504
  const rowCount = extractAffectedRows(results);
505
+ if (options.readonly) {
506
+ await conn.query("COMMIT");
507
+ }
495
508
  return { rows, rowCount };
496
509
  } catch (error) {
510
+ if (options.readonly) {
511
+ try {
512
+ await conn.query("ROLLBACK");
513
+ } catch {
514
+ }
515
+ }
497
516
  console.error("Error executing query:", error);
498
517
  throw error;
499
518
  } finally {
@@ -7,13 +7,13 @@ import {
7
7
  } from "./chunk-JFWX35TB.js";
8
8
  import {
9
9
  SQLRowLimiter
10
- } from "./chunk-BRXZ5ZQB.js";
10
+ } from "./chunk-BXXHWEQS.js";
11
11
  import {
12
12
  ConnectorRegistry,
13
13
  SafeURL,
14
14
  obfuscateDSNPassword,
15
15
  splitSQLStatements
16
- } from "./chunk-C7WEAPX4.js";
16
+ } from "./chunk-JEZZN2YZ.js";
17
17
 
18
18
  // src/connectors/mysql/index.ts
19
19
  import mysql from "mysql2/promise";
@@ -21,6 +21,8 @@ var MySQLDSNParser = class {
21
21
  async parse(dsn, config) {
22
22
  const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
23
23
  const timezone = config?.timezone;
24
+ const charset = config?.charset;
25
+ const collation = config?.collation;
24
26
  if (!this.isValidDSN(dsn)) {
25
27
  const obfuscatedDSN = obfuscateDSNPassword(dsn);
26
28
  const expectedFormat = this.getSampleDSN();
@@ -61,6 +63,10 @@ Expected: ${expectedFormat}`
61
63
  if (timezone !== void 0) {
62
64
  config2.timezone = timezone;
63
65
  }
66
+ const charsetOrCollation = collation ?? charset;
67
+ if (charsetOrCollation !== void 0) {
68
+ config2.charset = charsetOrCollation;
69
+ }
64
70
  if (url.password && url.password.includes("X-Amz-Credential")) {
65
71
  config2.authPlugins = {
66
72
  mysql_clear_password: () => () => {
@@ -472,6 +478,9 @@ var MySQLConnector = class _MySQLConnector {
472
478
  }
473
479
  const conn = await this.pool.getConnection();
474
480
  try {
481
+ if (options.readonly) {
482
+ await conn.query("START TRANSACTION READ ONLY");
483
+ }
475
484
  let processedSQL = sql;
476
485
  if (options.maxRows) {
477
486
  const statements = splitSQLStatements(sql, "mysql");
@@ -499,8 +508,17 @@ var MySQLConnector = class _MySQLConnector {
499
508
  const [firstResult] = results;
500
509
  const rows = parseQueryResults(firstResult);
501
510
  const rowCount = extractAffectedRows(firstResult);
511
+ if (options.readonly) {
512
+ await conn.query("COMMIT");
513
+ }
502
514
  return { rows, rowCount };
503
515
  } catch (error) {
516
+ if (options.readonly) {
517
+ try {
518
+ await conn.query("ROLLBACK");
519
+ } catch {
520
+ }
521
+ }
504
522
  console.error("Error executing query:", error);
505
523
  throw error;
506
524
  } finally {
@@ -3,13 +3,13 @@ import {
3
3
  } from "./chunk-JFWX35TB.js";
4
4
  import {
5
5
  SQLRowLimiter
6
- } from "./chunk-BRXZ5ZQB.js";
6
+ } from "./chunk-BXXHWEQS.js";
7
7
  import {
8
8
  ConnectorRegistry,
9
9
  SafeURL,
10
10
  obfuscateDSNPassword,
11
11
  splitSQLStatements
12
- } from "./chunk-C7WEAPX4.js";
12
+ } from "./chunk-JEZZN2YZ.js";
13
13
 
14
14
  // src/connectors/postgres/index.ts
15
15
  import fs from "fs";
@@ -491,6 +491,25 @@ var PostgresConnector = class _PostgresConnector {
491
491
  const statements = splitSQLStatements(sql, "postgres");
492
492
  if (statements.length === 1) {
493
493
  const processedStatement = SQLRowLimiter.applyMaxRows(statements[0], options.maxRows);
494
+ if (options.readonly) {
495
+ await client.query("BEGIN READ ONLY");
496
+ try {
497
+ const result2 = parameters && parameters.length > 0 ? await client.query(processedStatement, parameters) : await client.query(processedStatement);
498
+ await client.query("COMMIT");
499
+ return { rows: result2.rows, rowCount: result2.rowCount ?? result2.rows.length };
500
+ } catch (error) {
501
+ try {
502
+ await client.query("ROLLBACK");
503
+ } catch {
504
+ }
505
+ console.error(`[PostgreSQL executeSQL] ERROR: ${error.message}`);
506
+ console.error(`[PostgreSQL executeSQL] SQL: ${processedStatement}`);
507
+ if (parameters && parameters.length > 0) {
508
+ console.error(`[PostgreSQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
509
+ }
510
+ throw error;
511
+ }
512
+ }
494
513
  let result;
495
514
  if (parameters && parameters.length > 0) {
496
515
  try {
@@ -511,7 +530,7 @@ var PostgresConnector = class _PostgresConnector {
511
530
  }
512
531
  let allRows = [];
513
532
  let totalRowCount = 0;
514
- await client.query("BEGIN");
533
+ await client.query(options.readonly ? "BEGIN READ ONLY" : "BEGIN");
515
534
  try {
516
535
  for (let statement of statements) {
517
536
  const processedStatement = SQLRowLimiter.applyMaxRows(statement, options.maxRows);
@@ -525,7 +544,10 @@ var PostgresConnector = class _PostgresConnector {
525
544
  }
526
545
  await client.query("COMMIT");
527
546
  } catch (error) {
528
- await client.query("ROLLBACK");
547
+ try {
548
+ await client.query("ROLLBACK");
549
+ } catch {
550
+ }
529
551
  throw error;
530
552
  }
531
553
  return { rows: allRows, rowCount: totalRowCount };
@@ -2,9 +2,9 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-IOKERPF2.js";
5
+ } from "./chunk-COEDRFSK.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
- import "./chunk-C7WEAPX4.js";
7
+ import "./chunk-JEZZN2YZ.js";
8
8
  export {
9
9
  ToolRegistry,
10
10
  getToolRegistry,
@@ -3,13 +3,13 @@ import {
3
3
  } from "./chunk-JFWX35TB.js";
4
4
  import {
5
5
  SQLRowLimiter
6
- } from "./chunk-BRXZ5ZQB.js";
6
+ } from "./chunk-BXXHWEQS.js";
7
7
  import {
8
8
  ConnectorRegistry,
9
9
  SafeURL,
10
10
  obfuscateDSNPassword,
11
11
  splitSQLStatements
12
- } from "./chunk-C7WEAPX4.js";
12
+ } from "./chunk-JEZZN2YZ.js";
13
13
 
14
14
  // src/connectors/sqlite/suppress-experimental-warning.ts
15
15
  var installed = false;
@@ -319,6 +319,9 @@ var SQLiteConnector = class _SQLiteConnector {
319
319
  if (!this.db) {
320
320
  throw new Error("Not connected to SQLite database");
321
321
  }
322
+ if (options.readonly) {
323
+ this.db.exec("PRAGMA query_only = ON");
324
+ }
322
325
  try {
323
326
  const statements = splitSQLStatements(sql, "sqlite");
324
327
  if (statements.length === 1) {
@@ -375,19 +378,30 @@ var SQLiteConnector = class _SQLiteConnector {
375
378
  }
376
379
  let totalChanges = 0;
377
380
  for (const statement of writeStatements) {
381
+ if (options.readonly) {
382
+ this.db.exec("PRAGMA query_only = ON");
383
+ }
378
384
  const result = this.prepare(statement).run();
379
385
  totalChanges += Number(result.changes);
380
386
  }
381
387
  let allRows = [];
382
388
  for (let statement of readStatements) {
389
+ if (options.readonly) {
390
+ this.db.exec("PRAGMA query_only = ON");
391
+ }
383
392
  statement = SQLRowLimiter.applyMaxRows(statement, options.maxRows);
384
393
  const result = this.prepare(statement).all();
385
394
  allRows.push(...result);
386
395
  }
387
396
  return { rows: allRows, rowCount: totalChanges + allRows.length };
388
397
  }
389
- } catch (error) {
390
- throw error;
398
+ } finally {
399
+ if (options.readonly) {
400
+ try {
401
+ this.db.exec("PRAGMA query_only = OFF");
402
+ } catch {
403
+ }
404
+ }
391
405
  }
392
406
  }
393
407
  };
@@ -3,13 +3,13 @@ import {
3
3
  } from "./chunk-WVVMH6FJ.js";
4
4
  import {
5
5
  SQLRowLimiter
6
- } from "./chunk-BRXZ5ZQB.js";
6
+ } from "./chunk-BXXHWEQS.js";
7
7
  import {
8
8
  ConnectorRegistry,
9
9
  SafeURL,
10
10
  obfuscateDSNPassword,
11
11
  stripCommentsAndStrings
12
- } from "./chunk-C7WEAPX4.js";
12
+ } from "./chunk-JEZZN2YZ.js";
13
13
 
14
14
  // src/connectors/sqlserver/index.ts
15
15
  import sql from "mssql";
@@ -469,6 +469,9 @@ var _SQLServerConnector = class _SQLServerConnector {
469
469
  if (options.maxRows) {
470
470
  processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sqlQuery, options.maxRows);
471
471
  }
472
+ if (options.readonly) {
473
+ return await this.executeReadOnly(processedSQL, parameters);
474
+ }
472
475
  const request = this.connection.request();
473
476
  const messages = [];
474
477
  request.on(
@@ -525,6 +528,97 @@ var _SQLServerConnector = class _SQLServerConnector {
525
528
  throw new Error(`Failed to execute query: ${error.message}`);
526
529
  }
527
530
  }
531
+ /**
532
+ * Execute a query inside a transaction that always rolls back, preventing
533
+ * any modifications from persisting. SQL Server has no native READ ONLY
534
+ * transaction mode, so this is the defense-in-depth backstop behind the
535
+ * keyword classifier.
536
+ *
537
+ * Because the rollback guard is application-level (not engine-enforced),
538
+ * we reject dangerous keywords in the stripped SQL before opening the
539
+ * transaction:
540
+ * - COMMIT/ROLLBACK: would end the outer transaction, letting writes persist
541
+ * - EXEC/EXECUTE/sp_executesql/xp_cmdshell: dynamic SQL can carry hidden
542
+ * COMMIT/ROLLBACK inside string literals that stripCommentsAndStrings removes
543
+ */
544
+ async executeReadOnly(processedSQL, parameters) {
545
+ const cleaned = stripCommentsAndStrings(processedSQL, "sqlserver").toLowerCase();
546
+ if (/\b(?:commit|rollback)\b/.test(cleaned)) {
547
+ throw new Error(
548
+ "Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed"
549
+ );
550
+ }
551
+ if (/\b(?:exec|execute|sp_executesql|xp_cmdshell)\b/.test(cleaned)) {
552
+ throw new Error(
553
+ "Read-only mode: dynamic SQL execution (EXEC, EXECUTE, sp_executesql, xp_cmdshell) is not allowed"
554
+ );
555
+ }
556
+ const transaction = new sql.Transaction(this.connection);
557
+ await transaction.begin();
558
+ const request = new sql.Request(transaction);
559
+ const messages = [];
560
+ request.on(
561
+ "info",
562
+ (info) => {
563
+ messages.push({
564
+ text: info.message,
565
+ severity: info.class !== void 0 ? String(info.class) : void 0,
566
+ code: info.number,
567
+ line: info.lineNumber
568
+ });
569
+ }
570
+ );
571
+ if (parameters && parameters.length > 0) {
572
+ parameters.forEach((param, index) => {
573
+ const paramName = `p${index + 1}`;
574
+ if (typeof param === "string") {
575
+ request.input(paramName, sql.VarChar, param);
576
+ } else if (typeof param === "number") {
577
+ if (Number.isInteger(param)) {
578
+ request.input(paramName, sql.Int, param);
579
+ } else {
580
+ request.input(paramName, sql.Float, param);
581
+ }
582
+ } else if (typeof param === "boolean") {
583
+ request.input(paramName, sql.Bit, param);
584
+ } else if (param === null || param === void 0) {
585
+ request.input(paramName, sql.VarChar, param);
586
+ } else if (Array.isArray(param)) {
587
+ request.input(paramName, sql.VarChar, JSON.stringify(param));
588
+ } else {
589
+ request.input(paramName, sql.VarChar, JSON.stringify(param));
590
+ }
591
+ });
592
+ }
593
+ let result;
594
+ let queryFailed = false;
595
+ try {
596
+ result = await request.query(processedSQL);
597
+ } catch (error) {
598
+ queryFailed = true;
599
+ console.error(`[SQL Server executeReadOnly] ERROR: ${error.message}`);
600
+ console.error(`[SQL Server executeReadOnly] SQL: ${processedSQL}`);
601
+ if (parameters && parameters.length > 0) {
602
+ console.error(`[SQL Server executeReadOnly] Parameters: ${JSON.stringify(parameters)}`);
603
+ }
604
+ throw error;
605
+ } finally {
606
+ try {
607
+ await transaction.rollback();
608
+ } catch (rollbackError) {
609
+ if (!queryFailed) {
610
+ throw new Error(
611
+ `Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
612
+ );
613
+ }
614
+ }
615
+ }
616
+ return {
617
+ rows: result.recordset || [],
618
+ rowCount: result.rowsAffected[0] || 0,
619
+ ...messages.length > 0 ? { messages } : {}
620
+ };
621
+ }
528
622
  /**
529
623
  * Return the estimated execution plan for a query using SHOWPLAN_XML.
530
624
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.22.5",
3
+ "version": "0.23.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": {
@@ -21,6 +21,8 @@
21
21
  "build": "pnpm run generate:api-types && tsup && cd frontend && pnpm run build",
22
22
  "build:backend": "pnpm run generate:api-types && tsup",
23
23
  "build:frontend": "cd frontend && pnpm run build",
24
+ "build:mcpb": "pnpm run build:backend && node scripts/build-mcpb.mjs",
25
+ "test:mcpb": "node scripts/smoke-test-mcpb.mjs",
24
26
  "start": "node dist/index.js",
25
27
  "dev": "concurrently --kill-others \"pnpm run dev:backend\" \"pnpm run dev:frontend\"",
26
28
  "dev:backend": "NODE_ENV=development tsx src/index.ts --transport=http",
@@ -58,6 +60,7 @@
58
60
  "pg": "^8.13.3"
59
61
  },
60
62
  "devDependencies": {
63
+ "@anthropic-ai/mcpb": "^2.1.2",
61
64
  "@testcontainers/mariadb": "^11.0.3",
62
65
  "@testcontainers/mssqlserver": "^11.0.3",
63
66
  "@testcontainers/mysql": "^11.0.3",