@dbx-app/mcp-server 0.3.0 → 0.4.1
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 +40 -4
- package/dist/connections.d.ts +1 -0
- package/dist/connections.js +43 -22
- package/dist/database.js +48 -0
- package/dist/index.js +39 -4
- package/dist/schema-context.d.ts +25 -0
- package/dist/schema-context.js +43 -0
- package/dist/sql-safety.d.ts +10 -0
- package/dist/sql-safety.js +103 -0
- package/package.json +4 -3
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
133
|
+
- **8 个工具** — 列出/添加/删除连接、列出表、查看表结构、获取 Schema 上下文、执行 SQL、在 DBX 中打开表
|
|
116
134
|
- **连接池** — 跨查询复用数据库连接
|
|
117
|
-
-
|
|
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) 并配置了至少一个数据库连接
|
package/dist/connections.d.ts
CHANGED
|
@@ -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>;
|
package/dist/connections.js
CHANGED
|
@@ -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
|
|
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:
|
|
55
|
-
db_type:
|
|
56
|
-
driver_profile:
|
|
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:
|
|
59
|
-
host:
|
|
60
|
-
port:
|
|
61
|
-
username:
|
|
79
|
+
url_params: normalized.url_params ?? "",
|
|
80
|
+
host: normalized.host,
|
|
81
|
+
port: normalized.port,
|
|
82
|
+
username: normalized.username,
|
|
62
83
|
password: "",
|
|
63
|
-
database:
|
|
84
|
+
database: normalized.database ?? null,
|
|
64
85
|
color: null,
|
|
65
|
-
ssh_enabled:
|
|
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:
|
|
74
|
-
proxy_type:
|
|
75
|
-
proxy_host:
|
|
76
|
-
proxy_port:
|
|
77
|
-
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:
|
|
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 (
|
|
87
|
-
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "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 (
|
|
90
|
-
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "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
|
|
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
|
|
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,
|
|
115
|
-
|
|
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
|
+
"version": "0.4.1",
|
|
4
4
|
"mcpName": "io.github.t8y2/dbx",
|
|
5
|
-
"description": "MCP server for DBX
|
|
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
|
+
}
|