@dbx-app/mcp-server 0.2.2 → 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) 并配置了至少一个数据库连接
@@ -10,8 +10,15 @@ export interface ConnectionConfig {
10
10
  database?: string;
11
11
  url_params?: string;
12
12
  ssh_enabled: boolean;
13
+ proxy_enabled?: boolean;
14
+ proxy_type?: "socks5" | "http";
15
+ proxy_host?: string;
16
+ proxy_port?: number;
17
+ proxy_username?: string;
18
+ proxy_password?: string;
13
19
  ssl: boolean;
14
20
  }
21
+ export declare function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig;
15
22
  export declare function loadConnections(): Promise<ConnectionConfig[]>;
16
23
  export declare function findConnection(name: string): Promise<ConnectionConfig | undefined>;
17
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,10 +47,12 @@ 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");
54
+ if (!config.proxy_password)
55
+ config.proxy_password = getSecret(db, row.id, "proxy_password");
34
56
  configs.push(config);
35
57
  }
36
58
  db.close();
@@ -47,20 +69,21 @@ export async function findConnection(name) {
47
69
  export async function addConnection(config) {
48
70
  const id = randomUUID();
49
71
  const db = openDb();
72
+ const normalized = canonicalizeConnection({ ...config, id });
50
73
  const full = {
51
74
  id,
52
- name: config.name,
53
- db_type: config.db_type,
54
- 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,
55
78
  driver_label: null,
56
- url_params: config.url_params ?? "",
57
- host: config.host,
58
- port: config.port,
59
- username: config.username,
79
+ url_params: normalized.url_params ?? "",
80
+ host: normalized.host,
81
+ port: normalized.port,
82
+ username: normalized.username,
60
83
  password: "",
61
- database: config.database ?? null,
84
+ database: normalized.database ?? null,
62
85
  color: null,
63
- ssh_enabled: config.ssh_enabled ?? false,
86
+ ssh_enabled: normalized.ssh_enabled ?? false,
64
87
  ssh_host: "",
65
88
  ssh_port: 22,
66
89
  ssh_user: "",
@@ -68,20 +91,29 @@ export async function addConnection(config) {
68
91
  ssh_key_path: "",
69
92
  ssh_key_passphrase: "",
70
93
  ssh_expose_lan: false,
71
- ssl: config.ssl ?? false,
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 ?? "",
99
+ proxy_password: "",
100
+ ssl: normalized.ssl ?? false,
72
101
  sysdba: false,
73
102
  connection_string: null,
74
103
  };
75
104
  const configJson = JSON.stringify(full);
76
105
  const insert = db.transaction(() => {
77
106
  db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson);
78
- if (config.password) {
79
- 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);
109
+ }
110
+ if (normalized.proxy_password) {
111
+ db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "proxy_password", normalized.proxy_password);
80
112
  }
81
113
  });
82
114
  insert();
83
115
  db.close();
84
- return { ...config, id };
116
+ return normalized;
85
117
  }
