dsh-dbhub-live 2.0.0 → 3.1.2
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/AGENTS.md +97 -0
- package/LICENSE +21 -21
- package/README.en.md +162 -0
- package/README.md +162 -110
- package/cordis.patch.yml +8 -8
- package/doc/REQUIREMENTS.md +65 -0
- package/lib/adhoc.mjs +97 -0
- package/lib/client.js +604 -0
- package/lib/collect.mjs +118 -0
- package/lib/config.mjs +492 -0
- package/lib/i18n.mjs +139 -0
- package/lib/index.mjs +345 -1417
- package/lib/mcp.mjs +462 -0
- package/lib/options.mjs +94 -0
- package/lib/runtime.mjs +255 -0
- package/lib/state.mjs +108 -0
- package/lib/tools.mjs +462 -0
- package/package.json +61 -39
- package/test/adhoc.test.mjs +29 -0
- package/test/client-format.test.mjs +88 -0
- package/test/i18n.test.mjs +27 -0
- package/test/init.test.mjs +163 -0
- package/test/options.test.mjs +41 -0
- package/test/state.test.mjs +85 -0
- package/test/util.test.mjs +96 -0
package/lib/tools.mjs
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// dsh-dbhub-live: plugin-owned tools (never synced away by the server).
|
|
2
|
+
//
|
|
3
|
+
// - dbhub_configure per-workspace connection configuration
|
|
4
|
+
// - dbhub_list_sources enumerate every registered connection source (for the
|
|
5
|
+
// model to discover named environments without guessing)
|
|
6
|
+
// - dbhub_query ad-hoc temporary query on any DSN
|
|
7
|
+
// - dbhub_query_objects ad-hoc temporary object search on any DSN
|
|
8
|
+
// Every tool guards on the plugin's enable/disable toggle first, so disabling
|
|
9
|
+
// the plugin flips all dbhub tools to a friendly message immediately.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
12
|
+
import {
|
|
13
|
+
store, saveStore, listWorkspaces, slugify, envSlug, shortHash, maskDsn,
|
|
14
|
+
setWorkspaceEnv,
|
|
15
|
+
} from './config.mjs'
|
|
16
|
+
import {
|
|
17
|
+
ensureRunning, isServerUp, coreDisposers, refreshToolCount, disabledMessage,
|
|
18
|
+
collectSources, resolveSource, sourceLabel, runOnServer,
|
|
19
|
+
} from './mcp.mjs'
|
|
20
|
+
import { runAdhoc } from './adhoc.mjs'
|
|
21
|
+
import { walkForCandidates, extractDsnCandidates, askUser, answerItemOf, sessionCwd } from './collect.mjs'
|
|
22
|
+
import { currentT } from './i18n.mjs'
|
|
23
|
+
import * as state from './state.mjs'
|
|
24
|
+
|
|
25
|
+
function disabledReply() {
|
|
26
|
+
return { ok: false, text: disabledMessage() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One shared body for the source-parameterized persistent tools: resolve the
|
|
30
|
+
// `source` to a configured connection, strip the routing arg, and hand the
|
|
31
|
+
// rest to the live dbhub source tool. Routing keeps the tool DECLARATION
|
|
32
|
+
// count constant no matter how many workspaces × environments exist.
|
|
33
|
+
async function runSourceTool(ctx, subprocess, base, args, exec) {
|
|
34
|
+
if (!state.isEnabled()) return disabledReply()
|
|
35
|
+
const src = await resolveSource(ctx, subprocess, args.source)
|
|
36
|
+
if (!src) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
text: currentT('result.noSource', { src: String((args && args.source) || '') }),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const mcpArgs = {}
|
|
43
|
+
for (const key of Object.keys(args || {})) {
|
|
44
|
+
if (key !== 'source') mcpArgs[key] = args[key]
|
|
45
|
+
}
|
|
46
|
+
const label = currentT('label.conn', { src: src.id, dsn: maskDsn(src.row.dsn) })
|
|
47
|
+
return runOnServer(ctx, subprocess, base + '_' + src.id, mcpArgs, exec, label)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── dbhub_configure body ──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
// Persist one environment and report the resulting tool name.
|
|
53
|
+
async function persistEnv(ctx, subprocess, wsPath, slug, env, dsn, source) {
|
|
54
|
+
const envName = setWorkspaceEnv(wsPath, env, dsn, source)
|
|
55
|
+
await ensureRunning(ctx, subprocess)
|
|
56
|
+
if (!isServerUp()) {
|
|
57
|
+
return { ok: false, text: currentT('result.sevSaveFail') }
|
|
58
|
+
}
|
|
59
|
+
refreshToolCount()
|
|
60
|
+
const suffix = envSlug(envName)
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
text: currentT('result.savedOk', {
|
|
64
|
+
slug,
|
|
65
|
+
env: envName,
|
|
66
|
+
tool: slug + (suffix ? '_' + suffix : ''),
|
|
67
|
+
}),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runConfigure(ctx, subprocess, args, exec) {
|
|
72
|
+
if (!state.isEnabled()) return disabledReply()
|
|
73
|
+
const userQuestions = ctx.get('userQuestions')
|
|
74
|
+
const workspaces = await listWorkspaces(ctx)
|
|
75
|
+
const currentCwd = sessionCwd(exec.agent)
|
|
76
|
+
let target = workspaces.find((w) => w.path === (args.workspace || currentCwd))
|
|
77
|
+
if (!target) {
|
|
78
|
+
const byTitle = workspaces.find((w) => w.title === args.workspace)
|
|
79
|
+
target = byTitle || (workspaces.find((w) => w.path === currentCwd) || workspaces[0])
|
|
80
|
+
}
|
|
81
|
+
if (!target) return { ok: false, text: currentT('result.noWslabel') }
|
|
82
|
+
const wsPath = target.path
|
|
83
|
+
const slug = slugify(target.title) + '_' + shortHash(wsPath)
|
|
84
|
+
const env = args.env
|
|
85
|
+
|
|
86
|
+
// 1) explicit dsn argument
|
|
87
|
+
if (typeof args.dsn === 'string' && args.dsn.trim() !== '') {
|
|
88
|
+
return persistEnv(ctx, subprocess, wsPath, slug, env, args.dsn.trim(), 'user')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ans = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
92
|
+
{
|
|
93
|
+
id: 'mode',
|
|
94
|
+
header: '配置工作区数据库连接:' + target.title,
|
|
95
|
+
question: '未检测到自动配置(mise env/.env)。怎么提供连接信息?',
|
|
96
|
+
options: [
|
|
97
|
+
{ label: '输入完整 DSN', description: '如 mysql://user:pass@host:3306/db' },
|
|
98
|
+
{ label: '填写分项(类型/主机/端口/账号/密码/库名)' },
|
|
99
|
+
{ label: '扫描项目配置文件(需授权,可能读取含密码的文件)' },
|
|
100
|
+
{ label: '取消' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
])
|
|
104
|
+
const mode = answerItemOf(ans, 'mode')
|
|
105
|
+
const chosen = mode && mode.selected && mode.selected[0]
|
|
106
|
+
if (!chosen || chosen === '取消') return { ok: false, text: currentT('result.modeCanceled') }
|
|
107
|
+
|
|
108
|
+
if (chosen === '输入完整 DSN') {
|
|
109
|
+
const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
110
|
+
{
|
|
111
|
+
id: 'dsn',
|
|
112
|
+
header: '完整 DSN',
|
|
113
|
+
question: '请输入数据库连接串',
|
|
114
|
+
},
|
|
115
|
+
])
|
|
116
|
+
const dsn = answerItemOf(ans2, 'dsn')
|
|
117
|
+
const text = dsn && dsn.custom && dsn.custom.trim()
|
|
118
|
+
if (!text) return { ok: false, text: currentT('result.noDsn') }
|
|
119
|
+
return persistEnv(ctx, subprocess, wsPath, slug, env, text, 'user')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (chosen === '填写分项(类型/主机/端口/账号/密码/库名)') {
|
|
123
|
+
const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
124
|
+
{
|
|
125
|
+
id: 'type',
|
|
126
|
+
header: '类型',
|
|
127
|
+
question: '数据库类型',
|
|
128
|
+
options: [{ label: 'mysql' }, { label: 'postgres' }, { label: 'mariadb' }, { label: 'sqlite' }, { label: 'sqlserver' }],
|
|
129
|
+
},
|
|
130
|
+
{ id: 'host', header: '主机', question: '主机地址', options: [{ label: '留空(默认 localhost)' }] },
|
|
131
|
+
{ id: 'port', header: '端口', question: '端口(可留空)', options: [{ label: '留空' }] },
|
|
132
|
+
{ id: 'user', header: '账号', question: '用户名(可留空)', options: [{ label: '留空' }] },
|
|
133
|
+
{ id: 'password', header: '密码', question: '密码(可留空)', options: [{ label: '留空' }] },
|
|
134
|
+
{ id: 'db', header: '库名', question: '数据库名(可留空)', options: [{ label: '留空' }] },
|
|
135
|
+
])
|
|
136
|
+
const val = (id) => {
|
|
137
|
+
const item = answerItemOf(ans2, id)
|
|
138
|
+
if (item && item.custom && item.custom.trim()) return item.custom.trim()
|
|
139
|
+
if (item && item.selected && item.selected[0]) {
|
|
140
|
+
const s = item.selected[0]
|
|
141
|
+
return s.startsWith('留空') ? '' : s
|
|
142
|
+
}
|
|
143
|
+
return ''
|
|
144
|
+
}
|
|
145
|
+
const type = (val('type') || 'mysql').toLowerCase()
|
|
146
|
+
const host = val('host')
|
|
147
|
+
const port = val('port')
|
|
148
|
+
const user = val('user')
|
|
149
|
+
const password = val('password')
|
|
150
|
+
const database = val('db')
|
|
151
|
+
let dsn
|
|
152
|
+
if (type === 'sqlite') dsn = 'sqlite:///' + (host || 'test.db')
|
|
153
|
+
else dsn = type + '://' + encodeURIComponent(user) + ':' + encodeURIComponent(password) + '@' + (host || 'localhost') + (port ? ':' + port : '') + '/' + encodeURIComponent(database)
|
|
154
|
+
return persistEnv(ctx, subprocess, wsPath, slug, env, dsn, 'user')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// scan path: authorization gate FIRST
|
|
158
|
+
const auth = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
159
|
+
{
|
|
160
|
+
id: 'auth',
|
|
161
|
+
header: '授权扫描',
|
|
162
|
+
question: '将扫描工作区项目中的常见数据库配置文件(.env / application*.yml / docker-compose / jdbc.properties 等)' +
|
|
163
|
+
'并读取其中的连接信息(可能包含账号密码)。此操作会读取敏感文件且消耗较多 token,是否授权?',
|
|
164
|
+
options: [
|
|
165
|
+
{ label: '授权扫描', description: '扫描后列出候选供你确认,确认才持久化' },
|
|
166
|
+
{ label: '取消' },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
])
|
|
170
|
+
const authChosen = answerItemOf(auth, 'auth')
|
|
171
|
+
const authOk = authChosen && authChosen.selected && authChosen.selected[0] === '授权扫描'
|
|
172
|
+
if (!authOk) return { ok: false, text: currentT('result.scanDenied') }
|
|
173
|
+
|
|
174
|
+
const budget = { count: 0 }
|
|
175
|
+
const files = walkForCandidates(wsPath, budget)
|
|
176
|
+
if (files.length === 0) return { ok: false, text: currentT('result.scanEmpty') }
|
|
177
|
+
const candidates = []
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
let text
|
|
180
|
+
try {
|
|
181
|
+
const st = statSync(f)
|
|
182
|
+
if (st.size > 64 * 1024) continue
|
|
183
|
+
text = readFileSync(f, 'utf8')
|
|
184
|
+
} catch (e) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
for (const c of extractDsnCandidates(f, text)) {
|
|
188
|
+
const rel = f.replace(wsPath, '.').replace(/\\/g, '/')
|
|
189
|
+
candidates.push({ label: rel + ' → ' + maskDsn(c.dsn), dsn: c.dsn, via: c.via })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (candidates.length === 0) return { ok: false, text: currentT('result.scanNone', { n: files.length }) }
|
|
193
|
+
const pick = await askUser(userQuestions, exec.agent, exec.signal, [
|
|
194
|
+
{
|
|
195
|
+
id: 'pick',
|
|
196
|
+
header: '选择连接',
|
|
197
|
+
question: '扫描到以下候选(密码已打码),选择要使用的:',
|
|
198
|
+
options: candidates.map((c) => ({ label: c.label, description: '来源: ' + c.via })),
|
|
199
|
+
},
|
|
200
|
+
])
|
|
201
|
+
const picked = answerItemOf(pick, 'pick')
|
|
202
|
+
const label = picked && picked.selected && picked.selected[0]
|
|
203
|
+
if (!label) return { ok: false, text: '未选择,已取消' }
|
|
204
|
+
const match = candidates.find((c) => c.label === label)
|
|
205
|
+
if (!match) return { ok: false, text: '选择无效' }
|
|
206
|
+
const envName = setWorkspaceEnv(wsPath, env, match.dsn, 'collected')
|
|
207
|
+
const suffix = envSlug(envName)
|
|
208
|
+
await ensureRunning(ctx, subprocess)
|
|
209
|
+
if (!isServerUp()) {
|
|
210
|
+
return { ok: false, text: currentT('result.sevSaveFail') }
|
|
211
|
+
}
|
|
212
|
+
refreshToolCount()
|
|
213
|
+
return { ok: true, text: currentT('result.scanPicked', { env: envName, slug: slug + (suffix ? '_' + suffix : '') }) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── tool builders ─────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
// Enumerate every registered connection source so the model can discover
|
|
219
|
+
// named environments (test/prod/...) and their tool names without guessing.
|
|
220
|
+
function buildListSourcesTool(ctx, subprocess) {
|
|
221
|
+
return {
|
|
222
|
+
name: 'dbhub_list_sources',
|
|
223
|
+
description:
|
|
224
|
+
'列出当前已注册的全部数据库连接源(每个 = 工作区 × 环境)及其对应的常驻查询工具名。' +
|
|
225
|
+
'注意:列表中的连接串是脱敏显示(密码为 ****),仅供识别环境,绝不能拷进 dbhub_query / dbhub_query_objects 直连(会失败);' +
|
|
226
|
+
'查询某个环境请直接调用该行给出的 dbhub_execute_sql_<工作区>[_<环境>] 工具(已内置真实凭据)。' +
|
|
227
|
+
'当用户提到某个环境(如 test/测试)但你不确定它是否已注册时,先调用本工具确认环境名,再调用对应环境的工具,避免误用默认(生产)库。无参数。',
|
|
228
|
+
parameters: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {},
|
|
231
|
+
required: [],
|
|
232
|
+
},
|
|
233
|
+
timeoutMs: 30000,
|
|
234
|
+
isConcurrencySafe: () => true,
|
|
235
|
+
output: {
|
|
236
|
+
schema: {},
|
|
237
|
+
render: (_args, value) => [
|
|
238
|
+
{
|
|
239
|
+
type: 'text',
|
|
240
|
+
text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
async execute() {
|
|
245
|
+
if (!state.isEnabled()) return disabledReply()
|
|
246
|
+
const { rows } = await collectSources(ctx, subprocess)
|
|
247
|
+
if (rows.length === 0) {
|
|
248
|
+
return { ok: true, text: currentT('result.sourcesEmpty') }
|
|
249
|
+
}
|
|
250
|
+
const lines = rows.map((r) => {
|
|
251
|
+
const badge = r.persisted ? currentT('result.badgeSaved') : currentT('result.badgeAuto')
|
|
252
|
+
const srcId = slugify(r.title) + '_' + shortHash(r.wsPath) + (r.env === 'default' ? '' : '_' + envSlug(r.env))
|
|
253
|
+
return currentT('result.srcRow', {
|
|
254
|
+
title: r.title,
|
|
255
|
+
badge,
|
|
256
|
+
env: r.env,
|
|
257
|
+
dsn: maskDsn(r.dsn),
|
|
258
|
+
src: r.source || '?',
|
|
259
|
+
srcId,
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
text: currentT('result.sourcesIntro', { n: rows.length }) + '\n' + lines.join('\n'),
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildConfigureTool(ctx, subprocess) {
|
|
271
|
+
return {
|
|
272
|
+
name: 'dbhub_configure',
|
|
273
|
+
description:
|
|
274
|
+
'配置某个工作区的数据库连接(持久化)。参数:workspace(工作区路径或标题,默认当前会话工作区)、env(环境名,默认 default,同一工作区可添加多个环境)、dsn(完整连接串,可选)。' +
|
|
275
|
+
'未提供 dsn 时会询问用户:输入 DSN / 填写分项 / 授权扫描项目配置文件(读取 .env、application*.yml、docker-compose、jdbc.properties 等并列出候选供确认)。' +
|
|
276
|
+
'配置后自动生成 dbhub.toml、重启常驻 dbhub 服务并注册该工作区的工具(dbhub_execute_sql_<工作区>[_<环境>] 等)。' +
|
|
277
|
+
'每个工作区只需配置一次,之后自动持久化;有 mise env/.env 自动配置的工作区无需调用本工具。',
|
|
278
|
+
parameters: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
workspace: { type: 'string', description: '工作区路径或标题;默认当前会话工作区' },
|
|
282
|
+
env: { type: 'string', description: '环境名(如 prod/dev/test);默认 default,多个环境各自注册独立工具' },
|
|
283
|
+
dsn: { type: 'string', description: '完整数据库连接串;省略则询问用户或扫描' },
|
|
284
|
+
},
|
|
285
|
+
required: [],
|
|
286
|
+
},
|
|
287
|
+
timeoutMs: 120000,
|
|
288
|
+
output: {
|
|
289
|
+
schema: {},
|
|
290
|
+
render: (_args, value) => [
|
|
291
|
+
{
|
|
292
|
+
type: 'text',
|
|
293
|
+
text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
async execute(args, exec) {
|
|
298
|
+
return runConfigure(ctx, subprocess, args, exec)
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function registerCoreTools(ctx, subprocess) {
|
|
304
|
+
try {
|
|
305
|
+
const def = buildConfigureTool(ctx, subprocess)
|
|
306
|
+
const disposer = ctx.tools.register(def)
|
|
307
|
+
coreDisposers.set('dbhub_configure', disposer)
|
|
308
|
+
} catch (e) {
|
|
309
|
+
console.error('[dsh-dbhub-live] register dbhub_configure failed: ' + String((e && e.message) || e))
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const def = buildListSourcesTool(ctx, subprocess)
|
|
313
|
+
const disposer = ctx.tools.register(def)
|
|
314
|
+
coreDisposers.set('dbhub_list_sources', disposer)
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error('[dsh-dbhub-live] register dbhub_list_sources failed: ' + String((e && e.message) || e))
|
|
317
|
+
}
|
|
318
|
+
// Persistent source tools: ONE declaration per operation, routing by the
|
|
319
|
+
// `source` argument (constant tool count regardless of workspace × env).
|
|
320
|
+
const sourceDefs = [
|
|
321
|
+
{
|
|
322
|
+
name: 'dbhub_execute_sql',
|
|
323
|
+
description:
|
|
324
|
+
'在指定数据源的常驻连接上执行 SQL。source 必填:数据源标识,先调用 dbhub_list_sources 查看全部可用值与对应环境(如 <工作区> 为默认环境,<工作区>_<环境> 为命名环境,例如通过 _test 指向测试库)。' +
|
|
325
|
+
'sql:要执行的 SQL(多语句用 ; 分隔)。结果自动标注所连环境。',
|
|
326
|
+
parameters: {
|
|
327
|
+
type: 'object',
|
|
328
|
+
properties: {
|
|
329
|
+
source: { type: 'string', description: '数据源标识(来自 dbhub_list_sources),按环境选择测试/生产' },
|
|
330
|
+
sql: { type: 'string', description: '要执行的 SQL(多语句用 ; 分隔)' },
|
|
331
|
+
},
|
|
332
|
+
required: ['source', 'sql'],
|
|
333
|
+
},
|
|
334
|
+
timeoutMs: 60000,
|
|
335
|
+
output: {
|
|
336
|
+
schema: {},
|
|
337
|
+
render: (_args, value) => [
|
|
338
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
async execute(args, exec) {
|
|
342
|
+
return runSourceTool(ctx, subprocess, 'execute_sql', args, exec)
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'dbhub_search_objects',
|
|
347
|
+
description:
|
|
348
|
+
'在指定数据源的常驻连接上搜索数据库对象(表/视图/列/索引等)。source 必填(见 dbhub_list_sources)。' +
|
|
349
|
+
'注意:dbhub 的 search_objects 仅对 sqlite 开放(🔒),MySQL/PostgreSQL 等请用 dbhub_execute_sql 直接查(如 SHOW TABLES)。',
|
|
350
|
+
parameters: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
properties: {
|
|
353
|
+
source: { type: 'string', description: '数据源标识(来自 dbhub_list_sources)' },
|
|
354
|
+
object_type: { type: 'string', enum: ['schema', 'table', 'view', 'column', 'procedure', 'function', 'index'], description: '对象类型' },
|
|
355
|
+
pattern: { type: 'string', description: 'LIKE 模式' },
|
|
356
|
+
schema: { type: 'string', description: '限定 schema' },
|
|
357
|
+
table: { type: 'string', description: '限定表(需 schema)' },
|
|
358
|
+
detail_level: { type: 'string', enum: ['names', 'summary', 'full'], description: '详细程度' },
|
|
359
|
+
limit: { type: 'integer', description: '最大结果数' },
|
|
360
|
+
},
|
|
361
|
+
required: ['source', 'object_type'],
|
|
362
|
+
},
|
|
363
|
+
timeoutMs: 60000,
|
|
364
|
+
output: {
|
|
365
|
+
schema: {},
|
|
366
|
+
render: (_args, value) => [
|
|
367
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
async execute(args, exec) {
|
|
371
|
+
const mcp = {}
|
|
372
|
+
for (const k of ['object_type', 'pattern', 'schema', 'table', 'detail_level', 'limit']) {
|
|
373
|
+
if (args[k] !== undefined && args[k] !== null) mcp[k] = args[k]
|
|
374
|
+
}
|
|
375
|
+
return runSourceTool(ctx, subprocess, 'search_objects', { source: args.source, ...mcp }, exec)
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
]
|
|
379
|
+
for (const def of sourceDefs) {
|
|
380
|
+
try {
|
|
381
|
+
const disposer = ctx.tools.register(def)
|
|
382
|
+
coreDisposers.set(def.name, disposer)
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error('[dsh-dbhub-live] register ' + def.name + ' failed: ' + String((e && e.message) || e))
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Ad-hoc temporary connections: model passes a full dsn per request; each
|
|
388
|
+
// call is an independent throwaway dbhub, so two calls can hit two different
|
|
389
|
+
// databases at once. Independent state -> safe to run concurrently.
|
|
390
|
+
const adhocDefs = [
|
|
391
|
+
{
|
|
392
|
+
name: 'dbhub_query',
|
|
393
|
+
description:
|
|
394
|
+
'临时动态连接任意数据库并执行 SQL(每次调用独立临时连接,可同时查多个不同库)。' +
|
|
395
|
+
'参数 dsn 必填:完整连接串,指定 ip/端口/账号/密码/库(如 mysql://user:pass@host:3306/db)。' +
|
|
396
|
+
'适合一次性排查/对比不同环境;与持久配置的工作区工具互不影响。多语句用 ; 分隔。',
|
|
397
|
+
parameters: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
properties: {
|
|
400
|
+
dsn: { type: 'string', description: '完整数据库连接串,如 mysql://root:pass@192.168.77.6:3306/tx_sd_jinengshu' },
|
|
401
|
+
sql: { type: 'string', description: '要执行的 SQL(多语句用 ; 分隔)' },
|
|
402
|
+
},
|
|
403
|
+
required: ['dsn', 'sql'],
|
|
404
|
+
},
|
|
405
|
+
timeoutMs: 60000,
|
|
406
|
+
isConcurrencySafe: () => true,
|
|
407
|
+
output: {
|
|
408
|
+
schema: {},
|
|
409
|
+
render: (_args, value) => [
|
|
410
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
async execute(args, exec) {
|
|
414
|
+
return runAdhoc(subprocess, args.dsn, 'execute_sql', { sql: args.sql }, exec)
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'dbhub_query_objects',
|
|
419
|
+
description:
|
|
420
|
+
'临时动态连接并搜索数据库对象(表/视图/列/索引等),每次调用独立临时连接。' +
|
|
421
|
+
'注意:dbhub 的 search_objects 仅对 sqlite 开放(🔒),MySQL/PostgreSQL 等请用 dbhub_query 直接查(如 SHOW TABLES)。' +
|
|
422
|
+
'参数 dsn 必填(ip/端口/账号/密码/库)。',
|
|
423
|
+
parameters: {
|
|
424
|
+
type: 'object',
|
|
425
|
+
properties: {
|
|
426
|
+
dsn: { type: 'string', description: '完整数据库连接串,如 mysql://root:pass@192.168.77.6:3306/tx_sd_jinengshu' },
|
|
427
|
+
object_type: { type: 'string', enum: ['schema', 'table', 'view', 'column', 'procedure', 'function', 'index'], description: '对象类型' },
|
|
428
|
+
pattern: { type: 'string', description: 'LIKE 模式' },
|
|
429
|
+
schema: { type: 'string', description: '限定 schema' },
|
|
430
|
+
table: { type: 'string', description: '限定表(需 schema)' },
|
|
431
|
+
detail_level: { type: 'string', enum: ['names', 'summary', 'full'], description: '详细程度' },
|
|
432
|
+
limit: { type: 'integer', description: '最大结果数' },
|
|
433
|
+
},
|
|
434
|
+
required: ['dsn', 'object_type'],
|
|
435
|
+
},
|
|
436
|
+
timeoutMs: 60000,
|
|
437
|
+
isConcurrencySafe: () => true,
|
|
438
|
+
output: {
|
|
439
|
+
schema: {},
|
|
440
|
+
render: (_args, value) => [
|
|
441
|
+
{ type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
|
|
442
|
+
],
|
|
443
|
+
},
|
|
444
|
+
async execute(args, exec) {
|
|
445
|
+
const mcp = {}
|
|
446
|
+
for (const k of ['object_type', 'pattern', 'schema', 'table', 'detail_level', 'limit']) {
|
|
447
|
+
if (args[k] !== undefined && args[k] !== null) mcp[k] = args[k]
|
|
448
|
+
}
|
|
449
|
+
return runAdhoc(subprocess, args.dsn, 'search_objects', mcp, exec)
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
]
|
|
453
|
+
for (const def of adhocDefs) {
|
|
454
|
+
try {
|
|
455
|
+
const disposer = ctx.tools.register(def)
|
|
456
|
+
coreDisposers.set(def.name, disposer)
|
|
457
|
+
} catch (e) {
|
|
458
|
+
console.error('[dsh-dbhub-live] register ' + def.name + ' failed: ' + String((e && e.message) || e))
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
refreshToolCount()
|
|
462
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-dbhub-live",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "DSH 数据库 MCP 桥接插件:常驻多源 dbhub 服务 +
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.mjs",
|
|
7
|
-
"
|
|
8
|
-
"lib",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-dbhub-live",
|
|
3
|
+
"version": "3.1.2",
|
|
4
|
+
"description": "DSH 数据库 MCP 桥接插件:常驻多源 dbhub 服务 + source 参数化常驻工具 + 临时动态连接 + 懒加载、浏览器状态卡片与可配置参数。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.mjs",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib",
|
|
15
|
+
"doc",
|
|
16
|
+
"test",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"README.en.md",
|
|
20
|
+
"AGENTS.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh",
|
|
25
|
+
"dbhub",
|
|
26
|
+
"mcp",
|
|
27
|
+
"database",
|
|
28
|
+
"mysql",
|
|
29
|
+
"postgres",
|
|
30
|
+
"sqlite",
|
|
31
|
+
"mariadb",
|
|
32
|
+
"sqlserver",
|
|
33
|
+
"plugin"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "node --check ./lib/index.mjs && node --check ./lib/config.mjs && node --check ./lib/state.mjs && node --check ./lib/options.mjs && node --check ./lib/runtime.mjs && node --check ./lib/mcp.mjs && node --check ./lib/adhoc.mjs && node --check ./lib/collect.mjs && node --check ./lib/tools.mjs && node --check ./lib/client.js",
|
|
37
|
+
"test": "node --test test/"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
49
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
50
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/mr-mihu/dsh-dbhub-live.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/mr-mihu/dsh-dbhub-live/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/mr-mihu/dsh-dbhub-live#readme",
|
|
60
|
+
"license": "MIT"
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Unit tests for the ad-hoc connection guard: surfaced (masked) DSNs must be
|
|
2
|
+
// refused loudly so the model never burns a failed auth round-trip with `****`.
|
|
3
|
+
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-adhoc-'))
|
|
11
|
+
process.env.DSH_HOME = home
|
|
12
|
+
const adhoc = await import('../lib/adhoc.mjs?adhoc=' + Date.now())
|
|
13
|
+
|
|
14
|
+
test.after(() => {
|
|
15
|
+
rmSync(home, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('isMaskedDsn detects masked passwords', () => {
|
|
19
|
+
assert.equal(adhoc.isMaskedDsn('mysql://root:****@10.253.0.3:3307/tx_zdsf_main_pro'), true)
|
|
20
|
+
assert.equal(adhoc.isMaskedDsn('postgres://u:****@h/db'), true)
|
|
21
|
+
assert.equal(adhoc.isMaskedDsn('mysql://root:**@h/db'), true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('isMaskedDsn tolerates real DSNs', () => {
|
|
25
|
+
assert.equal(adhoc.isMaskedDsn('mysql://root:hbtx.1234@10.253.0.3:3307/tx_zdsf_main_pro'), false)
|
|
26
|
+
assert.equal(adhoc.isMaskedDsn('sqlite:///C:/data/x.db'), false)
|
|
27
|
+
assert.equal(adhoc.isMaskedDsn(''), false)
|
|
28
|
+
assert.equal(adhoc.isMaskedDsn(undefined), false)
|
|
29
|
+
})
|