@bytebase/dbhub 0.24.0 → 1.0.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 +6 -0
- package/dist/{chunk-D2BIPC7V.js → chunk-S2V6RZ3C.js} +4 -2
- package/dist/index.js +105 -62
- package/dist/{mariadb-VZMU7LYA.js → mariadb-72C2BNCG.js} +2 -12
- package/dist/{mysql-MHIZK2UY.js → mysql-CDUKJMNN.js} +2 -12
- package/dist/{postgres-ZQFXCBYA.js → postgres-YAYHDXVD.js} +2 -14
- package/dist/{sqlite-JH5CNV5E.js → sqlite-5M7SDAQZ.js} +4 -18
- package/dist/{sqlserver-LHWZ7WOP.js → sqlserver-COKW7RIK.js} +227 -64
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -84,6 +84,10 @@ npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:p
|
|
|
84
84
|
|
|
85
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
86
|
|
|
87
|
+
**Claude Code Plugin:**
|
|
88
|
+
|
|
89
|
+
For Claude Code, the [DBHub plugin](https://dbhub.ai/claude-code-plugin) registers the MCP server with one install command — Claude Code prompts for your connection string and stores it in secure storage — and adds `/dbhub:setup` and `/dbhub:explore` skills. Read-only by design, like the MCP Bundle.
|
|
90
|
+
|
|
87
91
|
**Demo Mode:**
|
|
88
92
|
|
|
89
93
|
```bash
|
|
@@ -99,6 +103,8 @@ npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
|
|
|
99
103
|
> The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients.
|
|
100
104
|
>
|
|
101
105
|
> The HTTP transport also has built-in DNS-rebinding protection: it only accepts requests whose `Host` is loopback, this machine's own hostname/IPs, or a name you allow via [`--allowed-hosts`](https://dbhub.ai/config/command-line#allowed-hosts). If a client behind a reverse proxy or custom DNS name gets a `403`, add that hostname with `--allowed-hosts`.
|
|
106
|
+
>
|
|
107
|
+
> DBHub serves both MCP protocol eras on the same `/mcp` endpoint: the stateless **2026-07-28** revision (cacheable `tools/list`, no session state — safe behind round-robin load balancers) and the 2025-era Streamable HTTP protocol for older clients. 2026-era clients send the standard `Mcp-Method` / `Mcp-Name` headers on every request, so a fronting gateway or WAF can route and rate-limit `execute_sql` separately from `search_objects` without parsing JSON bodies.
|
|
102
108
|
|
|
103
109
|
See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
|
|
104
110
|
|
|
@@ -66,7 +66,8 @@ var SQLRowLimiter = class {
|
|
|
66
66
|
const trimmed = sql.trim();
|
|
67
67
|
const hasSemicolon = trimmed.endsWith(";");
|
|
68
68
|
const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
|
|
69
|
-
return `${sqlWithoutSemicolon}
|
|
69
|
+
return `${sqlWithoutSemicolon}
|
|
70
|
+
LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
@@ -107,7 +108,8 @@ var SQLRowLimiter = class {
|
|
|
107
108
|
const trimmed = sql.trim();
|
|
108
109
|
const hasSemicolon = trimmed.endsWith(";");
|
|
109
110
|
const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
|
|
110
|
-
return `SELECT * FROM (${sqlWithoutSemicolon}
|
|
111
|
+
return `SELECT * FROM (${sqlWithoutSemicolon}
|
|
112
|
+
) AS subq LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
|
|
111
113
|
}
|
|
112
114
|
return this.applyLimitToQuery(sql, maxRows);
|
|
113
115
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
} from "./chunk-MQR6VX5F.js";
|
|
19
19
|
import {
|
|
20
20
|
allowedKeywords,
|
|
21
|
-
isReadOnlySQL
|
|
21
|
+
isReadOnlySQL,
|
|
22
|
+
sqlServerDynamicSqlPattern,
|
|
23
|
+
sqlServerPassThroughPattern
|
|
22
24
|
} from "./chunk-JO7YQPBQ.js";
|
|
23
25
|
import {
|
|
24
26
|
loadConnectors
|
|
@@ -31,13 +33,14 @@ import {
|
|
|
31
33
|
getDatabaseTypeFromDSN,
|
|
32
34
|
getDefaultPortForType,
|
|
33
35
|
parseConnectionInfoFromDSN,
|
|
34
|
-
splitSQLStatements
|
|
36
|
+
splitSQLStatements,
|
|
37
|
+
stripCommentsAndStrings
|
|
35
38
|
} from "./chunk-JEZZN2YZ.js";
|
|
36
39
|
|
|
37
40
|
// src/server.ts
|
|
38
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
+
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
|
|
42
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
43
|
+
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
41
44
|
import express from "express";
|
|
42
45
|
import http from "http";
|
|
43
46
|
import path from "path";
|
|
@@ -98,6 +101,52 @@ function createToolSuccessResponse(data, meta = {}) {
|
|
|
98
101
|
};
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// src/utils/sql-access-policy.ts
|
|
105
|
+
var CLASS_SEVERITY = {
|
|
106
|
+
read: 0,
|
|
107
|
+
dml: 1,
|
|
108
|
+
ddl: 2,
|
|
109
|
+
admin: 3,
|
|
110
|
+
unknown: 4
|
|
111
|
+
};
|
|
112
|
+
var ddlPattern = /\b(?:create|alter|drop|truncate|rename)\b/i;
|
|
113
|
+
var dmlPattern = /\b(?:insert|update|delete|merge|replace\s+(?:(?:low_priority|delayed)\s+)?into)\b/i;
|
|
114
|
+
var grantRevokePattern = /\b(?:grant|revoke)\b/i;
|
|
115
|
+
function isAdminStatement(stripped, connectorType) {
|
|
116
|
+
if (grantRevokePattern.test(stripped)) return true;
|
|
117
|
+
return connectorType === "sqlserver" && (sqlServerDynamicSqlPattern.test(stripped) || sqlServerPassThroughPattern.test(stripped));
|
|
118
|
+
}
|
|
119
|
+
function policyFromReadonly(readonly) {
|
|
120
|
+
const write = readonly === true ? "deny" : "allow";
|
|
121
|
+
return { read: "allow", dml: write, ddl: write, admin: write, unknown: write };
|
|
122
|
+
}
|
|
123
|
+
function isReadOnlyPolicy(policy) {
|
|
124
|
+
return policy.dml === "deny" && policy.ddl === "deny" && policy.admin === "deny" && policy.unknown === "deny";
|
|
125
|
+
}
|
|
126
|
+
function classifyStatement(sql, connectorType) {
|
|
127
|
+
if (isReadOnlySQL(sql, connectorType)) {
|
|
128
|
+
return "read";
|
|
129
|
+
}
|
|
130
|
+
const stripped = stripCommentsAndStrings(sql, connectorType);
|
|
131
|
+
if (isAdminStatement(stripped, connectorType)) return "admin";
|
|
132
|
+
if (ddlPattern.test(stripped)) return "ddl";
|
|
133
|
+
if (dmlPattern.test(stripped)) return "dml";
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
function classifySQL(sql, connectorType) {
|
|
137
|
+
let strictest = "read";
|
|
138
|
+
for (const statement of splitSQLStatements(sql, connectorType)) {
|
|
139
|
+
const cls = classifyStatement(statement, connectorType);
|
|
140
|
+
if (CLASS_SEVERITY[cls] > CLASS_SEVERITY[strictest]) {
|
|
141
|
+
strictest = cls;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return strictest;
|
|
145
|
+
}
|
|
146
|
+
function sqlVerdict(policy, sql, connectorType) {
|
|
147
|
+
return policy[classifySQL(sql, connectorType)];
|
|
148
|
+
}
|
|
149
|
+
|
|
101
150
|
// src/requests/store.ts
|
|
102
151
|
var RequestStore = class {
|
|
103
152
|
constructor() {
|
|
@@ -149,8 +198,8 @@ var RequestStore = class {
|
|
|
149
198
|
var requestStore = new RequestStore();
|
|
150
199
|
|
|
151
200
|
// src/utils/client-identifier.ts
|
|
152
|
-
function getClientIdentifier(
|
|
153
|
-
const userAgent =
|
|
201
|
+
function getClientIdentifier(ctx) {
|
|
202
|
+
const userAgent = ctx?.http?.req?.headers.get("user-agent");
|
|
154
203
|
if (userAgent) {
|
|
155
204
|
return userAgent;
|
|
156
205
|
}
|
|
@@ -196,10 +245,7 @@ function tryClassifyConnectionError(error, rawSourceId, displaySourceId) {
|
|
|
196
245
|
var executeSqlSchema = {
|
|
197
246
|
sql: z.string().describe("SQL to execute (multiple statements separated by ;)")
|
|
198
247
|
};
|
|
199
|
-
|
|
200
|
-
const statements = splitSQLStatements(sql, connectorType);
|
|
201
|
-
return statements.every((statement) => isReadOnlySQL(statement, connectorType));
|
|
202
|
-
}
|
|
248
|
+
var executeSqlInputSchema = z.object(executeSqlSchema);
|
|
203
249
|
function createExecuteSqlToolHandler(sourceId) {
|
|
204
250
|
return async (args, extra) => {
|
|
205
251
|
const { sql } = args;
|
|
@@ -214,14 +260,14 @@ function createExecuteSqlToolHandler(sourceId) {
|
|
|
214
260
|
const actualSourceId = connector.getId();
|
|
215
261
|
const registry = getToolRegistry();
|
|
216
262
|
const toolConfig = registry.getBuiltinToolConfig(BUILTIN_TOOL_EXECUTE_SQL, actualSourceId);
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
263
|
+
const policy = policyFromReadonly(toolConfig?.readonly);
|
|
264
|
+
if (sqlVerdict(policy, sql, connector.id) === "deny") {
|
|
219
265
|
errorMessage = `Read-only mode is enabled. Only the following SQL operations are allowed: ${allowedKeywords[connector.id]?.join(", ") || "none"}`;
|
|
220
266
|
success = false;
|
|
221
267
|
return createToolErrorResponse(errorMessage, "READONLY_VIOLATION");
|
|
222
268
|
}
|
|
223
269
|
const executeOptions = {
|
|
224
|
-
readonly:
|
|
270
|
+
readonly: isReadOnlyPolicy(policy),
|
|
225
271
|
maxRows: toolConfig?.max_rows
|
|
226
272
|
};
|
|
227
273
|
result = await connector.executeSQL(sql, executeOptions);
|
|
@@ -264,6 +310,7 @@ var searchDatabaseObjectsSchema = {
|
|
|
264
310
|
detail_level: z2.enum(["names", "summary", "full"]).default("names").describe("Detail: names (minimal), summary (metadata), full (all)"),
|
|
265
311
|
limit: z2.number().int().positive().max(1e3).default(100).describe("Max results")
|
|
266
312
|
};
|
|
313
|
+
var searchDatabaseObjectsInputSchema = z2.object(searchDatabaseObjectsSchema);
|
|
267
314
|
function likePatternToRegex(pattern) {
|
|
268
315
|
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".");
|
|
269
316
|
return new RegExp(`^${escaped}$`, "i");
|
|
@@ -795,7 +842,7 @@ function getExecuteSqlMetadata(sourceId) {
|
|
|
795
842
|
const readonlyNote = executeOptions.readonly ? " [READ-ONLY MODE]" : "";
|
|
796
843
|
const maxRowsNote = executeOptions.maxRows ? ` (limited to ${executeOptions.maxRows} rows)` : "";
|
|
797
844
|
const description = isSingleSource ? `${userDescPrefix}Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` : `${userDescPrefix}Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`;
|
|
798
|
-
const isReadonly = executeOptions.readonly
|
|
845
|
+
const isReadonly = isReadOnlyPolicy(policyFromReadonly(executeOptions.readonly));
|
|
799
846
|
const annotations = {
|
|
800
847
|
title,
|
|
801
848
|
readOnlyHint: isReadonly,
|
|
@@ -971,9 +1018,17 @@ function buildZodSchemaFromParameters(parameters) {
|
|
|
971
1018
|
}
|
|
972
1019
|
return schemaShape;
|
|
973
1020
|
}
|
|
1021
|
+
var customToolInputSchemas = /* @__PURE__ */ new WeakMap();
|
|
1022
|
+
function getCustomToolInputSchema(toolConfig) {
|
|
1023
|
+
let schema = customToolInputSchemas.get(toolConfig);
|
|
1024
|
+
if (!schema) {
|
|
1025
|
+
schema = z4.object(buildZodSchemaFromParameters(toolConfig.parameters));
|
|
1026
|
+
customToolInputSchemas.set(toolConfig, schema);
|
|
1027
|
+
}
|
|
1028
|
+
return schema;
|
|
1029
|
+
}
|
|
974
1030
|
function createCustomToolHandler(toolConfig) {
|
|
975
|
-
const
|
|
976
|
-
const zodSchema = z4.object(zodSchemaShape);
|
|
1031
|
+
const zodSchema = getCustomToolInputSchema(toolConfig);
|
|
977
1032
|
return async (args, extra) => {
|
|
978
1033
|
const startTime = Date.now();
|
|
979
1034
|
let success = true;
|
|
@@ -983,12 +1038,12 @@ function createCustomToolHandler(toolConfig) {
|
|
|
983
1038
|
const validatedArgs = zodSchema.parse(args);
|
|
984
1039
|
await ConnectorManager.ensureConnected(toolConfig.source);
|
|
985
1040
|
const connector = ConnectorManager.getCurrentConnector(toolConfig.source);
|
|
1041
|
+
const policy = policyFromReadonly(toolConfig.readonly);
|
|
986
1042
|
const executeOptions = {
|
|
987
|
-
readonly:
|
|
1043
|
+
readonly: isReadOnlyPolicy(policy),
|
|
988
1044
|
maxRows: toolConfig.max_rows
|
|
989
1045
|
};
|
|
990
|
-
|
|
991
|
-
if (isReadonly && !isReadOnlySQL(toolConfig.statement, connector.id)) {
|
|
1046
|
+
if (sqlVerdict(policy, toolConfig.statement, connector.id) === "deny") {
|
|
992
1047
|
errorMessage = createReadonlyViolationMessage(toolConfig.name, toolConfig.source, connector.id);
|
|
993
1048
|
success = false;
|
|
994
1049
|
return createToolErrorResponse(errorMessage, "READONLY_VIOLATION");
|
|
@@ -1065,7 +1120,7 @@ function registerExecuteSqlTool(server, sourceId) {
|
|
|
1065
1120
|
metadata.name,
|
|
1066
1121
|
{
|
|
1067
1122
|
description: metadata.description,
|
|
1068
|
-
inputSchema:
|
|
1123
|
+
inputSchema: executeSqlInputSchema,
|
|
1069
1124
|
annotations: metadata.annotations
|
|
1070
1125
|
},
|
|
1071
1126
|
createExecuteSqlToolHandler(sourceId)
|
|
@@ -1077,7 +1132,7 @@ function registerSearchObjectsTool(server, sourceId) {
|
|
|
1077
1132
|
metadata.name,
|
|
1078
1133
|
{
|
|
1079
1134
|
description: metadata.description,
|
|
1080
|
-
inputSchema:
|
|
1135
|
+
inputSchema: searchDatabaseObjectsInputSchema,
|
|
1081
1136
|
annotations: {
|
|
1082
1137
|
title: metadata.title,
|
|
1083
1138
|
readOnlyHint: true,
|
|
@@ -1092,13 +1147,12 @@ function registerSearchObjectsTool(server, sourceId) {
|
|
|
1092
1147
|
function registerCustomTool(server, sourceId, toolConfig) {
|
|
1093
1148
|
const sourceConfig = ConnectorManager.getSourceConfig(sourceId);
|
|
1094
1149
|
const dbType = sourceConfig.type;
|
|
1095
|
-
const isReadOnly =
|
|
1096
|
-
const zodSchema = buildZodSchemaFromParameters(toolConfig.parameters);
|
|
1150
|
+
const isReadOnly = classifySQL(toolConfig.statement, dbType) === "read";
|
|
1097
1151
|
server.registerTool(
|
|
1098
1152
|
toolConfig.name,
|
|
1099
1153
|
{
|
|
1100
1154
|
description: toolConfig.description,
|
|
1101
|
-
inputSchema:
|
|
1155
|
+
inputSchema: getCustomToolInputSchema(toolConfig),
|
|
1102
1156
|
annotations: {
|
|
1103
1157
|
title: `${toolConfig.name} (${dbType})`,
|
|
1104
1158
|
readOnlyHint: isReadOnly,
|
|
@@ -1538,6 +1592,8 @@ var packageJsonPath = path.join(__dirname, "..", "package.json");
|
|
|
1538
1592
|
var packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
1539
1593
|
var SERVER_NAME = "DBHub MCP Server";
|
|
1540
1594
|
var SERVER_VERSION = packageJson.version;
|
|
1595
|
+
var TOOLS_LIST_CACHE_TTL_MS = 3e5;
|
|
1596
|
+
var logMcpError = (scope) => (error) => console.error(`MCP ${scope} error:`, error);
|
|
1541
1597
|
function generateBanner(version, modes = []) {
|
|
1542
1598
|
const modeText = modes.length > 0 ? ` [${modes.join(" | ")}]` : "";
|
|
1543
1599
|
return `
|
|
@@ -1594,10 +1650,18 @@ See documentation for more details on configuring database connections.
|
|
|
1594
1650
|
initialTools: sourceConfigsData.tools
|
|
1595
1651
|
});
|
|
1596
1652
|
const createServer = () => {
|
|
1597
|
-
const server = new McpServer(
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1653
|
+
const server = new McpServer(
|
|
1654
|
+
{
|
|
1655
|
+
name: SERVER_NAME,
|
|
1656
|
+
version: SERVER_VERSION
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
// `private` keeps caching client-side (no shared/proxy caches).
|
|
1660
|
+
cacheHints: {
|
|
1661
|
+
"tools/list": { ttlMs: TOOLS_LIST_CACHE_TTL_MS, cacheScope: "private" }
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
);
|
|
1601
1665
|
registerTools(server);
|
|
1602
1666
|
return server;
|
|
1603
1667
|
};
|
|
@@ -1639,7 +1703,7 @@ See documentation for more details on configuring database connections.
|
|
|
1639
1703
|
}
|
|
1640
1704
|
res.header("Access-Control-Allow-Origin", origin || "http://localhost");
|
|
1641
1705
|
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1642
|
-
res.header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id");
|
|
1706
|
+
res.header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name");
|
|
1643
1707
|
res.header("Access-Control-Allow-Credentials", "true");
|
|
1644
1708
|
if (req.method === "OPTIONS") {
|
|
1645
1709
|
return res.sendStatus(200);
|
|
@@ -1654,29 +1718,10 @@ See documentation for more details on configuring database connections.
|
|
|
1654
1718
|
app.get("/api/sources", listSources);
|
|
1655
1719
|
app.get("/api/sources/:sourceId", getSource);
|
|
1656
1720
|
app.get("/api/requests", listRequests);
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
});
|
|
1662
|
-
});
|
|
1663
|
-
app.post("/mcp", async (req, res) => {
|
|
1664
|
-
try {
|
|
1665
|
-
const transport = new StreamableHTTPServerTransport({
|
|
1666
|
-
sessionIdGenerator: void 0,
|
|
1667
|
-
// Disable session management for stateless mode
|
|
1668
|
-
enableJsonResponse: true
|
|
1669
|
-
// Use JSON responses (SSE not supported in stateless mode)
|
|
1670
|
-
});
|
|
1671
|
-
const server = createServer();
|
|
1672
|
-
await server.connect(transport);
|
|
1673
|
-
await transport.handleRequest(req, res, req.body);
|
|
1674
|
-
} catch (error) {
|
|
1675
|
-
console.error("Error handling request:", error);
|
|
1676
|
-
if (!res.headersSent) {
|
|
1677
|
-
res.status(500).json({ error: "Internal server error" });
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1721
|
+
const mcpHandler = createMcpHandler(createServer, { onerror: logMcpError("handler") });
|
|
1722
|
+
const mcpNodeHandler = toNodeHandler(mcpHandler, { onerror: logMcpError("adapter") });
|
|
1723
|
+
app.all("/mcp", (req, res) => {
|
|
1724
|
+
void mcpNodeHandler(req, res, req.body);
|
|
1680
1725
|
});
|
|
1681
1726
|
if (process.env.NODE_ENV !== "development") {
|
|
1682
1727
|
app.get("*", (req, res) => {
|
|
@@ -1712,16 +1757,14 @@ See documentation for more details on configuring database connections.
|
|
|
1712
1757
|
console.error(`MCP server endpoint at http://${userHost}:${boundPort}/mcp`);
|
|
1713
1758
|
});
|
|
1714
1759
|
} else {
|
|
1715
|
-
const
|
|
1716
|
-
const transport = new StdioServerTransport();
|
|
1717
|
-
await server.connect(transport);
|
|
1760
|
+
const stdioHandle = serveStdio(createServer, { onerror: logMcpError("stdio") });
|
|
1718
1761
|
console.error("MCP server running on stdio");
|
|
1719
1762
|
let isShuttingDown = false;
|
|
1720
1763
|
const shutdown = async () => {
|
|
1721
1764
|
if (isShuttingDown) return;
|
|
1722
1765
|
isShuttingDown = true;
|
|
1723
1766
|
console.error("Shutting down...");
|
|
1724
|
-
await
|
|
1767
|
+
await stdioHandle.close();
|
|
1725
1768
|
await connectorManager.disconnect();
|
|
1726
1769
|
process.exit(0);
|
|
1727
1770
|
};
|
|
@@ -1737,11 +1780,11 @@ See documentation for more details on configuring database connections.
|
|
|
1737
1780
|
|
|
1738
1781
|
// src/index.ts
|
|
1739
1782
|
var connectorModules = [
|
|
1740
|
-
{ load: () => import("./postgres-
|
|
1741
|
-
{ load: () => import("./sqlserver-
|
|
1742
|
-
{ load: () => import("./sqlite-
|
|
1743
|
-
{ load: () => import("./mysql-
|
|
1744
|
-
{ load: () => import("./mariadb-
|
|
1783
|
+
{ load: () => import("./postgres-YAYHDXVD.js"), name: "PostgreSQL", driver: "pg" },
|
|
1784
|
+
{ load: () => import("./sqlserver-COKW7RIK.js"), name: "SQL Server", driver: "mssql" },
|
|
1785
|
+
{ load: () => import("./sqlite-5M7SDAQZ.js"), name: "SQLite", driver: "node:sqlite" },
|
|
1786
|
+
{ load: () => import("./mysql-CDUKJMNN.js"), name: "MySQL", driver: "mysql2" },
|
|
1787
|
+
{ load: () => import("./mariadb-72C2BNCG.js"), name: "MariaDB", driver: "mariadb" }
|
|
1745
1788
|
];
|
|
1746
1789
|
loadConnectors(connectorModules).then(() => main()).catch((error) => {
|
|
1747
1790
|
console.error("Fatal error:", error);
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
SQLRowLimiter,
|
|
14
14
|
closeQuietly
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
16
16
|
import {
|
|
17
17
|
ConnectorRegistry,
|
|
18
18
|
SafeURL,
|
|
@@ -509,14 +509,7 @@ var MariaDBConnector = class _MariaDBConnector {
|
|
|
509
509
|
}
|
|
510
510
|
let results;
|
|
511
511
|
if (parameters && parameters.length > 0) {
|
|
512
|
-
|
|
513
|
-
results = await conn.query(processedSQL, parameters);
|
|
514
|
-
} catch (error) {
|
|
515
|
-
console.error(`[MariaDB executeSQL] ERROR: ${error.message}`);
|
|
516
|
-
console.error(`[MariaDB executeSQL] SQL: ${processedSQL}`);
|
|
517
|
-
console.error(`[MariaDB executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
518
|
-
throw error;
|
|
519
|
-
}
|
|
512
|
+
results = await conn.query(processedSQL, parameters);
|
|
520
513
|
} else {
|
|
521
514
|
results = await conn.query(processedSQL);
|
|
522
515
|
}
|
|
@@ -525,9 +518,6 @@ var MariaDBConnector = class _MariaDBConnector {
|
|
|
525
518
|
return { rows, rowCount };
|
|
526
519
|
}
|
|
527
520
|
);
|
|
528
|
-
} catch (error) {
|
|
529
|
-
console.error("Error executing query:", error);
|
|
530
|
-
throw error;
|
|
531
521
|
} finally {
|
|
532
522
|
conn.release();
|
|
533
523
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
SQLRowLimiter,
|
|
14
14
|
closeQuietly
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
16
16
|
import {
|
|
17
17
|
ConnectorRegistry,
|
|
18
18
|
SafeURL,
|
|
@@ -514,14 +514,7 @@ var MySQLConnector = class _MySQLConnector {
|
|
|
514
514
|
}
|
|
515
515
|
let results;
|
|
516
516
|
if (parameters && parameters.length > 0) {
|
|
517
|
-
|
|
518
|
-
results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters);
|
|
519
|
-
} catch (error) {
|
|
520
|
-
console.error(`[MySQL executeSQL] ERROR: ${error.message}`);
|
|
521
|
-
console.error(`[MySQL executeSQL] SQL: ${processedSQL}`);
|
|
522
|
-
console.error(`[MySQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
523
|
-
throw error;
|
|
524
|
-
}
|
|
517
|
+
results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters);
|
|
525
518
|
} else {
|
|
526
519
|
results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs });
|
|
527
520
|
}
|
|
@@ -531,9 +524,6 @@ var MySQLConnector = class _MySQLConnector {
|
|
|
531
524
|
return { rows, rowCount };
|
|
532
525
|
}
|
|
533
526
|
);
|
|
534
|
-
} catch (error) {
|
|
535
|
-
console.error("Error executing query:", error);
|
|
536
|
-
throw error;
|
|
537
527
|
} finally {
|
|
538
528
|
conn.release();
|
|
539
529
|
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
SQLRowLimiter,
|
|
6
6
|
closeQuietly
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
8
8
|
import {
|
|
9
9
|
ConnectorRegistry,
|
|
10
10
|
SafeURL,
|
|
@@ -508,24 +508,12 @@ var PostgresConnector = class _PostgresConnector {
|
|
|
508
508
|
await client.query("ROLLBACK");
|
|
509
509
|
} catch {
|
|
510
510
|
}
|
|
511
|
-
console.error(`[PostgreSQL executeSQL] ERROR: ${error.message}`);
|
|
512
|
-
console.error(`[PostgreSQL executeSQL] SQL: ${processedStatement}`);
|
|
513
|
-
if (parameters && parameters.length > 0) {
|
|
514
|
-
console.error(`[PostgreSQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
515
|
-
}
|
|
516
511
|
throw error;
|
|
517
512
|
}
|
|
518
513
|
}
|
|
519
514
|
let result;
|
|
520
515
|
if (parameters && parameters.length > 0) {
|
|
521
|
-
|
|
522
|
-
result = await client.query(processedStatement, parameters);
|
|
523
|
-
} catch (error) {
|
|
524
|
-
console.error(`[PostgreSQL executeSQL] ERROR: ${error.message}`);
|
|
525
|
-
console.error(`[PostgreSQL executeSQL] SQL: ${processedStatement}`);
|
|
526
|
-
console.error(`[PostgreSQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
527
|
-
throw error;
|
|
528
|
-
}
|
|
516
|
+
result = await client.query(processedStatement, parameters);
|
|
529
517
|
} else {
|
|
530
518
|
result = await client.query(processedStatement);
|
|
531
519
|
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
SQLRowLimiter,
|
|
6
6
|
closeQuietly
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
8
8
|
import {
|
|
9
9
|
ConnectorRegistry,
|
|
10
10
|
SafeURL,
|
|
@@ -339,15 +339,8 @@ var SQLiteConnector = class _SQLiteConnector {
|
|
|
339
339
|
}
|
|
340
340
|
if (isReadStatement) {
|
|
341
341
|
if (parameters && parameters.length > 0) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return { rows, rowCount: rows.length };
|
|
345
|
-
} catch (error) {
|
|
346
|
-
console.error(`[SQLite executeSQL] ERROR: ${error.message}`);
|
|
347
|
-
console.error(`[SQLite executeSQL] SQL: ${processedStatement}`);
|
|
348
|
-
console.error(`[SQLite executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
342
|
+
const rows = this.prepare(processedStatement).all(...parameters);
|
|
343
|
+
return { rows, rowCount: rows.length };
|
|
351
344
|
} else {
|
|
352
345
|
const rows = this.prepare(processedStatement).all();
|
|
353
346
|
return { rows, rowCount: rows.length };
|
|
@@ -355,14 +348,7 @@ var SQLiteConnector = class _SQLiteConnector {
|
|
|
355
348
|
} else {
|
|
356
349
|
let result;
|
|
357
350
|
if (parameters && parameters.length > 0) {
|
|
358
|
-
|
|
359
|
-
result = this.prepare(processedStatement).run(...parameters);
|
|
360
|
-
} catch (error) {
|
|
361
|
-
console.error(`[SQLite executeSQL] ERROR: ${error.message}`);
|
|
362
|
-
console.error(`[SQLite executeSQL] SQL: ${processedStatement}`);
|
|
363
|
-
console.error(`[SQLite executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
364
|
-
throw error;
|
|
365
|
-
}
|
|
351
|
+
result = this.prepare(processedStatement).run(...parameters);
|
|
366
352
|
} else {
|
|
367
353
|
result = this.prepare(processedStatement).run();
|
|
368
354
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
SQLRowLimiter,
|
|
12
12
|
closeQuietly
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
14
14
|
import {
|
|
15
15
|
ConnectorRegistry,
|
|
16
16
|
SafeURL,
|
|
@@ -475,7 +475,10 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
475
475
|
sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
|
|
476
476
|
);
|
|
477
477
|
if (/^explain\b/i.test(afterNoise)) {
|
|
478
|
-
|
|
478
|
+
const { analyze, query } = _SQLServerConnector.parseExplainPrefix(
|
|
479
|
+
afterNoise.slice("explain".length).trim()
|
|
480
|
+
);
|
|
481
|
+
return analyze ? this.explainAnalyzeQuery(query, options, parameters) : this.explainQuery(query, options.readonly, parameters);
|
|
479
482
|
}
|
|
480
483
|
try {
|
|
481
484
|
let processedSQL = sqlQuery;
|
|
@@ -499,39 +502,8 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
499
502
|
});
|
|
500
503
|
}
|
|
501
504
|
);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const paramName = `p${index + 1}`;
|
|
505
|
-
if (typeof param === "string") {
|
|
506
|
-
request.input(paramName, sql.VarChar, param);
|
|
507
|
-
} else if (typeof param === "number") {
|
|
508
|
-
if (Number.isInteger(param)) {
|
|
509
|
-
request.input(paramName, sql.Int, param);
|
|
510
|
-
} else {
|
|
511
|
-
request.input(paramName, sql.Float, param);
|
|
512
|
-
}
|
|
513
|
-
} else if (typeof param === "boolean") {
|
|
514
|
-
request.input(paramName, sql.Bit, param);
|
|
515
|
-
} else if (param === null || param === void 0) {
|
|
516
|
-
request.input(paramName, sql.VarChar, param);
|
|
517
|
-
} else if (Array.isArray(param)) {
|
|
518
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
519
|
-
} else {
|
|
520
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
let result;
|
|
525
|
-
try {
|
|
526
|
-
result = await request.query(processedSQL);
|
|
527
|
-
} catch (error) {
|
|
528
|
-
if (parameters && parameters.length > 0) {
|
|
529
|
-
console.error(`[SQL Server executeSQL] ERROR: ${error.message}`);
|
|
530
|
-
console.error(`[SQL Server executeSQL] SQL: ${processedSQL}`);
|
|
531
|
-
console.error(`[SQL Server executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
532
|
-
}
|
|
533
|
-
throw error;
|
|
534
|
-
}
|
|
505
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
506
|
+
const result = await request.query(processedSQL);
|
|
535
507
|
return {
|
|
536
508
|
rows: result.recordset || [],
|
|
537
509
|
rowCount: result.rowsAffected[0] || 0,
|
|
@@ -541,6 +513,39 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
541
513
|
throw new Error(`Failed to execute query: ${error.message}`);
|
|
542
514
|
}
|
|
543
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Bind positional parameters as @p1, @p2, ... inferring the SQL Server type
|
|
518
|
+
* from each JavaScript value. Shared by every execution path so they cannot
|
|
519
|
+
* drift in how a given value is typed.
|
|
520
|
+
*
|
|
521
|
+
* Works for `batch` as well as `query`: node-mssql prepends the matching
|
|
522
|
+
* DECLARE/SET statements when the request is a batch.
|
|
523
|
+
*/
|
|
524
|
+
static bindParameters(request, parameters) {
|
|
525
|
+
if (!parameters || parameters.length === 0) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
parameters.forEach((param, index) => {
|
|
529
|
+
const paramName = `p${index + 1}`;
|
|
530
|
+
if (typeof param === "string") {
|
|
531
|
+
request.input(paramName, sql.VarChar, param);
|
|
532
|
+
} else if (typeof param === "number") {
|
|
533
|
+
if (Number.isInteger(param)) {
|
|
534
|
+
request.input(paramName, sql.Int, param);
|
|
535
|
+
} else {
|
|
536
|
+
request.input(paramName, sql.Float, param);
|
|
537
|
+
}
|
|
538
|
+
} else if (typeof param === "boolean") {
|
|
539
|
+
request.input(paramName, sql.Bit, param);
|
|
540
|
+
} else if (param === null || param === void 0) {
|
|
541
|
+
request.input(paramName, sql.VarChar, param);
|
|
542
|
+
} else if (Array.isArray(param)) {
|
|
543
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
544
|
+
} else {
|
|
545
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
544
549
|
/**
|
|
545
550
|
* Reject the constructs that escape SQL Server's read-only guards, for use by
|
|
546
551
|
* both read-only execution paths.
|
|
@@ -606,39 +611,13 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
606
611
|
});
|
|
607
612
|
}
|
|
608
613
|
);
|
|
609
|
-
|
|
610
|
-
parameters.forEach((param, index) => {
|
|
611
|
-
const paramName = `p${index + 1}`;
|
|
612
|
-
if (typeof param === "string") {
|
|
613
|
-
request.input(paramName, sql.VarChar, param);
|
|
614
|
-
} else if (typeof param === "number") {
|
|
615
|
-
if (Number.isInteger(param)) {
|
|
616
|
-
request.input(paramName, sql.Int, param);
|
|
617
|
-
} else {
|
|
618
|
-
request.input(paramName, sql.Float, param);
|
|
619
|
-
}
|
|
620
|
-
} else if (typeof param === "boolean") {
|
|
621
|
-
request.input(paramName, sql.Bit, param);
|
|
622
|
-
} else if (param === null || param === void 0) {
|
|
623
|
-
request.input(paramName, sql.VarChar, param);
|
|
624
|
-
} else if (Array.isArray(param)) {
|
|
625
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
626
|
-
} else {
|
|
627
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
}
|
|
614
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
631
615
|
let result;
|
|
632
616
|
let queryFailed = false;
|
|
633
617
|
try {
|
|
634
618
|
result = await request.query(processedSQL);
|
|
635
619
|
} catch (error) {
|
|
636
620
|
queryFailed = true;
|
|
637
|
-
console.error(`[SQL Server executeReadOnly] ERROR: ${error.message}`);
|
|
638
|
-
console.error(`[SQL Server executeReadOnly] SQL: ${processedSQL}`);
|
|
639
|
-
if (parameters && parameters.length > 0) {
|
|
640
|
-
console.error(`[SQL Server executeReadOnly] Parameters: ${JSON.stringify(parameters)}`);
|
|
641
|
-
}
|
|
642
621
|
throw error;
|
|
643
622
|
} finally {
|
|
644
623
|
try {
|
|
@@ -671,7 +650,7 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
671
650
|
* off the shared pool, so a concurrent query can never land on a connection
|
|
672
651
|
* with SHOWPLAN enabled (which would return a plan instead of its results).
|
|
673
652
|
*/
|
|
674
|
-
async explainQuery(innerQuery, readonly) {
|
|
653
|
+
async explainQuery(innerQuery, readonly, parameters) {
|
|
675
654
|
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
676
655
|
if (!cleaned) {
|
|
677
656
|
throw new Error("EXPLAIN requires a statement to analyze");
|
|
@@ -692,7 +671,9 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
692
671
|
try {
|
|
693
672
|
await explainPool.connect();
|
|
694
673
|
await explainPool.request().batch("SET SHOWPLAN_XML ON");
|
|
695
|
-
const
|
|
674
|
+
const planRequest = explainPool.request();
|
|
675
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
676
|
+
const planResult = await planRequest.batch(innerQuery);
|
|
696
677
|
const planRow = planResult.recordset?.[0];
|
|
697
678
|
const planXml = planRow ? Object.values(planRow)[0] : null;
|
|
698
679
|
return {
|
|
@@ -705,6 +686,177 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
705
686
|
await explainPool.close();
|
|
706
687
|
}
|
|
707
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Splits the modifiers between EXPLAIN and its statement, covering the same
|
|
691
|
+
* forms the read-only classifier recognises (see utils/allowed-keywords.ts):
|
|
692
|
+
* `ANALYZE`, `ANALYZE VERBOSE`, `(ANALYZE)`, `(ANALYZE, BUFFERS)`,
|
|
693
|
+
* `(ANALYZE false)`.
|
|
694
|
+
*
|
|
695
|
+
* Only ANALYZE carries a meaning here — it selects STATISTICS XML over
|
|
696
|
+
* SHOWPLAN_XML. The rest are PostgreSQL planner knobs with no SQL Server
|
|
697
|
+
* counterpart, so they are refused by name: silently dropping an option would
|
|
698
|
+
* quietly hand back something other than what was asked for.
|
|
699
|
+
*/
|
|
700
|
+
static parseExplainPrefix(afterExplain) {
|
|
701
|
+
if (afterExplain.startsWith("(")) {
|
|
702
|
+
const close = afterExplain.indexOf(")");
|
|
703
|
+
if (close < 0) {
|
|
704
|
+
throw new Error("EXPLAIN option list is missing its closing ')'");
|
|
705
|
+
}
|
|
706
|
+
let analyze = false;
|
|
707
|
+
for (const part of afterExplain.slice(1, close).split(",")) {
|
|
708
|
+
const token = part.trim();
|
|
709
|
+
if (!token) continue;
|
|
710
|
+
const parsed = _SQLServerConnector.EXPLAIN_OPTION.exec(token);
|
|
711
|
+
if (!parsed) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`EXPLAIN option '${token}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const name = parsed[1];
|
|
717
|
+
if (!/^analyze$/i.test(name)) {
|
|
718
|
+
throw new Error(
|
|
719
|
+
`EXPLAIN option '${name}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const value = parsed[2];
|
|
723
|
+
if (value === void 0 || _SQLServerConnector.EXPLAIN_ON.test(value)) {
|
|
724
|
+
analyze = true;
|
|
725
|
+
} else if (_SQLServerConnector.EXPLAIN_OFF.test(value)) {
|
|
726
|
+
analyze = false;
|
|
727
|
+
} else {
|
|
728
|
+
throw new Error(`EXPLAIN option 'ANALYZE' expects a boolean, got '${value}'.`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { analyze, query: afterExplain.slice(close + 1).trim() };
|
|
732
|
+
}
|
|
733
|
+
const analyzeKeyword = /^analyze\b/i.exec(afterExplain);
|
|
734
|
+
if (!analyzeKeyword) {
|
|
735
|
+
return { analyze: false, query: afterExplain };
|
|
736
|
+
}
|
|
737
|
+
const rest = afterExplain.slice(analyzeKeyword[0].length).trim();
|
|
738
|
+
const disabled = _SQLServerConnector.EXPLAIN_BARE_DISABLED.exec(rest);
|
|
739
|
+
if (disabled) {
|
|
740
|
+
return { analyze: false, query: rest.slice(disabled[0].length).trim() };
|
|
741
|
+
}
|
|
742
|
+
const trailing = /^([A-Za-z_]+)\b/.exec(rest);
|
|
743
|
+
if (trailing && /^verbose$/i.test(trailing[1])) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
"EXPLAIN option 'VERBOSE' is not supported on SQL Server \u2014 only ANALYZE is."
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
return { analyze: true, query: rest };
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Run a statement under SET STATISTICS XML and return its *actual* execution
|
|
752
|
+
* plan — the Postgres `EXPLAIN ANALYZE` contract.
|
|
753
|
+
*
|
|
754
|
+
* SHOWPLAN_XML (plain EXPLAIN) compiles without executing, so its plan carries
|
|
755
|
+
* only estimates. STATISTICS XML runs the statement, so the plan reports real
|
|
756
|
+
* row counts and execution counts. The flip side is that this path is *not*
|
|
757
|
+
* inherently read-only the way explainQuery is, so under `options.readonly` the
|
|
758
|
+
* statement runs inside a transaction that always rolls back.
|
|
759
|
+
*
|
|
760
|
+
* The dedicated single-connection pool serves the same purpose as in
|
|
761
|
+
* explainQuery: the STATISTICS XML session toggle must never leak onto a
|
|
762
|
+
* shared pool connection, where a concurrent query would inherit it.
|
|
763
|
+
*/
|
|
764
|
+
async explainAnalyzeQuery(innerQuery, options, parameters) {
|
|
765
|
+
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
766
|
+
if (!cleaned) {
|
|
767
|
+
throw new Error("EXPLAIN ANALYZE requires a statement to analyze");
|
|
768
|
+
}
|
|
769
|
+
if (/\bset\s+statistics\b/i.test(cleaned)) {
|
|
770
|
+
throw new Error("EXPLAIN ANALYZE does not support SET STATISTICS statements");
|
|
771
|
+
}
|
|
772
|
+
if (/\bset\s+showplan\b/i.test(cleaned)) {
|
|
773
|
+
throw new Error("EXPLAIN ANALYZE does not support SET SHOWPLAN statements");
|
|
774
|
+
}
|
|
775
|
+
if (options.readonly) {
|
|
776
|
+
this.assertNoReadOnlyEscapes(innerQuery, { transactionControl: true });
|
|
777
|
+
}
|
|
778
|
+
if (!this.config) {
|
|
779
|
+
throw new Error("Not connected to SQL Server database");
|
|
780
|
+
}
|
|
781
|
+
const explainPool = new sql.ConnectionPool({
|
|
782
|
+
...this.config,
|
|
783
|
+
pool: { ...this.config.pool, max: 1, min: 1 }
|
|
784
|
+
});
|
|
785
|
+
try {
|
|
786
|
+
await explainPool.connect();
|
|
787
|
+
await explainPool.request().batch("SET STATISTICS XML ON");
|
|
788
|
+
let planResult;
|
|
789
|
+
if (options.readonly) {
|
|
790
|
+
planResult = await _SQLServerConnector.batchRolledBack(
|
|
791
|
+
explainPool,
|
|
792
|
+
innerQuery,
|
|
793
|
+
parameters
|
|
794
|
+
);
|
|
795
|
+
} else {
|
|
796
|
+
const planRequest = explainPool.request();
|
|
797
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
798
|
+
planResult = await planRequest.batch(innerQuery);
|
|
799
|
+
}
|
|
800
|
+
const planXml = _SQLServerConnector.extractPlanXml(planResult);
|
|
801
|
+
return {
|
|
802
|
+
rows: planXml != null ? [{ plan: planXml }] : [],
|
|
803
|
+
rowCount: planXml != null ? 1 : 0
|
|
804
|
+
};
|
|
805
|
+
} catch (error) {
|
|
806
|
+
throw new Error(`Failed to explain analyze query: ${error.message}`);
|
|
807
|
+
} finally {
|
|
808
|
+
await explainPool.close();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Run a batch inside a transaction that is always rolled back, so EXPLAIN
|
|
813
|
+
* ANALYZE can report a real plan without letting the statement's writes stick.
|
|
814
|
+
*/
|
|
815
|
+
static async batchRolledBack(pool, innerQuery, parameters) {
|
|
816
|
+
const transaction = new sql.Transaction(pool);
|
|
817
|
+
await transaction.begin();
|
|
818
|
+
let queryFailed = false;
|
|
819
|
+
try {
|
|
820
|
+
const request = new sql.Request(transaction);
|
|
821
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
822
|
+
return await request.batch(innerQuery);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
queryFailed = true;
|
|
825
|
+
throw error;
|
|
826
|
+
} finally {
|
|
827
|
+
try {
|
|
828
|
+
await transaction.rollback();
|
|
829
|
+
} catch (rollbackError) {
|
|
830
|
+
if (!queryFailed) {
|
|
831
|
+
throw new Error(
|
|
832
|
+
`Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Pull the ShowPlanXML document out of a STATISTICS XML result.
|
|
840
|
+
*
|
|
841
|
+
* STATISTICS XML interleaves each statement's plan with that statement's own
|
|
842
|
+
* result sets, so the plan sits at no fixed index — and `recordset` (singular)
|
|
843
|
+
* would hand back the statement's data instead. Scan from the end for the
|
|
844
|
+
* first single-column row holding a plan document.
|
|
845
|
+
*/
|
|
846
|
+
static extractPlanXml(result) {
|
|
847
|
+
const recordsets = result.recordsets ?? [];
|
|
848
|
+
for (let i = recordsets.length - 1; i >= 0; i--) {
|
|
849
|
+
const firstRow = recordsets[i]?.[0];
|
|
850
|
+
if (!firstRow) continue;
|
|
851
|
+
const values = Object.values(firstRow);
|
|
852
|
+
if (values.length !== 1) continue;
|
|
853
|
+
const value = values[0];
|
|
854
|
+
if (typeof value === "string" && value.includes("<ShowPlanXML")) {
|
|
855
|
+
return value;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
708
860
|
};
|
|
709
861
|
/**
|
|
710
862
|
* Leading whitespace and SQL comments to skip before looking for a keyword.
|
|
@@ -713,6 +865,17 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
713
865
|
* comment passes validation but reaches the server untranslated.
|
|
714
866
|
*/
|
|
715
867
|
_SQLServerConnector.LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
|
|
868
|
+
/** Boolean spellings PostgreSQL accepts for an EXPLAIN option. */
|
|
869
|
+
_SQLServerConnector.EXPLAIN_ON = /^(?:true|on|1)$/i;
|
|
870
|
+
_SQLServerConnector.EXPLAIN_OFF = /^(?:false|off|0)$/i;
|
|
871
|
+
/**
|
|
872
|
+
* One option inside `EXPLAIN (...)`: a name, then optionally a value written
|
|
873
|
+
* either space-separated as PostgreSQL spells it (`ANALYZE false`) or with an
|
|
874
|
+
* equals sign, which the read-only classifier also accepts (`ANALYZE = 0`).
|
|
875
|
+
*/
|
|
876
|
+
_SQLServerConnector.EXPLAIN_OPTION = /^([A-Za-z_]+)(?:(?:\s*=\s*|\s+)(\S+))?$/;
|
|
877
|
+
/** A disabling boolean directly after a bare `EXPLAIN ANALYZE`. */
|
|
878
|
+
_SQLServerConnector.EXPLAIN_BARE_DISABLED = /^(?:=\s*)?(?:false|off|0)\b/i;
|
|
716
879
|
var SQLServerConnector = _SQLServerConnector;
|
|
717
880
|
var sqlServerConnector = new SQLServerConnector();
|
|
718
881
|
ConnectorRegistry.register(sqlServerConnector);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebase/dbhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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": {
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iarna/toml": "^2.2.5",
|
|
47
|
-
"@modelcontextprotocol/
|
|
47
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
48
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
48
49
|
"dotenv": "^16.4.7",
|
|
49
50
|
"express": "^4.18.2",
|
|
50
51
|
"ssh-config": "^5.0.3",
|
|
51
52
|
"ssh2": "^1.16.0",
|
|
52
|
-
"zod": "^
|
|
53
|
+
"zod": "^4.2.0"
|
|
53
54
|
},
|
|
54
55
|
"optionalDependencies": {
|
|
55
56
|
"@aws-sdk/rds-signer": "^3.1001.0",
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@anthropic-ai/mcpb": "^2.1.2",
|
|
65
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
64
66
|
"@testcontainers/mariadb": "^11.0.3",
|
|
65
67
|
"@testcontainers/mssqlserver": "^11.0.3",
|
|
66
68
|
"@testcontainers/mysql": "^11.0.3",
|