@bytebase/dbhub 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -2
  2. package/dist/index.js +199 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,10 +10,18 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <a href="https://cursor.com/install-mcp?name=dbhub&config=eyJjb21tYW5kIjoibnB4IEBieXRlYmFzZS9kYmh1YiIsImVudiI6eyJUUkFOU1BPUlQiOiJzdGRpbyIsIkRTTiI6InBvc3RncmVzOi8vdXNlcjpwYXNzd29yZEBsb2NhbGhvc3Q6NTQzMi9kYm5hbWU%2Fc3NsbW9kZT1kaXNhYmxlIiwiUkVBRE9OTFkiOiJ0cnVlIn19"><img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Add dbhub MCP server to Cursor" height="32" /></a>
14
13
  <a href="https://discord.gg/BjEkZpsJzn"><img src="https://img.shields.io/badge/%20-Hang%20out%20on%20Discord-5865F2?style=for-the-badge&logo=discord&labelColor=EEEEEE" alt="Join our Discord" height="32" /></a>
15
14
  </p>
16
15
 
16
+ <p>
17
+ Add to Cursor by copying the below link to browser
18
+
19
+ ```text
20
+ cursor://anysphere.cursor-deeplink/mcp/install?name=dbhub&config=eyJjb21tYW5kIjoibnB4IEBieXRlYmFzZS9kYmh1YiIsImVudiI6eyJUUkFOU1BPUlQiOiJzdGRpbyIsIkRTTiI6InBvc3RncmVzOi8vdXNlcjpwYXNzd29yZEBsb2NhbGhvc3Q6NTQzMi9kYm5hbWU%2Fc3NsbW9kZT1kaXNhYmxlIiwiUkVBRE9OTFkiOiJ0cnVlIn19
21
+ ```
22
+
23
+ </p>
24
+
17
25
  DBHub is a universal database gateway implementing the Model Context Protocol (MCP) server interface. This gateway allows MCP-compatible clients to connect to and explore different databases.
18
26
 
