@bytebase/dbhub 0.22.6 → 0.24.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.
@@ -1,9 +1,16 @@
1
+ import {
2
+ sqlServerDynamicSqlKeywords,
3
+ sqlServerDynamicSqlPattern,
4
+ sqlServerPassThroughKeywords,
5
+ sqlServerPassThroughPattern
6
+ } from "./chunk-JO7YQPBQ.js";
1
7
  import {
2
8
  isDriverNotInstalled
3
9
  } from "./chunk-WVVMH6FJ.js";
4
10
  import {
5
- SQLRowLimiter
6
- } from "./chunk-BXXHWEQS.js";
11
+ SQLRowLimiter,
12
+ closeQuietly
13
+ } from "./chunk-D2BIPC7V.js";
7
14
  import {
8
15
  ConnectorRegistry,
9
16
  SafeURL,
@@ -156,8 +163,14 @@ var _SQLServerConnector = class _SQLServerConnector {
156
163
  if (!this.config.options) {
157
164
  this.config.options = {};
158
165
  }
159
- this.connection = await new sql.ConnectionPool(this.config).connect();
166
+ this.connection = new sql.ConnectionPool(this.config);
167
+ await this.connection.connect();
160
168
  } catch (error) {
169
+ if (this.connection) {
170
+ const connection = this.connection;
171
+ this.connection = void 0;
172
+ await closeQuietly(() => connection.close());
173
+ }
161
174
  throw error;
162
175
  }
163
176
  }
@@ -462,13 +475,16 @@ var _SQLServerConnector = class _SQLServerConnector {
462
475
  sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
463
476
  );
464
477
  if (/^explain\b/i.test(afterNoise)) {
465
- return this.explainQuery(afterNoise.slice("explain".length).trim());
478
+ return this.explainQuery(afterNoise.slice("explain".length).trim(), options.readonly);
466
479
  }
467
480
  try {
468
481
  let processedSQL = sqlQuery;
469
482
  if (options.maxRows) {
470
483
  processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sqlQuery, options.maxRows);
471
484
  }
485
+ if (options.readonly) {
486
+ return await this.executeReadOnly(processedSQL, parameters);
487
+ }
472
488
  const request = this.connection.request();
473
489
  const messages = [];
474
490
  request.on(
@@ -525,6 +541,122 @@ var _SQLServerConnector = class _SQLServerConnector {
525
541
  throw new Error(`Failed to execute query: ${error.message}`);
526
542
  }
527
543
  }
544
+ /**
545
+ * Reject the constructs that escape SQL Server's read-only guards, for use by
546
+ * both read-only execution paths.
547
+ *
548
+ * - Dynamic SQL (sqlServerDynamicSqlKeywords): can carry hidden COMMIT/ROLLBACK
549
+ * inside string literals that stripCommentsAndStrings removes
550
+ * - Pass-through data sources (sqlServerPassThroughKeywords): execute on a
551
+ * remote or ad-hoc source, so a local rollback never reaches them
552
+ * - COMMIT/ROLLBACK, when `transactionControl` is set: would end the wrapping
553
+ * transaction, letting writes persist. Only meaningful for the transaction
554
+ * path; the EXPLAIN path opens no transaction of its own.
555
+ *
556
+ * Both keyword lists are imported from the read-only classifier rather than
557
+ * redeclared, so the classifier and these backstops cannot drift apart.
558
+ *
559
+ * Note the COMMIT/ROLLBACK check is SQL Server-only by design. MySQL/MariaDB
560
+ * wrap batches in a transaction too, but there `commit`, `prepare` and
561
+ * `execute` are absent from their allow-lists in allowedKeywords, and
562
+ * execute-sql.ts requires every split statement to pass the classifier — so a
563
+ * transaction-control statement can never reach their backstop.
564
+ */
565
+ assertNoReadOnlyEscapes(sqlText, { transactionControl = false } = {}) {
566
+ const cleaned = stripCommentsAndStrings(sqlText, "sqlserver").toLowerCase();
567
+ if (transactionControl && /\b(?:commit|rollback)\b/.test(cleaned)) {
568
+ throw new Error(
569
+ "Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed"
570
+ );
571
+ }
572
+ if (sqlServerDynamicSqlPattern.test(cleaned)) {
573
+ throw new Error(
574
+ `Read-only mode: dynamic SQL execution (${sqlServerDynamicSqlKeywords.map((k) => k.toUpperCase()).join(", ")}) is not allowed`
575
+ );
576
+ }
577
+ if (sqlServerPassThroughPattern.test(cleaned)) {
578
+ throw new Error(
579
+ `Read-only mode: pass-through data sources (${sqlServerPassThroughKeywords.map((k) => k.toUpperCase()).join(", ")}) are not allowed`
580
+ );
581
+ }
582
+ }
583
+ /**
584
+ * Execute a query inside a transaction that always rolls back, preventing
585
+ * any modifications from persisting. SQL Server has no native READ ONLY
586
+ * transaction mode, so this is the defense-in-depth backstop behind the
587
+ * keyword classifier.
588
+ *
589
+ * Dangerous constructs are rejected before the transaction opens; see
590
+ * assertNoReadOnlyEscapes.
591
+ */
592
+ async executeReadOnly(processedSQL, parameters) {
593
+ this.assertNoReadOnlyEscapes(processedSQL, { transactionControl: true });
594
+ const transaction = new sql.Transaction(this.connection);
595
+ await transaction.begin();
596
+ const request = new sql.Request(transaction);
597
+ const messages = [];
598
+ request.on(
599
+ "info",
600
+ (info) => {
601
+ messages.push({
602
+ text: info.message,
603
+ severity: info.class !== void 0 ? String(info.class) : void 0,
604
+ code: info.number,
605
+ line: info.lineNumber
606
+ });
607
+ }
608
+ );
609
+ if (parameters && parameters.length > 0) {
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
+ }
631
+ let result;
632
+ let queryFailed = false;
633
+ try {
634
+ result = await request.query(processedSQL);
635
+ } catch (error) {
636
+ 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
+ throw error;
643
+ } finally {
644
+ try {
645
+ await transaction.rollback();
646
+ } catch (rollbackError) {
647
+ if (!queryFailed) {
648
+ throw new Error(
649
+ `Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
650
+ );
651
+ }
652
+ }
653
+ }
654
+ return {
655
+ rows: result.recordset || [],
656
+ rowCount: result.rowsAffected[0] || 0,
657
+ ...messages.length > 0 ? { messages } : {}
658
+ };
659
+ }
528
660
  /**
529
661
  * Return the estimated execution plan for a query using SHOWPLAN_XML.
530
662
  *
@@ -539,11 +671,14 @@ var _SQLServerConnector = class _SQLServerConnector {
539
671
  * off the shared pool, so a concurrent query can never land on a connection
540
672
  * with SHOWPLAN enabled (which would return a plan instead of its results).
541
673
  */
542
- async explainQuery(innerQuery) {
674
+ async explainQuery(innerQuery, readonly) {
543
675
  const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
544
676
  if (!cleaned) {
545
677
  throw new Error("EXPLAIN requires a statement to analyze");
546
678
  }
679
+ if (readonly) {
680
+ this.assertNoReadOnlyEscapes(innerQuery);
681
+ }
547
682
  if (/\bset\s+showplan/i.test(cleaned)) {
548
683
  throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
549
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.22.6",
3
+ "version": "0.24.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",