dsh-db-mcp 0.6.5 → 0.6.9
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 +83 -41
- package/README.zh.md +252 -0
- package/client.js +101 -2
- package/package.json +5 -3
- package/server/engines.py +34 -6
- package/server/main.py +2 -1
- package/src/host.js +1 -1
- package/src/mcp.js +45 -1
- package/src/tools.js +5 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
|
|
5
|
+
English | [中文](README.zh.md)
|
|
6
|
+
|
|
5
7
|
DSH plugin that adds a **Database** entry to the sidebar with a data-source
|
|
6
8
|
management panel, backed by a Python MCP server that lets the agent query those
|
|
7
9
|
databases through tools (not through the page).
|
|
@@ -11,39 +13,44 @@ databases through tools (not through the page).
|
|
|
11
13
|
and never come back to the page.
|
|
12
14
|
- Import the connections DBeaver already has, including its encrypted passwords.
|
|
13
15
|
- Read-only by default, with two independent write gates and an audit log.
|
|
14
|
-
- The agent gets
|
|
16
|
+
- The agent gets eight MCP tools, not a SQL editor in the UI.
|
|
15
17
|
|
|
16
18
|
Requirements and staged plan: [`docs/requirements.md`](docs/requirements.md).
|
|
17
19
|
|
|
18
20
|
## What exists today (P7)
|
|
19
21
|
|
|
20
|
-
- **Host half** (`index.js`
|
|
22
|
+
- **Host half** (`index.js` → `src/host.js`): a Cordis plugin serving
|
|
21
23
|
`/plugins/db-mcp/api` from the DSH web server.
|
|
24
|
+
|
|
22
25
|
| Route | Purpose |
|
|
23
26
|
| ----- | ------- |
|
|
24
27
|
| `GET /health` | plugin version, Host facts, Python discovery, runtime paths, registry state, MCP status, settings |
|
|
25
28
|
| `GET /datasources` | registry, credentials projected away |
|
|
26
29
|
| `GET /schema?datasource=…&table=…[&schema=…]` | read-only structure: tables, or the columns of one table |
|
|
30
|
+
| `GET /groups` | declared groups plus every group label in use |
|
|
27
31
|
| `GET /dbeaver/scan` | connections already configured in DBeaver |
|
|
28
32
|
| `GET /mcp/status` | Python server phase, tool list, last error, stderr tail |
|
|
29
|
-
| `GET /settings`
|
|
33
|
+
| `GET /settings` · `POST /settings` | global write switch and audit switch |
|
|
30
34
|
| `GET /audit?limit=N` | the tail of the tool-call audit log |
|
|
31
35
|
| `GET /python/env` | interpreter, venv path, installed driver versions |
|
|
32
36
|
| `POST /python/setup` | create the venv and `pip install -r server/requirements.txt` |
|
|
33
37
|
| `POST /datasources/save` | create or update a source (`id` is immutable) |
|
|
34
38
|
| `POST /datasources/delete` | remove a source and its stored secret |
|
|
35
39
|
| `POST /datasources/test` | real login probe: SQLite over `node:sqlite`, the rest through the Python drivers |
|
|
40
|
+
| `POST /datasources/bulk` | test / read-only / group / delete a set of sources |
|
|
41
|
+
| `POST /groups/save` · `POST /groups/delete` | create, rename or delete a group |
|
|
36
42
|
| `POST /dbeaver/import` | copy selected DBeaver connections into the registry |
|
|
37
|
-
| `POST /mcp/start`
|
|
43
|
+
| `POST /mcp/start` · `POST /mcp/restart` | start or restart the Python MCP server |
|
|
38
44
|
| `POST /mcp/call` | dev helper: call one MCP tool through the panel |
|
|
39
45
|
| `POST /reload` | dev helper: re-import `src/host.js` without restarting DSH |
|
|
46
|
+
|
|
40
47
|
- **Client half** (`client.js`): sidebar entry (`db-mcp`, order 25) plus the main
|
|
41
48
|
panel with the source list, the minimal create/edit form (one connection string),
|
|
42
49
|
test/delete/default actions, a read-only *structure* browser, the DBeaver import
|
|
43
50
|
flow, a *Restart MCP* button, the runtime diagnostics card (tool count, global
|
|
44
51
|
write switch, venv and package versions with a one-click install) and an
|
|
45
52
|
*audit log* card. Copy is localized (zh/en) through the client `locale` service.
|
|
46
|
-
- **Python server** (`server/`): `main.py` (FastMCP,
|
|
53
|
+
- **Python server** (`server/`): `main.py` (FastMCP, eight tools), `engines.py`
|
|
47
54
|
(per-engine connect/introspect/query), `store.py` (reads the registry).
|
|
48
55
|
The plugin spawns it over stdio and publishes its tools as `mcp__db__*` on the
|
|
49
56
|
host `tools` service — it does not go through a `dsh-mcp-client` Loader row.
|
|
@@ -65,7 +72,7 @@ Requirements and staged plan: [`docs/requirements.md`](docs/requirements.md).
|
|
|
65
72
|
|
|
66
73
|
### Python environment
|
|
67
74
|
|
|
68
|
-
Interpreter discovery order: plugin config `pythonPath`
|
|
75
|
+
Interpreter discovery order: plugin config `pythonPath` → `<DSH_HOME>/db-mcp/.venv/Scripts/python.exe` → that venv on POSIX → `python` → `python3`. Dependencies live in the runtime venv, not in the package:
|
|
69
76
|
|
|
70
77
|
python -m venv C:\Users\<you>\.dsh\db-mcp\.venv
|
|
71
78
|
C:\Users\<you>\.dsh\db-mcp\.venv\Scripts\python.exe -m pip install -r server\requirements.txt
|
|
@@ -81,14 +88,14 @@ panel row then shows the server banner, the connected user and database, and how
|
|
|
81
88
|
long the login took, for example:
|
|
82
89
|
|
|
83
90
|
```
|
|
84
|
-
Oracle AI Database 26ai Free Release 23.26.3.0.0
|
|
85
|
-
PostgreSQL 15.19
|
|
86
|
-
MySQL 8.0.46
|
|
91
|
+
Oracle AI Database 26ai Free Release 23.26.3.0.0 · DEMO@FREEPDB1 · select 1 from dual ok
|
|
92
|
+
PostgreSQL 15.19 · demo@demo · select version() ok
|
|
93
|
+
MySQL 8.0.46 · demo@%@demo · select version() ok
|
|
87
94
|
```
|
|
88
95
|
|
|
89
96
|
A wrong password or an unreachable listener now surfaces the driver's own
|
|
90
97
|
message (`ORA-01017`, `DPY-6005`, `password authentication failed` — instead of
|
|
91
|
-
"port reachable". Testing an unsaved form works too: `test_connection` accepts
|
|
98
|
+
"port reachable"). Testing an unsaved form works too: `test_connection` accepts
|
|
92
99
|
`kind` + `config` + `password` without writing the registry first. When the
|
|
93
100
|
Python server cannot start, the probe degrades to a TCP check and says so.
|
|
94
101
|
|
|
@@ -149,9 +156,9 @@ file path), and a DBeaver entry that matches one of them comes back with
|
|
|
149
156
|
its checkbox and says *already added as …*; `POST /dbeaver/import` reports such
|
|
150
157
|
ids under `skipped` instead of creating a second copy.
|
|
151
158
|
|
|
152
|
-
The panel navigates with a tab bar (
|
|
153
|
-
|
|
154
|
-
"back to list" button.
|
|
159
|
+
The panel navigates with a tab bar (*Data source list / New data source /
|
|
160
|
+
Import from DBeaver / Runtime / Audit log*): the active step is plain text, every
|
|
161
|
+
earlier step is a link back, and no page needs a separate "back to list" button.
|
|
155
162
|
|
|
156
163
|
## Test databases in Docker
|
|
157
164
|
|
|
@@ -191,8 +198,14 @@ Then create a source in the panel with the matching connection string, e.g.
|
|
|
191
198
|
plugin_manager install_bundle target=<absolute path to this directory>
|
|
192
199
|
```
|
|
193
200
|
|
|
201
|
+
or, from npm:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
dsh plugin add dsh-db-mcp@latest
|
|
205
|
+
```
|
|
206
|
+
|
|
194
207
|
The bundle patch (`cordis.patch.yml`) inserts one Loader row with id `db-mcp`.
|
|
195
|
-
|
|
208
|
+
A profile that links this directory keeps file edits live; the Host module is
|
|
196
209
|
imported once per process, hence `POST /plugins/db-mcp/api/reload` (or an app
|
|
197
210
|
restart) after changing `src/host.js`.
|
|
198
211
|
|
|
@@ -200,18 +213,21 @@ restart) after changing `src/host.js`.
|
|
|
200
213
|
|
|
201
214
|
The page follows the same chrome as the other first-party panels:
|
|
202
215
|
|
|
203
|
-
- a masthead with **‹
|
|
204
|
-
the
|
|
205
|
-
|
|
206
|
-
|
|
216
|
+
- a masthead with **‹ Back to chat** (closes the panel through
|
|
217
|
+
`ctx.layout.selectPanel(null)`), the **Database** title, and the
|
|
218
|
+
connection/version pills;
|
|
219
|
+
- a tab row — **Data source list / New data source / Import from DBeaver /
|
|
220
|
+
Runtime / Audit log** — with the active tab underlined.
|
|
207
221
|
|
|
208
|
-
Picking
|
|
209
|
-
masthead and the tabs stay put. Editing reuses the
|
|
210
|
-
browser is a sub-view of
|
|
211
|
-
|
|
212
|
-
itself owns the
|
|
222
|
+
Picking **New data source** or **Import from DBeaver** swaps only the card below
|
|
223
|
+
the tabs — the masthead and the tabs stay put. Editing reuses the **New data
|
|
224
|
+
source** tab; the structure browser is a sub-view of **Data source list** with
|
|
225
|
+
its own `← Sources` link. There is no second action bar: **Refresh** and
|
|
226
|
+
**Restart MCP** sit in the source-list card header, and the list itself owns the
|
|
227
|
+
group tools.
|
|
213
228
|
|
|
214
229
|
## Schema overview
|
|
230
|
+
|
|
215
231
|
The **Structure** button on a source row opens a read-only browser: the tables
|
|
216
232
|
(or views) of that source on the left, and the columns of the selected table on
|
|
217
233
|
the right — name, type, nullability, default value and primary-key marker. It is
|
|
@@ -223,19 +239,20 @@ open reads the live catalog.
|
|
|
223
239
|
|
|
224
240
|
Every source carries an optional **group** label (`group` in the registry,
|
|
225
241
|
editable under *More settings*). Groups are user-owned: type a name in the
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
there (drag it onto
|
|
229
|
-
renames just that source — its id never changes. The list renders one
|
|
230
|
-
group, sorted by name with the ungrouped sources last; while no group
|
|
231
|
-
list keeps its flat look.
|
|
232
|
-
|
|
233
|
-
Each group header carries a select-all box, so sources inside a group take part
|
|
234
|
-
bulk work exactly like loose ones. Tick any row
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
`POST /datasources/bulk` with
|
|
238
|
-
|
|
242
|
+
**New group** box to create one, **double-click a group name to rename it** (or
|
|
243
|
+
the delete button to drop the group), and drag a source row onto a group to move
|
|
244
|
+
it there (drag it onto **Ungrouped** to clear the label). Double-clicking a
|
|
245
|
+
source name renames just that source — its id never changes. The list renders one
|
|
246
|
+
section per group, sorted by name with the ungrouped sources last; while no group
|
|
247
|
+
exists the list keeps its flat look.
|
|
248
|
+
|
|
249
|
+
Each group header carries a select-all box, so sources inside a group take part
|
|
250
|
+
in bulk work exactly like loose ones. Tick any row to open the bulk bar: test all
|
|
251
|
+
selected sources, flip them to read-only or writable, move them into a group, or
|
|
252
|
+
delete them (two-step confirmation). Bulk moves go through
|
|
253
|
+
`POST /datasources/bulk` with
|
|
254
|
+
`{ action: 'test' | 'readOnly' | 'group' | 'delete', ids: [...], … }`, while group
|
|
255
|
+
CRUD uses `GET /groups`, `POST /groups/save` and `POST /groups/delete`.
|
|
239
256
|
|
|
240
257
|
## Releasing
|
|
241
258
|
|
|
@@ -257,17 +274,22 @@ release, before a trusted publisher exists for the package.
|
|
|
257
274
|
## Development
|
|
258
275
|
|
|
259
276
|
```
|
|
260
|
-
node dev/
|
|
277
|
+
node dev/engines.test.mjs # 11 checks: engines.py commit/rollback + affectedRows, stub connection
|
|
278
|
+
node dev/selftest.mjs # mounts src/host.js on a throwaway port, 46 checks
|
|
261
279
|
node dev/shims.test.mjs # mounts index.js, checks routes, /reload, tool publication
|
|
262
280
|
node dev/parse.test.mjs # 24 checks: connection-string parser (DSN/JDBC/TNS/paths)
|
|
263
|
-
node dev/render.test.mjs #
|
|
281
|
+
node dev/render.test.mjs # 40 checks: components and the result renderer, stub React
|
|
264
282
|
node dev/i18n.test.mjs # 12 checks: en/zh parity, no dead keys, no stray copy
|
|
265
283
|
node dev/p5.test.mjs # 15 checks: settings, audit log, python environment
|
|
266
284
|
node dev/mcp.test.mjs # 19 checks: real Python server over stdio, real SQLite + Oracle logins
|
|
267
285
|
```
|
|
268
286
|
|
|
269
287
|
`npm test` runs everything except `mcp.test.mjs`, which needs the venv and the
|
|
270
|
-
Oracle container. `
|
|
288
|
+
Oracle container. `dev/engines.test.mjs` needs a Python interpreter (the plugin
|
|
289
|
+
venv, else `python` on PATH) but no database: it drives `server/engines.py`
|
|
290
|
+
against a stub DB-API connection, covering the commit/rollback and
|
|
291
|
+
`affectedRows` contract of `run_query`. `node dev/bump.mjs 0.6.6` updates both
|
|
292
|
+
version strings.
|
|
271
293
|
|
|
272
294
|
## Safety switches
|
|
273
295
|
|
|
@@ -278,6 +300,9 @@ Two independent gates decide whether a statement may write:
|
|
|
278
300
|
with `user_error` and a hint pointing at the switch.
|
|
279
301
|
2. **Per-source flag** — `readOnly` on the data source, on by default. A source
|
|
280
302
|
stays read-only even when the global switch is on until you clear its flag.
|
|
303
|
+
Click the *read-only* / *writable* pill on a source row to flip it in one
|
|
304
|
+
click; the pill turns amber while that source accepts writes, and the notice
|
|
305
|
+
reminds you when the global switch is still off.
|
|
281
306
|
|
|
282
307
|
The Python server reads the global flag from `DB_MCP_ALLOW_WRITE` at start-up; the
|
|
283
308
|
host restarts it whenever the switch flips. Read-only is also enforced by the
|
|
@@ -285,13 +310,30 @@ driver itself where the engine allows it (`mode=ro` for SQLite,
|
|
|
285
310
|
`default_transaction_read_only` for PostgreSQL, `SET SESSION TRANSACTION READ ONLY`
|
|
286
311
|
for MySQL).
|
|
287
312
|
|
|
313
|
+
### What the plugin runs, and with what
|
|
314
|
+
|
|
315
|
+
- The Python server is started with a fixed interpreter path, a fixed script and a
|
|
316
|
+
fixed argument list — never through a shell, so no string is ever re-parsed by
|
|
317
|
+
`cmd.exe` or `/bin/sh`.
|
|
318
|
+
- The child does **not** inherit the host environment. It gets a short allowlist
|
|
319
|
+
(`PATH`, `TEMP`, `HOME`, `PYTHON*`, and similar) plus the three `DB_MCP_*` flags
|
|
320
|
+
the plugin sets. Tokens or cloud credentials in the DSH process are not passed
|
|
321
|
+
down.
|
|
322
|
+
- Connections to PostgreSQL, MySQL and Oracle are opened by that child; only SQLite
|
|
323
|
+
is read in-process. The raw TCP probe is a fallback used when the child cannot
|
|
324
|
+
start, and it never sends credentials.
|
|
325
|
+
- Passwords live in `secrets.json` (mode `0600`) and are never returned by any HTTP
|
|
326
|
+
route; the panel only learns `hasSecret`.
|
|
327
|
+
|
|
288
328
|
## Audit log
|
|
289
329
|
|
|
290
330
|
Every MCP tool call appends one JSON line to `<runtime>/logs/audit.jsonl` (0600):
|
|
291
331
|
time, tool, source, duration, row count, the SQL truncated to 2000 characters and
|
|
292
332
|
the error when it failed. Result rows are never logged. The file rotates to
|
|
293
333
|
`audit.1.jsonl` at 512 KiB and logging can be turned off with `auditLog: false` in
|
|
294
|
-
`settings.json`. The panel shows the last dozen entries
|
|
334
|
+
`settings.json`. The panel shows the last dozen entries on **your own clock** —
|
|
335
|
+
the file keeps UTC so it stays comparable across machines, the card converts each
|
|
336
|
+
stamp to the viewer's time zone and prints the offset it used.
|
|
295
337
|
|
|
296
338
|
`dev/shims.test.mjs` also asserts that `package.json` has no UTF-8 BOM: the host
|
|
297
339
|
parses it with `JSON.parse`, which rejects a BOM, and the plugin then shows up as
|
|
@@ -309,7 +351,7 @@ first.
|
|
|
309
351
|
| P4 | PostgreSQL / MySQL / Oracle dialects (done) |
|
|
310
352
|
| P5 | diagnostics, one-click venv, reload, audit log, global write switch (done) |
|
|
311
353
|
| P6 | i18n polish and docs (done) |
|
|
312
|
-
| P7 | read-only schema overview (done) |
|
|
354
|
+
| P7 | read-only schema overview, groups, bulk actions (done) |
|
|
313
355
|
|
|
314
356
|
## License
|
|
315
357
|
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# dsh-db-mcp
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
[English](README.md) | 中文
|
|
6
|
+
|
|
7
|
+
一个 DSH 插件:在侧边栏加一个 **数据库** 入口,配一个数据源管理面板,背后是 Python 写的 MCP 服务——让智能体通过工具查询这些数据库,而不是在页面里执行 SQL。
|
|
8
|
+
|
|
9
|
+
- 一个面板管四种引擎:SQLite、PostgreSQL、MySQL、Oracle。
|
|
10
|
+
- 一条连接串填满整张表单;口令只存在本机,永不回传给页面。
|
|
11
|
+
- 可以把 DBeaver 里已有的连接(连同它加密保存的口令)直接导入。
|
|
12
|
+
- 默认只读,两道互相独立的写闸门,加一份审计日志。
|
|
13
|
+
- 给智能体的是 8 个 MCP 工具,界面里没有 SQL 编辑器。
|
|
14
|
+
|
|
15
|
+
需求与阶段计划见 [`docs/requirements.md`](docs/requirements.md)。
|
|
16
|
+
|
|
17
|
+
## 现在有什么(P7)
|
|
18
|
+
|
|
19
|
+
- **Host 半边**(`index.js` → `src/host.js`):一个 Cordis 插件,通过 DSH 的 web server 提供 `/plugins/db-mcp/api`。
|
|
20
|
+
|
|
21
|
+
| 路由 | 用途 |
|
|
22
|
+
| ---- | ---- |
|
|
23
|
+
| `GET /health` | 插件版本、Host 信息、Python 探测、运行时路径、注册表状态、MCP 状态、设置 |
|
|
24
|
+
| `GET /datasources` | 注册表,口令字段已剔除 |
|
|
25
|
+
| `GET /schema?datasource=…&table=…[&schema=…]` | 只读结构:表清单,或某张表的列 |
|
|
26
|
+
| `GET /groups` | 已声明的分组,加上所有正在使用的分组名 |
|
|
27
|
+
| `GET /dbeaver/scan` | DBeaver 里已配置的连接 |
|
|
28
|
+
| `GET /mcp/status` | Python 服务所处阶段、工具清单、最后的错误、stderr 尾部 |
|
|
29
|
+
| `GET /settings` · `POST /settings` | 全局写开关与审计开关 |
|
|
30
|
+
| `GET /audit?limit=N` | 工具调用审计日志的末尾若干条 |
|
|
31
|
+
| `GET /python/env` | 解释器、venv 路径、已安装的驱动版本 |
|
|
32
|
+
| `POST /python/setup` | 建 venv 并 `pip install -r server/requirements.txt` |
|
|
33
|
+
| `POST /datasources/save` | 新建或更新数据源(`id` 不可改) |
|
|
34
|
+
| `POST /datasources/delete` | 删除数据源及其口令 |
|
|
35
|
+
| `POST /datasources/test` | 真实登录探测:SQLite 走 `node:sqlite`,其余走 Python 驱动 |
|
|
36
|
+
| `POST /datasources/bulk` | 对一批数据源做 测试 / 只读 / 分组 / 删除 |
|
|
37
|
+
| `POST /groups/save` · `POST /groups/delete` | 新建、重命名、删除分组 |
|
|
38
|
+
| `POST /dbeaver/import` | 把选中的 DBeaver 连接复制进注册表 |
|
|
39
|
+
| `POST /mcp/start` · `POST /mcp/restart` | 启动或重启 Python MCP 服务 |
|
|
40
|
+
| `POST /mcp/call` | 开发辅助:经面板调用某个 MCP 工具 |
|
|
41
|
+
| `POST /reload` | 开发辅助:不重启 DSH 重新导入 `src/host.js` |
|
|
42
|
+
|
|
43
|
+
- **Client 半边**(`client.js`):侧边栏入口(`db-mcp`,order 25)加主面板——数据源列表、极简的新建/编辑表单(一条连接串)、测试/删除/设为默认、只读的「结构」浏览、DBeaver 导入流程、「重启 MCP」按钮、运行时诊断卡(工具数、全局写开关、venv 与依赖版本及一键安装)、以及「审计日志」卡。文案经客户端 `locale` 服务做中英本地化。
|
|
44
|
+
- **Python 服务**(`server/`):`main.py`(FastMCP,8 个工具)、`engines.py`(按引擎连接/内省/查询)、`store.py`(读注册表)。插件用 stdio 拉起它,把它的工具以 `mcp__db__*` 发布到 Host 的 `tools` 服务上——**不**经过 `dsh-mcp-client` 的 Loader 行。
|
|
45
|
+
|
|
46
|
+
### 智能体拿到的工具
|
|
47
|
+
|
|
48
|
+
| 工具 | 用途 |
|
|
49
|
+
| ---- | ---- |
|
|
50
|
+
| `mcp__db__list_datasources` | 已登记的数据源(id、类型、目标、是否只读) |
|
|
51
|
+
| `mcp__db__list_tables` | 某个数据源的表/视图 |
|
|
52
|
+
| `mcp__db__describe_table` | 列、类型、是否可空、主键 |
|
|
53
|
+
| `mcp__db__sample_rows` | 某张表的少量样本 |
|
|
54
|
+
| `mcp__db__run_query` | 执行一条语句;只读源拒绝写 |
|
|
55
|
+
| `mcp__db__explain_query` | 查询计划 |
|
|
56
|
+
| `mcp__db__get_ddl` | `CREATE TABLE` 语句(SQLite 取原文,其余按目录重建) |
|
|
57
|
+
| `mcp__db__test_connection` | 真实登录、执行 `select 1`、回报服务器/用户/模式 |
|
|
58
|
+
|
|
59
|
+
`datasource` 处处可选:不填就用默认数据源。
|
|
60
|
+
|
|
61
|
+
### Python 环境
|
|
62
|
+
|
|
63
|
+
解释器探测顺序:插件配置 `pythonPath` → `<DSH_HOME>/db-mcp/.venv/Scripts/python.exe` → POSIX 上的同一个 venv → `python` → `python3`。依赖装在运行时 venv 里,不随包分发:
|
|
64
|
+
|
|
65
|
+
python -m venv C:\Users\<you>\.dsh\db-mcp\.venv
|
|
66
|
+
C:\Users\<you>\.dsh\db-mcp\.venv\Scripts\python.exe -m pip install -r server\requirements.txt
|
|
67
|
+
|
|
68
|
+
`mcp` 必须固定在 1.x(2.x 把 `FastMCP` 改名成了 `MCPServer`)。
|
|
69
|
+
|
|
70
|
+
## 「测试」按钮是真的登录吗?
|
|
71
|
+
|
|
72
|
+
是。PostgreSQL、MySQL、Oracle 的 **测试** 走 `mcp__db__test_connection`,经 Python MCP 服务执行一次真实认证登录加 `select 1`(Oracle 用 `select 1 from dual`)。列表行随后显示服务器横幅、登录用户与库名、以及登录耗时,例如:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Oracle AI Database 26ai Free Release 23.26.3.0.0 · DEMO@FREEPDB1 · select 1 from dual ok
|
|
76
|
+
PostgreSQL 15.19 · demo@demo · select version() ok
|
|
77
|
+
MySQL 8.0.46 · demo@%@demo · select version() ok
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
口令错误或端口不通时,显示的是驱动自己的信息(`ORA-01017`、`DPY-6005`、`password authentication failed`),而不是笼统的「端口可达」。未保存的表单也能测:`test_connection` 接受 `kind` + `config` + `password`,不需要先写注册表。Python 服务起不来时,探测退回 TCP 检查,并明确说明这一点。
|
|
81
|
+
|
|
82
|
+
## 一条连接串填完整张表单
|
|
83
|
+
|
|
84
|
+
新建/编辑表单刻意做得和 DBeaver 的连接对话框一样小:**连接串**、**用户**、**口令**(带「保存密码」勾选),就这些。粘贴 JDBC URL、DSN、Oracle TNS 描述符或 `.db` 路径,类型、主机、端口、库名/服务名、名称与标识符都会自动带出;推导出来的值默认收在「更多设置」里,与模式、SID、SSL 模式、字符集、只读开关放在一起。
|
|
85
|
+
|
|
86
|
+
支持的写法(完整矩阵见 `dev/parse.test.mjs`):
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
postgresql://alice:secret@db.example.com:6543/orders?sslmode=require
|
|
90
|
+
jdbc:postgresql://localhost:5432/appdb
|
|
91
|
+
mysql://root:pw@127.0.0.1:3306/shop?charset=utf8mb4
|
|
92
|
+
jdbc:mariadb://10.0.0.5:3307/legacy
|
|
93
|
+
oracle://system:oracle@localhost:1521/XEPDB1
|
|
94
|
+
jdbc:oracle:thin:@//ora:1521/ORCLPDB1 # 服务名
|
|
95
|
+
jdbc:oracle:thin:@ora:1521:ORCL # SID
|
|
96
|
+
(DESCRIPTION=(ADDRESS=(HOST=tns-host)(PORT=1522))(CONNECT_DATA=(SERVICE_NAME=svc1)))
|
|
97
|
+
jdbc:sqlite:C:\data\app.db
|
|
98
|
+
sqlite:///C:/data/chinook.db
|
|
99
|
+
C:\data\app.db / /var/lib/app.sqlite3
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
连接串里的口令只发送一次给 `POST /datasources/save`,之后只存在 `secrets.json` 里,永不回显。
|
|
103
|
+
|
|
104
|
+
写操作一律用 `POST`(同源校验比较 `Origin` 与 `Host`)。口令不会进 `datasources.json`,只存在 `secrets.json`,页面拿到的唯一信号是 `hasSecret`。
|
|
105
|
+
|
|
106
|
+
## 从 DBeaver 导入
|
|
107
|
+
|
|
108
|
+
`GET /dbeaver/scan` 会在 `%APPDATA%\DBeaverData\workspace*\General\.dbeaver\` 下找 `data-sources.json`(同时兼容 `General` 布局与 Linux/macOS 位置)。同级存在 `credentials-config.json` 时,用 DBeaver 内置的密钥解密(AES-128-CBC、零 IV、前 16 字节前缀,参考 `Tatsh/dbeaver-creds`)。从未保存过口令的工作区,导入出来的连接口令为空,界面显示「无密码」。
|
|
109
|
+
|
|
110
|
+
通过 DBeaver 驱动对话框建的连接,往往只有一条 JDBC URL(`configurationType: URL`),既没有 `database` 字段也没有 `user` 字段。导入器因此会自己解析 `jdbc:mysql://host:port/db`、`jdbc:postgresql://…`、`jdbc:oracle:thin:@…`,补上表单字段缺的部分,并在解密凭据里存了用户名时从那里取。导入的数据源按 DBeaver 连接名命名(再 slug 成 id),保持 `readOnly: true`,已存的口令会复制进 `secrets.json`;仍然缺的东西写进 `warnings`。
|
|
111
|
+
|
|
112
|
+
已经配置过的连接不会被导入两次:每个数据源用 类型 + 主机 + 端口 + 库名/服务名/SID + 用户 做身份(SQLite 用文件路径),命中的 DBeaver 条目会带上 `alreadyImported: true`、`duplicateId`、`duplicateName` 返回。面板把它的复选框禁用并标注「已作为 … 添加」,`POST /dbeaver/import` 则把这类 id 放进 `skipped`,不会建出第二份。
|
|
113
|
+
|
|
114
|
+
面板用标签栏导航(*数据源列表 / 新建数据源 / 从 DBeaver 导入 / 运行时诊断 / 审计日志*):当前步骤是纯文本、可回退的步骤是链接,任何页面都不需要额外的「返回列表」按钮。
|
|
115
|
+
|
|
116
|
+
## 用 Docker 起测试库
|
|
117
|
+
|
|
118
|
+
宿主机什么都不用装,下面每个引擎都是用完即弃的容器,演示账号 `demo` / `dbmcp-demo`。
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
# PostgreSQL —— 镜像最小、启动最快,推荐第一个试
|
|
122
|
+
docker run -d --name dbmcp-pg -p 5432:5432 \
|
|
123
|
+
-e POSTGRES_USER=demo -e POSTGRES_PASSWORD=dbmcp-demo -e POSTGRES_DB=demo \
|
|
124
|
+
postgres:15-alpine
|
|
125
|
+
|
|
126
|
+
# MySQL
|
|
127
|
+
docker run -d --name dbmcp-mysql -p 3306:3306 \
|
|
128
|
+
-e MYSQL_USER=demo -e MYSQL_PASSWORD=dbmcp-demo -e MYSQL_DATABASE=demo \
|
|
129
|
+
-e MYSQL_ROOT_PASSWORD=dbmcp-root mysql:8.0-debian
|
|
130
|
+
|
|
131
|
+
# Oracle Free —— 镜像好几个 GB,要一两分钟才就绪
|
|
132
|
+
docker run -d --name dbmcp-oracle -p 1521:1521 -e ORACLE_PASSWORD=dbmcp-demo \
|
|
133
|
+
gvenzl/oracle-free:23-slim
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Windows 上 1521 常常落在 Hyper-V 的保留端口段里,`docker run` 会报「尝试访问套接字的方式被其访问权限禁止」。用 `netsh interface ipv4 show excludedportrange protocol=tcp` 查一下,换一个宿主端口发布即可,例如 `-p 11521:1521`,数据源里填 11521。
|
|
137
|
+
|
|
138
|
+
然后在面板里用对应的连接串建数据源,例如
|
|
139
|
+
`postgresql://demo:dbmcp-demo@127.0.0.1:5432/demo`、
|
|
140
|
+
`mysql://demo:dbmcp-demo@127.0.0.1:3306/demo` 或
|
|
141
|
+
`oracle://system:dbmcp-demo@127.0.0.1:1521/FREEPDB1`。
|
|
142
|
+
|
|
143
|
+
## 装进一个 profile
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
plugin_manager install_bundle target=<本目录的绝对路径>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
或者从 npm 装:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
dsh plugin add dsh-db-mcp@latest
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
bundle patch(`cordis.patch.yml`)只插入一行 Loader 配置,id 为 `db-mcp`。以链接方式装入的 profile 改文件即生效;Host 模块每个进程只导入一次,所以改完 `src/host.js` 要 `POST /plugins/db-mcp/api/reload`(或重启应用)。
|
|
156
|
+
|
|
157
|
+
## 面板布局
|
|
158
|
+
|
|
159
|
+
页面与其它官方面板保持一致的外壳:
|
|
160
|
+
|
|
161
|
+
- 页头:**‹ 返回会话**(经 `ctx.layout.selectPanel(null)` 关闭面板)、**数据库** 标题,以及连接状态与版本药丸;
|
|
162
|
+
- 标签栏 —— **数据源列表 / 新建数据源 / 从 DBeaver 导入 / 运行时诊断 / 审计日志**,当前标签带下划线。
|
|
163
|
+
|
|
164
|
+
点「新建数据源」或「从 DBeaver 导入」只切换标签下方那张卡片,页头与标签栏不动。编辑复用「新建数据源」标签;结构浏览是「数据源列表」下的子视图,带自己的 `← 数据源` 链接。没有第二排操作栏:**刷新** 与 **重启 MCP** 在数据源列表卡片的头部,分组相关的操作归列表自己管。
|
|
165
|
+
|
|
166
|
+
## 结构概览
|
|
167
|
+
|
|
168
|
+
数据源行上的「结构」按钮打开只读浏览器:左边是该源的表(或视图),右边是选中表的列——名称、类型、可空、默认值、主键标记。数据由 `GET /schema` 提供,它只把 `list_tables` / `describe_table` 转发给 MCP 服务,页面本身永远执行不了 SQL。不缓存:每次打开都读实时目录。
|
|
169
|
+
|
|
170
|
+
## 分组与批量操作
|
|
171
|
+
|
|
172
|
+
每个数据源带一个可选的**分组**标签(注册表里的 `group`,可在「更多设置」里改)。分组归用户所有:在「新建分组」框里输入名字即可创建,**双击组名改名**(或按删除按钮删组),把数据源行拖到某个组上即可移过去(拖到「未分组」上则清除标签)。双击数据源名只改这一条的名字,id 永不改变。列表按组渲染成若干区块,组名排序、未分组的排最后;没有分组时列表保持原来的平铺样式。
|
|
173
|
+
|
|
174
|
+
每个组头都有全选复选框,所以组内的数据源和散在外面的完全一样,都能参与批量操作。勾选任意行即出现批量操作条:批量测试、设为只读或允许写、移动到某个分组、或删除(两步确认)。批量移动走 `POST /datasources/bulk`,body 形如 `{ action: 'test' | 'readOnly' | 'group' | 'delete', ids: [...], … }`;分组的增删改走 `GET /groups`、`POST /groups/save`、`POST /groups/delete`。
|
|
175
|
+
|
|
176
|
+
## 发布
|
|
177
|
+
|
|
178
|
+
包由 GitHub Actions 通过 [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) 发布:`.github/workflows/publish.yml` 用 workflow 的 OIDC token 换短期发布凭据,因此仓库里没有任何 `NPM_TOKEN` 密钥。
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
node dev/bump.mjs 0.6.5 # 同时改 package.json 与 src/host.js
|
|
182
|
+
git commit -am "chore: release 0.6.5"
|
|
183
|
+
git tag v0.6.5 && git push origin main --tags
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
workflow 会拒绝 tag 与 `package.json` 版本不一致的情况,先跑可移植测试(`npm run test:ci`),再带 `--provenance` 发布,最后轮询 registry 确认版本已出现。首次发布(包还没有 trusted publisher 时)仍可本地 `npm publish`。
|
|
187
|
+
|
|
188
|
+
## 开发
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
node dev/engines.test.mjs # 11 项:engines.py 的 commit/rollback 与 affectedRows,用假连接
|
|
192
|
+
node dev/selftest.mjs # 在临时端口挂载 src/host.js,46 项断言
|
|
193
|
+
node dev/shims.test.mjs # 挂载 index.js,检查路由、/reload、工具发布
|
|
194
|
+
node dev/parse.test.mjs # 24 项:连接串解析(DSN/JDBC/TNS/路径)
|
|
195
|
+
node dev/render.test.mjs # 40 项:用 stub React 渲染组件与结果渲染器
|
|
196
|
+
node dev/i18n.test.mjs # 12 项:中英键集合一致、无死键、无漏译
|
|
197
|
+
node dev/p5.test.mjs # 15 项:设置、审计日志、Python 环境
|
|
198
|
+
node dev/mcp.test.mjs # 19 项:真实 Python 服务走 stdio,真实 SQLite 与 Oracle 登录
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`npm test` 跑除 `mcp.test.mjs` 之外的全部(后者需要 venv 与 Oracle 容器)。`dev/engines.test.mjs` 只需要一个 Python 解释器(插件 venv,否则 PATH 里的 `python`),不连数据库:它用假 DB-API 连接驱动 `server/engines.py`,守住 `run_query` 的 commit/rollback 与 `affectedRows` 契约。`node dev/bump.mjs 0.6.6` 会同步两处版本号。
|
|
202
|
+
|
|
203
|
+
## 安全闸门
|
|
204
|
+
|
|
205
|
+
是否允许写入由两道互相独立的闸门决定:
|
|
206
|
+
|
|
207
|
+
1. **全局开关** —— `<运行时目录>/settings.json` 的 `allowWrite`,在诊断卡上切换。默认关闭;关闭期间所有写语句都以 `user_error` 拒绝,并给出指向该开关的提示。
|
|
208
|
+
2. **单源标记** —— 数据源的 `readOnly`,默认开启。即使全局开关打开,只要不清掉这条标记,该源仍然只读。点数据源行上的「只读 / 可写」药丸即可一键切换:该源可写时药丸变成琥珀色,若全局开关仍关着,提示条会提醒你去打开它。
|
|
209
|
+
|
|
210
|
+
Python 服务启动时从环境变量 `DB_MCP_ALLOW_WRITE` 读取全局标记;开关一变,Host 就会重启它。只要引擎支持,只读还会在驱动层再兜一次(SQLite 的 `mode=ro`、PostgreSQL 的 `default_transaction_read_only`、MySQL 的 `SET SESSION TRANSACTION READ ONLY`)。
|
|
211
|
+
|
|
212
|
+
### 插件会启动什么,以及带着什么
|
|
213
|
+
|
|
214
|
+
- Python 服务用固定的解释器路径、固定脚本、固定参数启动,**从不经过 shell**,所以任何字符串都不会被 `cmd.exe` 或 `/bin/sh` 再解析一次。
|
|
215
|
+
- 子进程**不继承** Host 的环境变量。它只拿到一张很短的允许清单(`PATH`、`TEMP`、`HOME`、`PYTHON*` 之类)再加上插件自己设置的 `DB_MCP_*`。DSH 进程里的 token、云凭据不会被带下去。
|
|
216
|
+
- PostgreSQL / MySQL / Oracle 都由这个子进程连接,只有 SQLite 是在进程内读的。裸 TCP 探测只是子进程起不来时的兜底,且不发任何凭据。
|
|
217
|
+
- 口令存在 `secrets.json`(`0600`),任何 HTTP 接口都不会返回它;面板只知道 `hasSecret`。
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
## 审计日志
|
|
221
|
+
|
|
222
|
+
每次 MCP 工具调用都会向 `<运行时目录>/logs/audit.jsonl`(0600)追加一行 JSON:时间、工具、数据源、耗时、行数、截断到 2000 字符的 SQL,以及失败时的错误。结果行永不记录。文件超过 512 KiB 轮转为 `audit.1.jsonl`,也可以在 `settings.json` 里用 `auditLog: false` 关掉。面板显示最近十几条,时间按**本机时区**换算——文件里存的是 UTC,这样换机器也仍然可比;卡片会把每条时间戳转成浏览者的时区,并标出所用偏移。
|
|
223
|
+
|
|
224
|
+
`dev/shims.test.mjs` 还断言 `package.json` 不带 UTF-8 BOM:Host 用 `JSON.parse` 读它,而 `JSON.parse` 会拒绝 BOM,插件随之显示为 `availability: missing`,并不加载。会「添加 BOM」的编辑器会静默弄坏 bundle——如果重启后侧边栏入口消失,先查这一项。
|
|
225
|
+
|
|
226
|
+
## 阶段表
|
|
227
|
+
|
|
228
|
+
| 阶段 | 内容 |
|
|
229
|
+
| ---- | ---- |
|
|
230
|
+
| P1 | 可安装的骨架:侧边栏入口、空面板、Host 健康接口(已完成) |
|
|
231
|
+
| P2 | 数据源 CRUD、连接测试、DBeaver 导入(已完成) |
|
|
232
|
+
| P3 | Python MCP 服务,工具以 `mcp__db__*` 发布(已完成) |
|
|
233
|
+
| P4 | PostgreSQL / MySQL / Oracle 方言(已完成) |
|
|
234
|
+
| P5 | 诊断、一键 venv、热重载、审计日志、全局写开关(已完成) |
|
|
235
|
+
| P6 | 多语言打磨与文档(已完成) |
|
|
236
|
+
| P7 | 只读结构概览、分组、批量操作(已完成) |
|
|
237
|
+
|
|
238
|
+
## 许可
|
|
239
|
+
|
|
240
|
+
Apache License 2.0 —— 见 [LICENSE](LICENSE)。
|
|
241
|
+
|
|
242
|
+
## 运行时目录
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
$DSH_HOME/db-mcp/
|
|
246
|
+
datasources.json # 数据源档案,不含口令,0600
|
|
247
|
+
secrets.json # 各源口令,0600,仅 Host 可读
|
|
248
|
+
settings.json # 全局写开关 + 审计开关,0600
|
|
249
|
+
logs/mcp.log # 已掩码的日志
|
|
250
|
+
logs/audit.jsonl # 每次 MCP 工具调用一行,超过 512 KiB 轮转
|
|
251
|
+
.venv/ # 插件内 venv:mcp、psycopg、PyMySQL、oracledb
|
|
252
|
+
```
|
package/client.js
CHANGED
|
@@ -167,6 +167,12 @@ window.__ModuleLoader__.load({
|
|
|
167
167
|
'audit.err': 'failed',
|
|
168
168
|
'audit.hint': 'One line per MCP tool call: tool, source, duration and outcome. Query results are never logged.',
|
|
169
169
|
'audit.rows': '{n} row(s)',
|
|
170
|
+
'audit.tz': 'Times follow your computer clock ({zone}); the log file keeps UTC.',
|
|
171
|
+
'action.lockSource': 'Click to make this source read-only',
|
|
172
|
+
'action.unlockSource': 'Click to allow writes on this source',
|
|
173
|
+
'notice.nowReadOnly': '{name} is read-only now.',
|
|
174
|
+
'notice.nowWritable': '{name} accepts writes now.',
|
|
175
|
+
'notice.nowWritableLocked': '{name} accepts writes now, but the global write switch is still off — turn it on under Runtime.',
|
|
170
176
|
'action.schema': 'Structure',
|
|
171
177
|
'action.backToSources': 'Data sources',
|
|
172
178
|
'tab.list': 'Data source list',
|
|
@@ -339,6 +345,12 @@ window.__ModuleLoader__.load({
|
|
|
339
345
|
'audit.err': '失败',
|
|
340
346
|
'audit.hint': '每次 MCP 工具调用记一行:工具、数据源、耗时与结果。查询结果不会被记录。',
|
|
341
347
|
'audit.rows': '{n} 行',
|
|
348
|
+
'audit.tz': '时间按本机时区显示({zone}),日志文件里存的是 UTC。',
|
|
349
|
+
'action.lockSource': '点击改为只读',
|
|
350
|
+
'action.unlockSource': '点击允许写',
|
|
351
|
+
'notice.nowReadOnly': '{name} 已改为只读。',
|
|
352
|
+
'notice.nowWritable': '{name} 现在允许写。',
|
|
353
|
+
'notice.nowWritableLocked': '{name} 现在允许写,但全局写开关还关着——到「运行时诊断」里打开它。',
|
|
342
354
|
'action.schema': '结构',
|
|
343
355
|
'action.backToSources': '数据源',
|
|
344
356
|
'tab.list': '数据源列表',
|
|
@@ -581,6 +593,20 @@ window.__ModuleLoader__.load({
|
|
|
581
593
|
font-size: 12px;
|
|
582
594
|
color: var(--dsw-alias-label-secondary);
|
|
583
595
|
}
|
|
596
|
+
button.dbmcp-pill {
|
|
597
|
+
font-family: inherit;
|
|
598
|
+
background: transparent;
|
|
599
|
+
cursor: pointer;
|
|
600
|
+
transition: color 120ms ease, border-color 120ms ease;
|
|
601
|
+
}
|
|
602
|
+
button.dbmcp-pill:hover {
|
|
603
|
+
color: var(--dsw-alias-brand-primary);
|
|
604
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
605
|
+
}
|
|
606
|
+
button.dbmcp-pill.is-open {
|
|
607
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
608
|
+
border-color: var(--dsw-alias-state-warn-primary);
|
|
609
|
+
}
|
|
584
610
|
.dbmcp-grid {
|
|
585
611
|
display: grid;
|
|
586
612
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
@@ -1440,6 +1466,38 @@ window.__ModuleLoader__.load({
|
|
|
1440
1466
|
return `${hours}h ${minutes % 60}m`
|
|
1441
1467
|
}
|
|
1442
1468
|
|
|
1469
|
+
/** UTC offset of the viewer's clock, e.g. "UTC+08:00". */
|
|
1470
|
+
function zoneLabel() {
|
|
1471
|
+
const offset = -new Date().getTimezoneOffset()
|
|
1472
|
+
const sign = offset >= 0 ? '+' : '-'
|
|
1473
|
+
const abs = Math.abs(offset)
|
|
1474
|
+
const pad = (part) => String(part).padStart(2, '0')
|
|
1475
|
+
return `UTC${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* The audit log keeps UTC ISO stamps so the file stays comparable across
|
|
1480
|
+
* machines; the panel shows them on the viewer's clock instead.
|
|
1481
|
+
*/
|
|
1482
|
+
function localClock(value) {
|
|
1483
|
+
const at = Date.parse(String(value || ''))
|
|
1484
|
+
if (!Number.isFinite(at)) return String(value || '').slice(11, 19)
|
|
1485
|
+
const date = new Date(at)
|
|
1486
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
1487
|
+
.map((part) => String(part).padStart(2, '0'))
|
|
1488
|
+
.join(':')
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/** Full local timestamp, used as the hover title of an audit row. */
|
|
1492
|
+
function localStamp(value) {
|
|
1493
|
+
const at = Date.parse(String(value || ''))
|
|
1494
|
+
if (!Number.isFinite(at)) return String(value || '')
|
|
1495
|
+
const date = new Date(at)
|
|
1496
|
+
const pad = (part) => String(part).padStart(2, '0')
|
|
1497
|
+
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
1498
|
+
return `${day} ${localClock(value)} ${zoneLabel()}`
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1443
1501
|
function pythonLabel(t, python) {
|
|
1444
1502
|
if (!python) return '-'
|
|
1445
1503
|
if (python.ok) return `${python.command} ${python.version}`
|
|
@@ -2248,6 +2306,29 @@ window.__ModuleLoader__.load({
|
|
|
2248
2306
|
}
|
|
2249
2307
|
}
|
|
2250
2308
|
|
|
2309
|
+
/** Flip one source between read-only and writable by clicking its pill. */
|
|
2310
|
+
const toggleReadOnly = async (source) => {
|
|
2311
|
+
const wasWritable = source.readOnly === false
|
|
2312
|
+
const nowWritable = !wasWritable
|
|
2313
|
+
const name = source.name || source.id
|
|
2314
|
+
setBusy(true)
|
|
2315
|
+
try {
|
|
2316
|
+
await apiPost('/datasources/bulk', { action: 'readOnly', ids: [source.id], readOnly: !nowWritable })
|
|
2317
|
+
const globalOpen = Boolean(settings && settings.allowWrite)
|
|
2318
|
+
const message = !nowWritable
|
|
2319
|
+
? t('notice.nowReadOnly').replace('{name}', name)
|
|
2320
|
+
: globalOpen
|
|
2321
|
+
? t('notice.nowWritable').replace('{name}', name)
|
|
2322
|
+
: t('notice.nowWritableLocked').replace('{name}', name)
|
|
2323
|
+
setNotice({ message, isError: false })
|
|
2324
|
+
load()
|
|
2325
|
+
} catch (error) {
|
|
2326
|
+
setNotice({ message: String((error && error.message) || error), isError: true })
|
|
2327
|
+
} finally {
|
|
2328
|
+
setBusy(false)
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2251
2332
|
const bulkDelete = async () => {
|
|
2252
2333
|
if (selected.length === 0) return
|
|
2253
2334
|
if (!pendingBulkDelete) {
|
|
@@ -2501,7 +2582,20 @@ window.__ModuleLoader__.load({
|
|
|
2501
2582
|
),
|
|
2502
2583
|
h('span', { className: 'dbmcp-pill' }, source.kind),
|
|
2503
2584
|
source.isDefault ? h('span', { className: 'dbmcp-pill' }, t('sources.default')) : null,
|
|
2504
|
-
h(
|
|
2585
|
+
h(
|
|
2586
|
+
'button',
|
|
2587
|
+
{
|
|
2588
|
+
type: 'button',
|
|
2589
|
+
className: 'dbmcp-pill is-toggle ' + (source.readOnly === false ? 'is-open' : 'is-locked'),
|
|
2590
|
+
title: source.readOnly === false ? t('action.lockSource') : t('action.unlockSource'),
|
|
2591
|
+
'aria-pressed': source.readOnly === false,
|
|
2592
|
+
onClick: (event) => {
|
|
2593
|
+
event.stopPropagation()
|
|
2594
|
+
void toggleReadOnly(source)
|
|
2595
|
+
},
|
|
2596
|
+
},
|
|
2597
|
+
source.readOnly === false ? t('sources.readwrite') : t('sources.readonly'),
|
|
2598
|
+
),
|
|
2505
2599
|
),
|
|
2506
2600
|
h('div', { className: 'dbmcp-item-target' }, [source.target, source.id].filter(Boolean).join(' · ')),
|
|
2507
2601
|
h(
|
|
@@ -2933,7 +3027,11 @@ window.__ModuleLoader__.load({
|
|
|
2933
3027
|
h(
|
|
2934
3028
|
'div',
|
|
2935
3029
|
{ className: 'dbmcp-audit', key: `${entry.at || 'entry'}-${index}` },
|
|
2936
|
-
h(
|
|
3030
|
+
h(
|
|
3031
|
+
'span',
|
|
3032
|
+
{ className: 'dbmcp-audit-time', title: localStamp(entry.at) },
|
|
3033
|
+
localClock(entry.at),
|
|
3034
|
+
),
|
|
2937
3035
|
h('span', { className: 'dbmcp-audit-tool' }, entry.tool || '?'),
|
|
2938
3036
|
h('span', { className: 'dbmcp-pill' }, entry.datasource || t('sources.default')),
|
|
2939
3037
|
h('span', { className: 'dbmcp-hint' }, `${entry.ms ?? 0}ms`),
|
|
@@ -2950,6 +3048,7 @@ window.__ModuleLoader__.load({
|
|
|
2950
3048
|
),
|
|
2951
3049
|
),
|
|
2952
3050
|
h('p', { className: 'dbmcp-hint' }, t('audit.hint')),
|
|
3051
|
+
h('p', { className: 'dbmcp-hint' }, t('audit.tz').replace('{zone}', zoneLabel())),
|
|
2953
3052
|
audit.file ? h('p', { className: 'dbmcp-hint' }, `${t('audit.file')}: ${audit.file}`) : null,
|
|
2954
3053
|
),
|
|
2955
3054
|
),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "DSH database plugin: a data-source management panel plus a Python MCP server that exposes those sources to the agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -41,11 +41,13 @@
|
|
|
41
41
|
"server/requirements.txt",
|
|
42
42
|
"docs/*.md",
|
|
43
43
|
"LICENSE",
|
|
44
|
-
"README.md"
|
|
44
|
+
"README.md",
|
|
45
|
+
"README.zh.md"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
|
-
"test": "node dev/selftest.mjs && node dev/shims.test.mjs && node dev/parse.test.mjs && node dev/render.test.mjs && node dev/i18n.test.mjs && node dev/p5.test.mjs",
|
|
48
|
+
"test": "node dev/engines.test.mjs && node dev/selftest.mjs && node dev/shims.test.mjs && node dev/parse.test.mjs && node dev/render.test.mjs && node dev/i18n.test.mjs && node dev/p5.test.mjs",
|
|
48
49
|
"test:ci": "node dev/shims.test.mjs && node dev/parse.test.mjs && node dev/render.test.mjs && node dev/i18n.test.mjs && node dev/p5.test.mjs",
|
|
50
|
+
"test:engines": "node dev/engines.test.mjs",
|
|
49
51
|
"test:mcp": "node dev/mcp.test.mjs"
|
|
50
52
|
},
|
|
51
53
|
"dsh": {
|
package/server/engines.py
CHANGED
|
@@ -553,7 +553,9 @@ def run_query(profile: dict, connection, sql: str, max_rows: int) -> dict:
|
|
|
553
553
|
ensure_readable(sql, profile)
|
|
554
554
|
kind = profile.get("kind")
|
|
555
555
|
started = time.perf_counter()
|
|
556
|
+
is_write = _first_keyword(sql) not in READ_STARTERS
|
|
556
557
|
cursor = connection.cursor()
|
|
558
|
+
affected = None
|
|
557
559
|
try:
|
|
558
560
|
if kind == "oracle" and profile.get("readOnly"):
|
|
559
561
|
try:
|
|
@@ -562,14 +564,24 @@ def run_query(profile: dict, connection, sql: str, max_rows: int) -> dict:
|
|
|
562
564
|
pass
|
|
563
565
|
cursor.execute(sql)
|
|
564
566
|
columns, rows, truncated = _rows(cursor, max_rows)
|
|
565
|
-
if
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
567
|
+
if is_write:
|
|
568
|
+
affected = _affected_rows(cursor)
|
|
569
|
+
if not is_write or profile.get("readOnly"):
|
|
570
|
+
# Reads (and anything on a read-only source) must not hold the session
|
|
571
|
+
# open in a transaction; writes need the commit below instead.
|
|
572
|
+
if not connection_autocommit(kind):
|
|
573
|
+
try:
|
|
574
|
+
connection.rollback()
|
|
575
|
+
except Exception: # noqa: BLE001 - nothing to roll back
|
|
576
|
+
pass
|
|
577
|
+
else:
|
|
578
|
+
# No engine here autocommits by default: `sqlite3.connect` runs in the
|
|
579
|
+
# legacy implicit-transaction mode, and MySQL/PostgreSQL/Oracle are
|
|
580
|
+
# explicit-commit. A write not committed here dies with the connection.
|
|
581
|
+
connection.commit()
|
|
570
582
|
finally:
|
|
571
583
|
cursor.close()
|
|
572
|
-
|
|
584
|
+
result = {
|
|
573
585
|
"sql": sql,
|
|
574
586
|
"columns": columns,
|
|
575
587
|
"rows": rows,
|
|
@@ -577,6 +589,22 @@ def run_query(profile: dict, connection, sql: str, max_rows: int) -> dict:
|
|
|
577
589
|
"truncated": truncated,
|
|
578
590
|
"elapsedMs": int((time.perf_counter() - started) * 1000),
|
|
579
591
|
}
|
|
592
|
+
if affected is not None:
|
|
593
|
+
result["affectedRows"] = affected
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _affected_rows(cursor) -> int | None:
|
|
598
|
+
"""Rows touched by a write, when the driver reports it.
|
|
599
|
+
|
|
600
|
+
`rowcount` is -1 for statements whose count is unknown (and some drivers raise
|
|
601
|
+
instead), so anything but a real, non-negative int becomes `None`.
|
|
602
|
+
"""
|
|
603
|
+
try:
|
|
604
|
+
value = cursor.rowcount
|
|
605
|
+
except Exception: # noqa: BLE001 - drivers without a usable rowcount
|
|
606
|
+
return None
|
|
607
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
|
580
608
|
|
|
581
609
|
|
|
582
610
|
def connection_autocommit(kind: str) -> bool:
|
package/server/main.py
CHANGED
|
@@ -206,7 +206,8 @@ def run_query(sql: str, datasource: Optional[str] = None, max_rows: Optional[int
|
|
|
206
206
|
|
|
207
207
|
Read-only datasources accept SELECT/WITH/SHOW/EXPLAIN statements only.
|
|
208
208
|
Results are capped by `max_rows` (default 200); `truncated` says whether more
|
|
209
|
-
rows existed.
|
|
209
|
+
rows existed. Writes (INSERT/UPDATE/DELETE/DDL) are committed and report
|
|
210
|
+
`affectedRows` instead of a result set.
|
|
210
211
|
"""
|
|
211
212
|
try:
|
|
212
213
|
limit = _clamp_rows(max_rows)
|
package/src/host.js
CHANGED
|
@@ -33,7 +33,7 @@ const { createBridge } = await import(new URL(`./tools.js?v=${GENERATION}`, impo
|
|
|
33
33
|
|
|
34
34
|
const API_PREFIX = '/plugins/db-mcp/api'
|
|
35
35
|
const PLUGIN_NAME = 'dsh-db-mcp'
|
|
36
|
-
const PLUGIN_VERSION = '0.6.
|
|
36
|
+
const PLUGIN_VERSION = '0.6.9'
|
|
37
37
|
const SCHEMA_VERSION = 1
|
|
38
38
|
const REGISTRY_FILE = 'datasources.json'
|
|
39
39
|
const SECRETS_FILE = 'secrets.json'
|
package/src/mcp.js
CHANGED
|
@@ -16,6 +16,50 @@ import { spawn } from 'node:child_process';
|
|
|
16
16
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
17
|
const INIT_TIMEOUT_MS = 60_000;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The Python child gets a small, explicit environment instead of a copy of ours.
|
|
21
|
+
* Spreading `process.env` would hand every token and cloud credential in the
|
|
22
|
+
* DSH process to a driver stack that only ever needs to find its interpreter.
|
|
23
|
+
*/
|
|
24
|
+
const ENV_ALLOW = [
|
|
25
|
+
'PATH',
|
|
26
|
+
'Path',
|
|
27
|
+
'PATHEXT',
|
|
28
|
+
'SystemRoot',
|
|
29
|
+
'SYSTEMROOT',
|
|
30
|
+
'windir',
|
|
31
|
+
'ComSpec',
|
|
32
|
+
'COMSPEC',
|
|
33
|
+
'TEMP',
|
|
34
|
+
'TMP',
|
|
35
|
+
'TMPDIR',
|
|
36
|
+
'HOME',
|
|
37
|
+
'USERPROFILE',
|
|
38
|
+
'HOMEDRIVE',
|
|
39
|
+
'HOMEPATH',
|
|
40
|
+
'APPDATA',
|
|
41
|
+
'LOCALAPPDATA',
|
|
42
|
+
'PROGRAMDATA',
|
|
43
|
+
'PROGRAMFILES',
|
|
44
|
+
'LANG',
|
|
45
|
+
'LC_ALL',
|
|
46
|
+
'TZ',
|
|
47
|
+
'PWD',
|
|
48
|
+
'PYTHONPATH',
|
|
49
|
+
'PYTHONHOME',
|
|
50
|
+
'PYTHONIOENCODING',
|
|
51
|
+
'VIRTUAL_ENV',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function childEnvironment(extra) {
|
|
55
|
+
const env = {};
|
|
56
|
+
for (const key of ENV_ALLOW) {
|
|
57
|
+
const value = process.env[key];
|
|
58
|
+
if (typeof value === 'string' && value !== '') env[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return Object.assign(env, extra || {});
|
|
61
|
+
}
|
|
62
|
+
|
|
19
63
|
export class McpError extends Error {
|
|
20
64
|
constructor(message, { code = 'mcp_error', data } = {}) {
|
|
21
65
|
super(message);
|
|
@@ -61,7 +105,7 @@ export class McpClient {
|
|
|
61
105
|
if (this.#child) return this;
|
|
62
106
|
const child = spawn(this.command, this.args, {
|
|
63
107
|
cwd: this.cwd,
|
|
64
|
-
env:
|
|
108
|
+
env: childEnvironment(this.env),
|
|
65
109
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
110
|
windowsHide: true,
|
|
67
111
|
});
|
package/src/tools.js
CHANGED
|
@@ -41,6 +41,9 @@ export function renderResult(value) {
|
|
|
41
41
|
return `error [${code}]: ${message}${hint ? `\n${hint}` : ''}`;
|
|
42
42
|
}
|
|
43
43
|
if (Array.isArray(value.columns) && Array.isArray(value.rows)) {
|
|
44
|
+
if (typeof value.affectedRows === 'number' && value.columns.length === 0) {
|
|
45
|
+
return `ok: ${value.affectedRows} row(s) affected, ${value.elapsedMs}ms`;
|
|
46
|
+
}
|
|
44
47
|
const head = value.columns.join(' | ');
|
|
45
48
|
const body = value.rows
|
|
46
49
|
.slice(0, 50)
|
|
@@ -173,7 +176,8 @@ export function createBridge({
|
|
|
173
176
|
if (error) entry.error = String((error && error.message) || error);
|
|
174
177
|
else {
|
|
175
178
|
const parsed = outcome && outcome.parsed;
|
|
176
|
-
|
|
179
|
+
// A write reports how many rows it touched; a read reports how many came back.
|
|
180
|
+
const rows = parsed && (parsed.affectedRows ?? parsed.rowCount ?? (Array.isArray(parsed.rows) ? parsed.rows.length : undefined));
|
|
177
181
|
if (typeof rows === 'number') entry.rows = rows;
|
|
178
182
|
if (parsed && parsed.error && parsed.error.code) entry.code = parsed.error.code;
|
|
179
183
|
}
|