19
27
  ```bash
@@ -159,7 +167,14 @@ Check https://docs.anthropic.com/en/docs/claude-code/mcp
159
167
 
160
168
  ### Cursor
161
169
 
162
- [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=dbhub&config=eyJjb21tYW5kIjoibnB4IEBieXRlYmFzZS9kYmh1YiIsImVudiI6eyJUUkFOU1BPUlQiOiJzdGRpbyIsIkRTTiI6InBvc3RncmVzOi8vdXNlcjpwYXNzd29yZEBsb2NhbGhvc3Q6NTQzMi9kYm5hbWU%2Fc3NsbW9kZT1kaXNhYmxlIiwiUkVBRE9OTFkiOiJ0cnVlIn19)
170
+ <p>
171
+ Add to Cursor by copying the below link to browser
172
+
173
+ ```text
174
+ cursor://anysphere.cursor-deeplink/mcp/install?name=dbhub&config=eyJjb21tYW5kIjoibnB4IEBieXRlYmFzZS9kYmh1YiIsImVudiI6eyJUUkFOU1BPUlQiOiJzdGRpbyIsIkRTTiI6InBvc3RncmVzOi8vdXNlcjpwYXNzd29yZEBsb2NhbGhvc3Q6NTQzMi9kYm5hbWU%2Fc3NsbW9kZT1kaXNhYmxlIiwiUkVBRE9OTFkiOiJ0cnVlIn19
175
+ ```
176
+
177
+ </p>
163
178
 
164
179
  ![cursor](https://raw.githubusercontent.com/bytebase/dbhub/main/resources/images/cursor.webp)
165
180
 
@@ -181,6 +196,18 @@ In read-only mode, only [readonly SQL operations](https://github.com/bytebase/db
181
196
 
182
197
  This provides an additional layer of security when connecting to production databases.
183
198
 
199
+ ### Row Limiting
200
+
201
+ You can limit the number of rows returned from SELECT queries using the `--max-rows` parameter. This helps prevent accidentally retrieving too much data from large tables:
202
+
203
+ ```bash
204
+ # Limit SELECT queries to return at most 1000 rows
205
+ npx @bytebase/dbhub --dsn "postgres://user:password@localhost:5432/dbname" --max-rows 1000
206
+ ```
207
+
208
+ - Row limiting is only applied to SELECT statements, not INSERT/UPDATE/DELETE
209
+ - If your query already has a `LIMIT` or `TOP` clause, DBHub uses the smaller value
210
+
184
211
  ### SSL Connections
185
212
 
186
213
  You can specify the SSL mode using the `sslmode` parameter in your DSN string:
@@ -353,6 +380,7 @@ Extra query parameters:
353
380
  | transport | `TRANSPORT` | Transport mode: `stdio` or `http` | `stdio` |
354
381
  | port | `PORT` | HTTP server port (only applicable when using `--transport=http`) | `8080` |
355
382
  | readonly | `READONLY` | Restrict SQL execution to read-only operations | `false` |
383
+ | max-rows | N/A | Limit the number of rows returned from SELECT queries | No limit |
356
384
  | demo | N/A | Run in demo mode with sample employee database | `false` |
357
385
  | ssh-host | `SSH_HOST` | SSH server hostname for tunnel connection | N/A |
358
386
  | ssh-port | `SSH_PORT` | SSH server port | `22` |
package/dist/index.js CHANGED
@@ -185,6 +185,97 @@ function obfuscateDSNPassword(dsn) {
185
185
  }
186
186
  }
187
187
 
188
+ // src/utils/sql-row-limiter.ts
189
+ var SQLRowLimiter = class {
190
+ /**
191
+ * Check if a SQL statement is a SELECT query that can benefit from row limiting
192
+ * Only handles SELECT queries
193
+ */
194
+ static isSelectQuery(sql2) {
195
+ const trimmed = sql2.trim().toLowerCase();
196
+ return trimmed.startsWith("select");
197
+ }
198
+ /**
199
+ * Check if a SQL statement already has a LIMIT clause
200
+ */
201
+ static hasLimitClause(sql2) {
202
+ const limitRegex = /\blimit\s+\d+/i;
203
+ return limitRegex.test(sql2);
204
+ }
205
+ /**
206
+ * Check if a SQL statement already has a TOP clause (SQL Server)
207
+ */
208
+ static hasTopClause(sql2) {
209
+ const topRegex = /\bselect\s+top\s+\d+/i;
210
+ return topRegex.test(sql2);
211
+ }
212
+ /**
213
+ * Extract existing LIMIT value from SQL if present
214
+ */
215
+ static extractLimitValue(sql2) {
216
+ const limitMatch = sql2.match(/\blimit\s+(\d+)/i);
217
+ if (limitMatch) {
218
+ return parseInt(limitMatch[1], 10);
219
+ }
220
+ return null;
221
+ }
222
+ /**
223
+ * Extract existing TOP value from SQL if present (SQL Server)
224
+ */
225
+ static extractTopValue(sql2) {
226
+ const topMatch = sql2.match(/\bselect\s+top\s+(\d+)/i);
227
+ if (topMatch) {
228
+ return parseInt(topMatch[1], 10);
229
+ }
230
+ return null;
231
+ }
232
+ /**
233
+ * Add or modify LIMIT clause in a SQL statement
234
+ */
235
+ static applyLimitToQuery(sql2, maxRows) {
236
+ const existingLimit = this.extractLimitValue(sql2);
237
+ if (existingLimit !== null) {
238
+ const effectiveLimit = Math.min(existingLimit, maxRows);
239
+ return sql2.replace(/\blimit\s+\d+/i, `LIMIT ${effectiveLimit}`);
240
+ } else {
241
+ const trimmed = sql2.trim();
242
+ const hasSemicolon = trimmed.endsWith(";");
243
+ const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
244
+ return `${sqlWithoutSemicolon} LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
245
+ }
246
+ }
247
+ /**
248
+ * Add or modify TOP clause in a SQL statement (SQL Server)
249
+ */
250
+ static applyTopToQuery(sql2, maxRows) {
251
+ const existingTop = this.extractTopValue(sql2);
252
+ if (existingTop !== null) {
253
+ const effectiveTop = Math.min(existingTop, maxRows);
254
+ return sql2.replace(/\bselect\s+top\s+\d+/i, `SELECT TOP ${effectiveTop}`);
255
+ } else {
256
+ return sql2.replace(/\bselect\s+/i, `SELECT TOP ${maxRows} `);
257
+ }
258
+ }
259
+ /**
260
+ * Apply maxRows limit to a SELECT query only
261
+ */
262
+ static applyMaxRows(sql2, maxRows) {
263
+ if (!maxRows || !this.isSelectQuery(sql2)) {
264
+ return sql2;
265
+ }
266
+ return this.applyLimitToQuery(sql2, maxRows);
267
+ }
268
+ /**
269
+ * Apply maxRows limit to a SELECT query using SQL Server TOP syntax
270
+ */
271
+ static applyMaxRowsForSQLServer(sql2, maxRows) {
272
+ if (!maxRows || !this.isSelectQuery(sql2)) {
273
+ return sql2;
274
+ }
275
+ return this.applyTopToQuery(sql2, maxRows);
276
+ }
277
+ };
278
+
188
279
  // src/connectors/postgres/index.ts