86
118
  export async function removeConnection(name) {
87
119
  const connection = await findConnection(name);
package/dist/database.js CHANGED
@@ -1,7 +1,14 @@
1
+ import { createServer, connect as netConnect } from "node:net";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { homedir, platform } from "node:os";
5
+ import Database from "better-sqlite3";
6
+ import { sqlSafetyFromEnv } from "./sql-safety.js";
1
7
  const MAX_ROWS = 100;
2
8
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
3
9
  const QUERY_TIMEOUT_MS = 30_000;
4
10
  const pools = new Map();
11
+ const proxyTunnels = new Map();
5
12
  function poolKey(config) {
6
13
  return `${config.id}:${config.database || ""}`;
7
14
  }
@@ -26,8 +33,9 @@ async function getPgPool(config) {
26
33
  return existing.pool;
27
34
  }
28
35
  const pg = await import("pg");
36
+ const endpoint = await connectionEndpoint(config);
29
37
  const pool = new pg.default.Pool({
30
- connectionString: buildConnectionUrl(config),
38
+ connectionString: buildConnectionUrl(config, endpoint),
31
39
  max: 3,
32
40
  idleTimeoutMillis: 30_000,
33
41
  connectionTimeoutMillis: 10_000,
@@ -46,8 +54,9 @@ async function getMysqlPool(config) {
46
54
  return existing.pool;
47
55
  }
48
56
  const mysql = await import("mysql2/promise");
57
+ const endpoint = await connectionEndpoint(config);
49
58
  const pool = mysql.default.createPool({
50
- uri: buildConnectionUrl(config),
59
+ uri: buildConnectionUrl(config, endpoint),
51
60
  connectionLimit: 3,
52
61
  idleTimeout: 30_000,
53
62
  connectTimeout: 10_000,
@@ -57,18 +66,195 @@ async function getMysqlPool(config) {
57
66
  resetIdleTimer(key, entry);
58
67
  return pool;
59
68
  }
60
- function buildConnectionUrl(config) {
69
+ async function connectionEndpoint(config) {
70
+ if (!config.proxy_enabled || !config.proxy_host)
71
+ return { host: config.host, port: config.port };
72
+ const existing = proxyTunnels.get(config.id);
73
+ if (existing)
74
+ return { host: "127.0.0.1", port: existing.port };
75
+ const server = createServer((inbound) => {
76
+ connectViaProxy(config)
77
+ .then((outbound) => {
78
+ inbound.pipe(outbound);
79
+ outbound.pipe(inbound);
80
+ })
81
+ .catch(() => inbound.destroy());
82
+ });
83
+ const port = await new Promise((resolve, reject) => {
84
+ server.once("error", reject);
85
+ server.listen(0, "127.0.0.1", () => {
86
+ const address = server.address();
87
+ if (address && typeof address === "object")
88
+ resolve(address.port);
89
+ else
90
+ reject(new Error("Failed to bind proxy tunnel"));
91
+ });
92
+ });
93
+ proxyTunnels.set(config.id, { server, port });
94
+ return { host: "127.0.0.1", port };
95
+ }
96
+ function buildConnectionUrl(config, endpoint) {
61
97
  const db = config.database || "";
62
98
  const params = config.url_params || "";
63
99
  const suffix = params ? `?${params}` : "";
64
100
  if (isMysqlType(config.db_type)) {
65
- return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${config.host}:${config.port}/${db}${suffix}`;
101
+ return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`;
102
+ }
103
+ return `postgres://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`;
104
+ }
105
+ function connectViaProxy(config) {
106
+ return new Promise((resolve, reject) => {
107
+ const socket = netConnect(config.proxy_port || 1080, config.proxy_host || "127.0.0.1");
108
+ socket.once("error", reject);
109
+ socket.once("connect", () => {
110
+ if ((config.proxy_type || "socks5") === "http") {
111
+ httpConnect(socket, config, resolve, reject);
112
+ }
113
+ else {
114
+ socks5Connect(socket, config, resolve, reject);
115
+ }
116
+ });
117
+ });
118
+ }
119
+ function httpConnect(socket, config, resolve, reject) {
120
+ const target = `${config.host}:${config.port}`;
121
+ const lines = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`];
122
+ if (config.proxy_username || config.proxy_password) {
123
+ const token = Buffer.from(`${config.proxy_username || ""}:${config.proxy_password || ""}`).toString("base64");
124
+ lines.push(`Proxy-Authorization: Basic ${token}`);
66
125
  }
67
- return `postgres://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${config.host}:${config.port}/${db}${suffix}`;
126
+ socket.write(`${lines.join("\r\n")}\r\n\r\n`);
127
+ let buffer = Buffer.alloc(0);
128
+ socket.on("data", function onData(chunk) {
129
+ buffer = Buffer.concat([buffer, chunk]);
130
+ const end = buffer.indexOf("\r\n\r\n");
131
+ if (end < 0)
132
+ return;
133
+ socket.off("data", onData);
134
+ const head = buffer.subarray(0, end).toString("utf8");
135
+ if (!/^HTTP\/1\.[01] 200\b/.test(head)) {
136
+ reject(new Error(`HTTP proxy CONNECT failed: ${head.split("\r\n")[0] || "invalid response"}`));
137
+ socket.destroy();
138
+ return;
139
+ }
140
+ const rest = buffer.subarray(end + 4);
141
+ if (rest.length)
142
+ socket.unshift(rest);
143
+ resolve(socket);
144
+ });
145
+ }
146
+ function socks5Connect(socket, config, resolve, reject) {
147
+ const wantsAuth = !!(config.proxy_username || config.proxy_password);
148
+ socket.write(Buffer.from(wantsAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00]));
149
+ socket.once("data", (method) => {
150
+ if (method.length < 2 || method[0] !== 0x05) {
151
+ reject(new Error("Invalid SOCKS greeting"));
152
+ socket.destroy();
153
+ return;
154
+ }
155
+ if (method[1] === 0x02) {
156
+ const user = Buffer.from(config.proxy_username || "");
157
+ const pass = Buffer.from(config.proxy_password || "");
158
+ socket.write(Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass]));
159
+ socket.once("data", (auth) => {
160
+ if (auth.length < 2 || auth[1] !== 0x00) {
161
+ reject(new Error("SOCKS proxy authentication failed"));
162
+ socket.destroy();
163
+ return;
164
+ }
165
+ sendSocksConnect(socket, config, resolve, reject);
166
+ });
167
+ }
168
+ else if (method[1] === 0x00) {
169
+ sendSocksConnect(socket, config, resolve, reject);
170
+ }
171
+ else {
172
+ reject(new Error("SOCKS proxy rejected authentication methods"));
173
+ socket.destroy();
174
+ }
175
+ });
176
+ }
177
+ function sendSocksConnect(socket, config, resolve, reject) {
178
+ const host = Buffer.from(config.host);
179
+ socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), host, portBytes(config.port)]));
180
+ socket.once("data", (res) => {
181
+ if (res.length < 4 || res[0] !== 0x05 || res[1] !== 0x00) {
182
+ reject(new Error(`SOCKS proxy connect failed with code ${res[1] ?? "unknown"}`));
183
+ socket.destroy();
184
+ return;
185
+ }
186
+ resolve(socket);
187
+ });
188
+ }
189
+ function portBytes(port) {
190
+ const buf = Buffer.alloc(2);
191
+ buf.writeUInt16BE(port);
192
+ return buf;
68
193
  }
