@bytebase/dbhub 0.22.2 → 0.22.4

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.
@@ -1094,6 +1094,92 @@ function validateToolsConfig(tools, sources, configPath) {
1094
1094
  }
1095
1095
  }
1096
1096
  }
1097
+ function getRawDSNQueryParam(dsn, key) {
1098
+ const queryStart = dsn.indexOf("?");
1099
+ if (queryStart === -1) {
1100
+ return null;
1101
+ }
1102
+ for (const pair of dsn.substring(queryStart + 1).split("&")) {
1103
+ if (pair === "") {
1104
+ continue;
1105
+ }
1106
+ const eq = pair.indexOf("=");
1107
+ const rawKey = eq === -1 ? pair : pair.substring(0, eq);
1108
+ if (rawKey === key) {
1109
+ const rawValue = eq === -1 ? "" : pair.substring(eq + 1);
1110
+ try {
1111
+ return decodeURIComponent(rawValue);
1112
+ } catch {
1113
+ return rawValue;
1114
+ }
1115
+ }
1116
+ }
1117
+ return null;
1118
+ }
1119
+ function validateDSNFieldConflicts(source, configPath) {
1120
+ const conflict = (field, fieldValue, dsnValue) => {
1121
+ throw new Error(
1122
+ `Configuration file ${configPath}: source '${source.id}' has conflicting ${field}: the DSN specifies '${dsnValue}' but the ${field} field is '${fieldValue}'. Set ${field} in only one place, or make the two values match.`
1123
+ );
1124
+ };
1125
+ const info = parseConnectionInfoFromDSN(source.dsn);
1126
+ if (source.type && info?.type && source.type !== info.type) {
1127
+ conflict("type", source.type, info.type);
1128
+ }
1129
+ if (info?.type === "sqlite") {
1130
+ return;
1131
+ }
1132
+ let url;
1133
+ try {
1134
+ url = new SafeURL(source.dsn);
1135
+ } catch {
1136
+ return;
1137
+ }
1138
+ if (info) {
1139
+ if (source.host && info.host && source.host.toLowerCase() !== info.host.toLowerCase()) {
1140
+ conflict("host", source.host, info.host);
1141
+ }
1142
+ if (source.port !== void 0 && info.port !== void 0 && source.port !== info.port) {
1143
+ conflict("port", String(source.port), String(info.port));
1144
+ }
1145
+ if (source.database && info.database && source.database !== info.database) {
1146
+ conflict("database", source.database, info.database);
1147
+ }
1148
+ if (source.user && info.user && source.user !== info.user) {
1149
+ conflict("user", source.user, info.user);
1150
+ }
1151
+ }
1152
+ if (source.password && source.password !== url.password) {
1153
+ if (!url.password) {
1154
+ throw new Error(
1155
+ `Configuration file ${configPath}: source '${source.id}' has a 'password' field but the DSN has no password. The field is ignored at connection time \u2014 add the password to the DSN, or use individual connection parameters instead of a DSN.`
1156
+ );
1157
+ }
1158
+ throw new Error(
1159
+ `Configuration file ${configPath}: source '${source.id}' has a 'password' field that conflicts with the password in the DSN. Set the password in only one place.`
1160
+ );
1161
+ }
1162
+ const dsnSslmode = getRawDSNQueryParam(source.dsn, "sslmode");
1163
+ if (source.sslmode && dsnSslmode !== null && dsnSslmode !== source.sslmode) {
1164
+ conflict("sslmode", source.sslmode, dsnSslmode);
1165
+ }
1166
+ const dsnSslrootcert = getRawDSNQueryParam(source.dsn, "sslrootcert");
1167
+ if (source.sslrootcert && dsnSslrootcert !== null && expandHomeDir(source.sslrootcert) !== expandHomeDir(dsnSslrootcert)) {
1168
+ conflict("sslrootcert", expandHomeDir(source.sslrootcert), expandHomeDir(dsnSslrootcert));
1169
+ }
1170
+ const dsnInstanceName = getRawDSNQueryParam(source.dsn, "instanceName");
1171
+ if (source.instanceName && dsnInstanceName !== null && dsnInstanceName !== source.instanceName) {
1172
+ conflict("instanceName", source.instanceName, dsnInstanceName);
1173
+ }
1174
+ const dsnAuthentication = getRawDSNQueryParam(source.dsn, "authentication");
1175
+ if (source.authentication && dsnAuthentication !== null && dsnAuthentication !== source.authentication) {
1176
+ conflict("authentication", source.authentication, dsnAuthentication);
1177
+ }
1178
+ const dsnDomain = getRawDSNQueryParam(source.dsn, "domain");
1179
+ if (source.domain && dsnDomain !== null && dsnDomain !== source.domain) {
1180
+ conflict("domain", source.domain, dsnDomain);
1181
+ }
1182
+ }
1097
1183
  function validateSourceConfig(source, configPath) {
1098
1184
  const hasConnectionParams = source.type && (source.type === "sqlite" ? source.database : source.host);
1099
1185
  if (!source.dsn && !hasConnectionParams) {
@@ -1176,6 +1262,9 @@ function validateSourceConfig(source, configPath) {
1176
1262
  );
1177
1263
  }
1178
1264
  }
