@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 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
- let privateKeyBuffer;
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
- targetConfig.password,
270
- privateKey,
271
- targetConfig.passphrase,
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: source.ssh_proxy_jump || resolvedSSHConfig?.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-WPXPKRDU.js";
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: mutatingPattern
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-IPRZ55YC.js");
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-47APXMSF.js"), name: "SQL Server", driver: "mssql" },
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-EK6TCER5.js"), name: "MySQL", driver: "mysql2" },
1833
- { load: () => import("./mariadb-JPEKVFEW.js"), name: "MariaDB", driver: "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: () => () => {
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-WPXPKRDU.js";
5
+ } from "./chunk-COEDRFSK.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
7
  import "./chunk-JEZZN2YZ.js";
8
8
  export {
@@ -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.22.6",
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",