69
194
  function isMysqlType(dbType) {
70
195
  return dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
71
196
  }
197
+ function isDirectType(dbType) {
198
+ switch (dbType) {
199
+ case "postgres":
200
+ case "redshift":
201
+ case "mysql":
202
+ case "doris":
203
+ case "starrocks":
204
+ case "sqlite":
205
+ return true;
206
+ default:
207
+ return false;
208
+ }
209
+ }
210
+ function bridgeAppDataDir() {
211
+ const home = homedir();
212
+ switch (platform()) {
213
+ case "darwin":
214
+ return join(home, "Library", "Application Support", "com.dbx.app");
215
+ case "win32":
216
+ return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
217
+ default:
218
+ return join(home, ".config", "com.dbx.app");
219
+ }
220
+ }
221
+ async function bridgeDataRequest(path, body) {
222
+ let bridgeUrl;
223
+ try {
224
+ const portFile = join(bridgeAppDataDir(), "mcp-bridge-port");
225
+ const port = (await readFile(portFile, "utf-8")).trim();
226
+ bridgeUrl = `http://127.0.0.1:${port}`;
227
+ }
228
+ catch {
229
+ throw new Error("DBX desktop app is not running. This database type requires DBX to be running for query execution.");
230
+ }
231
+ const res = await fetch(`${bridgeUrl}${path}`, {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify(body),
235
+ });
236
+ if (!res.ok) {
237
+ const errBody = await res.text().catch(() => "");
238
+ let errorMsg;
239
+ try {
240
+ const parsed = JSON.parse(errBody);
241
+ errorMsg = parsed.error || errBody;
242
+ }
243
+ catch {
244
+ errorMsg = errBody;
245
+ }
246
+ throw new Error(errorMsg || `Bridge request failed: ${res.status}`);
247
+ }
248
+ return res.json();
249
+ }
250
+ function convertBridgeQueryResult(result) {
251
+ const rows = result.rows.slice(0, MAX_ROWS).map((row) => {
252
+ const obj = {};
253
+ result.columns.forEach((col, i) => { obj[col] = row[i]; });
254
+ return obj;
255
+ });
256
+ return { columns: result.columns, rows, row_count: rows.length };
257
+ }
72
258
  function withTimeout(promise, ms) {
73
259
  return new Promise((resolve, reject) => {
74
260
  const timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms);
@@ -109,14 +295,64 @@ async function mysqlQuery(config, sql, params) {
109
295
  });
110
296
  }