1265
+ if (source.dsn) {
1266
+ validateDSNFieldConflicts(source, configPath);
1267
+ }
1179
1268
  if (source.sslrootcert !== void 0) {
1180
1269
  if (source.sslmode !== "verify-ca" && source.sslmode !== "verify-full") {
1181
1270
  throw new Error(
@@ -1350,9 +1439,51 @@ function expandHomeDir(filePath) {
1350
1439
  }
1351
1440
  return filePath;
1352
1441
  }
1442
+ function mergeSourceFieldsIntoDSN(dsn, source) {
1443
+ if (source.type === "sqlite") {
1444
+ return dsn;
1445
+ }
1446
+ try {
1447
+ new SafeURL(dsn);
1448
+ } catch {
1449
+ return dsn;
1450
+ }
1451
+ const hasParam = (key) => getRawDSNQueryParam(dsn, key) !== null;
1452
+ const additions = [];
1453
+ if (source.type === "sqlserver") {
1454
+ if (source.instanceName && !hasParam("instanceName")) {
1455
+ additions.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
1456
+ }
1457
+ if (source.authentication && !hasParam("authentication")) {
1458
+ additions.push(`authentication=${encodeURIComponent(source.authentication)}`);
1459
+ }
1460
+ if (source.domain && !hasParam("domain")) {
1461
+ additions.push(`domain=${encodeURIComponent(source.domain)}`);
1462
+ }
1463
+ }
1464
+ if (source.sslmode && !hasParam("sslmode")) {
1465
+ additions.push(`sslmode=${source.sslmode}`);
1466
+ }
1467
+ if (source.sslrootcert && source.type === "postgres" && (source.sslmode === "verify-ca" || source.sslmode === "verify-full") && !hasParam("sslrootcert")) {
1468
+ const expandedCertPath = expandHomeDir(source.sslrootcert);
1469
+ additions.push(`sslrootcert=${encodeURIComponent(expandedCertPath)}`);
1470
+ }
1471
+ if (additions.length === 0) {
1472
+ return dsn;
1473
+ }
1474
+ let separator;
1475
+ if (!dsn.includes("?")) {
1476
+ separator = "?";
1477
+ } else if (dsn.endsWith("?") || dsn.endsWith("&")) {
1478
+ separator = "";
1479
+ } else {
1480
+ separator = "&";
1481
+ }
1482
+ return `${dsn}${separator}${additions.join("&")}`;
1483
+ }
1353
1484
  function buildDSNFromSource(source) {
1354
1485
  if (source.dsn) {
1355
- return source.dsn;
1486
+ return mergeSourceFieldsIntoDSN(source.dsn, source);
1356
1487
  }
1357
1488
  if (!source.type) {
1358
1489
  throw new Error(
@@ -1434,6 +1565,53 @@ async function generateRdsAuthToken(params) {
1434
1565
  return signer.getAuthToken();
1435
1566
  }
1436
1567
 
1568
+ // src/utils/error-classifier.ts
1569
+ var TUNNEL_ERROR_MARKER = "__dbhubSSHTunnelError";
1570
+ var NETWORK_CODES = /* @__PURE__ */ new Set([
1571
+ "ECONNREFUSED",
1572
+ "ETIMEDOUT",
1573
+ "ENOTFOUND",
1574
+ "EHOSTUNREACH",
1575
+ "ENETUNREACH",
1576
+ "ECONNRESET"
1577
+ ]);
1578
+ var AUTH_CODES = {
1579
+ postgres: ["28P01", "28000"],
1580
+ mysql: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
1581
+ mariadb: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
1582
+ sqlserver: ["ELOGIN"],
1583
+ sqlite: []
1584
+ // no network/auth layer
1585
+ };
1586
+ function unreachableMessage(sourceId) {
1587
+ return `Source '${sourceId}' is unreachable. Verify the database is running and reachable (host, port, network), then retry.`;
1588
+ }
1589
+ function authMessage(sourceId) {
1590
+ return `Authentication failed for source '${sourceId}'. Verify the credentials/access for this source are valid, then retry.`;
1591
+ }
1592
+ function tunnelMessage(sourceId) {
1593
+ return `SSH tunnel for source '${sourceId}' failed to establish. Verify SSH host/credentials and bastion reachability, then retry.`;
1594
+ }
1595
+ function classifyConnectionError(error, connectorType, sourceId) {
1596
+ if (!error || typeof error !== "object") {
1597
+ return null;
1598
+ }
1599
+ const err = error;
1600
+ if (err[TUNNEL_ERROR_MARKER] === true) {
1601
+ return { code: "TUNNEL_FAILED", message: tunnelMessage(sourceId) };
1602
+ }
1603
+ const code = err.code;
1604
+ if (typeof code === "string" && NETWORK_CODES.has(code)) {
1605
+ return { code: "SOURCE_UNREACHABLE", message: unreachableMessage(sourceId) };
1606
+ }
1607
+ const authCodes = AUTH_CODES[connectorType];
1608
+ const errno = err.errno;
1609
+ if (typeof code === "string" && authCodes.includes(code) || typeof errno === "number" && authCodes.includes(errno)) {
1610
+ return { code: "AUTH_FAILED", message: authMessage(sourceId) };
1611
+ }
1612
+ return null;
1613
+ }
1614
+
1437
1615
  // src/connectors/manager.ts
1438
1616
  var managerInstance = null;
1439
1617
  var AWS_IAM_TOKEN_REFRESH_MS = 14 * 60 * 1e3;
@@ -1574,10 +1752,18 @@ var ConnectorManager = class {
1574
1752
  const targetHost = url.hostname;
1575
1753
  const targetPort = parseInt(url.port) || this.getDefaultPort(dsn);
1576
1754
  const tunnel = new SSHTunnel();
1577
- const tunnelInfo = await tunnel.establish(sshConfig, {
1578
- targetHost,
1579
- targetPort
1580
- });
1755
+ let tunnelInfo;
1756
+ try {
1757
+ tunnelInfo = await tunnel.establish(sshConfig, {
1758
+ targetHost,
1759
+ targetPort
1760
+ });
1761
+ } catch (error) {
1762
+ if (error && typeof error === "object") {
1763
+ error[TUNNEL_ERROR_MARKER] = true;
1764
+ }
1765
+ throw error;
1766
+ }
1581
1767
  url.hostname = "127.0.0.1";
1582
1768
  url.port = tunnelInfo.localPort.toString();
1583
1769
  actualDSN = url.toString();
@@ -2190,6 +2376,7 @@ export {
2190
2376
  BUILTIN_TOOL_SEARCH_OBJECTS,
2191
2377
  loadTomlConfig,
2192
2378
  resolveTomlConfigPath,
2379
+ classifyConnectionError,
2193
2380
  ConnectorManager,
2194
2381
  mapArgumentsToArray,
2195
2382
  ToolRegistry,
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  BUILTIN_TOOL_EXECUTE_SQL,
4
4
  BUILTIN_TOOL_SEARCH_OBJECTS,
5
5
  ConnectorManager,
6
+ classifyConnectionError,
6
7
  getToolRegistry,
7
8
  initializeToolRegistry,
8
9
  isDemoMode,
@@ -13,7 +14,7 @@ import {
13
14
  resolveSourceConfigs,
14
15
  resolveTomlConfigPath,
15
16
  resolveTransport
16
- } from "./chunk-MEBWJMLY.js";
17
+ } from "./chunk-DN45LGY4.js";
17
18
  import {
18
19
  loadConnectors
19
20
  } from "./chunk-WVVMH6FJ.js";
@@ -99,7 +100,9 @@ var allowedKeywords = {
99
100
  mysql: ["select", "with", "explain", "show", "describe", "desc"],
100
101
  mariadb: ["select", "with", "explain", "show", "describe", "desc"],
101
102
  sqlite: ["select", "with", "explain", "pragma"],
102
- sqlserver: ["select", "with", "explain", "showplan"]
103
+ // SQL Server has no native EXPLAIN statement; the connector translates a
104
+ // leading `EXPLAIN` into a SET SHOWPLAN_XML request (see SQLServerConnector).
105
+ sqlserver: ["select", "with", "explain"]
103
106
  };
104
107
  var mutatingKeywords = [
105
108
  "insert",
@@ -246,6 +249,20 @@ function trackToolRequest(metadata, startTime, extra, success, error) {
246
249
  error
247
250
  });
248
251
  }
252
+ function tryClassifyConnectionError(error, rawSourceId, displaySourceId) {
253
+ let connectorType;
254
+ try {
255
+ connectorType = ConnectorManager.getSourceConfig(rawSourceId)?.type;
256
+ } catch {
257
+ return null;
258
+ }
259
+ if (!connectorType) return null;
260
+ const classified = classifyConnectionError(error, connectorType, displaySourceId);
261
+ if (!classified) return null;
262
+ return createToolErrorResponse(classified.message, classified.code, {
263
+ source_id: displaySourceId
264
+ });
265
+ }
249
266
 
250
267
  // src/tools/execute-sql.ts
251
268
  var executeSqlSchema = {
@@ -290,6 +307,8 @@ function createExecuteSqlToolHandler(sourceId) {
290
307
  } catch (error) {
291
308
  success = false;
292
309
  errorMessage = error.message;
310
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
311
+ if (classified) return classified;
293
312
  return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
294
313
  } finally {
295
314
  trackToolRequest(
@@ -311,16 +330,25 @@ function createExecuteSqlToolHandler(sourceId) {
311
330
  import { z as z2 } from "zod";
312
331
  var searchDatabaseObjectsSchema = {
313
332
  object_type: z2.enum(["schema", "table", "view", "column", "procedure", "function", "index"]).describe("Object type to search"),
314
- pattern: z2.string().optional().default("%").describe("LIKE pattern (% = any chars, _ = one char). Default: %"),
333
+ pattern: z2.string().optional().default("%").describe("LIKE pattern (% = any chars, _ = one char)"),
315
334
  schema: z2.string().optional().describe("Filter to schema"),
316
335
  table: z2.string().optional().describe("Filter to table (requires schema; column/index only)"),
317
336
  detail_level: z2.enum(["names", "summary", "full"]).default("names").describe("Detail: names (minimal), summary (metadata), full (all)"),
318
- limit: z2.number().int().positive().max(1e3).default(100).describe("Max results (default: 100, max: 1000)")
337
+ limit: z2.number().int().positive().max(1e3).default(100).describe("Max results")
319
338
  };
320
339
  function likePatternToRegex(pattern) {
321
340
  const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".");
322
341
  return new RegExp(`^${escaped}$`, "i");
323
342
  }
343
+ async function resolveDefaultSchemas(connector) {
344
+ if (connector.getDefaultSchema) {
345
+ const defaultSchema = await connector.getDefaultSchema();
346
+ if (defaultSchema) {
347
+ return [defaultSchema];
348
+ }
349
+ }
350
+ return connector.getSchemas();
351
+ }
324
352
  async function getTableRowCount(connector, tableName, schemaName) {
325
353
  try {
326
354
  if (connector.getTableRowCount) {
@@ -348,7 +376,7 @@ async function getTableComment(connector, tableName, schemaName) {
348
376
  }
349
377
  }
350
378
  async function searchSchemas(connector, pattern, detailLevel, limit) {
351
- const schemas = await connector.getSchemas();
379
+ const schemas = await resolveDefaultSchemas(connector);
352
380
  const regex = likePatternToRegex(pattern);
353
381
  const matched = schemas.filter((schema) => regex.test(schema)).slice(0, limit);
354
382
  if (detailLevel === "names") {
@@ -379,7 +407,7 @@ async function searchTables(connector, pattern, schemaFilter, detailLevel, limit
379
407
  if (schemaFilter) {
380
408
  schemasToSearch = [schemaFilter];
381
409
  } else {
382
- schemasToSearch = await connector.getSchemas();
410
+ schemasToSearch = await resolveDefaultSchemas(connector);
383
411
  }
384
412
  for (const schemaName of schemasToSearch) {
385
413
  if (results.length >= limit) break;
@@ -532,7 +560,7 @@ async function searchColumns(connector, pattern, schemaFilter, tableFilter, deta
532
560
  if (schemaFilter) {
533
561
  schemasToSearch = [schemaFilter];
534
562
  } else {
535
- schemasToSearch = await connector.getSchemas();
563
+ schemasToSearch = await resolveDefaultSchemas(connector);
536
564
  }
537
565
  for (const schemaName of schemasToSearch) {
538
566
  if (results.length >= limit) break;
@@ -589,7 +617,7 @@ async function searchProcedures(connector, pattern, schemaFilter, detailLevel, l
589
617
  if (schemaFilter) {
590
618
  schemasToSearch = [schemaFilter];
591
619
  } else {
592
- schemasToSearch = await connector.getSchemas();
620
+ schemasToSearch = await resolveDefaultSchemas(connector);
593
621
  }
594
622
  for (const schemaName of schemasToSearch) {
595
623
  if (results.length >= limit) break;
@@ -637,7 +665,7 @@ async function searchIndexes(connector, pattern, schemaFilter, tableFilter, deta
637
665
  if (schemaFilter) {
638
666
  schemasToSearch = [schemaFilter];
639
667
  } else {
640
- schemasToSearch = await connector.getSchemas();
668
+ schemasToSearch = await resolveDefaultSchemas(connector);
641
669
  }
642
670
  for (const schemaName of schemasToSearch) {
643
671
  if (results.length >= limit) break;
@@ -760,6 +788,8 @@ function createSearchDatabaseObjectsToolHandler(sourceId) {
760
788
  } catch (error) {
761
789
  success = false;
762
790
  errorMessage = error.message;
791
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
792
+ if (classified) return classified;
763
793
  return createToolErrorResponse(
764
794
  `Error searching database objects: ${errorMessage}`,
765
795
  "SEARCH_ERROR"
@@ -864,7 +894,7 @@ function getSearchObjectsMetadata(sourceId) {
864
894
  const toolName = isSingleSource ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`;
865
895
  const title = isSingleSource ? `Search Database Objects (${dbType})` : `Search Database Objects on ${sourceId} (${dbType})`;
866
896
  const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description);
867
- const description = isSingleSource ? `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the ${dbType} database` : `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${sourceId}' ${dbType} database`;
897
+ const description = isSingleSource ? `${userDescPrefix}Search and list database objects on the ${dbType} database` : `${userDescPrefix}Search and list database objects on the '${sourceId}' ${dbType} database`;
868
898
  return {
869
899
  name: toolName,
870
900
  description,
@@ -905,7 +935,7 @@ function buildSearchObjectsTool(sourceId) {
905
935
  name: "object_type",
906
936
  type: "string",
907
937
  required: true,
908
- description: "Object type to search"
938
+ description: "Object type to search: schema, table, view, column, procedure, function, index"
909
939
  },
910
940
  {
911
941
  name: "pattern",
@@ -1053,6 +1083,8 @@ function createCustomToolHandler(toolConfig) {
1053
1083
  } catch (error) {
1054
1084
  success = false;
1055
1085
  errorMessage = error.message;
1086
+ const classified = tryClassifyConnectionError(error, toolConfig.source, toolConfig.source);
1087
+ if (classified) return classified;
1056
1088
  if (error instanceof z4.ZodError) {
1057
1089
  const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
1058
1090
  errorMessage = `Parameter validation failed: ${issues}`;
@@ -1546,7 +1578,7 @@ See documentation for more details on configuring database connections.
1546
1578
  const sources = sourceConfigsData.sources;
1547
1579
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1548
1580
  await connectorManager.connectWithSources(sources);
1549
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-FZPB5LRL.js");
1581
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-OF5GPCBK.js");
1550
1582
  initializeToolRegistry2({
1551
1583
  sources: sourceConfigsData.sources,
1552
1584
  tools: sourceConfigsData.tools
@@ -1695,10 +1727,10 @@ See documentation for more details on configuring database connections.
1695
1727
  // src/index.ts
1696
1728
  var connectorModules = [
1697
1729
  { load: () => import("./postgres-EKVOBAH6.js"), name: "PostgreSQL", driver: "pg" },
1698
- { load: () => import("./sqlserver-5E7DSDVX.js"), name: "SQL Server", driver: "mssql" },
1730
+ { load: () => import("./sqlserver-5TFVBCMA.js"), name: "SQL Server", driver: "mssql" },
1699
1731
  { load: () => import("./sqlite-2GBIF3MQ.js"), name: "SQLite", driver: "node:sqlite" },
1700
- { load: () => import("./mysql-NCKXNVZJ.js"), name: "MySQL", driver: "mysql2" },
1701
- { load: () => import("./mariadb-DVQM4MMM.js"), name: "MariaDB", driver: "mariadb" }
1732
+ { load: () => import("./mysql-WG6UFALX.js"), name: "MySQL", driver: "mysql2" },
1733
+ { load: () => import("./mariadb-ERB4D3RP.js"), name: "MariaDB", driver: "mariadb" }
1702
1734
  ];
1703
1735
  loadConnectors(connectorModules).then(() => main()).catch((error) => {
1704
1736
  console.error("Fatal error:", error);
@@ -123,8 +123,9 @@ var MariaDBConnector = class _MariaDBConnector {
123
123
  }
124
124
  try {
125
125
  const rows = await this.pool.query(`
126
- SELECT SCHEMA_NAME
126
+ SELECT SCHEMA_NAME
127
127
  FROM INFORMATION_SCHEMA.SCHEMATA
128
+ WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
128
129
  ORDER BY SCHEMA_NAME
129
130
  `);
130
131
  return rows.map((row) => row.SCHEMA_NAME);
@@ -447,6 +448,18 @@ var MariaDBConnector = class _MariaDBConnector {
447
448
  const rows = await this.pool.query("SELECT DATABASE() AS DB");
448
449
  return rows[0].DB;
449
450
  }
451
+ /**
452
+ * Default search scope = the database named in the DSN. DATABASE() returns
453
+ * null when the connection was opened without a database, in which case
454
+ * callers fall back to the full server-wide schema list.
455
+ */
456
+ async getDefaultSchema() {
457
+ if (!this.pool) {
458
+ throw new Error("Not connected to database");
459
+ }
460
+ const rows = await this.pool.query("SELECT DATABASE() AS DB");
461
+ return rows[0]?.DB ?? null;
462
+ }
450
463
  async executeSQL(sql, options, parameters) {
451
464
  if (!this.pool) {
452
465
  throw new Error("Not connected to database");
@@ -129,8 +129,9 @@ var MySQLConnector = class _MySQLConnector {
129
129
  }
130
130
  try {
131
131
  const [rows] = await this.pool.query(`
132
- SELECT SCHEMA_NAME
132
+ SELECT SCHEMA_NAME
133
133
  FROM INFORMATION_SCHEMA.SCHEMATA
134
+ WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
134
135
  ORDER BY SCHEMA_NAME
135
136
  `);
136
137
  return rows.map((row) => row.SCHEMA_NAME);
@@ -453,6 +454,18 @@ var MySQLConnector = class _MySQLConnector {
453
454
  const [rows] = await this.pool.query("SELECT DATABASE() AS DB");
454
455
  return rows[0].DB;
455
456
  }
457
+ /**
458
+ * Default search scope = the database named in the DSN. DATABASE() returns
459
+ * null when the connection was opened without a database, in which case
460
+ * callers fall back to the full server-wide schema list.
461
+ */
462
+ async getDefaultSchema() {
463
+ if (!this.pool) {
464
+ throw new Error("Not connected to database");
465
+ }
466
+ const [rows] = await this.pool.query("SELECT DATABASE() AS DB");
467
+ return rows[0]?.DB ?? null;
468
+ }
456
469
  async executeSQL(sql, options, parameters) {
457
470
  if (!this.pool) {
458
471
  throw new Error("Not connected to database");
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-MEBWJMLY.js";
5
+ } from "./chunk-DN45LGY4.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
7
  import "./chunk-C7WEAPX4.js";
8
8
  export {
@@ -7,7 +7,8 @@ import {
7
7
  import {
8
8
  ConnectorRegistry,
9
9
  SafeURL,
10
- obfuscateDSNPassword
10
+ obfuscateDSNPassword,
11
+ stripCommentsAndStrings
11
12
  } from "./chunk-C7WEAPX4.js";
12
13
 
13
14
  // src/connectors/sqlserver/index.ts
@@ -135,7 +136,7 @@ Expected: ${expectedFormat}`
135
136
  }
136
137
  }
137
138
  };
138
- var SQLServerConnector = class _SQLServerConnector {
139
+ var _SQLServerConnector = class _SQLServerConnector {
139
140
  constructor() {
140
141
  this.id = "sqlserver";
141
142
  this.name = "SQL Server";
@@ -457,6 +458,12 @@ var SQLServerConnector = class _SQLServerConnector {
457
458
  if (!this.connection) {
458
459
  throw new Error("Not connected to SQL Server database");
459
460
  }
461
+ const afterNoise = sqlQuery.slice(
462
+ sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
463
+ );
464
+ if (/^explain\b/i.test(afterNoise)) {
465
+ return this.explainQuery(afterNoise.slice("explain".length).trim());
466
+ }
460
467
  try {
461
468
  let processedSQL = sqlQuery;
462
469
  if (options.maxRows) {
@@ -518,7 +525,60 @@ var SQLServerConnector = class _SQLServerConnector {
518
525
  throw new Error(`Failed to execute query: ${error.message}`);
519
526
  }
520
527
  }
528
+ /**
529
+ * Return the estimated execution plan for a query using SHOWPLAN_XML.
530
+ *
531
+ * SHOWPLAN_XML compiles the statement and returns its plan without executing
532
+ * it, but it has two constraints: `SET SHOWPLAN_XML ON` must be the only
533
+ * statement in its batch, and the setting is session scoped. The shared pool
534
+ * hands out a fresh connection per request() and an open transaction
535
+ * suppresses SHOWPLAN, so neither can carry the setting to a follow-up query.
536
+ *
537
+ * We therefore run the SET / query pair on a short-lived, single-connection
538
+ * pool built from the same config. The dedicated session keeps SHOWPLAN state
539
+ * off the shared pool, so a concurrent query can never land on a connection
540
+ * with SHOWPLAN enabled (which would return a plan instead of its results).
541
+ */
542
+ async explainQuery(innerQuery) {
543
+ const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
544
+ if (!cleaned) {
545
+ throw new Error("EXPLAIN requires a statement to analyze");
546
+ }
547
+ if (/\bset\s+showplan/i.test(cleaned)) {
548
+ throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
549
+ }
550
+ if (!this.config) {
551
+ throw new Error("Not connected to SQL Server database");
552
+ }
553
+ const explainPool = new sql.ConnectionPool({
554
+ ...this.config,
555
+ pool: { ...this.config.pool, max: 1, min: 1 }
556
+ });
557
+ try {
558
+ await explainPool.connect();
559
+ await explainPool.request().batch("SET SHOWPLAN_XML ON");
560
+ const planResult = await explainPool.request().batch(innerQuery);
561
+ const planRow = planResult.recordset?.[0];
562
+ const planXml = planRow ? Object.values(planRow)[0] : null;
563
+ return {
564
+ rows: planXml != null ? [{ plan: planXml }] : [],
565
+ rowCount: planXml != null ? 1 : 0
566
+ };
567
+ } catch (error) {
568
+ throw new Error(`Failed to explain query: ${error.message}`);
569
+ } finally {
570
+ await explainPool.close();
571
+ }
572
+ }
521
573
  };
574
+ /**
575
+ * Leading whitespace and SQL comments to skip before looking for a keyword.
576
+ * The read-only validator strips comments before checking the first keyword,
577
+ * so the connector must skip them too; otherwise an EXPLAIN preceded by a
578
+ * comment passes validation but reaches the server untranslated.
579
+ */
580
+ _SQLServerConnector.LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
581
+ var SQLServerConnector = _SQLServerConnector;
522
582
  var sqlServerConnector = new SQLServerConnector();
523
583
  ConnectorRegistry.register(sqlServerConnector);
524
584
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
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": {