@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.
- package/README.md +18 -0
- package/dist/index.js +176 -91
- 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
|
|
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
|
|
166
|
+
password: url.password
|
|
76
167
|
};
|
|
77
|
-
url.
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
options.sslmode
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1032
|
+
password: url.password
|
|
943
1033
|
};
|
|
944
|
-
url.
|
|
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
|
-
|
|
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
|
|
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:
|
|
1388
|
+
password: url.password
|
|
1299
1389
|
};
|
|
1300
|
-
url.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
}
|