111
297
  async function query(config, sql, params) {
298
+ if (config.db_type === "sqlite")
299
+ return sqliteQuery(config, sql);
112
300
  if (isMysqlType(config.db_type))
113
301
  return mysqlQuery(config, sql, params);
114
302
  return pgQuery(config, sql, params);
115
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
+ }
116
332
  export async function executeQuery(config, sql) {
117
- return query(config, sql);
333
+ if (isDirectType(config.db_type)) {
334
+ return query(config, sql);
335
+ }
336
+ const result = await bridgeDataRequest("/data/execute-query", {
337
+ connection_name: config.name,
338
+ database: config.database || "",
339
+ sql,
340
+ });
341
+ return convertBridgeQueryResult(result);
118
342
  }
119
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
+ }
348
+ if (!isDirectType(config.db_type)) {
349
+ const tables = await bridgeDataRequest("/data/list-tables", {
350
+ connection_name: config.name,
351
+ database: config.database || "",
352
+ schema: schema || "",
353
+ });
354
+ return tables.map((t) => ({ name: t.name, type: t.table_type || "TABLE" }));
355
+ }
120
356
  let result;
121
357
  if (isMysqlType(config.db_type)) {
122
358
  result = await query(config, `SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME`);
@@ -127,6 +363,33 @@ export async function listTables(config, schema) {
127
363
  return result.rows.map((r) => ({ name: String(r.name || r.NAME), type: String(r.type || r.TYPE || "TABLE") }));
128
364
  }
129
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
+ }
377
+ if (!isDirectType(config.db_type)) {
378
+ const columns = await bridgeDataRequest("/data/describe-table", {
379
+ connection_name: config.name,
380
+ database: config.database || "",
381
+ schema: schema || "",
382
+ table,
383
+ });
384
+ return columns.map((c) => ({
385
+ name: c.name,
386
+ data_type: c.data_type,
387
+ is_nullable: c.is_nullable,
388
+ column_default: c.column_default,
389
+ is_primary_key: c.is_primary_key,
390
+ comment: c.comment,
391
+ }));
392
+ }
130
393
  let result;
131
394
  if (isMysqlType(config.db_type)) {
132
395
  result = await query(config, `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, [table]);
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) {
@@ -36,7 +38,7 @@ function mdTable(headers, rows) {
36
38
  }
37
39
  const server = new McpServer({
38
40
  name: "dbx",
39
- version: "0.2.1",
41
+ version: "0.3.0",
40
42
  });
41
43
  server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
42
44
  const connections = await backend.loadConnections();
@@ -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"),
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.2.2",
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
+ }