@bytebase/dbhub 0.4.2 → 0.4.6

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.
Files changed (3) hide show
  1. package/README.md +6 -6
  2. package/dist/index.js +200 -89
  3. package/package.json +13 -4
package/README.md CHANGED
@@ -184,9 +184,9 @@ You can specify the SSL mode using the `sslmode` parameter in your DSN string:
184
184
  | PostgreSQL | ✅ | ✅ | Certificate verification |
185
185
  | MySQL | ✅ | ✅ | Certificate verification |
186
186
  | MariaDB | ✅ | ✅ | Certificate verification |
187
- | SQL Server | | | Built-in encryption |
187
+ | SQL Server | | | Certificate verification |
188
+ | Oracle | ✅ | ✅ | N/A (use Oracle client config) |
188
189
  | SQLite | ❌ | ❌ | N/A (file-based) |
189
- | Oracle | ❌ | ❌ | Built-in encryption |
190
190
 
191
191
  **SSL Mode Options:**
192
192
 
@@ -257,12 +257,12 @@ DBHub supports the following database connection string formats:
257
257
 
258
258
  | Database | DSN Format | Example |
259
259
  | ---------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
260
- | MySQL | `mysql://[user]:[password]@[host]:[port]/[database]` | `mysql://user:password@localhost:3306/dbname` |
261
- | MariaDB | `mariadb://[user]:[password]@[host]:[port]/[database]` | `mariadb://user:password@localhost:3306/dbname` |
260
+ | MySQL | `mysql://[user]:[password]@[host]:[port]/[database]` | `mysql://user:password@localhost:3306/dbname?sslmode=disable` |
261
+ | MariaDB | `mariadb://[user]:[password]@[host]:[port]/[database]` | `mariadb://user:password@localhost:3306/dbname?sslmode=disable` |
262
262
  | PostgreSQL | `postgres://[user]:[password]@[host]:[port]/[database]` | `postgres://user:password@localhost:5432/dbname?sslmode=disable` |
263
- | SQL Server | `sqlserver://[user]:[password]@[host]:[port]/[database]` | `sqlserver://user:password@localhost:1433/dbname` |
263
+ | SQL Server | `sqlserver://[user]:[password]@[host]:[port]/[database]` | `sqlserver://user:password@localhost:1433/dbname?sslmode=disable` |
264
264
  | SQLite | `sqlite:///[path/to/file]` or `sqlite::memory:` | `sqlite:///path/to/database.db`, `sqlite:C:/Users/YourName/data/database.db (windows)` or `sqlite::memory:` |
265
- | Oracle | `oracle://[user]:[password]@[host]:[port]/[service_name]` | `oracle://username:password@localhost:1521/service_name` |
265
+ | Oracle | `oracle://[user]:[password]@[host]:[port]/[service_name]` | `oracle://username:password@localhost:1521/service_name?sslmode=disable` |
266
266
 
267
267
  #### Oracle
268
268
 
