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/lib/mcp.mjs ADDED
@@ -0,0 +1,462 @@
1
+ // dsh-dbhub-live: persistent multi-source dbhub server + MCP client.
2
+ //
3
+ // ONE persistent `dbhub --transport stdio --config <generated dbhub.toml>`
4
+ // process, managed here: idle recycle, respawn on demand, lazy init. The toml
5
+ // is auto-generated from every registered workspace's connection config and
6
+ // hot-reloaded by dbhub itself — users never maintain it.
7
+ //
8
+ // This module also owns the per-workspace tool registrations and their
9
+ // disposers (the source of truth for the registered-tool count).
10
+
11
+ import { statSync } from 'node:fs'
12
+ import { join, basename } from 'node:path'
13
+ import {
14
+ DATA_DIR, dshHomeDir, STORE_PATH, TOML_PATH,
15
+ store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
16
+ slugify, envSlug, shortHash,
17
+ fingerprintOf, generateToml, maskDsn, writeToml,
18
+ } from './config.mjs'
19
+ import { buildSpawnArgv, resolveDbhubExe } from './runtime.mjs'
20
+ import { currentT } from './i18n.mjs'
21
+ import * as options from './options.mjs'
22
+ import * as state from './state.mjs'
23
+
24
+ // ── MCP client (JSON-RPC 2.0 over stdio) ─────────────────────────────────
25
+
26
+ export function createMcpClient(handle, onStderr, onNotification) {
27
+ let buf = ''
28
+ let nextId = 1
29
+ let closed = false
30
+ const pending = new Map()
31
+ const settleAll = (err) => {
32
+ for (const p of pending.values()) p.reject(err)
33
+ pending.clear()
34
+ }
35
+ if (handle.stdout) {
36
+ handle.stdout.on('data', (chunk) => {
37
+ buf += String(chunk)
38
+ let idx
39
+ while ((idx = buf.indexOf('\n')) >= 0) {
40
+ const line = buf.slice(0, idx).trim()
41
+ buf = buf.slice(idx + 1)
42
+ if (!line) continue
43
+ let msg
44
+ try {
45
+ msg = JSON.parse(line)
46
+ } catch (e) {
47
+ continue
48
+ }
49
+ if (msg && typeof msg.id === 'number' && pending.has(msg.id)) {
50
+ const p = pending.get(msg.id)
51
+ pending.delete(msg.id)
52
+ if (msg.error) p.reject(new Error('MCP error: ' + JSON.stringify(msg.error)))
53
+ else p.resolve(msg.result)
54
+ } else if (msg && msg.method && !msg.id && onNotification) {
55
+ try {
56
+ onNotification(msg)
57
+ } catch (e) {
58
+ /* contain listener failures */
59
+ }
60
+ }
61
+ }
62
+ })
63
+ }
64
+ if (handle.stderr && onStderr) handle.stderr.on('data', onStderr)
65
+ handle.done.then((outcome) => {
66
+ closed = true
67
+ settleAll(
68
+ new Error(
69
+ 'dbhub exited (code ' + outcome.exitCode + ')' + (outcome.signal ? ', signal ' + outcome.signal : ''),
70
+ ),
71
+ )
72
+ }).catch((err) => {
73
+ closed = true
74
+ settleAll(err instanceof Error ? err : new Error(String(err)))
75
+ })
76
+ return {
77
+ get closed() {
78
+ return closed
79
+ },
80
+ request(method, params) {
81
+ const id = nextId++
82
+ return new Promise((resolve, reject) => {
83
+ if (!handle.stdin) {
84
+ reject(new Error('dbhub stdin unavailable'))
85
+ return
86
+ }
87
+ if (closed) {
88
+ reject(new Error('dbhub process is down (will auto-restart on next call)'))
89
+ return
90
+ }
91
+ pending.set(id, { resolve, reject })
92
+ try {
93
+ handle.stdin.write(
94
+ JSON.stringify(
95
+ params === undefined
96
+ ? { jsonrpc: '2.0', id, method }
97
+ : { jsonrpc: '2.0', id, method, params },
98
+ ) + '\n',
99
+ )
100
+ } catch (e) {
101
+ pending.delete(id)
102
+ reject(e)
103
+ }
104
+ })
105
+ },
106
+ notify(method, params) {
107
+ if (!handle.stdin || closed) return
108
+ try {
109
+ handle.stdin.write(
110
+ JSON.stringify(
111
+ params === undefined
112
+ ? { jsonrpc: '2.0', method }
113
+ : { jsonrpc: '2.0', method, params },
114
+ ) + '\n',
115
+ )
116
+ } catch (e) {
117
+ /* ignore */
118
+ }
119
+ },
120
+ }
121
+ }
122
+
123
+ // ── server lifecycle ──────────────────────────────────────────────────────
124
+
125
+ const server = {
126
+ handle: undefined,
127
+ client: undefined,
128
+ generatedFingerprint: undefined,
129
+ starting: undefined,
130
+ }
131
+
132
+ export function terminateServer() {
133
+ const h = server.handle
134
+ server.handle = undefined
135
+ server.client = undefined
136
+ if (h) {
137
+ try {
138
+ h.terminate()
139
+ } catch (e) {
140
+ /* ignore */
141
+ }
142
+ }
143
+ }
144
+
145
+ async function spawnServer(subprocess, signal) {
146
+ let exe
147
+ try {
148
+ exe = await resolveDbhubExe(subprocess, signal)
149
+ } catch (e) {
150
+ return { error: '无法获取 dbhub: ' + String((e && e.message) || e) }
151
+ }
152
+ let handle
153
+ try {
154
+ handle = subprocess.spawn({
155
+ argv: buildSpawnArgv(subprocess, exe, ['--transport', 'stdio', '--config', TOML_PATH]),
156
+ cwd: DATA_DIR,
157
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
158
+ graceMs: 3000,
159
+ signal,
160
+ })
161
+ } catch (e) {
162
+ return { error: '启动 dbhub 失败: ' + String((e && e.message) || e) }
163
+ }
164
+ let stderrTail = ''
165
+ const client = createMcpClient(handle, (c) => {
166
+ stderrTail = (stderrTail + String(c)).slice(-4000)
167
+ }, () => {
168
+ // notifications/tools/list_changed — re-sync happens lazily on next call
169
+ // (the tools already registered stay valid; new ones appear after respawn).
170
+ console.log('[dsh-dbhub-live] tools/list_changed received')
171
+ })
172
+ try {
173
+ await client.request('initialize', {
174
+ protocolVersion: '2025-03-26',
175
+ capabilities: {},
176
+ clientInfo: { name: 'dsh-dbhub', version: '3.0.0' },
177
+ })
178
+ client.notify('notifications/initialized')
179
+ } catch (e) {
180
+ let detail = String((e && e.message) || e)
181
+ if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
182
+ try {
183
+ handle.terminate()
184
+ } catch (e2) {
185
+ /* ignore */
186
+ }
187
+ return { error: detail }
188
+ }
189
+ server.handle = handle
190
+ server.client = client
191
+ return {}
192
+ }
193
+
194
+ // ── tool registry (single-slot) ────────────────────────────────────────────
195
+ //
196
+ // Context budget: there is exactly ONE set of dbhub tools, period. Work
197
+ // workspaces × environments are NOT expanded into per-source tools (that
198
+ // would inject 2N tool declarations into every model turn); instead the
199
+ // source-parameterized tools dbhub_execute_sql / dbhub_search_objects resolve
200
+ // their `source` argument at call time against the live source list.
201
+ const toolDisposers = new Map() // modelName -> disposer (plugin-owned tools)
202
+ const coreDisposers = toolDisposers
203
+
204
+ /** Total registered dbhub tools (constant set: configure/list/execute/search/query/query_objects). */
205
+ export function activeToolCount() {
206
+ return coreDisposers.size
207
+ }
208
+
209
+ function refreshToolCount() {
210
+ state.setToolCount(activeToolCount())
211
+ }
212
+
213
+ /**
214
+ * Resolve a `source` reference (from dbhub_list_sources) to a live source row.
215
+ * Accepts: the exact source id (`<title>_<hash>[_<env>]`), the display titles
216
+ * `<title>` / `<title> <env>` / `<title>_<env>` / `<title>.<env>`.
217
+ * @returns the matched {@link collectSources} row + its source id, or
218
+ * `undefined` when nothing matches.
219
+ */
220
+ export async function resolveSource(ctx, subprocess, sourceRef) {
221
+ const wanted = String(sourceRef || '').trim().toLowerCase()
222
+ if (!wanted) return undefined
223
+ const { rows } = await collectSources(ctx, subprocess)
224
+ const keyed = rows.map((r, i) => {
225
+ const title = slugify(r.title)
226
+ const env = r.env === 'default' ? '' : envSlug(r.env)
227
+ const id = title + '_' + shortHash(r.wsPath) + (env ? '_' + env : '')
228
+ return { row: r, id, keys: new Set([id, title, env ? title + '_' + env : title, env ? title + ' ' + r.env : title, env ? title + '.' + r.env : title, String(i)]) }
229
+ })
230
+ return keyed.find((k) => k.keys.has(wanted) || k.id === wanted || (wanted.includes(k.id)))
231
+ }
232
+
233
+ /** Human label for a source row ({连接: <title> → masked dsn}). */
234
+ export function sourceLabel(row) {
235
+ return row ? '{连接: ' + row.title + ' → ' + maskDsn(row.dsn) + '}' : ''
236
+ }
237
+
238
+ // ── warm-call fast path & lazy ensure ─────────────────────────────────────
239
+
240
+ let idleDispose
241
+ function resetIdle(ctx) {
242
+ if (idleDispose) {
243
+ try {
244
+ idleDispose()
245
+ } catch (e) {
246
+ /* ignore */
247
+ }
248
+ idleDispose = undefined
249
+ }
250
+ if (!server.handle) return
251
+ const idleMs = (options.get('idleMinutes') || 10) * 60 * 1000
252
+ idleDispose = ctx.timeout(() => {
253
+ idleDispose = undefined
254
+ console.log('[dsh-dbhub-live] idle — recycling dbhub process')
255
+ terminateServer()
256
+ }, idleMs)
257
+ }
258
+
259
+ // Re-resolve configs only when the durable inputs change (workspace registry
260
+ // file, credentials store) — otherwise skip straight to a live-server check.
261
+ // Keeps repeated tool calls at ~0 overhead.
262
+ let lastInputMtime = 0
263
+
264
+ // Live connection-row cache: refreshed by every collectSources run so the
265
+ // settings publish path can mirror the card without an extra walk.
266
+ let lastRowCache = []
267
+
268
+ /** Masked connection rows for the card (never leaks passwords over the wire). */
269
+ export function summarizeRows(rows) {
270
+ return rows.map((r) => {
271
+ const srcId = slugify(r.title) + '_' + shortHash(r.wsPath) + (r.env === 'default' ? '' : '_' + envSlug(r.env))
272
+ return {
273
+ title: r.title,
274
+ path: r.wsPath,
275
+ env: r.env,
276
+ dsn: maskDsn(r.dsn),
277
+ source: r.source,
278
+ persisted: !!r.persisted,
279
+ srcId,
280
+ }
281
+ })
282
+ }
283
+
284
+ /** The last collected summary set (empty until the first walk). */
285
+ export function latestSummaries() {
286
+ return summarizeRows(lastRowCache)
287
+ }
288
+
289
+ /** mtime of the durable config inputs (workspace registry + credentials). */
290
+ export function configInputsMtime() {
291
+ let max = 0
292
+ for (const p of [join(dshHomeDir(), 'storages', 'workspace.json'), STORE_PATH]) {
293
+ try {
294
+ const m = statSync(p).mtimeMs
295
+ if (m > max) max = m
296
+ } catch (e) {
297
+ /* missing file is fine */
298
+ }
299
+ }
300
+ return max
301
+ }
302
+
303
+ /**
304
+ * Walk every workspace × environment and produce the live connection rows
305
+ * (for the card's masked summaries) and the dbhub source list (for the toml
306
+ * and per-workspace tool names). Shared by `ensureRunning` and the settings
307
+ * publish path so the card always mirrors what the server would load.
308
+ * Environment naming: the 'default' env keeps the legacy bare source id
309
+ * (`<title>_<hash>`); extra environments append `_<envSlug>`.
310
+ * @returns `{ rows, sources }` — rows: [{ wsPath, title, env, dsn, source,
311
+ * persisted }]; sources: [{ id, dsn }].
312
+ */
313
+ export async function collectSources(ctx, subprocess) {
314
+ const rows = []
315
+ const sources = []
316
+ try {
317
+ const workspaces = await listWorkspaces(ctx)
318
+ const persisted = listWorkspaceEnvironments(store)
319
+ const byPath = new Map()
320
+ for (const p of persisted) {
321
+ if (!byPath.has(p.wsPath)) byPath.set(p.wsPath, [])
322
+ byPath.get(p.wsPath).push(p)
323
+ }
324
+ for (const ws of workspaces) {
325
+ const title = ws.title || basename(ws.path)
326
+ const baseId = slugify(title) + '_' + shortHash(ws.path)
327
+ const resolved = await resolveWorkspaceEnvs(subprocess, ctx.get('fs'), ws.path, undefined)
328
+ const persistedEnvs = new Set((byPath.get(ws.path) || []).map((p) => p.env))
329
+ for (const r of resolved) {
330
+ const slug = envSlug(r.env)
331
+ const id = baseId + (slug ? '_' + slug : '')
332
+ rows.push({ wsPath: ws.path, title, env: r.env, dsn: r.dsn, source: r.source, persisted: persistedEnvs.has(r.env) })
333
+ sources.push({ id, dsn: r.dsn })
334
+ }
335
+ }
336
+ } catch (e) {
337
+ console.error('[dsh-dbhub-live] config generation failed: ' + String((e && e.message) || e))
338
+ }
339
+ lastRowCache = rows
340
+ return { rows, sources }
341
+ }
342
+
343
+ /**
344
+ * Make sure the persistent server is up and synced, initializing lazily on
345
+ * first use. Concurrent callers share one in-flight start. Regenerates the
346
+ * toml whenever the workspace set or its resolved DSNs change.
347
+ * @returns a promise settling when the server is ready (never rejects; the
348
+ * caller reads `server.client` to learn readiness).
349
+ */
350
+ export async function ensureRunning(ctx, subprocess) {
351
+ if (!state.isEnabled()) return
352
+ const inputMtime = configInputsMtime()
353
+ const serverUp = server.client && !server.client.closed
354
+ if (serverUp && inputMtime <= lastInputMtime && server.generatedFingerprint !== undefined) {
355
+ resetIdle(ctx)
356
+ return
357
+ }
358
+ const { sources } = await collectSources(ctx, subprocess)
359
+ lastInputMtime = inputMtime
360
+ const fp = fingerprintOf(sources)
361
+ // Never spawn with zero sources: dbhub treats an empty [[sources]] toml as
362
+ // fatal, so without a configured workspace the server must stay down and the
363
+ // fast path above retries as soon as a source appears (mtime/fingerprint).
364
+ if (sources.length === 0) {
365
+ if (fp !== server.generatedFingerprint) {
366
+ console.log('[dsh-dbhub-live] no workspace sources yet — use dbhub_configure')
367
+ server.generatedFingerprint = ''
368
+ }
369
+ if (server.handle) terminateServer()
370
+ return
371
+ }
372
+ if (fp !== server.generatedFingerprint) {
373
+ if (!writeToml(generateToml(sources))) {
374
+ // A blocked/unwritable storage dir is a real init failure: surface it in
375
+ // the state machine (phase 'error' + message) and retry on next call.
376
+ throw new Error('写入 dbhub.toml 失败(配置目录不可写)')
377
+ }
378
+ server.generatedFingerprint = fp
379
+ // config changed while a server is up -> restart is deterministic
380
+ // (dbhub also hot-reloads its config, but a respawn re-syncs cleanly).
381
+ if (server.handle) terminateServer()
382
+ }
383
+ if (server.client && !server.client.closed) {
384
+ resetIdle(ctx)
385
+ return
386
+ }
387
+ if (server.starting) {
388
+ try {
389
+ await server.starting
390
+ } catch (e) {
391
+ /* the owner below recorded the error */
392
+ }
393
+ return
394
+ }
395
+ server.starting = (async () => {
396
+ try {
397
+ state.setPhase('initializing')
398
+ const r = await spawnServer(subprocess, undefined)
399
+ if (r.error) throw new Error(r.error)
400
+ state.clearError()
401
+ state.setPhase('running')
402
+ refreshToolCount()
403
+ resetIdle(ctx)
404
+ } catch (e) {
405
+ state.recordError(String((e && e.message) || e))
406
+ throw e
407
+ } finally {
408
+ server.starting = undefined
409
+ }
410
+ })()
411
+ try {
412
+ await server.starting
413
+ } catch (e) {
414
+ /* failure recorded + surfaced through runOnServer's {ok:false} */
415
+ }
416
+ }
417
+
418
+ // ── MCP call over the persistent server ───────────────────────────────────
419
+
420
+ export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
421
+ if (!state.isEnabled()) {
422
+ return { ok: false, text: disabledMessage() }
423
+ }
424
+ try {
425
+ await ensureRunning(ctx, subprocess)
426
+ } catch (e) {
427
+ return { ok: false, text: currentT('result.serverStartFail', { msg: String((e && e.message) || e) }) }
428
+ }
429
+ if (!server.client) return { ok: false, text: currentT('result.serverUnavailable') }
430
+ try {
431
+ const res = await server.client.request('tools/call', { name: rawName, arguments: args || {} })
432
+ const textOf = (r) => {
433
+ if (r && Array.isArray(r.content)) {
434
+ const parts = []
435
+ for (const b of r.content) if (b && typeof b.text === 'string') parts.push(b.text)
436
+ return parts.join('\n')
437
+ }
438
+ return JSON.stringify(r)
439
+ }
440
+ resetIdle(ctx)
441
+ const prefix = label ? label + '\n' : ''
442
+ if (res && res.isError) return { ok: false, text: textOf(res) }
443
+ if (res && res.structuredContent !== undefined) {
444
+ return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
445
+ }
446
+ return { ok: true, text: prefix + textOf(res) }
447
+ } catch (e) {
448
+ return { ok: false, text: String((e && e.message) || e) }
449
+ }
450
+ }
451
+
452
+ // Shared "plugin disabled" copy for every dbhub tool (locale-aware).
453
+ export function disabledMessage() {
454
+ return currentT('result.disabled')
455
+ }
456
+
457
+ /** Whether the persistent server currently answers calls. */
458
+ export function isServerUp() {
459
+ return !!(server.client && !server.client.closed)
460
+ }
461
+
462
+ export { coreDisposers, refreshToolCount }
@@ -0,0 +1,94 @@
1
+ // dsh-dbhub-live: deployment-tunable options.
2
+ //
3
+ // Editable through the Settings -> Plugins -> dsh-dbhub-live card. Precedence:
4
+ // user settings (settings document) > process environment > built-in default
5
+ // Environment variables only seed the boot values; once the settings document
6
+ // claims a field (first publish writes the section), that field is owned by
7
+ // the settings UI until it is unset — so an env change no longer applies to a
8
+ // claimed field. This mirrors how the dsh settings system treats user layers.
9
+
10
+ const BUILTIN_DEFAULTS = {
11
+ dbhubPackage: '@bytebase/dbhub',
12
+ updateIntervalDays: 7,
13
+ idleMinutes: 10,
14
+ }
15
+
16
+ function envNumber(name, fallback) {
17
+ const raw = process.env[name]
18
+ if (raw === undefined || raw === '') return fallback
19
+ const n = Number(raw)
20
+ return Number.isFinite(n) && n >= 0 ? n : fallback
21
+ }
22
+
23
+ const options = {
24
+ dbhubPackage: process.env.DSH_DBHUB_PACKAGE || BUILTIN_DEFAULTS.dbhubPackage,
25
+ updateIntervalDays: envNumber('DSH_DBHUB_UPDATE_DAYS', BUILTIN_DEFAULTS.updateIntervalDays),
26
+ idleMinutes: BUILTIN_DEFAULTS.idleMinutes,
27
+ }
28
+
29
+ const listeners = new Set()
30
+
31
+ function emit() {
32
+ for (const fn of [...listeners]) {
33
+ try {
34
+ fn()
35
+ } catch (e) {
36
+ /* contain listener failures */
37
+ }
38
+ }
39
+ }
40
+
41
+ /** Subscribe to option changes; returns the disposer. */
42
+ export function subscribe(fn) {
43
+ listeners.add(fn)
44
+ return () => listeners.delete(fn)
45
+ }
46
+
47
+ /**
48
+ * Detached JSON snapshot of the settings-exposed options. `dbhubPackage` is
49
+ * deliberately excluded: the auto-install package stays an environment-seeded
50
+ * internal knob (DSH_DBHUB_PACKAGE / built-in default), not a settings field.
51
+ */
52
+ export function snapshot() {
53
+ return {
54
+ updateIntervalDays: options.updateIntervalDays,
55
+ idleMinutes: options.idleMinutes,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Read one option by id ('dbhubPackage' is internal-only).
61
+ * @param id - 'dbhubPackage' | 'updateIntervalDays' | 'idleMinutes'.
62
+ * @returns the current value.
63
+ */
64
+ export function get(id) {
65
+ return options[id]
66
+ }
67
+
68
+ // Per-field validators for the settings-exposed options; 'dbhubPackage' is
69
+ // deliberately not patchable (it stays an env/internal knob).
70
+ const VALIDATORS = {
71
+ updateIntervalDays: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
72
+ idleMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
73
+ }
74
+
75
+ /**
76
+ * Apply a validated patch onto the options.
77
+ * @param patch - partial option values (prefixes the env seed at boot).
78
+ * @returns whether any option actually changed.
79
+ */
80
+ export function applyPatch(patch) {
81
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false
82
+ let changed = false
83
+ for (const [key, validator] of Object.entries(VALIDATORS)) {
84
+ const value = patch[key]
85
+ if (value === undefined) continue
86
+ if (!validator(value)) continue
87
+ const next = key === 'dbhubPackage' ? String(value).trim() : value
88
+ if (next === options[key]) continue
89
+ options[key] = next
90
+ changed = true
91
+ }
92
+ if (changed) emit()
93
+ return changed
94
+ }