dsh-db-mcp 0.6.4

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/src/host.js ADDED
@@ -0,0 +1,1507 @@
1
+ /**
2
+ * dsh-db-mcp — Host implementation (loaded by ../index.js, re-importable).
3
+ *
4
+ * Owns the data-source registry and the JSON API behind the management panel:
5
+ * GET /health runtime facts for the diagnostics card
6
+ * GET /datasources registry, credentials projected away
7
+ * GET /dbeaver/scan connections already configured in DBeaver
8
+ * POST /datasources/save create or update a source
9
+ * POST /datasources/delete remove a source and its stored secret
10
+ * POST /datasources/test real login probe (SQLite via node:sqlite, the rest via the Python drivers)
11
+ * POST /dbeaver/import copy DBeaver connections into the registry
12
+ *
13
+ * Storage lives under <DSH_HOME>/db-mcp:
14
+ * datasources.json registry without credentials (atomic write, mode 0600)
15
+ * secrets.json per-source passwords, mode 0600, never returned to the page
16
+ *
17
+ * The Python MCP server and the MCP row wiring arrive in P3.
18
+ */
19
+ import { execFile } from 'node:child_process'
20
+ import { createDecipheriv } from 'node:crypto'
21
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
22
+ import { existsSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { connect as netConnect } from 'node:net'
27
+
28
+ // Cache-busting: index.js appends ?v=<generation> to this module's URL, and the
29
+ // nested imports must carry the same marker or Node would serve the previous
30
+ // copy after a reload.
31
+ const GENERATION = new URL(import.meta.url).searchParams.get('v') || '0'
32
+ const { createBridge } = await import(new URL(`./tools.js?v=${GENERATION}`, import.meta.url).href)
33
+
34
+ const API_PREFIX = '/plugins/db-mcp/api'
35
+ const PLUGIN_NAME = 'dsh-db-mcp'
36
+ const PLUGIN_VERSION = '0.6.4'
37
+ const SCHEMA_VERSION = 1
38
+ const REGISTRY_FILE = 'datasources.json'
39
+ const SECRETS_FILE = 'secrets.json'
40
+ const SETTINGS_FILE = 'settings.json'
41
+ const AUDIT_FILE = 'logs/audit.jsonl'
42
+ const AUDIT_LIMIT_BYTES = 512 * 1024
43
+ const PYTHON_CACHE_MS = 30_000
44
+ const MAX_BODY_BYTES = 1024 * 1024
45
+ const MAX_SOURCES = 32
46
+
47
+ const KINDS = ['sqlite', 'postgres', 'mysql', 'oracle']
48
+ const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, oracle: 1521 }
49
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/
50
+
51
+ /** DBeaver's own secure-storage key (DefaultSecureStorage.java). */
52
+ const DBEAVER_KEY = Buffer.from('babb4a9f774ab853c96c2d653dfe544a', 'hex')
53
+ const DBEAVER_IV = Buffer.alloc(16, 0)
54
+
55
+ /* ------------------------------------------------------------ small utils */
56
+
57
+ function text(value, fallback = '') {
58
+ return typeof value === 'string' ? value : fallback
59
+ }
60
+
61
+ function trimmed(value) {
62
+ return text(value).trim()
63
+ }
64
+
65
+ function cleanRecord(value) {
66
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
67
+ }
68
+
69
+ function isoNow() {
70
+ return new Date().toISOString()
71
+ }
72
+
73
+ async function readJson(file) {
74
+ let raw
75
+ try {
76
+ raw = await readFile(file, 'utf8')
77
+ } catch (error) {
78
+ if (error && error.code === 'ENOENT') return { missing: true, value: null, error: null }
79
+ return { missing: false, value: null, error: String((error && error.message) || error) }
80
+ }
81
+ try {
82
+ return { missing: false, value: JSON.parse(raw), error: null }
83
+ } catch (error) {
84
+ return { missing: false, value: null, error: `invalid JSON in ${file}: ${(error && error.message) || error}` }
85
+ }
86
+ }
87
+
88
+ async function writeJsonAtomic(file, value, mode) {
89
+ await mkdir(join(file, '..'), { recursive: true })
90
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`
91
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: mode || 0o600 })
92
+ await rename(temp, file)
93
+ if (mode) {
94
+ try {
95
+ await chmod(file, mode)
96
+ } catch {
97
+ /* best effort where POSIX modes are unavailable */
98
+ }
99
+ }
100
+ }
101
+
102
+ /* --------------------------------------------------------------- schemas */
103
+
104
+ function describeTarget(kind, config) {
105
+ const cfg = cleanRecord(config)
106
+ if (kind === 'sqlite') return trimmed(cfg.file)
107
+ const host = trimmed(cfg.host)
108
+ const port = cfg.port ? `:${cfg.port}` : ''
109
+ const database = trimmed(cfg.database) || trimmed(cfg.serviceName) || trimmed(cfg.sid)
110
+ return [host + port, database].filter(Boolean).join(' / ')
111
+ }
112
+
113
+ /**
114
+ * What makes two entries "the same connection": the engine, the endpoint, the
115
+ * database (or file) and the account. Names do not count, so a DBeaver entry
116
+ * that was already imported under another name is still a duplicate.
117
+ */
118
+ function sourceIdentity(kind, config, user) {
119
+ const cfg = cleanRecord(config)
120
+ if (kind === 'sqlite') {
121
+ const file = trimmed(cfg.file)
122
+ return `sqlite|${process.platform === 'win32' ? file.toLowerCase() : file}`
123
+ }
124
+ const host = trimmed(cfg.host).toLowerCase()
125
+ const port = String(cfg.port || DEFAULT_PORTS[kind] || '').trim()
126
+ const database = (trimmed(cfg.database) || trimmed(cfg.serviceName) || trimmed(cfg.sid)).toLowerCase()
127
+ const account = (user === undefined ? trimmed(cfg.user) : trimmed(user)).toLowerCase()
128
+ return `${kind || ''}|${host}|${port}|${database}|${account}`
129
+ }
130
+
131
+ /** Connection settings minus anything credential-shaped. */
132
+ function configToPublic(kind, config) { const cfg = cleanRecord(config)
133
+ if (kind === 'sqlite') return { file: trimmed(cfg.file), busyTimeoutMs: cfg.busyTimeoutMs ?? null }
134
+ return {
135
+ host: trimmed(cfg.host),
136
+ port: cfg.port ?? null,
137
+ database: trimmed(cfg.database),
138
+ serviceName: trimmed(cfg.serviceName),
139
+ sid: trimmed(cfg.sid),
140
+ user: trimmed(cfg.user),
141
+ schema: trimmed(cfg.schema),
142
+ sslMode: trimmed(cfg.sslMode),
143
+ charset: trimmed(cfg.charset),
144
+ }
145
+ }
146
+
147
+ function publicProfile(profile, secrets) {
148
+ if (!profile || typeof profile !== 'object') return null
149
+ const config = cleanRecord(profile.config)
150
+ const secret = secrets && profile.id ? secrets[profile.id] : null
151
+ return {
152
+ id: profile.id,
153
+ name: profile.name,
154
+ kind: profile.kind,
155
+ readOnly: profile.readOnly !== false,
156
+ isDefault: profile.isDefault === true,
157
+ tags: Array.isArray(profile.tags) ? profile.tags.filter((tag) => typeof tag === 'string') : [],
158
+ group: typeof profile.group === 'string' ? profile.group : '',
159
+ config: configToPublic(profile.kind, config),
160
+ target: describeTarget(profile.kind, config),
161
+ hasSecret: Boolean(secret && trimmed(secret.password)),
162
+ lastProbe: profile.lastProbe ?? null,
163
+ createdAt: profile.createdAt ?? null,
164
+ updatedAt: profile.updatedAt ?? null,
165
+ }
166
+ }
167
+
168
+ /** Validate raw user input, returning { profile, password, error }. */
169
+ function groupName(value) {
170
+ return typeof value === 'string' ? value.trim().slice(0, 48) : ''
171
+ }
172
+
173
+ function normalizeInput(input, existing) {
174
+ const raw = cleanRecord(input)
175
+ const kind = trimmed(raw.kind) || (existing && existing.kind) || ''
176
+ if (!KINDS.includes(kind)) return { error: `unsupported kind: ${kind || '(empty)'}` }
177
+
178
+ const id = trimmed(raw.id) || (existing && existing.id) || ''
179
+ if (!ID_PATTERN.test(id)) {
180
+ return { error: 'id must be 1-32 chars of a-z, 0-9 or "-", starting with a letter or digit' }
181
+ }
182
+ if (existing && trimmed(raw.id) && trimmed(raw.id) !== existing.id) {
183
+ return { error: 'id cannot be changed after creation' }
184
+ }
185
+
186
+ const name = trimmed(raw.name) || (existing && existing.name) || id
187
+ if (name.length > 64) return { error: 'name must be at most 64 characters' }
188
+
189
+ const incoming = cleanRecord(raw.config)
190
+ const config = { ...(Object.keys(incoming).length > 0 ? incoming : cleanRecord(existing && existing.config)) }
191
+
192
+ if (kind === 'sqlite') {
193
+ const file = trimmed(config.file)
194
+ if (!file) return { error: 'sqlite requires config.file' }
195
+ config.file = file
196
+ delete config.host
197
+ delete config.port
198
+ delete config.user
199
+ } else {
200
+ const host = trimmed(config.host)
201
+ if (!host) return { error: `${kind} requires config.host` }
202
+ config.host = host
203
+ const port = Number(config.port) || DEFAULT_PORTS[kind]
204
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: 'config.port must be 1-65535' }
205
+ config.port = port
206
+ config.user = trimmed(config.user)
207
+ config.database = trimmed(config.database)
208
+ config.serviceName = trimmed(config.serviceName)
209
+ config.sid = trimmed(config.sid)
210
+ if (kind === 'oracle' && !config.serviceName && !config.sid) {
211
+ return { error: 'oracle requires config.serviceName or config.sid' }
212
+ }
213
+ if (kind !== 'oracle' && !config.database) return { error: `${kind} requires config.database` }
214
+ delete config.file
215
+ }
216
+
217
+ const readOnly = raw.readOnly === undefined || raw.readOnly === null
218
+ ? (existing ? existing.readOnly !== false : true)
219
+ : raw.readOnly === true
220
+ const tags = Array.isArray(raw.tags)
221
+ ? raw.tags.map((tag) => trimmed(tag)).filter(Boolean).slice(0, 8)
222
+ : existing && Array.isArray(existing.tags) ? existing.tags : []
223
+ const group = raw.group === undefined || raw.group === null
224
+ ? (existing && typeof existing.group === 'string' ? existing.group : '')
225
+ : trimmed(raw.group)
226
+ if (group.length > 48) return { error: 'group must be at most 48 characters' }
227
+
228
+ const profile = {
229
+ id,
230
+ name,
231
+ kind,
232
+ config,
233
+ readOnly,
234
+ isDefault: raw.isDefault === true || (existing ? existing.isDefault === true : false),
235
+ tags,
236
+ group,
237
+ createdAt: (existing && existing.createdAt) || isoNow(),
238
+ updatedAt: isoNow(),
239
+ lastProbe: existing ? existing.lastProbe ?? null : null,
240
+ }
241
+
242
+ const password = raw.password === undefined || raw.password === null ? undefined : String(raw.password)
243
+ return { profile, password }
244
+ }
245
+
246
+ /* ----------------------------------------------------------------- probes */
247
+
248
+ function tcpProbe(host, port, timeoutMs) {
249
+ return new Promise((resolve) => {
250
+ const started = Date.now()
251
+ const socket = netConnect({ host, port })
252
+ let settled = false
253
+ const finish = (ok, message) => {
254
+ if (settled) return
255
+ settled = true
256
+ socket.destroy()
257
+ resolve({ ok, message, latencyMs: Date.now() - started })
258
+ }
259
+ socket.setTimeout(Math.max(500, timeoutMs))
260
+ socket.once('connect', () => finish(true, `TCP ${host}:${port} reachable`))
261
+ socket.once('timeout', () => finish(false, `TCP timeout after ${timeoutMs}ms`))
262
+ socket.once('error', (error) => finish(false, `${(error && error.code) || 'connect failed'}: ${(error && error.message) || error}`))
263
+ })
264
+ }
265
+
266
+ async function sqliteProbe(file, timeoutMs) {
267
+ const started = Date.now()
268
+ try {
269
+ await stat(file)
270
+ } catch {
271
+ return { ok: false, message: `file not found: ${file}`, latencyMs: Date.now() - started, detail: null }
272
+ }
273
+ let sqlite
274
+ try {
275
+ sqlite = await import('node:sqlite')
276
+ } catch (error) {
277
+ return {
278
+ ok: false,
279
+ message: `this Host has no node:sqlite (${(error && error.message) || error}); the Python probe covers it from P3`,
280
+ latencyMs: Date.now() - started,
281
+ detail: null,
282
+ }
283
+ }
284
+ let db
285
+ try {
286
+ db = new sqlite.DatabaseSync(file, { readOnly: true, timeout: Math.max(1000, timeoutMs) })
287
+ const tables = db.prepare("select name from sqlite_master where type = 'table' order by name limit 50").all()
288
+ const probe = db.prepare('select 1 as ok').get()
289
+ return {
290
+ ok: Boolean(probe && probe.ok === 1),
291
+ message: `SQLite opened read-only, ${tables.length} table(s)`,
292
+ latencyMs: Date.now() - started,
293
+ detail: { kind: 'sqlite', engine: 'node:sqlite', tables: tables.map((row) => row.name) },
294
+ }
295
+ } catch (error) {
296
+ return { ok: false, message: String((error && error.message) || error), latencyMs: Date.now() - started, detail: null }
297
+ } finally {
298
+ if (db) {
299
+ try {
300
+ db.close()
301
+ } catch {
302
+ /* ignore */
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ async function testProfile(profile, secret, options) {
309
+ const timeoutMs = (options && options.probeTimeoutMs) || 10_000
310
+ const config = cleanRecord(profile && profile.config)
311
+ if (!profile || !KINDS.includes(profile.kind)) {
312
+ return { ok: false, message: 'unknown datasource kind', detail: null, latencyMs: 0 }
313
+ }
314
+ if (profile.kind === 'sqlite') {
315
+ const result = await sqliteProbe(trimmed(config.file), timeoutMs)
316
+ return { ...result, target: describeTarget(profile.kind, config), hasPassword: false }
317
+ }
318
+
319
+ const password = secret && trimmed(secret.password) ? trimmed(secret.password) : undefined
320
+ const target = describeTarget(profile.kind, config)
321
+ const remote = options && options.remote
322
+ if (typeof remote === 'function') {
323
+ // A real login through the Python driver: this is what the panel's Test
324
+ // button shows for PostgreSQL, MySQL and Oracle.
325
+ const attempt = await remote(profile, password).catch(() => null)
326
+ if (attempt && typeof attempt.ok === 'boolean') {
327
+ const error = attempt.error || null
328
+ const message =
329
+ attempt.message ||
330
+ (error && (error.hint ? `${error.message} — ${error.hint}` : error.message)) ||
331
+ (attempt.ok ? 'connected' : 'connection failed')
332
+ return {
333
+ ok: attempt.ok === true,
334
+ message,
335
+ latencyMs: Number(attempt.latencyMs) || 0,
336
+ target,
337
+ hasPassword: Boolean(password),
338
+ detail: attempt.detail || { kind: profile.kind, stage: 'login' },
339
+ }
340
+ }
341
+ }
342
+
343
+ const host = trimmed(config.host)
344
+ const port = Number(config.port) || DEFAULT_PORTS[profile.kind]
345
+ const reach = await tcpProbe(host, port, timeoutMs)
346
+ const note = reach.ok
347
+ ? `${profile.kind} port reachable, but the authenticated login did not run (the Python MCP server is not up)`
348
+ : reach.message
349
+ return {
350
+ ok: reach.ok,
351
+ message: note,
352
+ latencyMs: reach.latencyMs,
353
+ target,
354
+ hasPassword: Boolean(password),
355
+ detail: { kind: profile.kind, stage: 'tcp', host, port, user: trimmed(config.user) || null },
356
+ }
357
+ }
358
+
359
+ /* ----------------------------------------------------------------- python */
360
+
361
+ function run(file, args, timeout) {
362
+ return new Promise((resolve) => {
363
+ execFile(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
364
+ resolve({
365
+ ok: !error,
366
+ stdout: String(stdout || '').trim(),
367
+ stderr: String(stderr || '').trim(),
368
+ error: error ? String(error.message || error) : null,
369
+ })
370
+ })
371
+ })
372
+ }
373
+
374
+ async function probeOne(command, timeout) {
375
+ const result = await run(command, ['--version'], timeout)
376
+ const banner = result.stdout || result.stderr
377
+ const match = /Python\s+(\d+\.\d+\.\d+)/i.exec(banner)
378
+ if (result.ok && match) return { ok: true, command, version: match[1], detail: banner.split('\n')[0] }
379
+ return { ok: false, command, version: null, detail: (result.error || banner || 'no Python banner').slice(0, 200) }
380
+ }
381
+
382
+ async function detectPython(config, cache) {
383
+ if (cache.value && Date.now() - cache.at < PYTHON_CACHE_MS) return cache.value
384
+ const timeout = (config && config.probeTimeoutMs) || 8000
385
+ const candidates = []
386
+ if (config && config.pythonPath) candidates.push(config.pythonPath)
387
+ candidates.push('python', 'python3')
388
+ const attempts = []
389
+ let found = null
390
+ for (const command of candidates) {
391
+ const attempt = await probeOne(command, timeout)
392
+ attempts.push(attempt)
393
+ if (attempt.ok) {
394
+ found = attempt
395
+ break
396
+ }
397
+ }
398
+ const value = found
399
+ ? { ok: true, command: found.command, version: found.version, attempts }
400
+ : { ok: false, command: null, version: null, attempts }
401
+ cache.value = value
402
+ cache.at = Date.now()
403
+ return value
404
+ }
405
+
406
+ /* ---------------------------------------------------------------- DBeaver */
407
+
408
+ function dbeaverRoots() {
409
+ const roots = []
410
+ if (process.env.APPDATA) roots.push(join(process.env.APPDATA, 'DBeaverData'))
411
+ roots.push(join(homedir(), 'AppData', 'Roaming', 'DBeaverData'))
412
+ roots.push(join(homedir(), '.local', 'share', 'DBeaverData'))
413
+ roots.push(join(homedir(), 'Library', 'DBeaverData'))
414
+ return [...new Set(roots)]
415
+ }
416
+
417
+ async function listDir(dir) {
418
+ try {
419
+ const { readdir } = await import('node:fs/promises')
420
+ return await readdir(dir, { withFileTypes: true })
421
+ } catch {
422
+ return []
423
+ }
424
+ }
425
+
426
+ /** Decrypt DBeaver's credentials-config.json (AES-128-CBC, zero IV, 16-byte prefix). */
427
+ function decryptDBeaverCredentials(buffer) {
428
+ try {
429
+ const decipher = createDecipheriv('aes-128-cbc', DBEAVER_KEY, DBEAVER_IV)
430
+ const plain = Buffer.concat([decipher.update(buffer), decipher.final()])
431
+ for (const candidate of [plain.subarray(16), plain]) {
432
+ const body = candidate.toString('utf8').replace(/\u0000+$/g, '').trim()
433
+ if (!body.startsWith('{')) continue
434
+ try {
435
+ const parsed = JSON.parse(body)
436
+ if (parsed && typeof parsed === 'object') return parsed
437
+ } catch {
438
+ /* try the next shape */
439
+ }
440
+ }
441
+ return null
442
+ } catch {
443
+ return null
444
+ }
445
+ }
446
+
447
+ function pickCredential(entry) {
448
+ const queue = [entry]
449
+ const seen = new Set()
450
+ while (queue.length > 0) {
451
+ const node = queue.shift()
452
+ if (!node || typeof node !== 'object' || seen.has(node)) continue
453
+ seen.add(node)
454
+ const password = node.password ?? node['#password']
455
+ if (typeof password === 'string') {
456
+ return { password, user: typeof node.user === 'string' ? node.user : null }
457
+ }
458
+ for (const value of Object.values(node)) {
459
+ if (value && typeof value === 'object') queue.push(value)
460
+ }
461
+ }
462
+ return null
463
+ }
464
+
465
+ function mapDBeaverProvider(provider, driver) {
466
+ const p = `${provider || ''} ${driver || ''}`.toLowerCase()
467
+ if (p.includes('sqlite')) return 'sqlite'
468
+ if (p.includes('postgres')) return 'postgres'
469
+ if (p.includes('mysql') || p.includes('mariadb')) return 'mysql'
470
+ if (p.includes('oracle')) return 'oracle'
471
+ return null
472
+ }
473
+
474
+ function parseOracleUrl(url) {
475
+ const match = /jdbc:oracle:thin:@(?:\/\/)?([^:/]+)(?::(\d+))?[:/]([^?]+)/i.exec(url || '')
476
+ if (!match) return null
477
+ return { host: match[1], port: match[2] ? Number(match[2]) : 1521, serviceName: match[3] }
478
+ }
479
+
480
+ /**
481
+ * DBeaver stores a connection either field-by-field (host/port/database) or, for
482
+ * the URL configuration type, only as a JDBC URL — the MySQL connection created
483
+ * through the driver dialog has just `host`, `port` and the URL, with the
484
+ * database and the user only inside the URL. Parse it so both shapes import.
485
+ */
486
+ function parseJdbcUrl(url) {
487
+ const text = trimmed(url)
488
+ if (!text) return null
489
+ const body = text.replace(/^jdbc:/i, '')
490
+ const match = /^([a-z0-9+]+):\/\/(?:([^:@/]+)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?]+)(?::(\d+))?(?:\/([^?;]*))?/i.exec(body)
491
+ if (!match) return null
492
+ return {
493
+ scheme: match[1].toLowerCase(),
494
+ user: match[2] ? decodeURIComponent(match[2]) : '',
495
+ password: match[3] ? decodeURIComponent(match[3]) : '',
496
+ host: match[4].replace(/^\[|\]$/g, ''),
497
+ port: match[5] ? Number(match[5]) : null,
498
+ database: match[6] ? decodeURIComponent(match[6].split(';')[0]) : '',
499
+ }
500
+ }
501
+
502
+ function mapDBeaverConnection(connection) {
503
+ const cfg = cleanRecord(connection && connection.configuration)
504
+ const kind = mapDBeaverProvider(connection && connection.provider, connection && connection.driver)
505
+ if (!kind) return { ok: false, reason: `unsupported provider: ${(connection && connection.provider) || 'unknown'}` }
506
+ const fromUrl = parseJdbcUrl(cfg.url)
507
+ // With configurationType=URL the URL is what DBeaver actually connects with,
508
+ // so its values win over the (possibly stale) form fields.
509
+ const preferUrl = /^url$/i.test(trimmed(cfg.configurationType))
510
+ const pick = (fromUrlValue, fieldValue, fallback = '') => {
511
+ const url = fromUrlValue === undefined || fromUrlValue === null ? '' : fromUrlValue
512
+ const field = typeof fieldValue === 'string' ? fieldValue.trim() : fieldValue
513
+ return (preferUrl ? url || field : field || url) ?? fallback
514
+ }
515
+
516
+ if (kind === 'sqlite') {
517
+ const file = trimmed(cfg.database) || trimmed(cfg.url).replace(/^jdbc:sqlite:/i, '')
518
+ if (!file) return { ok: false, reason: 'no database file in the DBeaver entry' }
519
+ return { ok: true, kind, config: { file }, user: '' }
520
+ }
521
+
522
+ if (kind === 'oracle') {
523
+ const thin = parseOracleUrl(cfg.url)
524
+ const host = pick(thin && thin.host, trimmed(cfg.host))
525
+ const port = Number(pick(thin && thin.port, Number(cfg.port)) || 0) || 1521
526
+ const serviceName = trimmed(cfg.serviceName) || (thin && thin.serviceName) || ''
527
+ const sid = trimmed(cfg.sid) || ''
528
+ if (!host || (!serviceName && !sid)) return { ok: false, reason: 'incomplete Oracle host/service in the DBeaver entry' }
529
+ return {
530
+ ok: true,
531
+ kind,
532
+ config: { host, port, serviceName, sid, user: trimmed(cfg.user), schema: trimmed(cfg.schema) },
533
+ user: trimmed(cfg.user),
534
+ }
535
+ }
536
+
537
+ const host = pick(fromUrl && fromUrl.host, trimmed(cfg.host))
538
+ const database = pick(fromUrl && fromUrl.database, trimmed(cfg.database))
539
+ const user = pick(fromUrl && fromUrl.user, trimmed(cfg.user))
540
+ const port = Number(pick(fromUrl && fromUrl.port, Number(cfg.port)) || 0) || DEFAULT_PORTS[kind]
541
+ if (!host || !database) {
542
+ const seen = [
543
+ `host=${host || '?'}`,
544
+ `database=${database || '?'}`,
545
+ `url=${trimmed(cfg.url) || '-'}`,
546
+ ].join(', ')
547
+ return { ok: false, reason: `incomplete host/database in the DBeaver entry (${seen})` }
548
+ }
549
+ return {
550
+ ok: true,
551
+ kind,
552
+ config: {
553
+ host,
554
+ port,
555
+ database,
556
+ user,
557
+ schema: trimmed(cfg.schema),
558
+ sslMode: trimmed(cfg.sslMode),
559
+ },
560
+ user,
561
+ }
562
+ }
563
+
564
+ async function scanDBeaver(options = {}) {
565
+ // The scanning endpoint must never hand a password to the page, but the
566
+ // import path needs it to seed secrets.json, so it is opt-in here.
567
+ const withSecrets = options.withSecrets === true
568
+ const existing = Array.isArray(options.existing) ? options.existing : []
569
+ // Anything already configured — or already seen earlier in this scan — is a duplicate.
570
+ const taken = new Map()
571
+ for (const profile of existing) {
572
+ taken.set(sourceIdentity(profile.kind, profile.config, profile.config && profile.config.user), {
573
+ id: profile.id,
574
+ name: profile.name || profile.id,
575
+ })
576
+ }
577
+ const roots = dbeaverRoots()
578
+ const files = []
579
+ for (const root of roots) {
580
+ const entries = await listDir(root)
581
+ for (const entry of entries) {
582
+ if (!entry.isDirectory()) continue
583
+ if (!/^workspace\d*$/i.test(entry.name) && entry.name !== 'General') continue
584
+ const base = entry.name === 'General' ? root : join(root, entry.name)
585
+ for (const candidate of [
586
+ join(base, 'General', '.dbeaver', 'data-sources.json'),
587
+ join(base, '.dbeaver', 'data-sources.json'),
588
+ ]) {
589
+ try {
590
+ await stat(candidate)
591
+ if (!files.includes(candidate)) files.push(candidate)
592
+ } catch {
593
+ /* not here */
594
+ }
595
+ }
596
+ }
597
+ }
598
+
599
+ const sources = []
600
+ const problems = []
601
+ for (const file of files) {
602
+ const read = await readJson(file)
603
+ if (read.error || !read.value) {
604
+ problems.push({ file, error: read.error || 'unreadable' })
605
+ continue
606
+ }
607
+ const credentialsFile = join(file, '..', 'credentials-config.json')
608
+ let credentials = null
609
+ try {
610
+ credentials = decryptDBeaverCredentials(await readFile(credentialsFile))
611
+ } catch {
612
+ credentials = null
613
+ }
614
+ const connections = cleanRecord(read.value.connections)
615
+ const items = []
616
+ for (const [id, connection] of Object.entries(connections)) {
617
+ const mapped = mapDBeaverConnection(connection)
618
+ if (!mapped.ok) {
619
+ items.push({
620
+ id,
621
+ name: (connection && connection.name) || id,
622
+ provider: (connection && connection.provider) || null,
623
+ importable: false,
624
+ reason: mapped.reason,
625
+ })
626
+ continue
627
+ }
628
+ const credential = credentials ? pickCredential(credentials[id]) : null
629
+ const config = { ...mapped.config }
630
+ const credentialUser = (credential && credential.user) || ''
631
+ if (!config.user && credentialUser) config.user = credentialUser
632
+ const identity = sourceIdentity(mapped.kind, config, config.user)
633
+ const duplicate = taken.get(identity) || null
634
+ const displayName = (connection && connection.name) || id
635
+ if (!duplicate) taken.set(identity, { id, name: displayName })
636
+ items.push({
637
+ id,
638
+ name: displayName,
639
+ provider: (connection && connection.provider) || null,
640
+ driver: (connection && connection.driver) || null,
641
+ kind: mapped.kind,
642
+ config,
643
+ user: config.user || '',
644
+ target: describeTarget(mapped.kind, config),
645
+ hasPassword: Boolean(credential && credential.password),
646
+ importable: true,
647
+ alreadyImported: Boolean(duplicate),
648
+ ...(duplicate ? { duplicateId: duplicate.id, duplicateName: duplicate.name } : {}),
649
+ ...(withSecrets && credential && credential.password ? { password: credential.password } : {}),
650
+ })
651
+ }
652
+ sources.push({ file, credentialsFile, credentialsFound: Boolean(credentials), connections: items })
653
+ }
654
+
655
+ return { roots, sources, problems }
656
+ }
657
+
658
+ /* -------------------------------------------------------------- transport */
659
+
660
+ function sendJson(res, status, body) {
661
+ const payload = JSON.stringify(body)
662
+ res.writeHead(status, {
663
+ 'content-type': 'application/json; charset=utf-8',
664
+ 'cache-control': 'no-store',
665
+ 'content-length': Buffer.byteLength(payload),
666
+ })
667
+ res.end(payload)
668
+ }
669
+
670
+ /** Same-origin guard for writes: a browser POST from our own page carries our Host. */
671
+ function sameOrigin(request) {
672
+ const origin = request.headers.origin
673
+ if (!origin) return true
674
+ try {
675
+ return new URL(origin).host === request.headers.host
676
+ } catch {
677
+ return false
678
+ }
679
+ }
680
+
681
+ function readBody(request) {
682
+ return new Promise((resolve, reject) => {
683
+ const chunks = []
684
+ let size = 0
685
+ request.on('data', (chunk) => {
686
+ size += chunk.length
687
+ if (size > MAX_BODY_BYTES) {
688
+ reject(new Error('request body too large'))
689
+ request.destroy()
690
+ return
691
+ }
692
+ chunks.push(chunk)
693
+ })
694
+ request.on('end', () => {
695
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
696
+ if (!raw) return resolve({})
697
+ try {
698
+ const parsed = JSON.parse(raw)
699
+ resolve(parsed && typeof parsed === 'object' ? parsed : {})
700
+ } catch (error) {
701
+ reject(new Error(`invalid JSON body: ${(error && error.message) || error}`))
702
+ }
703
+ })
704
+ request.on('error', reject)
705
+ })
706
+ }
707
+
708
+ /* ------------------------------------------------------------------- host */
709
+
710
+ export function createHost(options = {}) {
711
+ const config = cleanRecord(options.config)
712
+ const dir = options.dir || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'db-mcp')
713
+ const startedAt = Date.now()
714
+ const pythonCache = { at: 0, value: null }
715
+ const registryFile = join(dir, REGISTRY_FILE)
716
+ const secretsFile = join(dir, SECRETS_FILE)
717
+
718
+ const bridge = createBridge({
719
+ ctx: options.ctx,
720
+ config,
721
+ packageDir: options.packageDir || fileURLToPath(new URL('..', import.meta.url)),
722
+ runtimeDir: dir,
723
+ settings: () => readSettings(),
724
+ audit: (entry) => appendAudit(entry),
725
+ log: (message) => {
726
+ if (config.debug) console.log(`[db-mcp] ${message}`)
727
+ },
728
+ })
729
+
730
+ async function ensureMcp() {
731
+ try {
732
+ return await bridge.ensure()
733
+ } catch (error) {
734
+ return { ...bridge.status(), phase: 'failed', error: String((error && error.message) || error) }
735
+ }
736
+ }
737
+
738
+ async function readRegistry() {
739
+ const read = await readJson(registryFile)
740
+ if (read.missing) return { datasources: [], groups: [], error: null, missing: true }
741
+ if (read.error) return { datasources: [], groups: [], error: read.error, missing: false }
742
+ const list = read.value && Array.isArray(read.value.datasources) ? read.value.datasources : []
743
+ const declared = read.value && Array.isArray(read.value.groups) ? read.value.groups : []
744
+ const names = [
745
+ ...declared.map((name) => groupName(name)),
746
+ ...list.map((item) => groupName(item && item.group)),
747
+ ].filter(Boolean)
748
+ return {
749
+ datasources: list.filter((item) => item && typeof item === 'object'),
750
+ groups: [...new Set(names)],
751
+ error: null,
752
+ missing: false,
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Groups are user-created; a group that still holds sources survives even when
758
+ * the caller does not pass the list again, so every existing write keeps them.
759
+ */
760
+ async function writeRegistry(datasources, groups) {
761
+ let kept = groups
762
+ if (!Array.isArray(kept)) {
763
+ const read = await readJson(registryFile)
764
+ kept = read.value && Array.isArray(read.value.groups) ? read.value.groups : []
765
+ }
766
+ const names = [
767
+ ...kept.map((name) => groupName(name)),
768
+ ...datasources.map((item) => groupName(item && item.group)),
769
+ ].filter(Boolean)
770
+ await writeJsonAtomic(
771
+ registryFile,
772
+ {
773
+ schemaVersion: SCHEMA_VERSION,
774
+ updatedAt: isoNow(),
775
+ groups: [...new Set(names)],
776
+ datasources,
777
+ },
778
+ 0o600,
779
+ )
780
+ }
781
+
782
+ async function readSecrets() {
783
+ const read = await readJson(secretsFile)
784
+ const secrets = read.value && cleanRecord(read.value.secrets) ? read.value.secrets : {}
785
+ return { secrets, error: read.error || null }
786
+ }
787
+
788
+ async function writeSecrets(secrets) {
789
+ await writeJsonAtomic(secretsFile, { schemaVersion: SCHEMA_VERSION, secrets }, 0o600)
790
+ }
791
+
792
+ const settingsFile = join(dir, SETTINGS_FILE)
793
+ const auditFile = join(dir, AUDIT_FILE)
794
+
795
+ async function readSettings() {
796
+ const read = await readJson(settingsFile)
797
+ const value = cleanRecord(read.value)
798
+ return {
799
+ file: settingsFile,
800
+ error: read.error || null,
801
+ // Writes stay off until somebody deliberately turns them on for a source.
802
+ allowWrite: value.allowWrite === true,
803
+ auditLog: value.auditLog !== false,
804
+ updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : null,
805
+ }
806
+ }
807
+
808
+ async function writeSettings(patch) {
809
+ const current = await readSettings()
810
+ const next = {
811
+ allowWrite: patch.allowWrite === undefined ? current.allowWrite : patch.allowWrite === true,
812
+ auditLog: patch.auditLog === undefined ? current.auditLog : patch.auditLog === true,
813
+ updatedAt: isoNow(),
814
+ }
815
+ await writeJsonAtomic(settingsFile, next, 0o600)
816
+ return { ...next, file: settingsFile, error: null }
817
+ }
818
+
819
+ async function appendAudit(entry) {
820
+ const settings = await readSettings()
821
+ if (!settings.auditLog) return
822
+ try {
823
+ await mkdir(join(dir, 'logs'), { recursive: true })
824
+ await writeFile(auditFile, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' })
825
+ const info = await stat(auditFile).catch(() => null)
826
+ if (info && info.size > AUDIT_LIMIT_BYTES) {
827
+ await rename(auditFile, join(dir, 'logs', 'audit.1.jsonl')).catch(() => {})
828
+ }
829
+ } catch {
830
+ // Auditing must never break a tool call.
831
+ }
832
+ }
833
+
834
+ async function readAudit(limit = 20) {
835
+ let raw
836
+ try {
837
+ raw = await readFile(auditFile, 'utf8')
838
+ } catch (error) {
839
+ if (error && error.code === 'ENOENT') return { entries: [], error: null }
840
+ return { entries: [], error: String((error && error.message) || error) }
841
+ }
842
+ const entries = []
843
+ for (const line of raw.split('\n')) {
844
+ if (!line.trim()) continue
845
+ try {
846
+ entries.push(JSON.parse(line))
847
+ } catch {
848
+ // skip a torn line
849
+ }
850
+ }
851
+ return { entries: entries.slice(-Math.max(1, Math.min(200, limit))).reverse(), error: null }
852
+ }
853
+
854
+ async function pythonEnv() {
855
+ const python = await detectPython(config, pythonCache)
856
+ const venvDir = join(dir, '.venv')
857
+ const venvPython = join(venvDir, 'Scripts', 'python.exe')
858
+ const venvPosix = join(venvDir, 'bin', 'python')
859
+ const interpreter = existsSync(venvPython) ? venvPython : existsSync(venvPosix) ? venvPosix : null
860
+ const requirements = join(options.packageDir || fileURLToPath(new URL('..', import.meta.url)), 'server', 'requirements.txt')
861
+ let packages = null
862
+ if (interpreter) {
863
+ const script = [
864
+ 'import importlib.metadata as m',
865
+ 'out = []',
866
+ "for name in ['mcp', 'psycopg', 'PyMySQL', 'oracledb']:",
867
+ ' try:',
868
+ " out.append(name + '=' + m.version(name))",
869
+ ' except Exception:',
870
+ " out.append(name + '=missing')",
871
+ "print(';'.join(out))",
872
+ ].join('\n')
873
+ const probe = await run(interpreter, ['-c', script], 20000)
874
+ packages = probe.ok ? probe.stdout : null
875
+ }
876
+ return {
877
+ ok: Boolean(interpreter),
878
+ detected: python,
879
+ venvDir,
880
+ venvPython: interpreter,
881
+ requirementsFile: requirements,
882
+ requirementsPresent: existsSync(requirements),
883
+ packages,
884
+ error: interpreter ? null : 'no virtual environment at ' + venvDir,
885
+ }
886
+ }
887
+
888
+ async function setupPython() {
889
+ const python = await detectPython(config, pythonCache)
890
+ if (!python.ok || !python.command) {
891
+ return { status: 400, body: { ok: false, error: 'no usable Python interpreter was found on this machine' } }
892
+ }
893
+ const venvDir = join(dir, '.venv')
894
+ const venvPython = process.platform === 'win32' ? join(venvDir, 'Scripts', 'python.exe') : join(venvDir, 'bin', 'python')
895
+ const steps = []
896
+ if (!existsSync(venvPython)) {
897
+ const created = await run(python.command, ['-m', 'venv', venvDir], 180000)
898
+ steps.push({ step: 'create venv', ok: created.ok, output: (created.stdout || created.stderr || created.error || '').slice(-2000) })
899
+ if (!created.ok) return { status: 500, body: { ok: false, steps } }
900
+ }
901
+ const requirements = join(options.packageDir || fileURLToPath(new URL('..', import.meta.url)), 'server', 'requirements.txt')
902
+ const install = await run(venvPython, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', requirements], 600000)
903
+ steps.push({ step: 'pip install -r server/requirements.txt', ok: install.ok, output: (install.stdout || install.stderr || install.error || '').slice(-4000) })
904
+ if (install.ok) await bridge.restart().catch(() => {})
905
+ return { status: install.ok ? 200 : 500, body: { ok: install.ok, steps, python: await pythonEnv() } }
906
+ }
907
+
908
+ async function listDatasources() {
909
+ const registry = await readRegistry()
910
+ const { secrets } = await readSecrets()
911
+ return {
912
+ file: registryFile,
913
+ missing: registry.missing,
914
+ error: registry.error,
915
+ datasources: registry.datasources.map((item) => publicProfile(item, secrets)).filter(Boolean),
916
+ }
917
+ }
918
+
919
+ async function health() {
920
+ const listing = await listDatasources()
921
+ const registry = await readRegistry()
922
+ const python = await detectPython(config, pythonCache)
923
+ const mcpState = bridge.status()
924
+ if (mcpState.phase === 'idle') void ensureMcp()
925
+ return {
926
+ ok: listing.error === null,
927
+ plugin: { name: PLUGIN_NAME, version: PLUGIN_VERSION, phase: 'P7' },
928
+ host: {
929
+ node: process.version,
930
+ platform: `${process.platform}-${process.arch}`,
931
+ pid: process.pid,
932
+ startedAt: new Date(startedAt).toISOString(),
933
+ uptimeMs: Date.now() - startedAt,
934
+ },
935
+ python,
936
+ runtime: {
937
+ dir,
938
+ registryFile,
939
+ registryMissing: listing.missing,
940
+ registryError: listing.error,
941
+ secretsFile,
942
+ dbeaverRoots: dbeaverRoots(),
943
+ },
944
+ datasources: listing.datasources,
945
+ groups: registry.groups || [],
946
+ settings: await readSettings(),
947
+ mcp: {
948
+ serverName: trimmed(config.mcpServerName) || 'db',
949
+ wired: mcpState.phase === 'ready',
950
+ phase: mcpState.phase,
951
+ error: mcpState.error,
952
+ tools: mcpState.tools,
953
+ python: mcpState.python,
954
+ command: mcpState.command,
955
+ serverInfo: mcpState.serverInfo,
956
+ startedAt: mcpState.startedAt,
957
+ stderr: mcpState.stderr,
958
+ },
959
+ }
960
+ }
961
+
962
+ async function saveDatasource(payload) {
963
+ const registry = await readRegistry()
964
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
965
+ const list = [...registry.datasources]
966
+ const requestedId = trimmed(payload.id)
967
+ const index = requestedId ? list.findIndex((item) => item.id === requestedId) : -1
968
+ const existing = index >= 0 ? list[index] : null
969
+
970
+ const normalized = normalizeInput(payload, existing)
971
+ if (normalized.error) return { status: 400, body: { ok: false, error: normalized.error } }
972
+
973
+ const profile = normalized.profile
974
+ if (!existing && list.length >= MAX_SOURCES) {
975
+ return { status: 400, body: { ok: false, error: `at most ${MAX_SOURCES} datasources are supported` } }
976
+ }
977
+
978
+ if (profile.isDefault || list.length === 0) {
979
+ for (const item of list) item.isDefault = false
980
+ profile.isDefault = true
981
+ }
982
+ if (!list.some((item) => item.isDefault)) profile.isDefault = true
983
+
984
+ if (index >= 0) list[index] = profile
985
+ else list.push(profile)
986
+
987
+ await writeRegistry(list)
988
+
989
+ const secretChange = {}
990
+ if (normalized.password !== undefined) {
991
+ const { secrets } = await readSecrets()
992
+ if (normalized.password === '') {
993
+ if (secrets[profile.id]) {
994
+ delete secrets[profile.id]
995
+ secretChange.cleared = true
996
+ }
997
+ } else {
998
+ secrets[profile.id] = { password: normalized.password }
999
+ secretChange.saved = true
1000
+ }
1001
+ await writeSecrets(secrets)
1002
+ }
1003
+
1004
+ const { secrets } = await readSecrets()
1005
+ return { status: 200, body: { ok: true, datasource: publicProfile(profile, secrets), secret: secretChange } }
1006
+ }
1007
+
1008
+ /**
1009
+ * One endpoint for the list checkboxes: delete, move to a group, flip the
1010
+ * read-only flag, or probe several sources in one call.
1011
+ */
1012
+ async function bulkDatasources(payload) {
1013
+ const action = trimmed(payload.action)
1014
+ const ids = Array.isArray(payload.ids) ? [...new Set(payload.ids.map((id) => trimmed(id)).filter(Boolean))] : []
1015
+ if (!['delete', 'group', 'readOnly', 'test'].includes(action)) {
1016
+ return { status: 400, body: { ok: false, error: `unsupported bulk action: ${action || '(empty)'}` } }
1017
+ }
1018
+ if (ids.length === 0) return { status: 400, body: { ok: false, error: 'ids must be a non-empty array' } }
1019
+
1020
+ const registry = await readRegistry()
1021
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1022
+ const known = new Set(registry.datasources.map((item) => item.id))
1023
+ const missing = ids.filter((id) => !known.has(id))
1024
+ if (missing.length === ids.length) {
1025
+ return { status: 404, body: { ok: false, error: `unknown datasources: ${ids.join(', ')}` } }
1026
+ }
1027
+ const target = ids.filter((id) => known.has(id))
1028
+
1029
+ if (action === 'delete') {
1030
+ const list = registry.datasources.filter((item) => !target.includes(item.id))
1031
+ if (list.length > 0 && !list.some((item) => item.isDefault)) list[0].isDefault = true
1032
+ await writeRegistry(list)
1033
+ const { secrets } = await readSecrets()
1034
+ let changed = false
1035
+ for (const id of target) {
1036
+ if (secrets[id]) {
1037
+ delete secrets[id]
1038
+ changed = true
1039
+ }
1040
+ }
1041
+ if (changed) await writeSecrets(secrets)
1042
+ return {
1043
+ status: 200,
1044
+ body: { ok: true, action, affected: target.length, datasources: list.map((item) => publicProfile(item, secrets)) },
1045
+ }
1046
+ }
1047
+
1048
+ if (action === 'test') {
1049
+ const results = []
1050
+ for (const id of target) {
1051
+ const stored = registry.datasources.find((item) => item.id === id)
1052
+ const { secrets } = await readSecrets()
1053
+ const result = await testProfile(stored, secrets[id] || null, {
1054
+ probeTimeoutMs: Number(config.probeTimeoutMs) || 10_000,
1055
+ remote: remoteProbe,
1056
+ })
1057
+ const probe = { ok: result.ok, message: result.message, latencyMs: result.latencyMs, at: isoNow(), detail: result.detail || null }
1058
+ results.push({ id, probe })
1059
+ }
1060
+ const merged = registry.datasources.map((item) => {
1061
+ const hit = results.find((entry) => entry.id === item.id)
1062
+ return hit ? { ...item, lastProbe: hit.probe } : item
1063
+ })
1064
+ try {
1065
+ await writeRegistry(merged)
1066
+ } catch {
1067
+ /* the probe result still matters even if the note cannot be stored */
1068
+ }
1069
+ const { secrets } = await readSecrets()
1070
+ return {
1071
+ status: 200,
1072
+ body: {
1073
+ ok: true,
1074
+ action,
1075
+ affected: target.length,
1076
+ results,
1077
+ datasources: merged.map((item) => publicProfile(item, secrets)),
1078
+ },
1079
+ }
1080
+ }
1081
+
1082
+ const next = registry.datasources.map((item) => {
1083
+ if (!target.includes(item.id)) return item
1084
+ if (action === 'group') return { ...item, group: trimmed(payload.group).slice(0, 48), updatedAt: isoNow() }
1085
+ return { ...item, readOnly: payload.readOnly !== false, updatedAt: isoNow() }
1086
+ })
1087
+ await writeRegistry(next)
1088
+ const { secrets } = await readSecrets()
1089
+ return {
1090
+ status: 200,
1091
+ body: { ok: true, action, affected: target.length, datasources: next.map((item) => publicProfile(item, secrets)) },
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ * User-created groups. A group is just a name shared by some sources, so
1097
+ * creating one writes an empty group, renaming rewrites the members and
1098
+ * deleting empties them.
1099
+ */
1100
+ async function listGroups() {
1101
+ const registry = await readRegistry()
1102
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1103
+ return { status: 200, body: { ok: true, groups: registry.groups } }
1104
+ }
1105
+
1106
+ async function saveGroup(payload) {
1107
+ const name = groupName(payload.name)
1108
+ const from = groupName(payload.from)
1109
+ if (!name) return { status: 400, body: { ok: false, error: 'group name is required' } }
1110
+ if (name.length > 48) return { status: 400, body: { ok: false, error: 'group name must be at most 48 characters' } }
1111
+
1112
+ const registry = await readRegistry()
1113
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1114
+ if (from && from !== name && !registry.groups.includes(from)) {
1115
+ return { status: 404, body: { ok: false, error: `unknown group: ${from}` } }
1116
+ }
1117
+ if (name !== from && registry.groups.includes(name)) {
1118
+ return { status: 400, body: { ok: false, error: `group already exists: ${name}` } }
1119
+ }
1120
+
1121
+ const datasources = from && from !== name
1122
+ ? registry.datasources.map((item) => (groupName(item.group) === from ? { ...item, group: name, updatedAt: isoNow() } : item))
1123
+ : registry.datasources
1124
+ const groups = [...new Set(registry.groups.map((item) => (item === from ? name : item)).concat([name]))]
1125
+ await writeRegistry(datasources, groups)
1126
+ const { secrets } = await readSecrets()
1127
+ return {
1128
+ status: 200,
1129
+ body: { ok: true, groups, datasources: datasources.map((item) => publicProfile(item, secrets)) },
1130
+ }
1131
+ }
1132
+
1133
+ async function deleteGroup(payload) {
1134
+ const name = groupName(payload.name)
1135
+ if (!name) return { status: 400, body: { ok: false, error: 'group name is required' } }
1136
+ const registry = await readRegistry()
1137
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1138
+ if (!registry.groups.includes(name)) {
1139
+ return { status: 404, body: { ok: false, error: `unknown group: ${name}` } }
1140
+ }
1141
+ const datasources = registry.datasources.map((item) =>
1142
+ groupName(item.group) === name ? { ...item, group: '', updatedAt: isoNow() } : item,
1143
+ )
1144
+ const groups = registry.groups.filter((item) => item !== name)
1145
+ await writeRegistry(datasources, groups)
1146
+ const { secrets } = await readSecrets()
1147
+ return {
1148
+ status: 200,
1149
+ body: { ok: true, groups, datasources: datasources.map((item) => publicProfile(item, secrets)) },
1150
+ }
1151
+ }
1152
+
1153
+ async function deleteDatasource(payload) {
1154
+ const id = trimmed(payload.id)
1155
+ if (!id) return { status: 400, body: { ok: false, error: 'id is required' } }
1156
+ const registry = await readRegistry()
1157
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1158
+ const list = registry.datasources.filter((item) => item.id !== id)
1159
+ if (list.length === registry.datasources.length) {
1160
+ return { status: 404, body: { ok: false, error: `unknown datasource: ${id}` } }
1161
+ }
1162
+ if (list.length > 0 && !list.some((item) => item.isDefault)) list[0].isDefault = true
1163
+ await writeRegistry(list)
1164
+ const { secrets } = await readSecrets()
1165
+ if (secrets[id]) {
1166
+ delete secrets[id]
1167
+ await writeSecrets(secrets)
1168
+ }
1169
+ return { status: 200, body: { ok: true, removed: id, datasources: list.map((item) => publicProfile(item, secrets)) } }
1170
+ }
1171
+
1172
+ /**
1173
+ * Read-only schema browsing for the panel (P7). It only calls list_tables and
1174
+ * describe_table, so it can never run SQL.
1175
+ */
1176
+ async function inspectSchema(search) {
1177
+ const id = trimmed(search.get('datasource'))
1178
+ if (!id) return [400, { ok: false, error: 'datasource is required' }]
1179
+ const registry = await readRegistry()
1180
+ if (registry.error) return [500, { ok: false, error: registry.error }]
1181
+ const profile = registry.datasources.find((item) => item.id === id) || null
1182
+ if (!profile) return [404, { ok: false, error: `unknown datasource: ${id}` }]
1183
+ const table = trimmed(search.get('table'))
1184
+ const schema = trimmed(search.get('schema'))
1185
+ const target = describeTarget(profile.kind, profile.config)
1186
+
1187
+ try {
1188
+ if (!table) {
1189
+ const args = schema ? { datasource: id, schema } : { datasource: id }
1190
+ const result = await bridge.call('list_tables', args, { audit: false })
1191
+ const value = result.value || {}
1192
+ if (value.ok === false) {
1193
+ return [200, { ok: false, datasource: id, target, error: value.error || { message: 'list_tables failed' } }]
1194
+ }
1195
+ return [200, { ok: true, datasource: id, target, tables: value.tables || [] }]
1196
+ }
1197
+ const args = schema ? { datasource: id, table, schema } : { datasource: id, table }
1198
+ const result = await bridge.call('describe_table', args, { audit: false })
1199
+ const value = result.value || {}
1200
+ if (value.ok === false) {
1201
+ return [200, { ok: false, datasource: id, target, table, error: value.error || { message: 'describe_table failed' } }]
1202
+ }
1203
+ return [
1204
+ 200,
1205
+ {
1206
+ ok: true,
1207
+ datasource: id,
1208
+ target,
1209
+ table: value.table || table,
1210
+ schema: value.schema || null,
1211
+ columns: value.columns || [],
1212
+ primaryKey: value.primaryKey || [],
1213
+ },
1214
+ ]
1215
+ } catch (error) {
1216
+ return [
1217
+ 503,
1218
+ {
1219
+ ok: false,
1220
+ error: String((error && error.message) || error),
1221
+ hint: 'start the MCP server from the panel and try again',
1222
+ },
1223
+ ]
1224
+ }
1225
+ }
1226
+
1227
+ async function remoteProbe(profile, password) { const args = profile.id
1228
+ ? { datasource: profile.id }
1229
+ : { kind: profile.kind, config: profile.config, read_only: Boolean(profile.readOnly) }
1230
+ if (password) args.password = password
1231
+ const result = await bridge.call('test_connection', args, { audit: false })
1232
+ return result.value
1233
+ }
1234
+
1235
+ async function testDatasource(payload) {
1236
+ const probeOptions = {
1237
+ probeTimeoutMs: Number(config.probeTimeoutMs) || 10_000,
1238
+ remote: remoteProbe,
1239
+ }
1240
+ const id = trimmed(payload.id)
1241
+ const registry = await readRegistry()
1242
+ const stored = id ? registry.datasources.find((item) => item.id === id) || null : null
1243
+
1244
+ let profile
1245
+ if (payload.kind && payload.config) {
1246
+ const normalized = normalizeInput({ ...payload, id: id || 'probe' }, null)
1247
+ if (normalized.error) return { status: 400, body: { ok: false, error: normalized.error } }
1248
+ profile = normalized.profile
1249
+ profile.id = (stored && stored.id) || null
1250
+ } else if (stored) {
1251
+ profile = stored
1252
+ } else {
1253
+ return { status: 404, body: { ok: false, error: `unknown datasource: ${id || '(none)'}` } }
1254
+ }
1255
+
1256
+ const { secrets } = await readSecrets()
1257
+ const secret = profile.id ? secrets[profile.id] : null
1258
+ const result = await testProfile(profile, secret, probeOptions)
1259
+ const probe = {
1260
+ ok: result.ok,
1261
+ message: result.message,
1262
+ latencyMs: result.latencyMs,
1263
+ at: isoNow(),
1264
+ detail: result.detail || null,
1265
+ }
1266
+
1267
+ if (stored) {
1268
+ const list = registry.datasources.map((item) =>
1269
+ item.id === stored.id ? { ...item, lastProbe: probe } : item,
1270
+ )
1271
+ try {
1272
+ await writeRegistry(list)
1273
+ } catch {
1274
+ /* a failed note must not fail the probe itself */
1275
+ }
1276
+ }
1277
+ return { status: 200, body: { ok: true, probe, target: result.target || describeTarget(profile.kind, profile.config) } }
1278
+ }
1279
+
1280
+ async function importDatasources(payload) {
1281
+ const ids = Array.isArray(payload.ids) ? payload.ids.map((id) => trimmed(id)).filter(Boolean) : []
1282
+ if (ids.length === 0) return { status: 400, body: { ok: false, error: 'ids is required' } }
1283
+ const registry = await readRegistry()
1284
+ if (registry.error) return { status: 500, body: { ok: false, error: registry.error } }
1285
+ const scan = await scanDBeaver({ withSecrets: true, existing: registry.datasources })
1286
+ const available = scan.sources.flatMap((source) =>
1287
+ source.connections.filter((item) => item.importable).map((item) => ({ ...item })),
1288
+ )
1289
+ const list = [...registry.datasources]
1290
+ const { secrets } = await readSecrets()
1291
+ const imported = []
1292
+ const skipped = []
1293
+ const failed = []
1294
+ const warnings = []
1295
+
1296
+ // Same endpoint + database + account means the source is already here.
1297
+ const identities = new Map()
1298
+ for (const profile of list) {
1299
+ identities.set(sourceIdentity(profile.kind, profile.config, profile.config && profile.config.user), profile.id)
1300
+ }
1301
+
1302
+ for (const id of ids) {
1303
+ const found = available.find((item) => item.id === id)
1304
+ if (!found) {
1305
+ failed.push({ id, error: 'not found in DBeaver' })
1306
+ continue
1307
+ }
1308
+ const identity = sourceIdentity(found.kind, found.config, found.config && found.config.user)
1309
+ const clash = identities.get(identity)
1310
+ if (clash) {
1311
+ skipped.push({ id, existingId: clash })
1312
+ continue
1313
+ }
1314
+ const slug = (trimmed(found.name) || id)
1315
+ .toLowerCase()
1316
+ .replace(/[^a-z0-9]+/g, '-')
1317
+ .replace(/^-+|-+$/g, '')
1318
+ .slice(0, 32) || 'imported'
1319
+ let candidate = slug
1320
+ let counter = 2
1321
+ while (list.some((item) => item.id === candidate)) {
1322
+ candidate = `${slug.slice(0, 27)}-${counter++}`
1323
+ }
1324
+
1325
+ const profile = {
1326
+ id: candidate,
1327
+ name: String(found.name || candidate).slice(0, 64),
1328
+ kind: found.kind,
1329
+ config: found.config,
1330
+ readOnly: true,
1331
+ isDefault: list.length === 0,
1332
+ tags: ['dbeaver'],
1333
+ createdAt: isoNow(),
1334
+ updatedAt: isoNow(),
1335
+ lastProbe: null,
1336
+ }
1337
+ list.push(profile)
1338
+ identities.set(identity, candidate)
1339
+ if (found.password) secrets[candidate] = { password: found.password }
1340
+ if (profile.kind !== 'sqlite' && !trimmed(profile.config && profile.config.user)) {
1341
+ warnings.push({
1342
+ id: candidate,
1343
+ message: 'DBeaver stored no user for this connection; open Edit and fill it in',
1344
+ })
1345
+ }
1346
+ if (profile.kind !== 'sqlite' && found.hasPassword === false) {
1347
+ warnings.push({
1348
+ id: candidate,
1349
+ message: 'DBeaver did not save a password for this connection; the source was imported without one',
1350
+ })
1351
+ }
1352
+ imported.push({ ...publicProfile(profile, secrets), sourceId: id })
1353
+ }
1354
+
1355
+ if (imported.length > 0) {
1356
+ await writeRegistry(list)
1357
+ await writeSecrets(secrets)
1358
+ }
1359
+ return {
1360
+ status: 200,
1361
+ body: {
1362
+ ok: failed.length === 0,
1363
+ imported,
1364
+ skipped,
1365
+ failed,
1366
+ warnings,
1367
+ datasources: list.map((item) => publicProfile(item, secrets)),
1368
+ },
1369
+ }
1370
+ }
1371
+
1372
+ async function handle(request, response) {
1373
+ let route = '/'
1374
+ try {
1375
+ const url = new URL(request.url, 'http://localhost')
1376
+ route = url.pathname.slice(API_PREFIX.length) || '/'
1377
+ } catch {
1378
+ return sendJson(response, 400, { ok: false, error: 'unreadable request url' })
1379
+ }
1380
+ const method = (request.method || 'GET').toUpperCase()
1381
+
1382
+ if (method !== 'GET' && method !== 'HEAD' && !sameOrigin(request)) {
1383
+ return sendJson(response, 403, { ok: false, error: 'cross-origin write rejected' })
1384
+ }
1385
+
1386
+ try {
1387
+ if (method === 'GET' || method === 'HEAD') {
1388
+ if (route === '/' || route === '/health') return sendJson(response, 200, await health())
1389
+ if (route === '/datasources') {
1390
+ const listing = await listDatasources()
1391
+ return sendJson(response, 200, { ok: listing.error === null, ...listing })
1392
+ }
1393
+ if (route === '/groups') {
1394
+ const result = await listGroups()
1395
+ return sendJson(response, result.status, result.body)
1396
+ }
1397
+ if (route === '/dbeaver/scan') {
1398
+ const registry = await readRegistry()
1399
+ return sendJson(response, 200, { ok: true, ...(await scanDBeaver({ existing: registry.datasources })) })
1400
+ }
1401
+ if (route === '/schema') {
1402
+ const search = new URL(request.url, 'http://localhost').searchParams
1403
+ return sendJson(response, ...(await inspectSchema(search)))
1404
+ }
1405
+ if (route === '/settings') return sendJson(response, 200, { ok: true, settings: await readSettings() })
1406
+ if (route === '/audit') {
1407
+ const limit = Math.min(200, Math.max(1, Number(new URL(request.url, 'http://localhost').searchParams.get('limit')) || 20))
1408
+ const listing = await readAudit(limit)
1409
+ return sendJson(response, 200, { ok: listing.error === null, file: auditFile, ...listing })
1410
+ }
1411
+ if (route === '/python/env' || route === '/python/status') return sendJson(response, 200, { ok: true, ...(await pythonEnv()) })
1412
+ if (route === '/mcp/status') {
1413
+ const state = bridge.status()
1414
+ return sendJson(response, 200, { ok: state.phase !== 'failed', ...state })
1415
+ }
1416
+ return sendJson(response, 404, { ok: false, error: `unknown route: ${method} ${route}` })
1417
+ }
1418
+
1419
+ if (method !== 'POST') return sendJson(response, 405, { ok: false, error: `method not allowed: ${method}` })
1420
+
1421
+ let payload
1422
+ try {
1423
+ payload = await readBody(request)
1424
+ } catch (error) {
1425
+ return sendJson(response, 400, { ok: false, error: String((error && error.message) || error) })
1426
+ }
1427
+
1428
+ if (route === '/datasources/save') {
1429
+ const result = await saveDatasource(payload)
1430
+ if (result.status === 200) void bridge.restart().catch(() => {})
1431
+ return sendJson(response, result.status, result.body)
1432
+ }
1433
+ if (route === '/datasources/delete') {
1434
+ const result = await deleteDatasource(payload)
1435
+ if (result.status === 200) void bridge.restart().catch(() => {})
1436
+ return sendJson(response, result.status, result.body)
1437
+ }
1438
+ if (route === '/datasources/test') {
1439
+ const result = await testDatasource(payload)
1440
+ return sendJson(response, result.status, result.body)
1441
+ }
1442
+ if (route === '/datasources/bulk') {
1443
+ const result = await bulkDatasources(payload)
1444
+ return sendJson(response, result.status, result.body)
1445
+ }
1446
+ if (route === '/groups/save') {
1447
+ const result = await saveGroup(payload)
1448
+ return sendJson(response, result.status, result.body)
1449
+ }
1450
+ if (route === '/groups/delete') {
1451
+ const result = await deleteGroup(payload)
1452
+ return sendJson(response, result.status, result.body)
1453
+ }
1454
+ if (route === '/dbeaver/import') {
1455
+ const result = await importDatasources(payload)
1456
+ return sendJson(response, result.status, result.body)
1457
+ }
1458
+ if (route === '/settings') {
1459
+ const settings = await writeSettings({
1460
+ allowWrite: payload.allowWrite,
1461
+ auditLog: payload.auditLog,
1462
+ })
1463
+ if (payload.allowWrite !== undefined) void bridge.restart().catch(() => {})
1464
+ return sendJson(response, 200, { ok: true, settings })
1465
+ }
1466
+ if (route === '/python/setup') {
1467
+ const result = await setupPython()
1468
+ return sendJson(response, result.status, result.body)
1469
+ }
1470
+ if (route === '/mcp/start' || route === '/mcp/restart') {
1471
+ const state = route === '/mcp/start' ? await ensureMcp() : await bridge.restart().catch((error) => ({ ...bridge.status(), phase: 'failed', error: String((error && error.message) || error) }))
1472
+ return sendJson(response, state.phase === 'ready' ? 200 : 500, { ok: state.phase === 'ready', ...state })
1473
+ }
1474
+ if (route === '/mcp/call') {
1475
+ const tool = trimmed(payload.tool)
1476
+ if (!tool) return sendJson(response, 400, { ok: false, error: 'tool is required' })
1477
+ try {
1478
+ const state = await ensureMcp()
1479
+ if (state.phase !== 'ready') return sendJson(response, 500, { ok: false, error: state.error || 'server not running', ...state })
1480
+ const callArgs = payload.arguments && typeof payload.arguments === 'object' ? { ...payload.arguments } : {}
1481
+ const source = trimmed(payload.datasource)
1482
+ if (source && callArgs.datasource === undefined) callArgs.datasource = source
1483
+ const result = await bridge.call(tool, callArgs)
1484
+ return sendJson(response, 200, { ok: true, ...result })
1485
+ } catch (error) {
1486
+ return sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
1487
+ }
1488
+ }
1489
+ return sendJson(response, 404, { ok: false, error: `unknown route: ${method} ${route}` })
1490
+ } catch (error) {
1491
+ return sendJson(response, 500, { ok: false, error: String((error && error.message) || error) })
1492
+ }
1493
+ }
1494
+
1495
+ // Publish the MCP tools as soon as the host half mounts, so the agent can use
1496
+ // them even if nobody opens the Database panel.
1497
+ void ensureMcp()
1498
+
1499
+ return {
1500
+ version: PLUGIN_VERSION,
1501
+ handle,
1502
+ __test: { parseJdbcUrl, mapDBeaverConnection, sourceIdentity },
1503
+ dispose: async () => {
1504
+ await bridge.dispose()
1505
+ },
1506
+ }
1507
+ }