package/dist/index.js CHANGED
@@ -57,6 +57,97 @@ var _ConnectorRegistry = class _ConnectorRegistry {
57
57
  _ConnectorRegistry.connectors = /* @__PURE__ */ new Map();
58
58
  var ConnectorRegistry = _ConnectorRegistry;
59
59
 
60
+ // src/utils/safe-url.ts
61
+ var SafeURL = class {
62
+ /**
63
+ * Parse a URL and handle special characters in passwords
64
+ * This is a safe alternative to the URL constructor
65
+ *
66
+ * @param urlString - The DSN string to parse
67
+ */
68
+ constructor(urlString) {
69
+ this.protocol = "";
70
+ this.hostname = "";
71
+ this.port = "";
72
+ this.pathname = "";
73
+ this.username = "";
74
+ this.password = "";
75
+ this.searchParams = /* @__PURE__ */ new Map();
76
+ if (!urlString || urlString.trim() === "") {
77
+ throw new Error("URL string cannot be empty");
78
+ }
79
+ try {
80
+ const protocolSeparator = urlString.indexOf("://");
81
+ if (protocolSeparator !== -1) {
82
+ this.protocol = urlString.substring(0, protocolSeparator + 1);
83
+ urlString = urlString.substring(protocolSeparator + 3);
84
+ } else {
85
+ throw new Error('Invalid URL format: missing protocol (e.g., "mysql://")');
86
+ }
87
+ const questionMarkIndex = urlString.indexOf("?");
88
+ let queryParams = "";
89
+ if (questionMarkIndex !== -1) {
90
+ queryParams = urlString.substring(questionMarkIndex + 1);
91
+ urlString = urlString.substring(0, questionMarkIndex);
92
+ queryParams.split("&").forEach((pair) => {
93
+ const parts = pair.split("=");
94
+ if (parts.length === 2 && parts[0] && parts[1]) {
95
+ this.searchParams.set(parts[0], decodeURIComponent(parts[1]));
96
+ }
97
+ });
98
+ }
99
+ const atIndex = urlString.indexOf("@");
100
+ if (atIndex !== -1) {
101
+ const auth = urlString.substring(0, atIndex);
102
+ urlString = urlString.substring(atIndex + 1);
103
+ const colonIndex2 = auth.indexOf(":");
104
+ if (colonIndex2 !== -1) {
105
+ this.username = auth.substring(0, colonIndex2);
106
+ this.password = auth.substring(colonIndex2 + 1);
107
+ this.username = decodeURIComponent(this.username);
108
+ this.password = decodeURIComponent(this.password);
109
+ } else {
110
+ this.username = auth;
111
+ }
112
+ }
113
+ const pathSeparatorIndex = urlString.indexOf("/");
114
+ if (pathSeparatorIndex !== -1) {
115
+ this.pathname = urlString.substring(pathSeparatorIndex);
116
+ urlString = urlString.substring(0, pathSeparatorIndex);
117
+ }
118
+ const colonIndex = urlString.indexOf(":");
119
+ if (colonIndex !== -1) {
120
+ this.hostname = urlString.substring(0, colonIndex);
121
+ this.port = urlString.substring(colonIndex + 1);
122
+ } else {
123
+ this.hostname = urlString;
124
+ }
125
+ if (this.protocol === "") {
126
+ throw new Error("Invalid URL: protocol is required");
127
+ }
128
+ } catch (error) {
129
+ throw new Error(`Failed to parse URL: ${error instanceof Error ? error.message : String(error)}`);
130
+ }
131
+ }
132
+ /**
133
+ * Helper method to safely get a parameter from query string
134
+ *
135
+ * @param name - The parameter name to retrieve
136
+ * @returns The parameter value or null if not found
137
+ */
138
+ getSearchParam(name) {
139
+ return this.searchParams.has(name) ? this.searchParams.get(name) : null;
140
+ }
141
+ /**
142
+ * Helper method to iterate over all parameters
143
+ *
144
+ * @param callback - Function to call for each parameter
145
+ */
146
+ forEachSearchParam(callback) {
147
+ this.searchParams.forEach((value, key) => callback(value, key));
148
+ }
149
+ };
150
+
60
151
  // src/connectors/postgres/index.ts
61
152
  var { Pool } = pg;
62
153
  var PostgresDSNParser = class {
@@ -65,16 +156,16 @@ var PostgresDSNParser = class {
65
156
  throw new Error(`Invalid PostgreSQL DSN: ${dsn}`);
66
157
  }
67
158
  try {
68
- const url = new URL(dsn);
159
+ const url = new SafeURL(dsn);
69
160
  const config = {
70
161
  host: url.hostname,
71
162
  port: url.port ? parseInt(url.port) : 5432,
72
- database: url.pathname.substring(1),
73
- // Remove leading '/'
163
+ database: url.pathname ? url.pathname.substring(1) : "",
164
+ // Remove leading '/' if exists
74
165
  user: url.username,
75
- password: url.password ? decodeURIComponent(url.password) : ""
166
+ password: url.password
76
167
  };
77
- url.searchParams.forEach((value, key) => {
168
+ url.forEachSearchParam((value, key) => {
78
169
  if (key === "sslmode") {
79
170
  if (value === "disable") {
80
171
  config.ssl = false;
@@ -97,8 +188,7 @@ var PostgresDSNParser = class {
97
188
  }
98
189
  isValidDSN(dsn) {
99
190
  try {
100
- const url = new URL(dsn);
101
- return url.protocol === "postgres:" || url.protocol === "postgresql:";
191
+ return dsn.startsWith("postgres://") || dsn.startsWith("postgresql://");
102
192
  } catch (error) {
103
193
  return false;
104
194
  }
@@ -386,66 +476,73 @@ var SQLServerDSNParser = class {
386
476
  "Invalid SQL Server DSN format. Expected: sqlserver://username:password@host:port/database"
387
477
  );
388
478
  }
389
- const url = new URL(dsn);
390
- const host = url.hostname;
391
- const port = url.port ? parseInt(url.port, 10) : 1433;
392
- const database = url.pathname.substring(1);
393
- const user = url.username;
394
- const password = url.password ? decodeURIComponent(url.password) : "";
395
- const options = {};
396
- for (const [key, value] of url.searchParams.entries()) {
397
- if (key === "encrypt") {
398
- options.encrypt = value === "true" ? true : value === "false" ? false : value;
399
- } else if (key === "trustServerCertificate") {
400
- options.trustServerCertificate = value === "true";
401
- } else if (key === "connectTimeout") {
402
- options.connectTimeout = parseInt(value, 10);
403
- } else if (key === "requestTimeout") {
404
- options.requestTimeout = parseInt(value, 10);
405
- } else if (key === "authentication") {
406
- options.authentication = value;
407
- }
408
- }
409
- const config = {
410
- user,
411
- password,
412
- server: host,
413
- port,
414
- database,
415
- options: {
416
- encrypt: options.encrypt ?? true,
417
- // Default to encrypted connection
418
- trustServerCertificate: options.trustServerCertificate === true,
419
- // Need explicit conversion to boolean
420
- connectTimeout: options.connectTimeout ?? 15e3,
421
- requestTimeout: options.requestTimeout ?? 15e3
479
+ try {
480
+ const url = new SafeURL(dsn);
481
+ const options = {};
482
+ url.forEachSearchParam((value, key) => {
483
+ if (key === "connectTimeout") {
484
+ options.connectTimeout = parseInt(value, 10);
485
+ } else if (key === "requestTimeout") {
486
+ options.requestTimeout = parseInt(value, 10);
487
+ } else if (key === "authentication") {
488
+ options.authentication = value;
489
+ } else if (key === "sslmode") {
490
+ options.sslmode = value;
491
+ }
492
+ });
493
+ if (options.sslmode) {
494
+ if (options.sslmode === "disable") {
495
+ options.encrypt = false;
496
+ } else if (options.sslmode === "require") {
497
+ options.encrypt = true;
498
+ options.trustServerCertificate = true;
499
+ }
422
500
  }
423
- };
424
- if (options.authentication === "azure-active-directory-access-token") {
425
- try {
426
- const credential = new DefaultAzureCredential();
427
- const token = await credential.getToken("https://database.windows.net/");
428
- config.authentication = {
429
- type: "azure-active-directory-access-token",
430
- options: {
431
- token: token.token
432
- }
433
- };
434
- } catch (error) {
435
- const errorMessage = error instanceof Error ? error.message : String(error);
436
- throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
501
+ const config = {
502
+ user: url.username,
503
+ password: url.password,
504
+ server: url.hostname,
505
+ port: url.port ? parseInt(url.port) : 1433,
506
+ // Default SQL Server port
507
+ database: url.pathname ? url.pathname.substring(1) : "",
508
+ // Remove leading slash
509
+ options: {
510
+ encrypt: options.encrypt ?? true,
511
+ // Default to encrypted connection
512
+ trustServerCertificate: options.trustServerCertificate === true,
513
+ connectTimeout: options.connectTimeout ?? 15e3,
514
+ requestTimeout: options.requestTimeout ?? 15e3
515
+ }
516
+ };
517
+ if (options.authentication === "azure-active-directory-access-token") {
518
+ try {
519
+ const credential = new DefaultAzureCredential();
520
+ const token = await credential.getToken("https://database.windows.net/");
521
+ config.authentication = {
522
+ type: "azure-active-directory-access-token",
523
+ options: {
524
+ token: token.token
525
+ }
526
+ };
527
+ } catch (error) {
528
+ const errorMessage = error instanceof Error ? error.message : String(error);
529
+ throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
530
+ }
437
531
  }
532
+ return config;
533
+ } catch (error) {
534
+ throw new Error(
535
+ `Failed to parse SQL Server DSN: ${error instanceof Error ? error.message : String(error)}`
536
+ );
438
537
  }
439
- return config;
440
538
  }
441
539
  getSampleDSN() {
442
- return "sqlserver://username:password@localhost:1433/database?encrypt=true";
540
+ return "sqlserver://username:password@localhost:1433/database?sslmode=require";
443
541
  }
444
542
  isValidDSN(dsn) {
445
543
  try {
446
- const url = new URL(dsn);
447
- return url.protocol === "sqlserver:";
448
- } catch (e) {
544
+ return dsn.startsWith("sqlserver://");
545
+ } catch (error) {
449
546
  return false;
450
547
  }
451
548
  }
@@ -723,7 +820,7 @@ var SQLiteDSNParser = class {
723
820
  throw new Error(`Invalid SQLite DSN: ${dsn}`);
724
821
  }
725
822
  try {
726
- const url = new URL(dsn);
823
+ const url = new SafeURL(dsn);
727
824
  let dbPath;
728
825
  if (url.hostname === "" && url.pathname === ":memory:") {
729
826
  dbPath = ":memory:";
@@ -746,8 +843,7 @@ var SQLiteDSNParser = class {
746
843
  }
747
844
  isValidDSN(dsn) {
748
845
  try {
749
- const url = new URL(dsn);
750
- return url.protocol === "sqlite:";
846
+ return dsn.startsWith("sqlite://");
751
847
  } catch (error) {
752
848
  return false;
753
849
  }
@@ -926,16 +1022,16 @@ var MySQLDSNParser = class {
926
1022
  throw new Error(`Invalid MySQL DSN: ${dsn}`);
927
1023
  }
928
1024
  try {
929
- const url = new URL(dsn);
1025
+ const url = new SafeURL(dsn);
930
1026
  const config = {
931
1027
  host: url.hostname,
932
1028
  port: url.port ? parseInt(url.port) : 3306,
933
- database: url.pathname.substring(1),
934
- // Remove leading '/'
1029
+ database: url.pathname ? url.pathname.substring(1) : "",
1030
+ // Remove leading '/' if exists
935
1031
  user: url.username,
936
- password: url.password ? decodeURIComponent(url.password) : ""
1032
+ password: url.password
937
1033
  };
938
- url.searchParams.forEach((value, key) => {
1034
+ url.forEachSearchParam((value, key) => {
939
1035
  if (key === "sslmode") {
940
1036
  if (value === "disable") {
941
1037
  config.ssl = void 0;
@@ -958,8 +1054,7 @@ var MySQLDSNParser = class {
958
1054
  }
959
1055
  isValidDSN(dsn) {
960
1056
  try {
961
- const url = new URL(dsn);
962
- return url.protocol === "mysql:";
1057
+ return dsn.startsWith("mysql://");
963
1058
  } catch (error) {
964
1059
  return false;
965
1060
  }
@@ -1283,15 +1378,16 @@ var MariadbDSNParser = class {
1283
1378
  throw new Error(`Invalid MariaDB DSN: ${dsn}`);
1284
1379
  }
1285
1380
  try {
1286
- const url = new URL(dsn);
1381
+ const url = new SafeURL(dsn);
1287
1382
  const config = {
1288
1383
  host: url.hostname,
1289
1384
  port: url.port ? parseInt(url.port) : 3306,
1290
- database: url.pathname.substring(1),
1385
+ database: url.pathname ? url.pathname.substring(1) : "",
1386
+ // Remove leading '/' if exists
1291
1387
  user: url.username,
1292
- password: decodeURIComponent(url.password)
1388
+ password: url.password
1293
1389
  };
1294
- url.searchParams.forEach((value, key) => {
1390
+ url.forEachSearchParam((value, key) => {
1295
1391
  if (key === "sslmode") {
1296
1392
  if (value === "disable") {
1297
1393
  config.ssl = void 0;
@@ -1314,8 +1410,7 @@ var MariadbDSNParser = class {
1314
1410
  }
1315
1411
  isValidDSN(dsn) {
1316
1412
  try {
1317
- const url = new URL(dsn);
1318
- return url.protocol === "mariadb:";
1413
+ return dsn.startsWith("mariadb://");
1319
1414
  } catch (error) {
1320
1415
  return false;
1321
1416
  }
@@ -1635,7 +1730,7 @@ ConnectorRegistry.register(mariadbConnector);
1635
1730
  // src/connectors/oracle/index.ts
1636
1731
  import oracledb from "oracledb";
1637
1732
  oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
1638
- var OracleConnector = class {
1733
+ var _OracleConnector = class _OracleConnector {
1639
1734
  // constructor(config: ConnectionConfig) { // Removed config
1640
1735
  constructor() {
1641
1736
  // Connector ID and Name are part of the Connector interface
@@ -1650,25 +1745,22 @@ var OracleConnector = class {
1650
1745
  throw new Error(`Invalid Oracle DSN: ${dsn}`);
1651
1746
  }
1652
1747
  try {
1653
- const url = new URL(dsn);
1654
- const username = url.username;
1655
- const password = url.password;
1656
- const host = url.hostname;
1657
- const port = url.port ? parseInt(url.port, 10) : 1521;
1748
+ const url = new SafeURL(dsn);
1658
1749
  let serviceName = url.pathname;
1659
1750
  if (serviceName.startsWith("/")) {
1660
1751
  serviceName = serviceName.substring(1);
1661
1752
  }
1662
- const connectString = `${host}:${port}/${serviceName}`;
1753
+ const port = url.port ? parseInt(url.port) : 1521;
1754
+ const connectString = `${url.hostname}:${port}/${serviceName}`;
1663
1755
  const config = {
1664
- user: username,
1665
- password,
1756
+ user: url.username,
1757
+ password: url.password,
1666
1758
  connectString,
1667
1759
  poolMin: 0,
1668
1760
  poolMax: 10,
1669
1761
  poolIncrement: 1
1670
1762
  };
1671
- url.searchParams.forEach((value, key) => {
1763
+ url.forEachSearchParam((value, key) => {
1672
1764
  switch (key.toLowerCase()) {
1673
1765
  case "poolmin":
1674
1766
  config.poolMin = parseInt(value, 10);
@@ -1679,6 +1771,15 @@ var OracleConnector = class {
1679
1771
  case "poolincrement":
1680
1772
  config.poolIncrement = parseInt(value, 10);
1681
1773
  break;
1774
+ case "sslmode":
1775
+ switch (value.toLowerCase()) {
1776
+ case "disable":
1777
+ break;
1778
+ case "require":
1779
+ config.sslServerDNMatch = false;
1780
+ break;
1781
+ }
1782
+ break;
1682
1783
  }
1683
1784
  });
1684
1785
  return config;
@@ -1687,17 +1788,23 @@ var OracleConnector = class {
1687
1788
  }
1688
1789
  },
1689
1790
  getSampleDSN: () => {
1690
- return "oracle://username:password@host:1521/service_name";
1791
+ return "oracle://username:password@host:1521/service_name?sslmode=require";
1691
1792
  },
1692
1793
  isValidDSN: (dsn) => {
1693
1794
  try {
1694
- const url = new URL(dsn);
1695
- return url.protocol === "oracle:";
1795
+ return dsn.startsWith("oracle://");
1696
1796
  } catch (error) {
1697
1797
  return false;
1698
1798
  }
1699
1799
  }
1700
1800
  };
1801
+ oracledb.autoCommit = true;
1802
+ }
1803
+ // Initialize Oracle client only once
1804
+ initClient() {
1805
+ if (_OracleConnector.clientInitialized) {
1806
+ return;
1807
+ }
1701
1808
  try {
1702
1809
  if (process.env.ORACLE_LIB_DIR) {
1703
1810
  oracledb.initOracleClient({ libDir: process.env.ORACLE_LIB_DIR });
@@ -1705,13 +1812,14 @@ var OracleConnector = class {
1705
1812
  } else {
1706
1813
  console.error("ORACLE_LIB_DIR not specified, will use Thin mode by default");
1707
1814
  }
1815
+ _OracleConnector.clientInitialized = true;
1708
1816
  } catch (err) {
1709
1817
  console.error("Failed to initialize Oracle client:", err);
1710
1818
  }
1711
- oracledb.autoCommit = true;
1712
1819
  }
1713
1820
  async connect(dsn, initializationScript) {
1714
1821
  try {
1822
+ this.initClient();
1715
1823
  const config = await this.dsnParser.parse(dsn);
1716
1824
  this.pool = await oracledb.createPool(config);
1717
1825
  const conn = await this.getConnection();
@@ -2092,6 +2200,9 @@ To resolve this, you need to use Thick mode:
2092
2200
  return this.pool.getConnection();
2093
2201
  }
2094
2202
  };
2203
+ // Track if we've already initialized the client
2204
+ _OracleConnector.clientInitialized = false;
2205
+ var OracleConnector = _OracleConnector;
2095
2206
  function formatOracleDataType(dataType, dataLength, dataPrecision, dataScale) {
2096
2207
  if (!dataType) {
2097
2208
  return "UNKNOWN";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.4.2",
3
+ "version": "0.4.6",
4
4
  "description": "Universal Database MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -29,18 +29,21 @@
29
29
  "zod": "^3.24.2"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/oracledb": "^6.6.0",
33
32
  "@types/better-sqlite3": "^7.6.12",
34
33
  "@types/express": "^4.17.21",
35
34
  "@types/mssql": "^9.1.7",
36
35
  "@types/node": "^22.13.10",
36
+ "@types/oracledb": "^6.6.0",
37
37
  "@types/pg": "^8.11.11",
38
38
  "cross-env": "^7.0.3",
39
+ "husky": "^9.0.11",
40
+ "lint-staged": "^15.2.2",
39
41
  "prettier": "^3.5.3",
40
42
  "ts-node": "^10.9.2",
41
43
  "tsup": "^8.4.0",
42
44
  "tsx": "^4.19.3",
43
- "typescript": "^5.8.2"
45
+ "typescript": "^5.8.2",
46
+ "vitest": "^1.6.1"
44
47
  },
45
48
  "compilerOptions": {
46
49
  "target": "ES2020",
@@ -54,10 +57,16 @@
54
57
  "include": [
55
58
  "src/**/*"
56
59
  ],
60
+ "lint-staged": {
61
+ "*.{js,ts}": "vitest related --run"
62
+ },
57
63
  "scripts": {
58
64
  "build": "tsup",
59
65
  "start": "node dist/index.js",
60
66
  "dev": "NODE_ENV=development tsx src/index.ts",
61
- "crossdev": "cross-env NODE_ENV=development tsx src/index.ts"
67
+ "crossdev": "cross-env NODE_ENV=development tsx src/index.ts",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest",
70
+ "pre-commit": "lint-staged"
62
71
  }
63
72
  }