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/LICENSE +202 -0
- package/README.md +326 -0
- package/client.js +2939 -0
- package/cordis.patch.yml +6 -0
- package/docs/requirements.md +960 -0
- package/icon.svg +8 -0
- package/index.js +119 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -0
- package/server/engines.py +617 -0
- package/server/main.py +260 -0
- package/server/requirements.txt +10 -0
- package/server/store.py +98 -0
- package/src/host.js +1507 -0
- package/src/mcp.js +264 -0
- package/src/tools.js +372 -0
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
|
+
}
|