189
280
  var { Pool } = pg;
190
281
  var PostgresDSNParser = class {
@@ -494,7 +585,7 @@ var PostgresConnector = class {
494
585
  client.release();
495
586
  }
496
587
  }
497
- async executeSQL(sql2) {
588
+ async executeSQL(sql2, options) {
498
589
  if (!this.pool) {
499
590
  throw new Error("Not connected to database");
500
591
  }
@@ -502,13 +593,15 @@ var PostgresConnector = class {
502
593
  try {
503
594
  const statements = sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
504
595
  if (statements.length === 1) {
505
- return await client.query(statements[0]);
596
+ const processedStatement = SQLRowLimiter.applyMaxRows(statements[0], options.maxRows);
597
+ return await client.query(processedStatement);
506
598
  } else {
507
599
  let allRows = [];
508
600
  await client.query("BEGIN");
509
601
  try {
510
- for (const statement of statements) {
511
- const result = await client.query(statement);
602
+ for (let statement of statements) {
603
+ const processedStatement = SQLRowLimiter.applyMaxRows(statement, options.maxRows);
604
+ const result = await client.query(processedStatement);
512
605
  if (result.rows && result.rows.length > 0) {
513
606
  allRows.push(...result.rows);
514
607
  }
@@ -858,12 +951,16 @@ var SQLServerConnector = class {
858
951
  throw new Error(`Failed to get stored procedure details: ${error.message}`);
859
952
  }
860
953
  }
861
- async executeSQL(sql2) {
954
+ async executeSQL(sql2, options) {
862
955
  if (!this.connection) {
863
956
  throw new Error("Not connected to SQL Server database");
864
957
  }
865
958
  try {
866
- const result = await this.connection.request().query(sql2);
959
+ let processedSQL = sql2;
960
+ if (options.maxRows) {
961
+ processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sql2, options.maxRows);
962
+ }
963
+ const result = await this.connection.request().query(processedSQL);
867
964
  return {
868
965
  rows: result.recordset || [],
869
966
  fields: result.recordset && result.recordset.length > 0 ? Object.keys(result.recordset[0]).map((key) => ({
@@ -1074,20 +1171,24 @@ var SQLiteConnector = class {
1074
1171
  "SQLite does not support stored procedures. Functions are defined programmatically through the SQLite API, not stored in the database."
1075
1172
  );
1076
1173
  }
1077
- async executeSQL(sql2) {
1174
+ async executeSQL(sql2, options) {
1078
1175
  if (!this.db) {
1079
1176
  throw new Error("Not connected to SQLite database");
1080
1177
  }
1081
1178
  try {
1082
1179
  const statements = sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
1083
1180
  if (statements.length === 1) {
1181
+ let processedStatement = statements[0];
1084
1182
  const trimmedStatement = statements[0].toLowerCase().trim();
1085
1183
  const isReadStatement = trimmedStatement.startsWith("select") || trimmedStatement.startsWith("with") || trimmedStatement.startsWith("explain") || trimmedStatement.startsWith("analyze") || trimmedStatement.startsWith("pragma") && (trimmedStatement.includes("table_info") || trimmedStatement.includes("index_info") || trimmedStatement.includes("index_list") || trimmedStatement.includes("foreign_key_list"));
1184
+ if (options.maxRows) {
1185
+ processedStatement = SQLRowLimiter.applyMaxRows(processedStatement, options.maxRows);
1186
+ }
1086
1187
  if (isReadStatement) {
1087
- const rows = this.db.prepare(statements[0]).all();
1188
+ const rows = this.db.prepare(processedStatement).all();
1088
1189
  return { rows };
1089
1190
  } else {
1090
- this.db.prepare(statements[0]).run();
1191
+ this.db.prepare(processedStatement).run();
1091
1192
  return { rows: [] };
1092
1193
  }
1093
1194
  } else {
@@ -1105,7 +1206,8 @@ var SQLiteConnector = class {
1105
1206
  this.db.exec(writeStatements.join("; "));
1106
1207
  }
1107
1208
  let allRows = [];
1108
- for (const statement of readStatements) {
1209
+ for (let statement of readStatements) {
1210
+ statement = SQLRowLimiter.applyMaxRows(statement, options.maxRows);
1109
1211
  const result = this.db.prepare(statement).all();
1110
1212
  allRows.push(...result);
1111
1213
  }
@@ -1467,18 +1569,29 @@ var MySQLConnector = class {
1467
1569
  const [rows] = await this.pool.query("SELECT DATABASE() AS DB");
1468
1570
  return rows[0].DB;
1469
1571
  }
1470
- async executeSQL(sql2) {
1572
+ async executeSQL(sql2, options) {
1471
1573
  if (!this.pool) {
1472
1574
  throw new Error("Not connected to database");
1473
1575
  }
1474
1576
  try {
1475
- const results = await this.pool.query(sql2);
1577
+ let processedSQL = sql2;
1578
+ if (options.maxRows) {
1579
+ const statements = sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
1580
+ const processedStatements = statements.map(
1581
+ (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
1582
+ );
1583
+ processedSQL = processedStatements.join("; ");
1584
+ if (sql2.trim().endsWith(";")) {
1585
+ processedSQL += ";";
1586
+ }
1587
+ }
1588
+ const results = await this.pool.query(processedSQL);
1476
1589
  const [firstResult] = results;
1477
- if (Array.isArray(firstResult) && firstResult.length > 0 && Array.isArray(firstResult[0]) && firstResult[0].length === 2) {
1590
+ if (Array.isArray(firstResult) && firstResult.length > 0 && Array.isArray(firstResult[0])) {
1478
1591
  let allRows = [];
1479
- for (const [rows, _fields] of firstResult) {
1480
- if (Array.isArray(rows)) {
1481
- allRows.push(...rows);
1592
+ for (const result of firstResult) {
1593
+ if (Array.isArray(result)) {
1594
+ allRows.push(...result);
1482
1595
  }
1483
1596
  }
1484
1597
  return { rows: allRows };
@@ -1845,12 +1958,23 @@ var MariaDBConnector = class {
1845
1958
  const rows = await this.pool.query("SELECT DATABASE() AS DB");
1846
1959
  return rows[0].DB;
1847
1960
  }
1848
- async executeSQL(sql2) {
1961
+ async executeSQL(sql2, options) {
1849
1962
  if (!this.pool) {
1850
1963
  throw new Error("Not connected to database");
1851
1964
  }
1852
1965
  try {
1853
- const results = await this.pool.query(sql2);
1966
+ let processedSQL = sql2;
1967
+ if (options.maxRows) {
1968
+ const statements = sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
1969
+ const processedStatements = statements.map(
1970
+ (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
1971
+ );
1972
+ processedSQL = processedStatements.join("; ");
1973
+ if (sql2.trim().endsWith(";")) {
1974
+ processedSQL += ";";
1975
+ }
1976
+ }
1977
+ const results = await this.pool.query(processedSQL);
1854
1978
  if (Array.isArray(results)) {
1855
1979
  if (results.length > 0 && Array.isArray(results[0]) && results[0].length > 0) {
1856
1980
  let allRows = [];
@@ -2031,6 +2155,7 @@ import dotenv from "dotenv";
2031
2155
  import path from "path";
2032
2156
  import fs from "fs";
2033
2157
  import { fileURLToPath } from "url";
2158
+ import { homedir as homedir2 } from "os";
2034
2159
 
2035
2160
  // src/utils/ssh-config-parser.ts
2036
2161
  import { readFileSync as readFileSync2, existsSync } from "fs";
@@ -2065,7 +2190,7 @@ function findDefaultSSHKey() {
2065
2190
  return void 0;
2066
2191
  }
2067
2192
  function parseSSHConfig(hostAlias, configPath) {
2068
- const sshConfigPath = configPath || join(homedir(), ".ssh", "config");
2193
+ const sshConfigPath = configPath;
2069
2194
  if (!existsSync(sshConfigPath)) {
2070
2195
  return null;
2071
2196
  }
@@ -2218,6 +2343,17 @@ function resolveTransport() {
2218
2343
  }
2219
2344
  return { type: "stdio", source: "default" };
2220
2345
  }
2346
+ function resolveMaxRows() {
2347
+ const args = parseCommandLineArgs();
2348
+ if (args["max-rows"]) {
2349
+ const maxRows = parseInt(args["max-rows"], 10);
2350
+ if (isNaN(maxRows) || maxRows <= 0) {
2351
+ throw new Error(`Invalid --max-rows value: ${args["max-rows"]}. Must be a positive integer.`);
2352
+ }
2353
+ return { maxRows, source: "command line argument" };
2354
+ }
2355
+ return null;
2356
+ }
2221
2357
  function resolvePort() {
2222
2358
  const args = parseCommandLineArgs();
2223
2359
  if (args.port) {
@@ -2260,7 +2396,9 @@ function resolveSSHConfig() {
2260
2396
  sources.push("SSH_HOST from environment");
2261
2397
  }
2262
2398
  if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
2263
- const sshConfigData = parseSSHConfig(sshConfigHost);
2399
+ const sshConfigPath = path.join(homedir2(), ".ssh", "config");
2400
+ console.error(`Attempting to parse SSH config for host '${sshConfigHost}' from: ${sshConfigPath}`);
2401
+ const sshConfigData = parseSSHConfig(sshConfigHost, sshConfigPath);
2264
2402
  if (sshConfigData) {
2265
2403
  config = { ...sshConfigData };
2266
2404
  sources.push(`SSH config for host '${sshConfigHost}'`);
@@ -2327,9 +2465,15 @@ var ConnectorManager = class {
2327
2465
  this.connected = false;
2328
2466
  this.sshTunnel = null;
2329
2467
  this.originalDSN = null;
2468
+ this.maxRows = null;
2330
2469
  if (!managerInstance) {
2331
2470
  managerInstance = this;
2332
2471
  }
2472
+ const maxRowsData = resolveMaxRows();
2473
+ if (maxRowsData) {
2474
+ this.maxRows = maxRowsData.maxRows;
2475
+ console.error(`Max rows limit: ${this.maxRows} (from ${maxRowsData.source})`);
2476
+ }
2333
2477
  }
2334
2478
  /**
2335
2479
  * Initialize and connect to the database using a DSN
@@ -2425,6 +2569,26 @@ var ConnectorManager = class {
2425
2569
  }
2426
2570
  return managerInstance.getConnector();
2427
2571
  }
2572
+ /**
2573
+ * Get execute options for SQL execution
2574
+ */
2575
+ getExecuteOptions() {
2576
+ const options = {};
2577
+ if (this.maxRows !== null) {
2578
+ options.maxRows = this.maxRows;
2579
+ }
2580
+ return options;
2581
+ }
2582
+ /**
2583
+ * Get the current execute options
2584
+ * This is used by tool handlers
2585
+ */
2586
+ static getCurrentExecuteOptions() {
2587
+ if (!managerInstance) {
2588
+ throw new Error("ConnectorManager not initialized");
2589
+ }
2590
+ return managerInstance.getExecuteOptions();
2591
+ }
2428
2592
  /**
2429
2593
  * Get default port for a database based on DSN protocol
2430
2594
  */
@@ -2844,9 +3008,20 @@ var executeSqlSchema = {
2844
3008
  function splitSQLStatements(sql2) {
2845
3009
  return sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
2846
3010
  }
3011
+ function stripSQLComments(sql2) {
3012
+ let cleaned = sql2.split("\n").map((line) => {
3013
+ const commentIndex = line.indexOf("--");
3014
+ return commentIndex >= 0 ? line.substring(0, commentIndex) : line;
3015
+ }).join("\n");
3016
+ cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, " ");
3017
+ return cleaned.trim();
3018
+ }
2847
3019
  function isReadOnlySQL(sql2, connectorType) {
2848
- const normalizedSQL = sql2.trim().toLowerCase();
2849
- const firstWord = normalizedSQL.split(/\s+/)[0];
3020
+ const cleanedSQL = stripSQLComments(sql2).toLowerCase();
3021
+ if (!cleanedSQL) {
3022
+ return true;
3023
+ }
3024
+ const firstWord = cleanedSQL.split(/\s+/)[0];
2850
3025
  const keywordList = allowedKeywords[connectorType] || allowedKeywords.default || [];
2851
3026
  return keywordList.includes(firstWord);
2852
3027
  }
@@ -2856,6 +3031,7 @@ function areAllStatementsReadOnly(sql2, connectorType) {
2856
3031
  }
2857
3032
  async function executeSqlToolHandler({ sql: sql2 }, _extra) {
2858
3033
  const connector = ConnectorManager.getCurrentConnector();
3034
+ const executeOptions = ConnectorManager.getCurrentExecuteOptions();
2859
3035
  try {
2860
3036
  if (isReadOnlyMode() && !areAllStatementsReadOnly(sql2, connector.id)) {
2861
3037
  return createToolErrorResponse(
@@ -2863,7 +3039,7 @@ async function executeSqlToolHandler({ sql: sql2 }, _extra) {
2863
3039
  "READONLY_VIOLATION"
2864
3040
  );
2865
3041
  }
2866
- const result = await connector.executeSQL(sql2);
3042
+ const result = await connector.executeSQL(sql2, executeOptions);
2867
3043
  const responseData = {
2868
3044
  rows: result.rows,
2869
3045
  count: result.rows.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Universal Database MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",