@dbx-app/mcp-server 0.3.0 → 0.4.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
@@ -7,9 +7,10 @@ MCP server for [DBX](https://github.com/t8y2/dbx) — lets AI agents (Claude Cod
7
7
  ## Features
8
8
 
9
9
  - **Zero config** — Automatically reads your DBX connections (including passwords from system keyring)
10
- - **7 tools** — List/add/remove connections, list tables, describe table, execute SQL, open table in DBX UI
10
+ - **8 tools** — List/add/remove connections, list tables, describe table, get schema context, execute SQL, open table in DBX UI
11
11
  - **Connection pooling** — Reuses database connections across queries
12
- - **PostgreSQL & MySQL** — Supports PostgreSQL, MySQL, and compatible databases (Doris, StarRocks, etc.)
12
+ - **Direct execution** — PostgreSQL, MySQL, SQLite, and compatible databases (Doris, StarRocks, etc.) can run without opening DBX
13
+ - **Read-only by default** — SQL execution blocks write and dangerous statements unless explicitly enabled
13
14
  - **DBX UI integration** — Open tables directly in the DBX desktop app from your AI agent
14
15
 
15
16
  ## Quick Start
@@ -73,9 +74,24 @@ In Claude Code, just ask:
73
74
  | `dbx_remove_connection` | Remove a database connection |
74
75
  | `dbx_list_tables` | List tables and views for a connection |
75
76
  | `dbx_describe_table` | Get column definitions for a table |
77
+ | `dbx_get_schema_context` | Get compact table and column context for writing SQL |
76
78
  | `dbx_execute_query` | Execute a SQL query (max 100 rows) |
77
79
  | `dbx_open_table` | Open a table in DBX desktop app UI |
78
80
 
81
+ ## SQL Safety
82
+
83
+ `dbx_execute_query` is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set:
84
+
85
+ ```bash
86
+ DBX_MCP_ALLOW_WRITES=1
87
+ ```
88
+
89
+ Dangerous statements such as `DROP`, `TRUNCATE`, and `ALTER` remain blocked unless you also set:
90
+
91
+ ```bash
92
+ DBX_MCP_ALLOW_DANGEROUS_SQL=1
93
+ ```
94
+
79
95
  ## How It Works
80
96
 
81
97
  ```
@@ -94,6 +110,8 @@ The MCP server reads your database connections from DBX's SQLite database:
94
110
 
95
111
  The `dbx_open_table` tool communicates with the running DBX app to open tables directly in the UI. This requires DBX to be running. If DBX is not running, the tool will return an error message.
96
112
 
113
+ PostgreSQL, MySQL, SQLite, Doris, StarRocks, and Redshift queries run directly from the MCP server. Other database types still use the DBX desktop bridge for query, table, and column operations unless `DBX_WEB_URL` is configured.
114
+
97
115
  ## Requirements
98
116
 
99
117
  - [DBX](https://github.com/t8y2/dbx) installed with at least one connection configured
@@ -112,9 +130,10 @@ MIT
112
130
  ### 特性
113
131
 
114
132
  - **零配置** — 自动读取 DBX 的连接配置
115
- - **7 个工具** — 列出/添加/删除连接、列出表、查看表结构、执行 SQL、在 DBX 中打开表
133
+ - **8 个工具** — 列出/添加/删除连接、列出表、查看表结构、获取 Schema 上下文、执行 SQL、在 DBX 中打开表
116
134
  - **连接池** — 跨查询复用数据库连接
117
- - **PostgreSQL 和 MySQL** — 支持 PostgreSQL、MySQL 及兼容数据库(Doris、StarRocks 等)
135
+ - **直接执行** — PostgreSQL、MySQL、SQLite 及兼容数据库(Doris、StarRocks 等)无需打开 DBX 即可查询
136
+ - **默认只读** — SQL 执行默认拦截写操作和危险语句
118
137
  - **DBX UI 联动** — 从 AI 助手直接在 DBX 桌面端打开表
119
138
 
120
139
  ### 快速开始
@@ -164,9 +183,24 @@ npx @dbx-app/mcp-server
164
183
  | `dbx_remove_connection` | 删除数据库连接 |
165
184
  | `dbx_list_tables` | 列出指定连接的表和视图 |
166
185
  | `dbx_describe_table` | 获取表的列定义 |
186
+ | `dbx_get_schema_context` | 获取适合 AI 写 SQL 的紧凑表结构上下文 |
167
187
  | `dbx_execute_query` | 执行 SQL 查询(最多返回 100 行) |
168
188
  | `dbx_open_table` | 在 DBX 桌面端打开指定表 |
169
189
 
190
+ ### SQL 安全
191
+
192
+ `dbx_execute_query` 默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置:
193
+
194
+ ```bash
195
+ DBX_MCP_ALLOW_WRITES=1
196
+ ```
197
+
198
+ `DROP`、`TRUNCATE`、`ALTER` 等危险语句仍会被拦截,除非额外设置:
199
+
200
+ ```bash
201
+ DBX_MCP_ALLOW_DANGEROUS_SQL=1
202
+ ```
203
+
170
204
  ### 工作原理
171
205
 
172
206
  MCP Server 从 DBX 的 SQLite 数据库读取连接信息:
@@ -179,6 +213,8 @@ MCP Server 从 DBX 的 SQLite 数据库读取连接信息:
179
213
 
180
214
  `dbx_open_table` 工具通过本地 HTTP 接口与运行中的 DBX 应用通信,直接在 UI 中打开表。需要 DBX 正在运行。
181
215
 
216
+ PostgreSQL、MySQL、SQLite、Doris、StarRocks、Redshift 查询可由 MCP Server 直接执行。其他数据库类型的查询、表列表、字段读取仍会走 DBX 桌面端 bridge,除非配置了 `DBX_WEB_URL` 使用 Web 后端。
217
+
182
218
  ### 系统要求
183
219
 
184
220
  - 已安装 [DBX](https://github.com/t8y2/dbx) 并配置了至少一个数据库连接
@@ -18,6 +18,7 @@ export interface ConnectionConfig {
18
18
  proxy_password?: string;
19
19
  ssl: boolean;
20
20
  }
21
+ export declare function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig;
21
22
  export declare function loadConnections(): Promise<ConnectionConfig[]>;
22
23
  export declare function findConnection(name: string): Promise<ConnectionConfig | undefined>;
23
24
  export declare function addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig>;
@@ -2,6 +2,24 @@ import { join } from "node:path";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import Database from "better-sqlite3";
5
+ export function canonicalizeConnection(config) {
6
+ if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") {
7
+ return {
8
+ ...config,
9
+ db_type: "tdengine",
10
+ driver_profile: "tdengine",
11
+ port: config.port === 0 || config.port === 6030 ? 6041 : config.port,
12
+ };
13
+ }
14
+ if (config.db_type === "tdengine") {
15
+ return {
16
+ ...config,
17
+ driver_profile: "tdengine",
18
+ port: config.port || 6041,
19
+ };
20
+ }
21
+ return config;
22
+ }
5
23
  function appDataDir() {
6
24
  const home = homedir();
7
25
  switch (platform()) {
@@ -18,7 +36,9 @@ function openDb(readonly = false) {
18
36
  return new Database(dbPath, { readonly });
19
37
  }
20
38
  function getSecret(db, connectionId, key) {
21
- const row = db.prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?").get(connectionId, key);
39
+ const row = db
40
+ .prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?")
41
+ .get(connectionId, key);
22
42
  return row?.secret ?? "";
23
43
  }
24
44
  export async function loadConnections() {
@@ -27,7 +47,7 @@ export async function loadConnections() {
27
47
  const rows = db.prepare("SELECT id, config_json FROM connections").all();
28
48
  const configs = [];
29
49
  for (const row of rows) {
30
- const config = JSON.parse(row.config_json);
50
+ const config = canonicalizeConnection(JSON.parse(row.config_json));
31
51
  config.id = row.id;
32
52
  if (!config.password)
33
53
  config.password = getSecret(db, row.id, "password");
@@ -49,20 +69,21 @@ export async function findConnection(name) {
49
69
  export async function addConnection(config) {
50
70
  const id = randomUUID();
51
71
  const db = openDb();
72
+ const normalized = canonicalizeConnection({ ...config, id });
52
73
  const full = {
53
74
  id,
54
- name: config.name,
55
- db_type: config.db_type,
56
- driver_profile: config.driver_profile ?? config.db_type,
75
+ name: normalized.name,
76
+ db_type: normalized.db_type,
77
+ driver_profile: normalized.driver_profile ?? normalized.db_type,
57
78
  driver_label: null,
58
- url_params: config.url_params ?? "",
59
- host: config.host,
60
- port: config.port,
61
- username: config.username,
79
+ url_params: normalized.url_params ?? "",
80
+ host: normalized.host,
81
+ port: normalized.port,
82
+ username: normalized.username,
62
83
  password: "",
63
- database: config.database ?? null,
84
+ database: normalized.database ?? null,
64
85
  color: null,
65
- ssh_enabled: config.ssh_enabled ?? false,
86
+ ssh_enabled: normalized.ssh_enabled ?? false,
66
87
  ssh_host: "",
67
88
  ssh_port: 22,
68
89
  ssh_user: "",
@@ -70,29 +91,29 @@ export async function addConnection(config) {
70
91
  ssh_key_path: "",
71
92
  ssh_key_passphrase: "",
72
93
  ssh_expose_lan: false,
73
- proxy_enabled: config.proxy_enabled ?? false,
74
- proxy_type: config.proxy_type ?? "socks5",
75
- proxy_host: config.proxy_host ?? "",
76
- proxy_port: config.proxy_port ?? 1080,
77
- proxy_username: config.proxy_username ?? "",
94
+ proxy_enabled: normalized.proxy_enabled ?? false,
95
+ proxy_type: normalized.proxy_type ?? "socks5",
96
+ proxy_host: normalized.proxy_host ?? "",
97
+ proxy_port: normalized.proxy_port ?? 1080,
98
+ proxy_username: normalized.proxy_username ?? "",
78
99
  proxy_password: "",
79
- ssl: config.ssl ?? false,
100
+ ssl: normalized.ssl ?? false,
80
101
  sysdba: false,
81
102
  connection_string: null,
82
103
  };
83
104
  const configJson = JSON.stringify(full);
84
105
  const insert = db.transaction(() => {
85
106
  db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson);
86
- if (config.password) {
87
- db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", config.password);
107
+ if (normalized.password) {
108
+ db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", normalized.password);
88
109
  }
89
- if (config.proxy_password) {
90
- db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "proxy_password", config.proxy_password);
110
+ if (normalized.proxy_password) {
111
+ db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "proxy_password", normalized.proxy_password);
91
112
  }
92
113
  });
93
114
  insert();
94
115
  db.close();
95
- return { ...config, id };
116
+ return normalized;
96
117
  }
97
118
  export async function removeConnection(name) {
98
119
  const connection = await findConnection(name);
package/dist/database.js CHANGED
@@ -2,6 +2,8 @@ import { createServer, connect as netConnect } from "node:net";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir, platform } from "node:os";
5
+ import Database from "better-sqlite3";
6
+ import { sqlSafetyFromEnv } from "./sql-safety.js";
5
7
  const MAX_ROWS = 100;
6
8
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
7
9
  const QUERY_TIMEOUT_MS = 30_000;
@@ -199,6 +201,7 @@ function isDirectType(dbType) {
199
201
  case "mysql":
200
202
  case "doris":
201
203
  case "starrocks":
204
+ case "sqlite":
202
205
  return true;
203
206
  default:
204
207
  return false;
@@ -292,10 +295,40 @@ async function mysqlQuery(config, sql, params) {
292
295
  });
293
296
  }
294
297
  async function query(config, sql, params) {
298
+ if (config.db_type === "sqlite")
299
+ return sqliteQuery(config, sql);
295
300
  if (isMysqlType(config.db_type))
296
301
  return mysqlQuery(config, sql, params);
297
302
  return pgQuery(config, sql, params);
298
303
  }
304
+ function sqlitePath(config) {
305
+ return expandTilde(config.host || config.database || "");
306
+ }
307
+ function expandTilde(path) {
308
+ if (path === "~")
309
+ return homedir();
310
+ if (path.startsWith("~/"))
311
+ return join(homedir(), path.slice(2));
312
+ return path;
313
+ }
314
+ function quoteSqliteIdentifier(identifier) {
315
+ return `"${identifier.replace(/"/g, '""')}"`;
316
+ }
317
+ function sqliteQuery(config, sql) {
318
+ const db = new Database(sqlitePath(config), { readonly: !sqlSafetyFromEnv().allowWrites });
319
+ try {
320
+ const stmt = db.prepare(sql);
321
+ if (stmt.reader) {
322
+ const rows = stmt.all().slice(0, MAX_ROWS);
323
+ return { columns: stmt.columns().map((column) => column.name), rows, row_count: rows.length };
324
+ }
325
+ const result = stmt.run();
326
+ return { columns: [], rows: [], row_count: result.changes };
327
+ }
328
+ finally {
329
+ db.close();
330
+ }
331
+ }
299
332
  export async function executeQuery(config, sql) {
300
333
  if (isDirectType(config.db_type)) {
301
334
  return query(config, sql);
@@ -308,6 +341,10 @@ export async function executeQuery(config, sql) {
308
341
  return convertBridgeQueryResult(result);
309
342
  }
310
343
  export async function listTables(config, schema) {
344
+ if (config.db_type === "sqlite") {
345
+ const result = await query(config, `SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`);
346
+ return result.rows.map((r) => ({ name: String(r.name || ""), type: String(r.type || "table") }));
347
+ }
311
348
  if (!isDirectType(config.db_type)) {
312
349
  const tables = await bridgeDataRequest("/data/list-tables", {
313
350
  connection_name: config.name,
@@ -326,6 +363,17 @@ export async function listTables(config, schema) {
326
363
  return result.rows.map((r) => ({ name: String(r.name || r.NAME), type: String(r.type || r.TYPE || "TABLE") }));
327
364
  }
328
365
  export async function describeTable(config, table, schema) {
366
+ if (config.db_type === "sqlite") {
367
+ const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`);
368
+ return result.rows.map((r) => ({
369
+ name: String(r.name || ""),
370
+ data_type: String(r.type || ""),
371
+ is_nullable: Number(r.notnull || 0) === 0,
372
+ column_default: r.dflt_value != null ? String(r.dflt_value) : null,
373
+ is_primary_key: Number(r.pk || 0) > 0,
374
+ comment: null,
375
+ }));
376
+ }
329
377
  if (!isDirectType(config.db_type)) {
330
378
  const columns = await bridgeDataRequest("/data/describe-table", {
331
379
  connection_name: config.name,
package/dist/index.js CHANGED
@@ -7,6 +7,8 @@ import { join } from "node:path";
7
7
  import { homedir, platform } from "node:os";
8
8
  import { loadConnections as desktopLoadConnections, findConnection as desktopFindConnection, addConnection as desktopAddConnection, removeConnection as desktopRemoveConnection, } from "./connections.js";
9
9
  import { listTables as desktopListTables, describeTable as desktopDescribeTable, executeQuery as desktopExecuteQuery, } from "./database.js";
10
+ import { buildSchemaContext, formatSchemaContext } from "./schema-context.js";
11
+ import { evaluateSqlSafety, sqlSafetyFromEnv } from "./sql-safety.js";
10
12
  const isWebMode = !!process.env.DBX_WEB_URL;
11
13
  let backend;
12
14
  if (isWebMode) {
@@ -85,6 +87,9 @@ server.tool("dbx_execute_query", "Execute a SQL query on a database connection (
85
87
  const config = await backend.findConnection(connection_name);
86
88
  if (!config)
87
89
  return text(`Connection "${connection_name}" not found`);
90
+ const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
91
+ if (!safety.allowed)
92
+ return text(`Query blocked: ${safety.reason}`);
88
93
  try {
89
94
  const result = await backend.executeQuery(config, sql);
90
95
  if (result.columns.length === 0)
@@ -97,11 +102,27 @@ server.tool("dbx_execute_query", "Execute a SQL query on a database connection (
97
102
  return text(`Query error: ${msg}`);
98
103
  }
99
104
  });
105
+ server.tool("dbx_get_schema_context", "Get compact table and column context for writing SQL", {
106
+ connection_name: z.string().describe("Name of the DBX connection"),
107
+ schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
108
+ tables: z.array(z.string()).optional().describe("Specific table names to include"),
109
+ max_tables: z.number().int().min(1).max(20).default(8).describe("Maximum number of tables to include"),
110
+ }, async ({ connection_name, schema, tables, max_tables }) => {
111
+ const config = await backend.findConnection(connection_name);
112
+ if (!config)
113
+ return text(`Connection "${connection_name}" not found`);
114
+ const context = await buildSchemaContext(backend, config, { schema, tables, maxTables: max_tables });
115
+ if (context.tables.length === 0)
116
+ return text("No matching tables found.");
117
+ return text(formatSchemaContext(context));
118
+ });
100
119
  server.tool("dbx_add_connection", "Add a new database connection to DBX", {
101
120
  name: z.string().describe("Connection name"),
102
- db_type: z.string().describe("Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, vastbase, goldendb, gaussdb, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, jdbc"),
121
+ db_type: z
122
+ .string()
123
+ .describe("Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, tdengine, jdbc, access"),
103
124
  host: z.string().describe("Database host"),
104
- port: z.number().describe("Database port"),
125
+ port: z.number().optional().describe("Database port (TDengine defaults to 6041)"),
105
126
  username: z.string().default("").describe("Username"),
106
127
  password: z.string().default("").describe("Password"),
107
128
  database: z.string().optional().describe("Default database name"),
@@ -110,9 +131,20 @@ server.tool("dbx_add_connection", "Add a new database connection to DBX", {
110
131
  const existing = await backend.findConnection(name);
111
132
  if (existing)
112
133
  return text(`Connection "${name}" already exists.`);
134
+ const FILE_BASED_TYPES = new Set(["sqlite", "duckdb", "access"]);
135
+ const resolvedPort = port ?? (db_type === "tdengine" ? 6041 : FILE_BASED_TYPES.has(db_type) ? 0 : undefined);
136
+ if (resolvedPort === undefined)
137
+ return text("Port is required for this database type.");
113
138
  const config = await backend.addConnection({
114
- name, db_type, host, port, username, password,
115
- database, ssl, ssh_enabled: false,
139
+ name,
140
+ db_type,
141
+ host,
142
+ port: resolvedPort,
143
+ username,
144
+ password,
145
+ database,
146
+ ssl,
147
+ ssh_enabled: false,
116
148
  });
117
149
  await notifyReload();
118
150
  return text(`Connection "${config.name}" added (id: ${config.id}).`);
@@ -171,6 +203,9 @@ if (!isWebMode) {
171
203
  sql: z.string().describe("SQL query to execute"),
172
204
  database: z.string().optional().describe("Database name"),
173
205
  }, async ({ connection_name, sql, database }) => {
206
+ const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
207
+ if (!safety.allowed)
208
+ return text(`Query blocked: ${safety.reason}`);
174
209
  return bridgeRequest("/execute-query", { connection_name, sql, database }, "Query sent to DBX");
175
210
  });
176
211
  }
@@ -0,0 +1,25 @@
1
+ import type { ConnectionConfig } from "./connections.js";
2
+ import type { ColumnInfo, TableInfo } from "./database.js";
3
+ export interface SchemaContextBackend {
4
+ listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
5
+ describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
6
+ }
7
+ export interface SchemaContextOptions {
8
+ schema?: string;
9
+ tables?: string[];
10
+ maxTables?: number;
11
+ }
12
+ export interface SchemaContextTable {
13
+ name: string;
14
+ type: string;
15
+ columns: ColumnInfo[];
16
+ }
17
+ export interface SchemaContext {
18
+ connection: string;
19
+ database: string;
20
+ schema: string;
21
+ truncated: boolean;
22
+ tables: SchemaContextTable[];
23
+ }
24
+ export declare function buildSchemaContext(backend: SchemaContextBackend, config: ConnectionConfig, options?: SchemaContextOptions): Promise<SchemaContext>;
25
+ export declare function formatSchemaContext(context: SchemaContext): string;
@@ -0,0 +1,43 @@
1
+ const DEFAULT_MAX_TABLES = 8;
2
+ export async function buildSchemaContext(backend, config, options = {}) {
3
+ const maxTables = Math.max(1, Math.min(options.maxTables ?? DEFAULT_MAX_TABLES, 20));
4
+ const availableTables = await backend.listTables(config, options.schema);
5
+ const requested = new Set((options.tables ?? []).map((table) => table.toLowerCase()));
6
+ const selected = requested.size
7
+ ? availableTables.filter((table) => requested.has(table.name.toLowerCase()))
8
+ : availableTables.slice(0, maxTables);
9
+ const limited = selected.slice(0, maxTables);
10
+ const tables = await Promise.all(limited.map(async (table) => ({
11
+ name: table.name,
12
+ type: table.type,
13
+ columns: await backend.describeTable(config, table.name, options.schema),
14
+ })));
15
+ return {
16
+ connection: config.name,
17
+ database: config.database || "",
18
+ schema: options.schema || "",
19
+ truncated: selected.length > limited.length || (!requested.size && availableTables.length > limited.length),
20
+ tables,
21
+ };
22
+ }
23
+ export function formatSchemaContext(context) {
24
+ const header = [
25
+ `Connection: ${context.connection}`,
26
+ context.database ? `Database: ${context.database}` : "",
27
+ context.schema ? `Schema: ${context.schema}` : "",
28
+ ].filter(Boolean);
29
+ const sections = context.tables.map((table) => {
30
+ const lines = table.columns.map((column) => {
31
+ const parts = [
32
+ column.name,
33
+ column.data_type,
34
+ column.is_nullable ? "NULL" : "NOT NULL",
35
+ column.is_primary_key ? "PK" : "",
36
+ ].filter(Boolean);
37
+ return `- ${parts.join(" ")}${column.comment ? ` -- ${column.comment}` : ""}`;
38
+ });
39
+ return [`## ${table.name}`, `Type: ${table.type}`, ...lines].join("\n");
40
+ });
41
+ const suffix = context.truncated ? "\n\nNote: table list was truncated; request specific table names for more context." : "";
42
+ return `${header.join("\n")}\n\n${sections.join("\n\n")}${suffix}`;
43
+ }
@@ -0,0 +1,10 @@
1
+ export interface SqlSafetyOptions {
2
+ allowWrites?: boolean;
3
+ allowDangerous?: boolean;
4
+ }
5
+ export interface SqlSafetyDecision {
6
+ allowed: boolean;
7
+ reason?: string;
8
+ }
9
+ export declare function evaluateSqlSafety(sql: string, options?: SqlSafetyOptions): SqlSafetyDecision;
10
+ export declare function sqlSafetyFromEnv(env?: NodeJS.ProcessEnv): SqlSafetyOptions;
@@ -0,0 +1,103 @@
1
+ const READ_KEYWORDS = new Set(["select", "with", "show", "describe", "desc", "explain"]);
2
+ const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
3
+ export function evaluateSqlSafety(sql, options = {}) {
4
+ const statements = splitSqlStatements(sql);
5
+ if (statements.length === 0)
6
+ return { allowed: false, reason: "SQL is empty." };
7
+ if (statements.length > 1)
8
+ return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." };
9
+ const normalized = stripSqlCommentsAndStrings(statements[0]).trim();
10
+ const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
11
+ if (!firstKeyword)
12
+ return { allowed: false, reason: "SQL statement is not recognized." };
13
+ const tokens = normalized.toLowerCase().match(/[a-z_]+/g) ?? [];
14
+ const dangerous = tokens.find((token) => DANGEROUS_KEYWORDS.has(token));
15
+ if (dangerous && !options.allowDangerous) {
16
+ return { allowed: false, reason: `Dangerous SQL keyword "${dangerous.toUpperCase()}" is blocked.` };
17
+ }
18
+ if (!options.allowWrites && !READ_KEYWORDS.has(firstKeyword)) {
19
+ return {
20
+ allowed: false,
21
+ reason: "MCP SQL execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
22
+ };
23
+ }
24
+ if (options.allowWrites && !options.allowDangerous) {
25
+ if (firstKeyword === "update" && !tokens.includes("where")) {
26
+ return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
27
+ }
28
+ if (firstKeyword === "delete" && !tokens.includes("where")) {
29
+ return { allowed: false, reason: "DELETE statements must include a WHERE clause." };
30
+ }
31
+ }
32
+ return { allowed: true };
33
+ }
34
+ export function sqlSafetyFromEnv(env = process.env) {
35
+ return {
36
+ allowWrites: env.DBX_MCP_ALLOW_WRITES === "1" || env.DBX_MCP_ALLOW_WRITES === "true",
37
+ allowDangerous: env.DBX_MCP_ALLOW_DANGEROUS_SQL === "1" || env.DBX_MCP_ALLOW_DANGEROUS_SQL === "true",
38
+ };
39
+ }
40
+ function splitSqlStatements(sql) {
41
+ const statements = [];
42
+ let current = "";
43
+ let quote = null;
44
+ let inLineComment = false;
45
+ let inBlockComment = false;
46
+ for (let i = 0; i < sql.length; i++) {
47
+ const char = sql[i];
48
+ const next = sql[i + 1];
49
+ if (inLineComment) {
50
+ current += char;
51
+ if (char === "\n")
52
+ inLineComment = false;
53
+ continue;
54
+ }
55
+ if (inBlockComment) {
56
+ current += char;
57
+ if (char === "*" && next === "/") {
58
+ current += next;
59
+ i++;
60
+ inBlockComment = false;
61
+ }
62
+ continue;
63
+ }
64
+ if (quote) {
65
+ current += char;
66
+ if (char === quote) {
67
+ if (next === quote) {
68
+ current += next;
69
+ i++;
70
+ }
71
+ else {
72
+ quote = null;
73
+ }
74
+ }
75
+ continue;
76
+ }
77
+ if (char === "-" && next === "-")
78
+ inLineComment = true;
79
+ if (char === "/" && next === "*")
80
+ inBlockComment = true;
81
+ if (char === "'" || char === '"' || char === "`")
82
+ quote = char;
83
+ if (char === ";") {
84
+ if (current.trim())
85
+ statements.push(current.trim());
86
+ current = "";
87
+ }
88
+ else {
89
+ current += char;
90
+ }
91
+ }
92
+ if (current.trim())
93
+ statements.push(current.trim());
94
+ return statements;
95
+ }
96
+ function stripSqlCommentsAndStrings(sql) {
97
+ return sql
98
+ .replace(/--.*$/gm, " ")
99
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
100
+ .replace(/'([^']|'')*'/g, "''")
101
+ .replace(/"([^"]|"")*"/g, '""')
102
+ .replace(/`([^`]|``)*`/g, "``");
103
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@dbx-app/mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.t8y2/dbx",
5
- "description": "MCP server for DBX \u2014 query databases from Claude Code, Cursor, and other AI agents",
5
+ "description": "MCP server for DBX — query databases from Claude Code, Cursor, and other AI agents",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "dbx-mcp-server": "dist/index.js"
@@ -12,6 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "tsx src/index.ts",
15
+ "test": "tsx --test tests/*.test.ts",
15
16
  "build": "tsc",
16
17
  "prepublishOnly": "tsc"
17
18
  },
@@ -52,4 +53,4 @@
52
53
  "esbuild"
53
54
  ]
54
55
  }
55
- }
56
+ }