@bytebase/dbhub 0.10.0 → 0.11.1
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 +110 -34
- package/dist/index.js +425 -67
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -22,14 +22,15 @@ DBHub is a universal database gateway implementing the Model Context Protocol (M
|
|
|
22
22
|
| | | | | |
|
|
23
23
|
| Claude Desktop +--->+ +--->+ PostgreSQL |
|
|
24
24
|
| | | | | |
|
|
25
|
-
|
|
|
25
|
+
| Claude Code +--->+ +--->+ SQL Server |
|
|
26
26
|
| | | | | |
|
|
27
|
-
|
|
|
27
|
+
| Cursor +--->+ DBHub +--->+ SQLite |
|
|
28
28
|
| | | | | |
|
|
29
|
-
|
|
|
29
|
+
| Other Clients +--->+ +--->+ MySQL |
|
|
30
30
|
| | | | | |
|
|
31
31
|
| | | +--->+ MariaDB |
|
|
32
32
|
| | | | | |
|
|
33
|
+
| | | | | |
|
|
33
34
|
+------------------+ +--------------+ +------------------+
|
|
34
35
|
MCP Clients MCP Server Databases
|
|
35
36
|
```
|
|
@@ -82,7 +83,7 @@ docker run --rm --init \
|
|
|
82
83
|
```
|
|
83
84
|
|
|
84
85
|
```bash
|
|
85
|
-
# Demo mode with sample employee database
|
|
86
|
+
# Demo mode with sqlite sample employee database
|
|
86
87
|
docker run --rm --init \
|
|
87
88
|
--name dbhub \
|
|
88
89
|
--publish 8080:8080 \
|
|
@@ -92,12 +93,14 @@ docker run --rm --init \
|
|
|
92
93
|
--demo
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
|
|
96
96
|
### NPM
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
# PostgreSQL example
|
|
100
100
|
npx @bytebase/dbhub --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
|
101
|
+
|
|
102
|
+
# Demo mode with sqlite sample employee database
|
|
103
|
+
npx @bytebase/dbhub --transport http --port 8080 --demo
|
|
101
104
|
```
|
|
102
105
|
|
|
103
106
|
```bash
|
|
@@ -150,6 +153,10 @@ npx @bytebase/dbhub --transport http --port 8080 --demo
|
|
|
150
153
|
}
|
|
151
154
|
```
|
|
152
155
|
|
|
156
|
+
### Claude Code
|
|
157
|
+
|
|
158
|
+
Check https://docs.anthropic.com/en/docs/claude-code/mcp
|
|
159
|
+
|
|
153
160
|
### Cursor
|
|
154
161
|
|
|
155
162
|
[](https://cursor.com/install-mcp?name=dbhub&config=eyJjb21tYW5kIjoibnB4IEBieXRlYmFzZS9kYmh1YiIsImVudiI6eyJUUkFOU1BPUlQiOiJzdGRpbyIsIkRTTiI6InBvc3RncmVzOi8vdXNlcjpwYXNzd29yZEBsb2NhbGhvc3Q6NTQzMi9kYm5hbWU%2Fc3NsbW9kZT1kaXNhYmxlIiwiUkVBRE9OTFkiOiJ0cnVlIn19)
|
|
@@ -161,17 +168,30 @@ npx @bytebase/dbhub --transport http --port 8080 --demo
|
|
|
161
168
|
|
|
162
169
|
## Usage
|
|
163
170
|
|
|
171
|
+
### Read-only Mode
|
|
172
|
+
|
|
173
|
+
You can run DBHub in read-only mode, which restricts SQL query execution to read-only operations:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Enable read-only mode
|
|
177
|
+
npx @bytebase/dbhub --readonly --dsn "postgres://user:password@localhost:5432/dbname"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
In read-only mode, only [readonly SQL operations](https://github.com/bytebase/dbhub/blob/main/src/utils/allowed-keywords.ts) are allowed.
|
|
181
|
+
|
|
182
|
+
This provides an additional layer of security when connecting to production databases.
|
|
183
|
+
|
|
164
184
|
### SSL Connections
|
|
165
185
|
|
|
166
186
|
You can specify the SSL mode using the `sslmode` parameter in your DSN string:
|
|
167
187
|
|
|
168
|
-
| Database | `sslmode=disable` | `sslmode=require` |
|
|
169
|
-
| ---------- | :---------------: | :---------------: |
|
|
170
|
-
| PostgreSQL | ✅ | ✅ |
|
|
171
|
-
| MySQL | ✅ | ✅ |
|
|
172
|
-
| MariaDB | ✅ | ✅ |
|
|
173
|
-
| SQL Server | ✅ | ✅ |
|
|
174
|
-
| SQLite | ❌ | ❌ |
|
|
188
|
+
| Database | `sslmode=disable` | `sslmode=require` | Default SSL Behavior |
|
|
189
|
+
| ---------- | :---------------: | :---------------: | :----------------------: |
|
|
190
|
+
| PostgreSQL | ✅ | ✅ | Certificate verification |
|
|
191
|
+
| MySQL | ✅ | ✅ | Certificate verification |
|
|
192
|
+
| MariaDB | ✅ | ✅ | Certificate verification |
|
|
193
|
+
| SQL Server | ✅ | ✅ | Certificate verification |
|
|
194
|
+
| SQLite | ❌ | ❌ | N/A (file-based) |
|
|
175
195
|
|
|
176
196
|
**SSL Mode Options:**
|
|
177
197
|
|
|
@@ -193,18 +213,70 @@ postgres://user:password@localhost:5432/dbname?sslmode=require
|
|
|
193
213
|
postgres://user:password@localhost:5432/dbname
|
|
194
214
|
```
|
|
195
215
|
|
|
196
|
-
###
|
|
216
|
+
### SSH Tunnel Support
|
|
197
217
|
|
|
198
|
-
|
|
218
|
+
DBHub supports connecting to databases through SSH tunnels, enabling secure access to databases in private networks or behind firewalls.
|
|
219
|
+
|
|
220
|
+
#### Using SSH Config File (Recommended)
|
|
221
|
+
|
|
222
|
+
DBHub can read SSH connection settings from your `~/.ssh/config` file. Simply use the host alias from your SSH config:
|
|
199
223
|
|
|
200
224
|
```bash
|
|
201
|
-
#
|
|
202
|
-
|
|
225
|
+
# If you have this in ~/.ssh/config:
|
|
226
|
+
# Host mybastion
|
|
227
|
+
# HostName bastion.example.com
|
|
228
|
+
# User ubuntu
|
|
229
|
+
# IdentityFile ~/.ssh/id_rsa
|
|
230
|
+
|
|
231
|
+
npx @bytebase/dbhub \
|
|
232
|
+
--dsn "postgres://dbuser:dbpass@database.internal:5432/mydb" \
|
|
233
|
+
--ssh-host mybastion
|
|
203
234
|
```
|
|
204
235
|
|
|
205
|
-
|
|
236
|
+
DBHub will automatically use the settings from your SSH config, including hostname, user, port, and identity file. If no identity file is specified in the config, DBHub will try common default locations (`~/.ssh/id_rsa`, `~/.ssh/id_ed25519`, etc.).
|
|
206
237
|
|
|
207
|
-
|
|
238
|
+
#### SSH with Password Authentication
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npx @bytebase/dbhub \
|
|
242
|
+
--dsn "postgres://dbuser:dbpass@database.internal:5432/mydb" \
|
|
243
|
+
--ssh-host bastion.example.com \
|
|
244
|
+
--ssh-user ubuntu \
|
|
245
|
+
--ssh-password mypassword
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### SSH with Private Key Authentication
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
npx @bytebase/dbhub \
|
|
252
|
+
--dsn "postgres://dbuser:dbpass@database.internal:5432/mydb" \
|
|
253
|
+
--ssh-host bastion.example.com \
|
|
254
|
+
--ssh-user ubuntu \
|
|
255
|
+
--ssh-key ~/.ssh/id_rsa
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### SSH with Private Key and Passphrase
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
npx @bytebase/dbhub \
|
|
262
|
+
--dsn "postgres://dbuser:dbpass@database.internal:5432/mydb" \
|
|
263
|
+
--ssh-host bastion.example.com \
|
|
264
|
+
--ssh-port 2222 \
|
|
265
|
+
--ssh-user ubuntu \
|
|
266
|
+
--ssh-key ~/.ssh/id_rsa \
|
|
267
|
+
--ssh-passphrase mykeypassphrase
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
#### Using Environment Variables
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
export SSH_HOST=bastion.example.com
|
|
274
|
+
export SSH_USER=ubuntu
|
|
275
|
+
export SSH_KEY=~/.ssh/id_rsa
|
|
276
|
+
npx @bytebase/dbhub --dsn "postgres://dbuser:dbpass@database.internal:5432/mydb"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Note**: When using SSH tunnels, the database host in your DSN should be the hostname/IP as seen from the SSH server (bastion host), not from your local machine.
|
|
208
280
|
|
|
209
281
|
### Configure your database connection
|
|
210
282
|
|
|
@@ -244,14 +316,13 @@ For real databases, a Database Source Name (DSN) is required. You can provide th
|
|
|
244
316
|
|
|
245
317
|
DBHub supports the following database connection string formats:
|
|
246
318
|
|
|
247
|
-
| Database | DSN Format
|
|
248
|
-
| ---------- |
|
|
249
|
-
| MySQL | `mysql://[user]:[password]@[host]:[port]/[database]`
|
|
250
|
-
| MariaDB | `mariadb://[user]:[password]@[host]:[port]/[database]`
|
|
251
|
-
| PostgreSQL | `postgres://[user]:[password]@[host]:[port]/[database]`
|
|
252
|
-
| SQL Server | `sqlserver://[user]:[password]@[host]:[port]/[database]`
|
|
253
|
-
| SQLite | `sqlite:///[path/to/file]` or `sqlite:///:memory:`
|
|
254
|
-
|
|
319
|
+
| Database | DSN Format | Example |
|
|
320
|
+
| ---------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
321
|
+
| MySQL | `mysql://[user]:[password]@[host]:[port]/[database]` | `mysql://user:password@localhost:3306/dbname?sslmode=disable` |
|
|
322
|
+
| MariaDB | `mariadb://[user]:[password]@[host]:[port]/[database]` | `mariadb://user:password@localhost:3306/dbname?sslmode=disable` |
|
|
323
|
+
| PostgreSQL | `postgres://[user]:[password]@[host]:[port]/[database]` | `postgres://user:password@localhost:5432/dbname?sslmode=disable` |
|
|
324
|
+
| SQL Server | `sqlserver://[user]:[password]@[host]:[port]/[database]` | `sqlserver://user:password@localhost:1433/dbname?sslmode=disable` |
|
|
325
|
+
| SQLite | `sqlite:///[path/to/file]` or `sqlite:///:memory:` | `sqlite:///path/to/database.db`, `sqlite:C:/Users/YourName/data/database.db (windows)` or `sqlite:///:memory:` |
|
|
255
326
|
|
|
256
327
|
#### SQL Server
|
|
257
328
|
|
|
@@ -276,13 +347,19 @@ Extra query parameters:
|
|
|
276
347
|
|
|
277
348
|
### Command line options
|
|
278
349
|
|
|
279
|
-
| Option
|
|
280
|
-
|
|
|
281
|
-
| dsn
|
|
282
|
-
| transport
|
|
283
|
-
| port
|
|
284
|
-
| readonly
|
|
285
|
-
| demo
|
|
350
|
+
| Option | Environment Variable | Description | Default |
|
|
351
|
+
| -------------- | -------------------- | ---------------------------------------------------------------- | ---------------------------- |
|
|
352
|
+
| dsn | `DSN` | Database connection string | Required if not in demo mode |
|
|
353
|
+
| transport | `TRANSPORT` | Transport mode: `stdio` or `http` | `stdio` |
|
|
354
|
+
| port | `PORT` | HTTP server port (only applicable when using `--transport=http`) | `8080` |
|
|
355
|
+
| readonly | `READONLY` | Restrict SQL execution to read-only operations | `false` |
|
|
356
|
+
| demo | N/A | Run in demo mode with sample employee database | `false` |
|
|
357
|
+
| ssh-host | `SSH_HOST` | SSH server hostname for tunnel connection | N/A |
|
|
358
|
+
| ssh-port | `SSH_PORT` | SSH server port | `22` |
|
|
359
|
+
| ssh-user | `SSH_USER` | SSH username | N/A |
|
|
360
|
+
| ssh-password | `SSH_PASSWORD` | SSH password (for password authentication) | N/A |
|
|
361
|
+
| ssh-key | `SSH_KEY` | Path to SSH private key file | N/A |
|
|
362
|
+
| ssh-passphrase | `SSH_PASSPHRASE` | Passphrase for SSH private key | N/A |
|
|
286
363
|
|
|
287
364
|
The demo mode uses an in-memory SQLite database loaded with the [sample employee database](https://github.com/bytebase/dbhub/tree/main/resources/employee-sqlite) that includes tables for employees, departments, titles, salaries, department employees, and department managers. The sample database includes SQL scripts for table creation, data loading, and testing.
|
|
288
365
|
|
|
@@ -379,7 +456,6 @@ docker pull mcr.microsoft.com/mssql/server:2019-latest
|
|
|
379
456
|
- Ensure Docker has sufficient memory allocated (4GB+ recommended)
|
|
380
457
|
- Consider running SQL Server tests separately if experiencing timeouts
|
|
381
458
|
|
|
382
|
-
|
|
383
459
|
**Network/Resource Issues:**
|
|
384
460
|
|
|
385
461
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -1881,89 +1881,148 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
1881
1881
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1882
1882
|
import express from "express";
|
|
1883
1883
|
import path3 from "path";
|
|
1884
|
-
import { readFileSync } from "fs";
|
|
1884
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1885
1885
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1886
1886
|
|
|
1887
|
-
// src/
|
|
1888
|
-
|
|
1889
|
-
|
|
1887
|
+
// src/utils/ssh-tunnel.ts
|
|
1888
|
+
import { Client } from "ssh2";
|
|
1889
|
+
import { readFileSync } from "fs";
|
|
1890
|
+
import { createServer } from "net";
|
|
1891
|
+
var SSHTunnel = class {
|
|
1890
1892
|
constructor() {
|
|
1891
|
-
this.
|
|
1892
|
-
this.
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
}
|
|
1893
|
+
this.sshClient = null;
|
|
1894
|
+
this.localServer = null;
|
|
1895
|
+
this.tunnelInfo = null;
|
|
1896
|
+
this.isConnected = false;
|
|
1896
1897
|
}
|
|
1897
1898
|
/**
|
|
1898
|
-
*
|
|
1899
|
+
* Establish an SSH tunnel
|
|
1900
|
+
* @param config SSH connection configuration
|
|
1901
|
+
* @param options Tunnel options including target host and port
|
|
1902
|
+
* @returns Promise resolving to tunnel information including local port
|
|
1899
1903
|
*/
|
|
1900
|
-
async
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1904
|
+
async establish(config, options) {
|
|
1905
|
+
if (this.isConnected) {
|
|
1906
|
+
throw new Error("SSH tunnel is already established");
|
|
1907
|
+
}
|
|
1908
|
+
return new Promise((resolve, reject) => {
|
|
1909
|
+
this.sshClient = new Client();
|
|
1910
|
+
const sshConfig = {
|
|
1911
|
+
host: config.host,
|
|
1912
|
+
port: config.port || 22,
|
|
1913
|
+
username: config.username
|
|
1914
|
+
};
|
|
1915
|
+
if (config.password) {
|
|
1916
|
+
sshConfig.password = config.password;
|
|
1917
|
+
} else if (config.privateKey) {
|
|
1918
|
+
try {
|
|
1919
|
+
const privateKey = readFileSync(config.privateKey);
|
|
1920
|
+
sshConfig.privateKey = privateKey;
|
|
1921
|
+
if (config.passphrase) {
|
|
1922
|
+
sshConfig.passphrase = config.passphrase;
|
|
1923
|
+
}
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
reject(new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`));
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
} else {
|
|
1929
|
+
reject(new Error("Either password or privateKey must be provided for SSH authentication"));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
this.sshClient.on("error", (err) => {
|
|
1933
|
+
this.cleanup();
|
|
1934
|
+
reject(new Error(`SSH connection error: ${err.message}`));
|
|
1935
|
+
});
|
|
1936
|
+
this.sshClient.on("ready", () => {
|
|
1937
|
+
console.error("SSH connection established");
|
|
1938
|
+
this.localServer = createServer((localSocket) => {
|
|
1939
|
+
this.sshClient.forwardOut(
|
|
1940
|
+
"127.0.0.1",
|
|
1941
|
+
0,
|
|
1942
|
+
options.targetHost,
|
|
1943
|
+
options.targetPort,
|
|
1944
|
+
(err, stream) => {
|
|
1945
|
+
if (err) {
|
|
1946
|
+
console.error("SSH forward error:", err);
|
|
1947
|
+
localSocket.end();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
localSocket.pipe(stream).pipe(localSocket);
|
|
1951
|
+
stream.on("error", (err2) => {
|
|
1952
|
+
console.error("SSH stream error:", err2);
|
|
1953
|
+
localSocket.end();
|
|
1954
|
+
});
|
|
1955
|
+
localSocket.on("error", (err2) => {
|
|
1956
|
+
console.error("Local socket error:", err2);
|
|
1957
|
+
stream.end();
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
const localPort = options.localPort || 0;
|
|
1963
|
+
this.localServer.listen(localPort, "127.0.0.1", () => {
|
|
1964
|
+
const address = this.localServer.address();
|
|
1965
|
+
if (!address || typeof address === "string") {
|
|
1966
|
+
this.cleanup();
|
|
1967
|
+
reject(new Error("Failed to get local server address"));
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
this.tunnelInfo = {
|
|
1971
|
+
localPort: address.port,
|
|
1972
|
+
targetHost: options.targetHost,
|
|
1973
|
+
targetPort: options.targetPort
|
|
1974
|
+
};
|
|
1975
|
+
this.isConnected = true;
|
|
1976
|
+
console.error(`SSH tunnel established: localhost:${address.port} -> ${options.targetHost}:${options.targetPort}`);
|
|
1977
|
+
resolve(this.tunnelInfo);
|
|
1978
|
+
});
|
|
1979
|
+
this.localServer.on("error", (err) => {
|
|
1980
|
+
this.cleanup();
|
|
1981
|
+
reject(new Error(`Local server error: ${err.message}`));
|
|
1982
|
+
});
|
|
1983
|
+
});
|
|
1984
|
+
this.sshClient.connect(sshConfig);
|
|
1985
|
+
});
|
|
1908
1986
|
}
|
|
1909
1987
|
/**
|
|
1910
|
-
*
|
|
1988
|
+
* Close the SSH tunnel and clean up resources
|
|
1911
1989
|
*/
|
|
1912
|
-
async
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1990
|
+
async close() {
|
|
1991
|
+
if (!this.isConnected) {
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
return new Promise((resolve) => {
|
|
1995
|
+
this.cleanup();
|
|
1996
|
+
this.isConnected = false;
|
|
1997
|
+
console.error("SSH tunnel closed");
|
|
1998
|
+
resolve();
|
|
1999
|
+
});
|
|
1921
2000
|
}
|
|
1922
2001
|
/**
|
|
1923
|
-
*
|
|
2002
|
+
* Clean up resources
|
|
1924
2003
|
*/
|
|
1925
|
-
|
|
1926
|
-
if (this.
|
|
1927
|
-
|
|
1928
|
-
this.
|
|
2004
|
+
cleanup() {
|
|
2005
|
+
if (this.localServer) {
|
|
2006
|
+
this.localServer.close();
|
|
2007
|
+
this.localServer = null;
|
|
1929
2008
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
*/
|
|
1934
|
-
getConnector() {
|
|
1935
|
-
if (!this.activeConnector) {
|
|
1936
|
-
throw new Error("No active connector. Call connectWithDSN() or connectWithType() first.");
|
|
2009
|
+
if (this.sshClient) {
|
|
2010
|
+
this.sshClient.end();
|
|
2011
|
+
this.sshClient = null;
|
|
1937
2012
|
}
|
|
1938
|
-
|
|
2013
|
+
this.tunnelInfo = null;
|
|
1939
2014
|
}
|
|
1940
2015
|
/**
|
|
1941
|
-
*
|
|
1942
|
-
*/
|
|
1943
|
-
isConnected() {
|
|
1944
|
-
return this.connected;
|
|
1945
|
-
}
|
|
1946
|
-
/**
|
|
1947
|
-
* Get all available connector types
|
|
2016
|
+
* Get current tunnel information
|
|
1948
2017
|
*/
|
|
1949
|
-
|
|
1950
|
-
return
|
|
2018
|
+
getTunnelInfo() {
|
|
2019
|
+
return this.tunnelInfo;
|
|
1951
2020
|
}
|
|
1952
2021
|
/**
|
|
1953
|
-
*
|
|
2022
|
+
* Check if tunnel is connected
|
|
1954
2023
|
*/
|
|
1955
|
-
|
|
1956
|
-
return
|
|
1957
|
-
}
|
|
1958
|
-
/**
|
|
1959
|
-
* Get the current active connector instance
|
|
1960
|
-
* This is used by resource and tool handlers
|
|
1961
|
-
*/
|
|
1962
|
-
static getCurrentConnector() {
|
|
1963
|
-
if (!managerInstance) {
|
|
1964
|
-
throw new Error("ConnectorManager not initialized");
|
|
1965
|
-
}
|
|
1966
|
-
return managerInstance.getConnector();
|
|
2024
|
+
getIsConnected() {
|
|
2025
|
+
return this.isConnected;
|
|
1967
2026
|
}
|
|
1968
2027
|
};
|
|
1969
2028
|
|
|
@@ -1972,6 +2031,102 @@ import dotenv from "dotenv";
|
|
|
1972
2031
|
import path from "path";
|
|
1973
2032
|
import fs from "fs";
|
|
1974
2033
|
import { fileURLToPath } from "url";
|
|
2034
|
+
|
|
2035
|
+
// src/utils/ssh-config-parser.ts
|
|
2036
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
2037
|
+
import { homedir } from "os";
|
|
2038
|
+
import { join } from "path";
|
|
2039
|
+
import SSHConfig from "ssh-config";
|
|
2040
|
+
var DEFAULT_SSH_KEYS = [
|
|
2041
|
+
"~/.ssh/id_rsa",
|
|
2042
|
+
"~/.ssh/id_ed25519",
|
|
2043
|
+
"~/.ssh/id_ecdsa",
|
|
2044
|
+
"~/.ssh/id_dsa"
|
|
2045
|
+
];
|
|
2046
|
+
function expandTilde(filePath) {
|
|
2047
|
+
if (filePath.startsWith("~/")) {
|
|
2048
|
+
return join(homedir(), filePath.substring(2));
|
|
2049
|
+
}
|
|
2050
|
+
return filePath;
|
|
2051
|
+
}
|
|
2052
|
+
function fileExists(filePath) {
|
|
2053
|
+
try {
|
|
2054
|
+
return existsSync(expandTilde(filePath));
|
|
2055
|
+
} catch {
|
|
2056
|
+
return false;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
function findDefaultSSHKey() {
|
|
2060
|
+
for (const keyPath of DEFAULT_SSH_KEYS) {
|
|
2061
|
+
if (fileExists(keyPath)) {
|
|
2062
|
+
return expandTilde(keyPath);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return void 0;
|
|
2066
|
+
}
|
|
2067
|
+
function parseSSHConfig(hostAlias, configPath) {
|
|
2068
|
+
const sshConfigPath = configPath || join(homedir(), ".ssh", "config");
|
|
2069
|
+
if (!existsSync(sshConfigPath)) {
|
|
2070
|
+
return null;
|
|
2071
|
+
}
|
|
2072
|
+
try {
|
|
2073
|
+
const configContent = readFileSync2(sshConfigPath, "utf8");
|
|
2074
|
+
const config = SSHConfig.parse(configContent);
|
|
2075
|
+
const hostConfig = config.compute(hostAlias);
|
|
2076
|
+
if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
const sshConfig = {};
|
|
2080
|
+
if (hostConfig.HostName) {
|
|
2081
|
+
sshConfig.host = hostConfig.HostName;
|
|
2082
|
+
} else {
|
|
2083
|
+
sshConfig.host = hostAlias;
|
|
2084
|
+
}
|
|
2085
|
+
if (hostConfig.Port) {
|
|
2086
|
+
sshConfig.port = parseInt(hostConfig.Port, 10);
|
|
2087
|
+
}
|
|
2088
|
+
if (hostConfig.User) {
|
|
2089
|
+
sshConfig.username = hostConfig.User;
|
|
2090
|
+
}
|
|
2091
|
+
if (hostConfig.IdentityFile) {
|
|
2092
|
+
const identityFile = Array.isArray(hostConfig.IdentityFile) ? hostConfig.IdentityFile[0] : hostConfig.IdentityFile;
|
|
2093
|
+
const expandedPath = expandTilde(identityFile);
|
|
2094
|
+
if (fileExists(expandedPath)) {
|
|
2095
|
+
sshConfig.privateKey = expandedPath;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
if (!sshConfig.privateKey) {
|
|
2099
|
+
const defaultKey = findDefaultSSHKey();
|
|
2100
|
+
if (defaultKey) {
|
|
2101
|
+
sshConfig.privateKey = defaultKey;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
if (hostConfig.ProxyJump || hostConfig.ProxyCommand) {
|
|
2105
|
+
console.error("Warning: ProxyJump/ProxyCommand in SSH config is not yet supported by DBHub");
|
|
2106
|
+
}
|
|
2107
|
+
if (!sshConfig.host || !sshConfig.username) {
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2110
|
+
return sshConfig;
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
|
|
2113
|
+
return null;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
function looksLikeSSHAlias(host) {
|
|
2117
|
+
if (host.includes(".")) {
|
|
2118
|
+
return false;
|
|
2119
|
+
}
|
|
2120
|
+
if (/^[\d:]+$/.test(host)) {
|
|
2121
|
+
return false;
|
|
2122
|
+
}
|
|
2123
|
+
if (/^[0-9a-fA-F:]+$/.test(host) && host.includes(":")) {
|
|
2124
|
+
return false;
|
|
2125
|
+
}
|
|
2126
|
+
return true;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/config/env.ts
|
|
1975
2130
|
var __filename = fileURLToPath(import.meta.url);
|
|
1976
2131
|
var __dirname = path.dirname(__filename);
|
|
1977
2132
|
function parseCommandLineArgs() {
|
|
@@ -2086,6 +2241,206 @@ function redactDSN(dsn) {
|
|
|
2086
2241
|
return dsn.replace(/\/\/([^:]+):([^@]+)@/, "//$1:***@");
|
|
2087
2242
|
}
|
|
2088
2243
|
}
|
|
2244
|
+
function resolveSSHConfig() {
|
|
2245
|
+
const args = parseCommandLineArgs();
|
|
2246
|
+
const hasSSHArgs = args["ssh-host"] || process.env.SSH_HOST;
|
|
2247
|
+
if (!hasSSHArgs) {
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
let config = {};
|
|
2251
|
+
let sources = [];
|
|
2252
|
+
let sshConfigHost;
|
|
2253
|
+
if (args["ssh-host"]) {
|
|
2254
|
+
sshConfigHost = args["ssh-host"];
|
|
2255
|
+
config.host = args["ssh-host"];
|
|
2256
|
+
sources.push("ssh-host from command line");
|
|
2257
|
+
} else if (process.env.SSH_HOST) {
|
|
2258
|
+
sshConfigHost = process.env.SSH_HOST;
|
|
2259
|
+
config.host = process.env.SSH_HOST;
|
|
2260
|
+
sources.push("SSH_HOST from environment");
|
|
2261
|
+
}
|
|
2262
|
+
if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
|
|
2263
|
+
const sshConfigData = parseSSHConfig(sshConfigHost);
|
|
2264
|
+
if (sshConfigData) {
|
|
2265
|
+
config = { ...sshConfigData };
|
|
2266
|
+
sources.push(`SSH config for host '${sshConfigHost}'`);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (args["ssh-port"]) {
|
|
2270
|
+
config.port = parseInt(args["ssh-port"], 10);
|
|
2271
|
+
sources.push("ssh-port from command line");
|
|
2272
|
+
} else if (process.env.SSH_PORT) {
|
|
2273
|
+
config.port = parseInt(process.env.SSH_PORT, 10);
|
|
2274
|
+
sources.push("SSH_PORT from environment");
|
|
2275
|
+
}
|
|
2276
|
+
if (args["ssh-user"]) {
|
|
2277
|
+
config.username = args["ssh-user"];
|
|
2278
|
+
sources.push("ssh-user from command line");
|
|
2279
|
+
} else if (process.env.SSH_USER) {
|
|
2280
|
+
config.username = process.env.SSH_USER;
|
|
2281
|
+
sources.push("SSH_USER from environment");
|
|
2282
|
+
}
|
|
2283
|
+
if (args["ssh-password"]) {
|
|
2284
|
+
config.password = args["ssh-password"];
|
|
2285
|
+
sources.push("ssh-password from command line");
|
|
2286
|
+
} else if (process.env.SSH_PASSWORD) {
|
|
2287
|
+
config.password = process.env.SSH_PASSWORD;
|
|
2288
|
+
sources.push("SSH_PASSWORD from environment");
|
|
2289
|
+
}
|
|
2290
|
+
if (args["ssh-key"]) {
|
|
2291
|
+
config.privateKey = args["ssh-key"];
|
|
2292
|
+
if (config.privateKey.startsWith("~/")) {
|
|
2293
|
+
config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
|
|
2294
|
+
}
|
|
2295
|
+
sources.push("ssh-key from command line");
|
|
2296
|
+
} else if (process.env.SSH_KEY) {
|
|
2297
|
+
config.privateKey = process.env.SSH_KEY;
|
|
2298
|
+
if (config.privateKey.startsWith("~/")) {
|
|
2299
|
+
config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
|
|
2300
|
+
}
|
|
2301
|
+
sources.push("SSH_KEY from environment");
|
|
2302
|
+
}
|
|
2303
|
+
if (args["ssh-passphrase"]) {
|
|
2304
|
+
config.passphrase = args["ssh-passphrase"];
|
|
2305
|
+
sources.push("ssh-passphrase from command line");
|
|
2306
|
+
} else if (process.env.SSH_PASSPHRASE) {
|
|
2307
|
+
config.passphrase = process.env.SSH_PASSPHRASE;
|
|
2308
|
+
sources.push("SSH_PASSPHRASE from environment");
|
|
2309
|
+
}
|
|
2310
|
+
if (!config.host || !config.username) {
|
|
2311
|
+
throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
|
|
2312
|
+
}
|
|
2313
|
+
if (!config.password && !config.privateKey) {
|
|
2314
|
+
throw new Error("SSH tunnel configuration requires either --ssh-password or --ssh-key for authentication");
|
|
2315
|
+
}
|
|
2316
|
+
return {
|
|
2317
|
+
config,
|
|
2318
|
+
source: sources.join(", ")
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// src/connectors/manager.ts
|
|
2323
|
+
var managerInstance = null;
|
|
2324
|
+
var ConnectorManager = class {
|
|
2325
|
+
constructor() {
|
|
2326
|
+
this.activeConnector = null;
|
|
2327
|
+
this.connected = false;
|
|
2328
|
+
this.sshTunnel = null;
|
|
2329
|
+
this.originalDSN = null;
|
|
2330
|
+
if (!managerInstance) {
|
|
2331
|
+
managerInstance = this;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Initialize and connect to the database using a DSN
|
|
2336
|
+
*/
|
|
2337
|
+
async connectWithDSN(dsn, initScript) {
|
|
2338
|
+
this.originalDSN = dsn;
|
|
2339
|
+
const sshConfig = resolveSSHConfig();
|
|
2340
|
+
let actualDSN = dsn;
|
|
2341
|
+
if (sshConfig) {
|
|
2342
|
+
console.error(`SSH tunnel configuration loaded from ${sshConfig.source}`);
|
|
2343
|
+
const url = new URL(dsn);
|
|
2344
|
+
const targetHost = url.hostname;
|
|
2345
|
+
const targetPort = parseInt(url.port) || this.getDefaultPort(dsn);
|
|
2346
|
+
this.sshTunnel = new SSHTunnel();
|
|
2347
|
+
const tunnelInfo = await this.sshTunnel.establish(sshConfig.config, {
|
|
2348
|
+
targetHost,
|
|
2349
|
+
targetPort
|
|
2350
|
+
});
|
|
2351
|
+
url.hostname = "127.0.0.1";
|
|
2352
|
+
url.port = tunnelInfo.localPort.toString();
|
|
2353
|
+
actualDSN = url.toString();
|
|
2354
|
+
console.error(`Database connection will use SSH tunnel through localhost:${tunnelInfo.localPort}`);
|
|
2355
|
+
}
|
|
2356
|
+
let connector = ConnectorRegistry.getConnectorForDSN(actualDSN);
|
|
2357
|
+
if (!connector) {
|
|
2358
|
+
throw new Error(`No connector found that can handle the DSN: ${actualDSN}`);
|
|
2359
|
+
}
|
|
2360
|
+
this.activeConnector = connector;
|
|
2361
|
+
await this.activeConnector.connect(actualDSN, initScript);
|
|
2362
|
+
this.connected = true;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Initialize and connect to the database using a specific connector type
|
|
2366
|
+
*/
|
|
2367
|
+
async connectWithType(connectorType, dsn) {
|
|
2368
|
+
const connector = ConnectorRegistry.getConnector(connectorType);
|
|
2369
|
+
if (!connector) {
|
|
2370
|
+
throw new Error(`Connector "${connectorType}" not found`);
|
|
2371
|
+
}
|
|
2372
|
+
this.activeConnector = connector;
|
|
2373
|
+
const connectionString = dsn || connector.dsnParser.getSampleDSN();
|
|
2374
|
+
await this.activeConnector.connect(connectionString);
|
|
2375
|
+
this.connected = true;
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Close the database connection
|
|
2379
|
+
*/
|
|
2380
|
+
async disconnect() {
|
|
2381
|
+
if (this.activeConnector && this.connected) {
|
|
2382
|
+
await this.activeConnector.disconnect();
|
|
2383
|
+
this.connected = false;
|
|
2384
|
+
}
|
|
2385
|
+
if (this.sshTunnel) {
|
|
2386
|
+
await this.sshTunnel.close();
|
|
2387
|
+
this.sshTunnel = null;
|
|
2388
|
+
}
|
|
2389
|
+
this.originalDSN = null;
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Get the active connector
|
|
2393
|
+
*/
|
|
2394
|
+
getConnector() {
|
|
2395
|
+
if (!this.activeConnector) {
|
|
2396
|
+
throw new Error("No active connector. Call connectWithDSN() or connectWithType() first.");
|
|
2397
|
+
}
|
|
2398
|
+
return this.activeConnector;
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Check if there's an active connection
|
|
2402
|
+
*/
|
|
2403
|
+
isConnected() {
|
|
2404
|
+
return this.connected;
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* Get all available connector types
|
|
2408
|
+
*/
|
|
2409
|
+
static getAvailableConnectors() {
|
|
2410
|
+
return ConnectorRegistry.getAvailableConnectors();
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Get sample DSNs for all available connectors
|
|
2414
|
+
*/
|
|
2415
|
+
static getAllSampleDSNs() {
|
|
2416
|
+
return ConnectorRegistry.getAllSampleDSNs();
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Get the current active connector instance
|
|
2420
|
+
* This is used by resource and tool handlers
|
|
2421
|
+
*/
|
|
2422
|
+
static getCurrentConnector() {
|
|
2423
|
+
if (!managerInstance) {
|
|
2424
|
+
throw new Error("ConnectorManager not initialized");
|
|
2425
|
+
}
|
|
2426
|
+
return managerInstance.getConnector();
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Get default port for a database based on DSN protocol
|
|
2430
|
+
*/
|
|
2431
|
+
getDefaultPort(dsn) {
|
|
2432
|
+
if (dsn.startsWith("postgres://") || dsn.startsWith("postgresql://")) {
|
|
2433
|
+
return 5432;
|
|
2434
|
+
} else if (dsn.startsWith("mysql://")) {
|
|
2435
|
+
return 3306;
|
|
2436
|
+
} else if (dsn.startsWith("mariadb://")) {
|
|
2437
|
+
return 3306;
|
|
2438
|
+
} else if (dsn.startsWith("sqlserver://")) {
|
|
2439
|
+
return 1433;
|
|
2440
|
+
}
|
|
2441
|
+
return 0;
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2089
2444
|
|
|
2090
2445
|
// src/config/demo-loader.ts
|
|
2091
2446
|
import fs2 from "fs";
|
|
@@ -2948,7 +3303,7 @@ function registerPrompts(server) {
|
|
|
2948
3303
|
var __filename3 = fileURLToPath3(import.meta.url);
|
|
2949
3304
|
var __dirname3 = path3.dirname(__filename3);
|
|
2950
3305
|
var packageJsonPath = path3.join(__dirname3, "..", "package.json");
|
|
2951
|
-
var packageJson = JSON.parse(
|
|
3306
|
+
var packageJson = JSON.parse(readFileSync3(packageJsonPath, "utf8"));
|
|
2952
3307
|
var SERVER_NAME = "DBHub MCP Server";
|
|
2953
3308
|
var SERVER_VERSION = packageJson.version;
|
|
2954
3309
|
function generateBanner(version, modes = []) {
|
|
@@ -2986,7 +3341,7 @@ See documentation for more details on configuring database connections.
|
|
|
2986
3341
|
`);
|
|
2987
3342
|
process.exit(1);
|
|
2988
3343
|
}
|
|
2989
|
-
const
|
|
3344
|
+
const createServer2 = () => {
|
|
2990
3345
|
const server = new McpServer2({
|
|
2991
3346
|
name: SERVER_NAME,
|
|
2992
3347
|
version: SERVER_VERSION
|
|
@@ -3040,6 +3395,9 @@ See documentation for more details on configuring database connections.
|
|
|
3040
3395
|
}
|
|
3041
3396
|
next();
|
|
3042
3397
|
});
|
|
3398
|
+
app.get("/healthz", (req, res) => {
|
|
3399
|
+
res.status(200).send("OK");
|
|
3400
|
+
});
|
|
3043
3401
|
app.post("/message", async (req, res) => {
|
|
3044
3402
|
try {
|
|
3045
3403
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -3048,7 +3406,7 @@ See documentation for more details on configuring database connections.
|
|
|
3048
3406
|
enableJsonResponse: false
|
|
3049
3407
|
// Use SSE streaming
|
|
3050
3408
|
});
|
|
3051
|
-
const server =
|
|
3409
|
+
const server = createServer2();
|
|
3052
3410
|
await server.connect(transport);
|
|
3053
3411
|
await transport.handleRequest(req, res, req.body);
|
|
3054
3412
|
} catch (error) {
|
|
@@ -3066,7 +3424,7 @@ See documentation for more details on configuring database connections.
|
|
|
3066
3424
|
console.error(`Connect to MCP server at http://0.0.0.0:${port}/message`);
|
|
3067
3425
|
});
|
|
3068
3426
|
} else {
|
|
3069
|
-
const server =
|
|
3427
|
+
const server = createServer2();
|
|
3070
3428
|
const transport = new StdioServerTransport();
|
|
3071
3429
|
console.error("Starting with STDIO transport");
|
|
3072
3430
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebase/dbhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Universal Database MCP Server",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"mssql": "^11.0.1",
|
|
26
26
|
"mysql2": "^3.13.0",
|
|
27
27
|
"pg": "^8.13.3",
|
|
28
|
+
"ssh-config": "^5.0.3",
|
|
29
|
+
"ssh2": "^1.16.0",
|
|
28
30
|
"zod": "^3.24.2"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
"@types/mssql": "^9.1.7",
|
|
38
40
|
"@types/node": "^22.13.10",
|
|
39
41
|
"@types/pg": "^8.11.11",
|
|
42
|
+
"@types/ssh2": "^1.15.5",
|
|
40
43
|
"cross-env": "^7.0.3",
|
|
41
44
|
"husky": "^9.0.11",
|
|
42
45
|
"lint-staged": "^15.2.2",
|