@bytebase/dbhub 0.4.5 → 0.4.7

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 +18 -0
  2. package/dist/index.js +176 -91
  3. package/package.json +13 -4
package/README.md CHANGED
@@ -338,6 +338,24 @@ The demo mode uses an in-memory SQLite database loaded with the [sample employee
338
338
  pnpm start --transport stdio --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
339
339
  ```
340
340
 
341
+ ### Testing
342
+
343
+ The project uses Vitest for testing:
344
+
345
+ - Run tests: `pnpm test`
346
+ - Run tests in watch mode: `pnpm test:watch`
347
+
348
+ #### Pre-commit Hooks (for Developers)
349
+
350
+ The project includes pre-commit hooks to run tests automatically before each commit:
351
+
352
+ 1. After cloning the repository, set up the pre-commit hooks:
353
+ ```bash
354
+ ./scripts/setup-husky.sh
355
+ ```
356
+
357
+ 2. This ensures the test suite runs automatically whenever you create a commit, preventing commits that would break tests.
358
+
341
359
  ### Debug with [MCP Inspector](https://github.com/modelcontextprotocol/inspector)
342
360
 
343
361
  #### stdio
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,72 +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 === "connectTimeout") {
398
- options.connectTimeout = parseInt(value, 10);
399
- } else if (key === "requestTimeout") {
400
- options.requestTimeout = parseInt(value, 10);
401
- } else if (key === "authentication") {
402
- options.authentication = value;
403
- } else if (key === "sslmode") {
404
- options.sslmode = value;
405
- }
406
- }
407
- if (options.sslmode) {
408
- if (options.sslmode === "disable") {
409
- options.encrypt = false;
410
- } else if (options.sslmode === "require") {
411
- options.encrypt = true;
412
- options.trustServerCertificate = true;
413
- }
414
- }
415
- const config = {
416
- user,
417
- password,
418
- server: host,
419
- port,
420
- database,
421
- options: {
422
- encrypt: options.encrypt ?? true,
423
- // Default to encrypted connection
424
- trustServerCertificate: options.trustServerCertificate === true,
425
- // Need explicit conversion to boolean
426
- connectTimeout: options.connectTimeout ?? 15e3,
427
- 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
+ }
428
500
  }
429
- };
430
- if (options.authentication === "azure-active-directory-access-token") {
431
- try {
432
- const credential = new DefaultAzureCredential();
433
- const token = await credential.getToken("https://database.windows.net/");
434
- config.authentication = {
435
- type: "azure-active-directory-access-token",
436
- options: {
437
- token: token.token
438
- }
439
- };
440
- } catch (error) {
441
- const errorMessage = error instanceof Error ? error.message : String(error);
442
- 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
+ }
443
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
+ );
444
537
  }
445
- return config;
446
538
  }
447
539
  getSampleDSN() {
448
540
  return "sqlserver://username:password@localhost:1433/database?sslmode=require";
449
541
  }
450
542
  isValidDSN(dsn) {
451
543
  try {
452
- const url = new URL(dsn);
453
- return url.protocol === "sqlserver:";
454
- } catch (e) {
544
+ return dsn.startsWith("sqlserver://");
545
+ } catch (error) {
455
546
  return false;
456
547
  }
457
548
  }
@@ -729,7 +820,7 @@ var SQLiteDSNParser = class {
729
820
  throw new Error(`Invalid SQLite DSN: ${dsn}`);
730
821
  }
731
822
  try {
732
- const url = new URL(dsn);
823
+ const url = new SafeURL(dsn);
733
824
  let dbPath;
734
825
  if (url.hostname === "" && url.pathname === ":memory:") {
735
826
  dbPath = ":memory:";
@@ -752,8 +843,7 @@ var SQLiteDSNParser = class {
752
843
  }
753
844
  isValidDSN(dsn) {
754
845
  try {
755
- const url = new URL(dsn);
756
- return url.protocol === "sqlite:";
846
+ return dsn.startsWith("sqlite://");
757
847
  } catch (error) {
758
848
  return false;
759
849
  }
@@ -932,16 +1022,16 @@ var MySQLDSNParser = class {
932
1022
  throw new Error(`Invalid MySQL DSN: ${dsn}`);
933
1023
  }
934
1024
  try {
935
- const url = new URL(dsn);
1025
+ const url = new SafeURL(dsn);
936
1026
  const config = {
937
1027
  host: url.hostname,
938
1028
  port: url.port ? parseInt(url.port) : 3306,
939
- database: url.pathname.substring(1),
940
- // Remove leading '/'
1029
+ database: url.pathname ? url.pathname.substring(1) : "",
1030
+ // Remove leading '/' if exists
941
1031
  user: url.username,
942
- password: url.password ? decodeURIComponent(url.password) : ""
1032
+ password: url.password
943
1033
  };
944
- url.searchParams.forEach((value, key) => {
1034
+ url.forEachSearchParam((value, key) => {
945
1035
  if (key === "sslmode") {
946
1036
  if (value === "disable") {
947
1037
  config.ssl = void 0;
@@ -964,8 +1054,7 @@ var MySQLDSNParser = class {
964
1054
  }
965
1055
  isValidDSN(dsn) {
966
1056
  try {
967
- const url = new URL(dsn);
968
- return url.protocol === "mysql:";
1057
+ return dsn.startsWith("mysql://");
969
1058
  } catch (error) {
970
1059
  return false;
971
1060
  }
@@ -1289,15 +1378,16 @@ var MariadbDSNParser = class {
1289
1378
  throw new Error(`Invalid MariaDB DSN: ${dsn}`);
1290
1379
  }
1291
1380
  try {
1292
- const url = new URL(dsn);
1381
+ const url = new SafeURL(dsn);
1293
1382
  const config = {
1294
1383
  host: url.hostname,
1295
1384
  port: url.port ? parseInt(url.port) : 3306,
1296
- database: url.pathname.substring(1),
1385
+ database: url.pathname ? url.pathname.substring(1) : "",
1386
+ // Remove leading '/' if exists
1297
1387
  user: url.username,
1298
- password: decodeURIComponent(url.password)
1388
+ password: url.password
1299
1389
  };
1300
- url.searchParams.forEach((value, key) => {
1390
+ url.forEachSearchParam((value, key) => {
1301
1391
  if (key === "sslmode") {
1302
1392
  if (value === "disable") {
1303
1393
  config.ssl = void 0;
@@ -1320,8 +1410,7 @@ var MariadbDSNParser = class {
1320
1410
  }
1321
1411
  isValidDSN(dsn) {
1322
1412
  try {
1323
- const url = new URL(dsn);
1324
- return url.protocol === "mariadb:";
1413
+ return dsn.startsWith("mariadb://");
1325
1414
  } catch (error) {
1326
1415
  return false;
1327
1416
  }
@@ -1656,25 +1745,22 @@ var _OracleConnector = class _OracleConnector {
1656
1745
  throw new Error(`Invalid Oracle DSN: ${dsn}`);
1657
1746
  }
1658
1747
  try {
1659
- const url = new URL(dsn);
1660
- const username = url.username;
1661
- const password = url.password;
1662
- const host = url.hostname;
1663
- const port = url.port ? parseInt(url.port, 10) : 1521;
1748
+ const url = new SafeURL(dsn);
1664
1749
  let serviceName = url.pathname;
1665
1750
  if (serviceName.startsWith("/")) {
1666
1751
  serviceName = serviceName.substring(1);
1667
1752
  }
1668
- const connectString = `${host}:${port}/${serviceName}`;
1753
+ const port = url.port ? parseInt(url.port) : 1521;
1754
+ const connectString = `${url.hostname}:${port}/${serviceName}`;
1669
1755
  const config = {
1670
- user: username,
1671
- password,
1756
+ user: url.username,
1757
+ password: url.password,
1672
1758
  connectString,
1673
1759
  poolMin: 0,
1674
1760
  poolMax: 10,
1675
1761
  poolIncrement: 1
1676
1762
  };
1677
- url.searchParams.forEach((value, key) => {
1763
+ url.forEachSearchParam((value, key) => {
1678
1764
  switch (key.toLowerCase()) {
1679
1765
  case "poolmin":
1680
1766
  config.poolMin = parseInt(value, 10);
@@ -1706,8 +1792,7 @@ var _OracleConnector = class _OracleConnector {
1706
1792
  },
1707
1793
  isValidDSN: (dsn) => {
1708
1794
  try {
1709
- const url = new URL(dsn);
1710
- return url.protocol === "oracle:";
1795
+ return dsn.startsWith("oracle://");
1711
1796
  } catch (error) {
1712
1797
  return false;
1713
1798
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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
  }