@bytebase/dbhub 0.22.6 → 0.23.0
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 +4 -0
- package/dist/{chunk-WPXPKRDU.js → chunk-COEDRFSK.js} +119 -32
- package/dist/index.js +10 -6
- package/dist/{mariadb-JPEKVFEW.js → mariadb-SPFBL46R.js} +8 -1
- package/dist/{mysql-EK6TCER5.js → mysql-MZWCPISB.js} +6 -0
- package/dist/{registry-IPRZ55YC.js → registry-ZME64VB2.js} +1 -1
- package/dist/{sqlserver-47APXMSF.js → sqlserver-65AGCEJX.js} +94 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -80,6 +80,10 @@ docker run --rm --init \
|
|
|
80
80
|
npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
**MCP Bundle (one-click install):**
|
|
84
|
+
|
|
85
|
+
Download `dbhub-<version>.mcpb` from the [latest release](https://github.com/bytebase/dbhub/releases/latest) and install it in any [MCPB-compatible client](https://github.com/modelcontextprotocol/mcpb) — Claude Desktop (double-click, or drag into Settings → Extensions), Claude Code, or MCP for Windows — then enter your database connection string. The bundle runs locally over stdio, is **read-only by design** (writes are rejected and the database session is set to read-only at the engine level), and needs no remote endpoint or OAuth setup — ideal for giving non-technical teammates curated, read-only database access. Pair it with a least-privilege, read-only database account. See the [MCP Bundle guide](https://dbhub.ai/mcpb) for details and for packaging your own bundle.
|
|
86
|
+
|
|
83
87
|
**Demo Mode:**
|
|
84
88
|
|
|
85
89
|
```bash
|
|
@@ -68,7 +68,8 @@ function findDefaultSSHKey() {
|
|
|
68
68
|
}
|
|
69
69
|
return void 0;
|
|
70
70
|
}
|
|
71
|
-
function parseSSHConfig(hostAlias, configPath) {
|
|
71
|
+
function parseSSHConfig(hostAlias, configPath, options = {}) {
|
|
72
|
+
const { requireUser = true } = options;
|
|
72
73
|
const sshConfigPath = resolveSymlink(configPath);
|
|
73
74
|
if (!isFile(sshConfigPath)) {
|
|
74
75
|
return null;
|
|
@@ -77,7 +78,7 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
77
78
|
const configContent = readFileSync(sshConfigPath, "utf8");
|
|
78
79
|
const config = SSHConfig.parse(configContent);
|
|
79
80
|
const hostConfig = config.compute(hostAlias);
|
|
80
|
-
if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
|
|
81
|
+
if (!hostConfig || !hostConfig.HostName && !hostConfig.User && !hostConfig.Port && !hostConfig.IdentityFile && !hostConfig.ProxyJump) {
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
84
|
const sshConfig = {};
|
|
@@ -111,10 +112,10 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
111
112
|
if (hostConfig.ProxyCommand) {
|
|
112
113
|
console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
|
|
113
114
|
}
|
|
114
|
-
if (!sshConfig.host || !sshConfig.username) {
|
|
115
|
+
if (!sshConfig.host || requireUser && !sshConfig.username) {
|
|
115
116
|
return null;
|
|
116
117
|
}
|
|
117
|
-
return sshConfig;
|
|
118
|
+
return requireUser ? sshConfig : sshConfig;
|
|
118
119
|
} catch (error) {
|
|
119
120
|
console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
121
|
return null;
|
|
@@ -193,6 +194,45 @@ function parseJumpHosts(proxyJump) {
|
|
|
193
194
|
}
|
|
194
195
|
return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
|
|
195
196
|
}
|
|
197
|
+
function resolveJumpHosts(proxyJump, configPath, visited = /* @__PURE__ */ new Set()) {
|
|
198
|
+
if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const resolved = [];
|
|
202
|
+
for (const token of proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0)) {
|
|
203
|
+
const hop = parseJumpHost(token);
|
|
204
|
+
if (!looksLikeSSHAlias(hop.host)) {
|
|
205
|
+
resolved.push(hop);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (visited.has(hop.host)) {
|
|
209
|
+
throw new Error(`Cycle detected in SSH ProxyJump chain at alias "${hop.host}"`);
|
|
210
|
+
}
|
|
211
|
+
const aliasConfig = parseSSHConfig(hop.host, configPath, { requireUser: false });
|
|
212
|
+
if (!aliasConfig) {
|
|
213
|
+
resolved.push(hop);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (aliasConfig.proxyJump) {
|
|
217
|
+
resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host)));
|
|
218
|
+
}
|
|
219
|
+
resolved.push({
|
|
220
|
+
host: aliasConfig.host,
|
|
221
|
+
// An explicit `:port` on the token wins; otherwise use the alias's Port (default 22).
|
|
222
|
+
port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22,
|
|
223
|
+
// An explicit `user@` on the token wins; otherwise the alias's User.
|
|
224
|
+
username: hop.username ?? aliasConfig.username,
|
|
225
|
+
privateKey: aliasConfig.privateKey,
|
|
226
|
+
passphrase: aliasConfig.passphrase
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return resolved;
|
|
230
|
+
}
|
|
231
|
+
function tokenHasExplicitPort(token) {
|
|
232
|
+
const atIndex = token.indexOf("@");
|
|
233
|
+
const hostPart = atIndex !== -1 ? token.slice(atIndex + 1) : token;
|
|
234
|
+
return hostPart.startsWith("[") ? /\]:\d+$/.test(hostPart) : /:\d+$/.test(hostPart);
|
|
235
|
+
}
|
|
196
236
|
|
|
197
237
|
// src/utils/ssh-tunnel.ts
|
|
198
238
|
var SSHTunnel = class {
|
|
@@ -215,29 +255,8 @@ var SSHTunnel = class {
|
|
|
215
255
|
}
|
|
216
256
|
this.isConnected = true;
|
|
217
257
|
try {
|
|
218
|
-
const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
|
|
219
|
-
|
|
220
|
-
if (config.privateKey) {
|
|
221
|
-
try {
|
|
222
|
-
const resolvedKeyPath = resolveSymlink(config.privateKey);
|
|
223
|
-
privateKeyBuffer = readFileSync2(resolvedKeyPath);
|
|
224
|
-
} catch {
|
|
225
|
-
try {
|
|
226
|
-
const decoded = Buffer.from(config.privateKey, "base64");
|
|
227
|
-
const text = decoded.toString("utf8");
|
|
228
|
-
if (text.includes("PRIVATE KEY")) {
|
|
229
|
-
privateKeyBuffer = decoded;
|
|
230
|
-
} else {
|
|
231
|
-
throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
|
|
232
|
-
}
|
|
233
|
-
} catch (decodeError) {
|
|
234
|
-
if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
|
|
235
|
-
throw decodeError;
|
|
236
|
-
}
|
|
237
|
-
throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
258
|
+
const jumpHosts = config.resolvedJumpHosts ?? (config.proxyJump ? parseJumpHosts(config.proxyJump) : []);
|
|
259
|
+
const privateKeyBuffer = config.privateKey ? this.loadPrivateKey(config.privateKey) : void 0;
|
|
241
260
|
if (!config.password && !privateKeyBuffer) {
|
|
242
261
|
throw new Error("Either password or privateKey must be provided for SSH authentication");
|
|
243
262
|
}
|
|
@@ -248,6 +267,30 @@ var SSHTunnel = class {
|
|
|
248
267
|
throw error;
|
|
249
268
|
}
|
|
250
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Load an SSH private key, supporting both a file path (with symlink resolution)
|
|
272
|
+
* and base64-encoded key content.
|
|
273
|
+
*/
|
|
274
|
+
loadPrivateKey(key) {
|
|
275
|
+
try {
|
|
276
|
+
const resolvedKeyPath = resolveSymlink(key);
|
|
277
|
+
return readFileSync2(resolvedKeyPath);
|
|
278
|
+
} catch {
|
|
279
|
+
try {
|
|
280
|
+
const decoded = Buffer.from(key, "base64");
|
|
281
|
+
const text = decoded.toString("utf8");
|
|
282
|
+
if (text.includes("PRIVATE KEY")) {
|
|
283
|
+
return decoded;
|
|
284
|
+
}
|
|
285
|
+
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
|
|
286
|
+
} catch (decodeError) {
|
|
287
|
+
if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
|
|
288
|
+
throw decodeError;
|
|
289
|
+
}
|
|
290
|
+
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
251
294
|
/**
|
|
252
295
|
* Establish a chain of SSH connections through jump hosts.
|
|
253
296
|
* @returns The final SSH client connected to the target host
|
|
@@ -257,6 +300,9 @@ var SSHTunnel = class {
|
|
|
257
300
|
for (let i = 0; i < jumpHosts.length; i++) {
|
|
258
301
|
const jumpHost = jumpHosts[i];
|
|
259
302
|
const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
|
|
303
|
+
const hopPrivateKey = jumpHost.privateKey ? this.loadPrivateKey(jumpHost.privateKey) : privateKey;
|
|
304
|
+
const hopPassword = targetConfig.password;
|
|
305
|
+
const hopPassphrase = jumpHost.passphrase ?? targetConfig.passphrase;
|
|
260
306
|
let client = null;
|
|
261
307
|
let forwardStream;
|
|
262
308
|
try {
|
|
@@ -266,9 +312,9 @@ var SSHTunnel = class {
|
|
|
266
312
|
port: jumpHost.port,
|
|
267
313
|
username: jumpHost.username || targetConfig.username
|
|
268
314
|
},
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
315
|
+
hopPassword,
|
|
316
|
+
hopPrivateKey,
|
|
317
|
+
hopPassphrase,
|
|
272
318
|
previousStream,
|
|
273
319
|
`jump host ${i + 1}`,
|
|
274
320
|
targetConfig.keepaliveInterval,
|
|
@@ -1367,6 +1413,30 @@ function validateSourceConfig(source, configPath) {
|
|
|
1367
1413
|
);
|
|
1368
1414
|
}
|
|
1369
1415
|
}
|
|
1416
|
+
if (source.charset !== void 0) {
|
|
1417
|
+
if (source.type !== "mysql" && source.type !== "mariadb") {
|
|
1418
|
+
throw new Error(
|
|
1419
|
+
`Configuration file ${configPath}: source '${source.id}' has 'charset' but it is only supported for MySQL and MariaDB sources.`
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
if (typeof source.charset !== "string" || source.charset.trim() === "") {
|
|
1423
|
+
throw new Error(
|
|
1424
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid charset '${source.charset}'. Must be a non-empty string naming a character set (e.g. "utf8mb4").`
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
if (source.collation !== void 0) {
|
|
1429
|
+
if (source.type !== "mysql" && source.type !== "mariadb") {
|
|
1430
|
+
throw new Error(
|
|
1431
|
+
`Configuration file ${configPath}: source '${source.id}' has 'collation' but it is only supported for MySQL and MariaDB sources.`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
if (typeof source.collation !== "string" || source.collation.trim() === "") {
|
|
1435
|
+
throw new Error(
|
|
1436
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid collation '${source.collation}'. Must be a non-empty string naming a collation (e.g. "utf8mb4_0900_ai_ci").`
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1370
1440
|
if (source.readonly !== void 0) {
|
|
1371
1441
|
throw new Error(
|
|
1372
1442
|
`Configuration file ${configPath}: source '${source.id}' has 'readonly' field, but readonly must be configured per-tool, not per-source. Move 'readonly' to [[tools]] configuration instead.`
|
|
@@ -1735,13 +1805,23 @@ var ConnectorManager = class {
|
|
|
1735
1805
|
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
|
|
1736
1806
|
let actualDSN = dsn;
|
|
1737
1807
|
if (source.ssh_host) {
|
|
1808
|
+
const sshConfigPath = getDefaultSSHConfigPath();
|
|
1738
1809
|
let resolvedSSHConfig = null;
|
|
1739
1810
|
if (looksLikeSSHAlias(source.ssh_host)) {
|
|
1740
|
-
const sshConfigPath = getDefaultSSHConfigPath();
|
|
1741
1811
|
console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
|
|
1742
1812
|
resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
|
|
1743
1813
|
}
|
|
1744
1814
|
const username = source.ssh_user || resolvedSSHConfig?.username;
|
|
1815
|
+
const proxyJump = source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump;
|
|
1816
|
+
let resolvedJumpHosts;
|
|
1817
|
+
try {
|
|
1818
|
+
resolvedJumpHosts = proxyJump ? resolveJumpHosts(proxyJump, sshConfigPath) : void 0;
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
if (error && typeof error === "object") {
|
|
1821
|
+
error[TUNNEL_ERROR_MARKER] = true;
|
|
1822
|
+
}
|
|
1823
|
+
throw error;
|
|
1824
|
+
}
|
|
1745
1825
|
const sshConfig = {
|
|
1746
1826
|
host: resolvedSSHConfig?.host || source.ssh_host,
|
|
1747
1827
|
port: source.ssh_port || resolvedSSHConfig?.port || 22,
|
|
@@ -1749,7 +1829,8 @@ var ConnectorManager = class {
|
|
|
1749
1829
|
password: source.ssh_password,
|
|
1750
1830
|
privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
|
|
1751
1831
|
passphrase: source.ssh_passphrase,
|
|
1752
|
-
proxyJump
|
|
1832
|
+
proxyJump,
|
|
1833
|
+
resolvedJumpHosts,
|
|
1753
1834
|
keepaliveInterval: source.ssh_keepalive_interval,
|
|
1754
1835
|
keepaliveCountMax: source.ssh_keepalive_count_max
|
|
1755
1836
|
};
|
|
@@ -1808,6 +1889,12 @@ var ConnectorManager = class {
|
|
|
1808
1889
|
if (source.timezone) {
|
|
1809
1890
|
config.timezone = source.timezone;
|
|
1810
1891
|
}
|
|
1892
|
+
if (source.charset) {
|
|
1893
|
+
config.charset = source.charset;
|
|
1894
|
+
}
|
|
1895
|
+
if (source.collation) {
|
|
1896
|
+
config.collation = source.collation;
|
|
1897
|
+
}
|
|
1811
1898
|
await connector.connect(actualDSN, source.init_script, config);
|
|
1812
1899
|
this.connectors.set(sourceId, connector);
|
|
1813
1900
|
if (!this.sourceIds.includes(sourceId)) {
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveSourceConfigs,
|
|
16
16
|
resolveTomlConfigPath,
|
|
17
17
|
resolveTransport
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-COEDRFSK.js";
|
|
19
19
|
import {
|
|
20
20
|
loadConnectors
|
|
21
21
|
} from "./chunk-WVVMH6FJ.js";
|
|
@@ -126,12 +126,16 @@ var mutatingPatternWithReplace = new RegExp(
|
|
|
126
126
|
`\\b(?:${mutatingKeywords.join("|")}|replace\\s+(?:(?:low_priority|delayed)\\s+)?into)\\b`,
|
|
127
127
|
"i"
|
|
128
128
|
);
|
|
129
|
+
var mutatingPatternSqlServer = new RegExp(
|
|
130
|
+
`\\b(?:${[...mutatingKeywords, "execute", "exec", "sp_executesql", "xp_cmdshell"].join("|")})\\b`,
|
|
131
|
+
"i"
|
|
132
|
+
);
|
|
129
133
|
var mutatingPatterns = {
|
|
130
134
|
postgres: mutatingPattern,
|
|
131
135
|
mysql: mutatingPatternWithReplace,
|
|
132
136
|
mariadb: mutatingPatternWithReplace,
|
|
133
137
|
sqlite: mutatingPatternWithReplace,
|
|
134
|
-
sqlserver:
|
|
138
|
+
sqlserver: mutatingPatternSqlServer
|
|
135
139
|
};
|
|
136
140
|
var selectIntoPattern = /\bselect\b[\s\S]+\binto\b/i;
|
|
137
141
|
var sqliteReadOnlyArgPragmas = /* @__PURE__ */ new Set([
|
|
@@ -1672,7 +1676,7 @@ See documentation for more details on configuring database connections.
|
|
|
1672
1676
|
const sources = sourceConfigsData.sources;
|
|
1673
1677
|
console.error(`Configuration source: ${sourceConfigsData.source}`);
|
|
1674
1678
|
await connectorManager.connectWithSources(sources);
|
|
1675
|
-
const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-
|
|
1679
|
+
const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZME64VB2.js");
|
|
1676
1680
|
initializeToolRegistry2({
|
|
1677
1681
|
sources: sourceConfigsData.sources,
|
|
1678
1682
|
tools: sourceConfigsData.tools
|
|
@@ -1827,10 +1831,10 @@ See documentation for more details on configuring database connections.
|
|
|
1827
1831
|
// src/index.ts
|
|
1828
1832
|
var connectorModules = [
|
|
1829
1833
|
{ load: () => import("./postgres-BKBCGDZS.js"), name: "PostgreSQL", driver: "pg" },
|
|
1830
|
-
{ load: () => import("./sqlserver-
|
|
1834
|
+
{ load: () => import("./sqlserver-65AGCEJX.js"), name: "SQL Server", driver: "mssql" },
|
|
1831
1835
|
{ load: () => import("./sqlite-CQ4LCBB6.js"), name: "SQLite", driver: "node:sqlite" },
|
|
1832
|
-
{ load: () => import("./mysql-
|
|
1833
|
-
{ load: () => import("./mariadb-
|
|
1836
|
+
{ load: () => import("./mysql-MZWCPISB.js"), name: "MySQL", driver: "mysql2" },
|
|
1837
|
+
{ load: () => import("./mariadb-SPFBL46R.js"), name: "MariaDB", driver: "mariadb" }
|
|
1834
1838
|
];
|
|
1835
1839
|
loadConnectors(connectorModules).then(() => main()).catch((error) => {
|
|
1836
1840
|
console.error("Fatal error:", error);
|
|
@@ -50,7 +50,14 @@ Expected: ${expectedFormat}`
|
|
|
50
50
|
// Controls how the driver interprets DATETIME values ("Z", "local", or "±HH:MM").
|
|
51
51
|
...config?.timezone !== void 0 && {
|
|
52
52
|
timezone: config.timezone
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
// Connection character set (e.g. "utf8mb4") / collation (e.g.
|
|
55
|
+
// "utf8mb4_general_ci"). The mariadb driver exposes both as distinct
|
|
56
|
+
// options, but a configured collation is authoritative (it implies its
|
|
57
|
+
// character set) and the driver ignores `collation` when `charset` is also
|
|
58
|
+
// passed. So forward the collation when present, otherwise the charset
|
|
59
|
+
// (which uses that character set's default collation).
|
|
60
|
+
...config?.collation !== void 0 ? { collation: config.collation } : config?.charset !== void 0 ? { charset: config.charset } : {}
|
|
54
61
|
};
|
|
55
62
|
url.forEachSearchParam((value, key) => {
|
|
56
63
|
if (key === "sslmode") {
|
|
@@ -21,6 +21,8 @@ var MySQLDSNParser = class {
|
|
|
21
21
|
async parse(dsn, config) {
|
|
22
22
|
const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
|
|
23
23
|
const timezone = config?.timezone;
|
|
24
|
+
const charset = config?.charset;
|
|
25
|
+
const collation = config?.collation;
|
|
24
26
|
if (!this.isValidDSN(dsn)) {
|
|
25
27
|
const obfuscatedDSN = obfuscateDSNPassword(dsn);
|
|
26
28
|
const expectedFormat = this.getSampleDSN();
|
|
@@ -61,6 +63,10 @@ Expected: ${expectedFormat}`
|
|
|
61
63
|
if (timezone !== void 0) {
|
|
62
64
|
config2.timezone = timezone;
|
|
63
65
|
}
|
|
66
|
+
const charsetOrCollation = collation ?? charset;
|
|
67
|
+
if (charsetOrCollation !== void 0) {
|
|
68
|
+
config2.charset = charsetOrCollation;
|
|
69
|
+
}
|
|
64
70
|
if (url.password && url.password.includes("X-Amz-Credential")) {
|
|
65
71
|
config2.authPlugins = {
|
|
66
72
|
mysql_clear_password: () => () => {
|
|
@@ -469,6 +469,9 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
469
469
|
if (options.maxRows) {
|
|
470
470
|
processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sqlQuery, options.maxRows);
|
|
471
471
|
}
|
|
472
|
+
if (options.readonly) {
|
|
473
|
+
return await this.executeReadOnly(processedSQL, parameters);
|
|
474
|
+
}
|
|
472
475
|
const request = this.connection.request();
|
|
473
476
|
const messages = [];
|
|
474
477
|
request.on(
|
|
@@ -525,6 +528,97 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
525
528
|
throw new Error(`Failed to execute query: ${error.message}`);
|
|
526
529
|
}
|
|
527
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Execute a query inside a transaction that always rolls back, preventing
|
|
533
|
+
* any modifications from persisting. SQL Server has no native READ ONLY
|
|
534
|
+
* transaction mode, so this is the defense-in-depth backstop behind the
|
|
535
|
+
* keyword classifier.
|
|
536
|
+
*
|
|
537
|
+
* Because the rollback guard is application-level (not engine-enforced),
|
|
538
|
+
* we reject dangerous keywords in the stripped SQL before opening the
|
|
539
|
+
* transaction:
|
|
540
|
+
* - COMMIT/ROLLBACK: would end the outer transaction, letting writes persist
|
|
541
|
+
* - EXEC/EXECUTE/sp_executesql/xp_cmdshell: dynamic SQL can carry hidden
|
|
542
|
+
* COMMIT/ROLLBACK inside string literals that stripCommentsAndStrings removes
|
|
543
|
+
*/
|
|
544
|
+
async executeReadOnly(processedSQL, parameters) {
|
|
545
|
+
const cleaned = stripCommentsAndStrings(processedSQL, "sqlserver").toLowerCase();
|
|
546
|
+
if (/\b(?:commit|rollback)\b/.test(cleaned)) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
"Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed"
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (/\b(?:exec|execute|sp_executesql|xp_cmdshell)\b/.test(cleaned)) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
"Read-only mode: dynamic SQL execution (EXEC, EXECUTE, sp_executesql, xp_cmdshell) is not allowed"
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
const transaction = new sql.Transaction(this.connection);
|
|
557
|
+
await transaction.begin();
|
|
558
|
+
const request = new sql.Request(transaction);
|
|
559
|
+
const messages = [];
|
|
560
|
+
request.on(
|
|
561
|
+
"info",
|
|
562
|
+
(info) => {
|
|
563
|
+
messages.push({
|
|
564
|
+
text: info.message,
|
|
565
|
+
severity: info.class !== void 0 ? String(info.class) : void 0,
|
|
566
|
+
code: info.number,
|
|
567
|
+
line: info.lineNumber
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
if (parameters && parameters.length > 0) {
|
|
572
|
+
parameters.forEach((param, index) => {
|
|
573
|
+
const paramName = `p${index + 1}`;
|
|
574
|
+
if (typeof param === "string") {
|
|
575
|
+
request.input(paramName, sql.VarChar, param);
|
|
576
|
+
} else if (typeof param === "number") {
|
|
577
|
+
if (Number.isInteger(param)) {
|
|
578
|
+
request.input(paramName, sql.Int, param);
|
|
579
|
+
} else {
|
|
580
|
+
request.input(paramName, sql.Float, param);
|
|
581
|
+
}
|
|
582
|
+
} else if (typeof param === "boolean") {
|
|
583
|
+
request.input(paramName, sql.Bit, param);
|
|
584
|
+
} else if (param === null || param === void 0) {
|
|
585
|
+
request.input(paramName, sql.VarChar, param);
|
|
586
|
+
} else if (Array.isArray(param)) {
|
|
587
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
588
|
+
} else {
|
|
589
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
let result;
|
|
594
|
+
let queryFailed = false;
|
|
595
|
+
try {
|
|
596
|
+
result = await request.query(processedSQL);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
queryFailed = true;
|
|
599
|
+
console.error(`[SQL Server executeReadOnly] ERROR: ${error.message}`);
|
|
600
|
+
console.error(`[SQL Server executeReadOnly] SQL: ${processedSQL}`);
|
|
601
|
+
if (parameters && parameters.length > 0) {
|
|
602
|
+
console.error(`[SQL Server executeReadOnly] Parameters: ${JSON.stringify(parameters)}`);
|
|
603
|
+
}
|
|
604
|
+
throw error;
|
|
605
|
+
} finally {
|
|
606
|
+
try {
|
|
607
|
+
await transaction.rollback();
|
|
608
|
+
} catch (rollbackError) {
|
|
609
|
+
if (!queryFailed) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
rows: result.recordset || [],
|
|
618
|
+
rowCount: result.rowsAffected[0] || 0,
|
|
619
|
+
...messages.length > 0 ? { messages } : {}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
528
622
|
/**
|
|
529
623
|
* Return the estimated execution plan for a query using SHOWPLAN_XML.
|
|
530
624
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebase/dbhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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": {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"build": "pnpm run generate:api-types && tsup && cd frontend && pnpm run build",
|
|
22
22
|
"build:backend": "pnpm run generate:api-types && tsup",
|
|
23
23
|
"build:frontend": "cd frontend && pnpm run build",
|
|
24
|
+
"build:mcpb": "pnpm run build:backend && node scripts/build-mcpb.mjs",
|
|
25
|
+
"test:mcpb": "node scripts/smoke-test-mcpb.mjs",
|
|
24
26
|
"start": "node dist/index.js",
|
|
25
27
|
"dev": "concurrently --kill-others \"pnpm run dev:backend\" \"pnpm run dev:frontend\"",
|
|
26
28
|
"dev:backend": "NODE_ENV=development tsx src/index.ts --transport=http",
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
"pg": "^8.13.3"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
63
|
+
"@anthropic-ai/mcpb": "^2.1.2",
|
|
61
64
|
"@testcontainers/mariadb": "^11.0.3",
|
|
62
65
|
"@testcontainers/mssqlserver": "^11.0.3",
|
|
63
66
|
"@testcontainers/mysql": "^11.0.3",
|