@dypai-ai/mcp 1.4.5 → 1.5.0

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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * manage_database — one tool for all DB ops that aren't simple single-statement SQL.
3
+ *
4
+ * Consolidates what used to be three separate tools (`run_migration`,
5
+ * `introspect`, `execute_script`) into a single discriminated-union tool,
6
+ * matching the `manage_users` / `manage_roles` / `manage_drafts` pattern
7
+ * the rest of the catalog already uses.
8
+ *
9
+ * Operations:
10
+ * apply_migration Apply a versioned .sql file with tracking + idempotency
11
+ * list_migrations List rows from system.applied_migrations
12
+ * introspect_schema List tables, views, functions in a schema
13
+ * introspect_table Full table detail: columns, PK, FKs, indexes, triggers, RLS
14
+ * introspect_function Function(s) with signatures + body
15
+ * execute_script Multi-statement transactional SQL (for one-off non-migration scripts)
16
+ *
17
+ * Why not individual tools:
18
+ * - Catalog density. Each extra tool taxes the agent's planning — it has
19
+ * to read descriptions, decide which fits, and remember edge cases.
20
+ * - Semantic cohesion. All of these are "schema-level / structural DB
21
+ * operations beyond a single SQL statement". They belong together.
22
+ * - Future-proof. `list_migrations`, `revert_migration`, `diff_schema`
23
+ * etc. slot in as new operations without bloating the catalog.
24
+ *
25
+ * execute_sql stays as its own tool because it's the hot path (called in
26
+ * ~80% of sessions) and forcing `operation:"query"` every time is friction.
27
+ */
28
+
29
+ import { readFile, access } from "fs/promises"
30
+ import { createHash } from "crypto"
31
+ import { basename, extname, isAbsolute } from "path"
32
+ import { proxyToolCall } from "./proxy.js"
33
+ import { validateSql, formatValidationError } from "./sql-guard.js"
34
+ import { resolveAndGuard } from "./sync/path-resolver.js"
35
+ import { maybeRefreshSchemaAfterExecuteSql } from "./sql-side-effects.js"
36
+
37
+ // Refresh local dypai/schema.sql if the SQL we just executed changed the
38
+ // public schema. No-ops silently when no local dypai/ folder is found.
39
+ async function maybeRefreshSchema(project_id, sql, result) {
40
+ try {
41
+ const refresh = await maybeRefreshSchemaAfterExecuteSql({ project_id, sql }, result)
42
+ if (refresh.refreshed) return { schema_refreshed: true, schema_path: refresh.path }
43
+ if (refresh.error) return { schema_refresh_warning: refresh.error }
44
+ } catch {
45
+ // Never let schema.sql bookkeeping break the actual DB operation.
46
+ }
47
+ return {}
48
+ }
49
+
50
+ // ─── helpers ────────────────────────────────────────────────────────────────
51
+
52
+ function sha256(s) {
53
+ return createHash("sha256").update(s).digest("hex")
54
+ }
55
+
56
+ function countStatements(sql) {
57
+ if (!sql) return 0
58
+ let s = sql
59
+ .replace(/--[^\n]*/g, "")
60
+ .replace(/\/\*[\s\S]*?\*\//g, "")
61
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\1\$/g, "''")
62
+ .replace(/'(?:''|[^'])*'/g, "''")
63
+ return s.split(";").map(x => x.trim()).filter(Boolean).length
64
+ }
65
+
66
+ function normalizeName(file) {
67
+ return basename(file, extname(file)) || file
68
+ }
69
+
70
+ async function pathExists(p) {
71
+ try { await access(p); return true } catch { return false }
72
+ }
73
+
74
+ function parseTableName(name) {
75
+ const m = String(name).trim().match(/^([a-zA-Z_][\w$]*)(?:\.([a-zA-Z_][\w$]*))?$/)
76
+ if (!m) return null
77
+ return m[2]
78
+ ? { schema: m[1], table: m[2] }
79
+ : { schema: "public", table: m[1] }
80
+ }
81
+
82
+ function parseFunctionName(name) {
83
+ const s = String(name).trim()
84
+ const sigM = s.match(/^([^(]+)\((.*)\)$/)
85
+ const ident = sigM ? sigM[1].trim() : s
86
+ const sig = sigM ? sigM[2].trim() : null
87
+ const m = ident.match(/^([a-zA-Z_][\w$]*)(?:\.([a-zA-Z_][\w$]*))?$/)
88
+ if (!m) return null
89
+ return m[2]
90
+ ? { schema: m[1], func: m[2], signature: sig }
91
+ : { schema: "public", func: m[1], signature: sig }
92
+ }
93
+
94
+ async function runSelect(project_id, sql) {
95
+ const v = validateSql(sql, { allowSelectOnly: true })
96
+ if (!v.ok) throw new Error(`manage_database: ${formatValidationError(v)}`)
97
+ const args = project_id ? { project_id, sql } : { sql }
98
+ const res = await proxyToolCall("execute_sql", args)
99
+ return Array.isArray(res?.rows) ? res.rows : (Array.isArray(res) ? res : [])
100
+ }
101
+
102
+ // ─── apply_migration ────────────────────────────────────────────────────────
103
+ //
104
+ // Thin client: reads the file, computes checksum, hands the body +
105
+ // metadata to the cloud `apply_migration` tool. The cloud handles the
106
+ // transaction, the guard, the tracking INSERT, and all idempotency checks.
107
+
108
+ async function applyMigration({ project_id, migration_file, dry_run, timeout_seconds }) {
109
+ if (!migration_file || typeof migration_file !== "string") {
110
+ return { success: false, error: "migration_file is required (string path)." }
111
+ }
112
+
113
+ let resolved
114
+ if (isAbsolute(migration_file)) {
115
+ resolved = { ok: true, path: migration_file, source: "absolute" }
116
+ } else {
117
+ resolved = resolveAndGuard(migration_file, {
118
+ project_id,
119
+ tool: "manage_database",
120
+ arg_name: "migration_file",
121
+ })
122
+ if (!resolved.ok) return resolved.error
123
+ }
124
+ const filePath = resolved.path
125
+
126
+ if (!(await pathExists(filePath))) {
127
+ return {
128
+ success: false,
129
+ error: `Migration file not found: ${filePath}`,
130
+ hint: "Create dypai/migrations/<NNNN>_<description>.sql and pass that path.",
131
+ }
132
+ }
133
+
134
+ const body = await readFile(filePath, "utf8")
135
+ if (!body.trim()) return { success: false, error: "Migration file is empty." }
136
+
137
+ // Early local validation — cleaner error than a network roundtrip just to
138
+ // get the same message back.
139
+ const v = validateSql(body)
140
+ if (!v.ok) {
141
+ return {
142
+ success: false,
143
+ error: formatValidationError(v),
144
+ resolved_path: filePath,
145
+ }
146
+ }
147
+
148
+ const name = normalizeName(filePath)
149
+ const checksum = sha256(body)
150
+ const statements = countStatements(body)
151
+
152
+ if (dry_run) {
153
+ return {
154
+ success: true,
155
+ status: "dry_run",
156
+ name,
157
+ checksum,
158
+ statements,
159
+ resolved_path: filePath,
160
+ body_preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
161
+ hint: "Call again with dry_run:false to apply.",
162
+ }
163
+ }
164
+
165
+ const args = { name, body, checksum, statements }
166
+ if (project_id) args.project_id = project_id
167
+ if (timeout_seconds) args.timeout_seconds = timeout_seconds
168
+
169
+ try {
170
+ const res = await proxyToolCall("apply_migration", args)
171
+ const base = {
172
+ success: res?.status === "applied" || res?.status === "skipped",
173
+ status: res?.status ?? "unknown",
174
+ name,
175
+ checksum,
176
+ statements,
177
+ resolved_path: filePath,
178
+ duration_ms: res?.duration_ms,
179
+ applied_at: res?.applied_at,
180
+ applied_by: res?.applied_by,
181
+ reason: res?.reason,
182
+ error: res?.error,
183
+ hint: res?.hint,
184
+ previous_checksum: res?.previous_checksum,
185
+ current_checksum: res?.current_checksum,
186
+ message:
187
+ res?.status === "applied"
188
+ ? `Migration '${name}' applied (${statements} statement${statements === 1 ? "" : "s"}).`
189
+ : res?.status === "skipped"
190
+ ? `Migration '${name}' already applied.`
191
+ : undefined,
192
+ }
193
+ const refreshInfo =
194
+ res?.status === "applied" ? await maybeRefreshSchema(project_id, body, res) : {}
195
+ return { ...base, ...refreshInfo }
196
+ } catch (e) {
197
+ return {
198
+ success: false,
199
+ status: "failed",
200
+ name,
201
+ resolved_path: filePath,
202
+ error: e.message,
203
+ }
204
+ }
205
+ }
206
+
207
+ // ─── list_migrations ────────────────────────────────────────────────────────
208
+
209
+ async function listMigrations({ project_id, limit = 50 }) {
210
+ const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 500)
211
+ const sql = `SELECT name, checksum, statements, applied_at, applied_by
212
+ FROM system.applied_migrations
213
+ ORDER BY applied_at DESC
214
+ LIMIT ${safeLimit}`
215
+ try {
216
+ const rows = await runSelect(project_id, sql)
217
+ return { success: true, total: rows.length, migrations: rows }
218
+ } catch (e) {
219
+ // Likely cause: system.applied_migrations not provisioned yet on this
220
+ // project. Surface cleanly rather than raw PG error.
221
+ if (/applied_migrations.*does not exist/i.test(e.message || "")) {
222
+ return {
223
+ success: true,
224
+ total: 0,
225
+ migrations: [],
226
+ hint:
227
+ "system.applied_migrations doesn't exist on this project's DB. " +
228
+ "Run sync_schema against the project or wait for the next provisioning cycle.",
229
+ }
230
+ }
231
+ return { success: false, error: e.message }
232
+ }
233
+ }
234
+
235
+ // ─── introspect_schema ──────────────────────────────────────────────────────
236
+
237
+ async function introspectSchema({ project_id, name }) {
238
+ const parsed = parseTableName(name)
239
+ if (!parsed) return { success: false, error: `Invalid schema name: ${name}` }
240
+ const s = parsed.schema.replace(/'/g, "''")
241
+
242
+ const [tables, views, functions] = await Promise.all([
243
+ runSelect(project_id, `SELECT tablename AS name FROM pg_catalog.pg_tables WHERE schemaname='${s}' ORDER BY tablename`),
244
+ runSelect(project_id, `SELECT viewname AS name FROM pg_catalog.pg_views WHERE schemaname='${s}' ORDER BY viewname`),
245
+ runSelect(project_id, `
246
+ SELECT p.proname AS name,
247
+ pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
248
+ pg_catalog.pg_get_function_result(p.oid) AS returns
249
+ FROM pg_catalog.pg_proc p
250
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
251
+ WHERE n.nspname = '${s}' AND p.prokind IN ('f','p')
252
+ ORDER BY p.proname, args
253
+ `),
254
+ ])
255
+
256
+ return {
257
+ success: true,
258
+ schema: parsed.schema,
259
+ tables: tables.map(r => r.name),
260
+ views: views.map(r => r.name),
261
+ functions: functions.map(r => ({ name: r.name, args: r.args, returns: r.returns })),
262
+ }
263
+ }
264
+
265
+ // ─── introspect_table ───────────────────────────────────────────────────────
266
+
267
+ async function introspectTable({ project_id, name }) {
268
+ const parsed = parseTableName(name)
269
+ if (!parsed) return { success: false, error: `Invalid table name: ${name}` }
270
+ const s = parsed.schema.replace(/'/g, "''")
271
+ const t = parsed.table.replace(/'/g, "''")
272
+
273
+ const [columns, pk, fks, indexes, triggers, rls, rowcount] = await Promise.all([
274
+ runSelect(project_id, `
275
+ SELECT column_name, data_type, is_nullable,
276
+ column_default, character_maximum_length, udt_name
277
+ FROM information_schema.columns
278
+ WHERE table_schema='${s}' AND table_name='${t}'
279
+ ORDER BY ordinal_position
280
+ `),
281
+ runSelect(project_id, `
282
+ SELECT kcu.column_name
283
+ FROM information_schema.table_constraints tc
284
+ JOIN information_schema.key_column_usage kcu
285
+ ON kcu.constraint_name=tc.constraint_name AND kcu.table_schema=tc.table_schema
286
+ WHERE tc.table_schema='${s}' AND tc.table_name='${t}'
287
+ AND tc.constraint_type='PRIMARY KEY'
288
+ ORDER BY kcu.ordinal_position
289
+ `),
290
+ runSelect(project_id, `
291
+ SELECT kcu.column_name,
292
+ ccu.table_schema AS foreign_schema,
293
+ ccu.table_name AS foreign_table,
294
+ ccu.column_name AS foreign_column,
295
+ rc.update_rule, rc.delete_rule
296
+ FROM information_schema.table_constraints tc
297
+ JOIN information_schema.key_column_usage kcu
298
+ ON kcu.constraint_name=tc.constraint_name AND kcu.table_schema=tc.table_schema
299
+ JOIN information_schema.referential_constraints rc
300
+ ON rc.constraint_name=tc.constraint_name AND rc.constraint_schema=tc.table_schema
301
+ JOIN information_schema.constraint_column_usage ccu
302
+ ON ccu.constraint_name=tc.constraint_name AND ccu.table_schema=tc.table_schema
303
+ WHERE tc.table_schema='${s}' AND tc.table_name='${t}' AND tc.constraint_type='FOREIGN KEY'
304
+ ORDER BY kcu.ordinal_position
305
+ `),
306
+ runSelect(project_id, `
307
+ SELECT indexname AS name, indexdef AS definition
308
+ FROM pg_catalog.pg_indexes
309
+ WHERE schemaname='${s}' AND tablename='${t}'
310
+ ORDER BY indexname
311
+ `),
312
+ runSelect(project_id, `
313
+ SELECT trigger_name AS name, event_manipulation AS event,
314
+ action_timing AS timing, action_statement AS body
315
+ FROM information_schema.triggers
316
+ WHERE event_object_schema='${s}' AND event_object_table='${t}'
317
+ ORDER BY trigger_name
318
+ `),
319
+ runSelect(project_id, `
320
+ SELECT polname AS name, polcmd AS command,
321
+ pg_catalog.pg_get_expr(polqual, polrelid) AS using_expr,
322
+ pg_catalog.pg_get_expr(polwithcheck, polrelid) AS check_expr,
323
+ (SELECT relrowsecurity FROM pg_catalog.pg_class c
324
+ WHERE c.relname='${t}'
325
+ AND c.relnamespace=(SELECT oid FROM pg_catalog.pg_namespace WHERE nspname='${s}')) AS rls_enabled
326
+ FROM pg_catalog.pg_policy p
327
+ JOIN pg_catalog.pg_class c ON c.oid=p.polrelid
328
+ JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
329
+ WHERE n.nspname='${s}' AND c.relname='${t}'
330
+ ORDER BY polname
331
+ `),
332
+ runSelect(project_id, `
333
+ SELECT reltuples::bigint AS approximate_rows
334
+ FROM pg_catalog.pg_class c
335
+ JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
336
+ WHERE n.nspname='${s}' AND c.relname='${t}'
337
+ LIMIT 1
338
+ `),
339
+ ])
340
+
341
+ const exists = columns.length > 0
342
+ return {
343
+ success: exists,
344
+ schema: parsed.schema,
345
+ table: parsed.table,
346
+ exists,
347
+ approximate_rows: rowcount[0]?.approximate_rows ?? null,
348
+ columns,
349
+ primary_key: pk.map(r => r.column_name),
350
+ foreign_keys: fks,
351
+ indexes,
352
+ triggers,
353
+ row_level_security: {
354
+ enabled: rls[0]?.rls_enabled === true,
355
+ policies: rls,
356
+ },
357
+ error: exists ? undefined : `Table ${parsed.schema}.${parsed.table} does not exist.`,
358
+ }
359
+ }
360
+
361
+ // ─── introspect_function ────────────────────────────────────────────────────
362
+
363
+ async function introspectFunction({ project_id, name }) {
364
+ const parsed = parseFunctionName(name)
365
+ if (!parsed) return { success: false, error: `Invalid function name: ${name}` }
366
+ const s = parsed.schema.replace(/'/g, "''")
367
+ const f = parsed.func.replace(/'/g, "''")
368
+
369
+ let overloads = await runSelect(project_id, `
370
+ SELECT p.oid,
371
+ p.proname AS name,
372
+ pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
373
+ pg_catalog.pg_get_function_result(p.oid) AS returns,
374
+ p.prokind,
375
+ l.lanname AS language,
376
+ p.prosecdef AS security_definer,
377
+ p.provolatile AS volatile_flag,
378
+ pg_catalog.pg_get_functiondef(p.oid) AS definition
379
+ FROM pg_catalog.pg_proc p
380
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
381
+ JOIN pg_catalog.pg_language l ON l.oid = p.prolang
382
+ WHERE n.nspname='${s}' AND p.proname='${f}' AND p.prokind IN ('f','p')
383
+ ORDER BY args
384
+ `)
385
+
386
+ if (parsed.signature != null) {
387
+ const norm = sig => sig.replace(/\s+/g, "").toLowerCase()
388
+ const wanted = norm(parsed.signature)
389
+ overloads = overloads.filter(o => norm(o.args) === wanted)
390
+ }
391
+
392
+ if (!overloads.length) {
393
+ return {
394
+ success: false,
395
+ schema: parsed.schema,
396
+ name: parsed.func,
397
+ error: parsed.signature
398
+ ? `No function ${parsed.schema}.${parsed.func}(${parsed.signature}) — signature not found.`
399
+ : `No function ${parsed.schema}.${parsed.func} exists.`,
400
+ hint: "Use operation='introspect_schema' to list functions.",
401
+ }
402
+ }
403
+
404
+ return {
405
+ success: true,
406
+ schema: parsed.schema,
407
+ name: parsed.func,
408
+ requested_signature: parsed.signature ?? null,
409
+ overloads: overloads.map(o => ({
410
+ signature: `${parsed.schema}.${parsed.func}(${o.args})`,
411
+ args: o.args,
412
+ returns: o.returns,
413
+ kind: o.prokind === "p" ? "procedure" : "function",
414
+ language: o.language,
415
+ security_definer: o.security_definer,
416
+ volatile: o.volatile_flag === "i" ? "immutable" : o.volatile_flag === "s" ? "stable" : "volatile",
417
+ definition: o.definition,
418
+ })),
419
+ }
420
+ }
421
+
422
+ // ─── execute_script ─────────────────────────────────────────────────────────
423
+
424
+ async function executeScript({ project_id, sql, timeout_seconds }) {
425
+ if (!sql || typeof sql !== "string") {
426
+ return { success: false, error: "sql is required (string)." }
427
+ }
428
+ const v = validateSql(sql)
429
+ if (!v.ok) return { success: false, error: formatValidationError(v) }
430
+
431
+ const args = { sql }
432
+ if (project_id) args.project_id = project_id
433
+ if (timeout_seconds) args.timeout_seconds = timeout_seconds
434
+ try {
435
+ const res = await proxyToolCall("execute_script", args)
436
+ const refreshInfo = await maybeRefreshSchema(project_id, sql, res)
437
+ return { success: true, ...res, ...refreshInfo }
438
+ } catch (e) {
439
+ return { success: false, error: e.message }
440
+ }
441
+ }
442
+
443
+ // ─── tool definition ────────────────────────────────────────────────────────
444
+
445
+ export const manageDatabaseTool = {
446
+ name: "manage_database",
447
+ description:
448
+ "Database operations beyond a single SQL statement. Use `execute_sql` for " +
449
+ "ad-hoc single-statement queries; this tool covers migrations, introspection, " +
450
+ "and multi-statement transactional scripts.\n\n" +
451
+ "Operations:\n" +
452
+ "- apply_migration: Apply dypai/migrations/<file>.sql with tracking + idempotency.\n" +
453
+ " Same file twice → no-op. Modified file → checksum_mismatch.\n" +
454
+ "- list_migrations: Show rows from system.applied_migrations (what has been applied, when, by whom).\n" +
455
+ "- introspect_schema: List tables, views, functions in a schema.\n" +
456
+ "- introspect_table: Full detail: columns, PK, FKs, indexes, triggers, RLS, approx rows.\n" +
457
+ "- introspect_function: Function(s) with signatures + body. Overloads disambiguated by signature.\n" +
458
+ "- execute_script: Multi-statement SQL in a single transaction. For one-off non-migration scripts;\n" +
459
+ " prefer apply_migration for anything you want versioned.\n\n" +
460
+ "All operations respect the SQL guard: writes to auth/storage/system/pg_* are blocked; SELECTs are allowed.",
461
+ inputSchema: {
462
+ type: "object",
463
+ properties: {
464
+ project_id: {
465
+ type: "string",
466
+ description: "Project UUID. Optional if your token is project-scoped.",
467
+ },
468
+ operation: {
469
+ type: "string",
470
+ enum: [
471
+ "apply_migration",
472
+ "list_migrations",
473
+ "introspect_schema",
474
+ "introspect_table",
475
+ "introspect_function",
476
+ "execute_script",
477
+ ],
478
+ description: "Which operation to run.",
479
+ },
480
+ // apply_migration
481
+ migration_file: {
482
+ type: "string",
483
+ description:
484
+ "apply_migration only. Path to the .sql file, absolute preferred. " +
485
+ "Relative paths are resolved via env vars (CLAUDE_PROJECT_DIR, WORKSPACE_FOLDER_PATHS) and project markers.",
486
+ },
487
+ dry_run: {
488
+ type: "boolean",
489
+ description: "apply_migration only. Validate + preview without executing. Default false.",
490
+ default: false,
491
+ },
492
+ // introspect_*
493
+ name: {
494
+ type: "string",
495
+ description:
496
+ "Identifier for introspect_* operations. " +
497
+ "schema='public' | table='public.orders' | function='public.foo' or 'public.foo(uuid,text)'.",
498
+ },
499
+ // execute_script
500
+ sql: {
501
+ type: "string",
502
+ description: "execute_script only. Multi-statement SQL to run in one transaction.",
503
+ },
504
+ // shared
505
+ timeout_seconds: {
506
+ type: "integer",
507
+ description: "apply_migration / execute_script: per-statement timeout (default 300, max 1800).",
508
+ },
509
+ limit: {
510
+ type: "integer",
511
+ description: "list_migrations only. Max rows returned (default 50, max 500).",
512
+ },
513
+ },
514
+ required: ["operation"],
515
+ allOf: [
516
+ { if: { properties: { operation: { const: "apply_migration" } }, required: ["operation"] },
517
+ then: { required: ["migration_file"] } },
518
+ { if: { properties: { operation: { const: "introspect_schema" } }, required: ["operation"] },
519
+ then: { required: ["name"] } },
520
+ { if: { properties: { operation: { const: "introspect_table" } }, required: ["operation"] },
521
+ then: { required: ["name"] } },
522
+ { if: { properties: { operation: { const: "introspect_function" } }, required: ["operation"] },
523
+ then: { required: ["name"] } },
524
+ { if: { properties: { operation: { const: "execute_script" } }, required: ["operation"] },
525
+ then: { required: ["sql"] } },
526
+ ],
527
+ },
528
+
529
+ async execute(args = {}) {
530
+ const { operation } = args
531
+ switch (operation) {
532
+ case "apply_migration": return applyMigration(args)
533
+ case "list_migrations": return listMigrations(args)
534
+ case "introspect_schema": return introspectSchema(args)
535
+ case "introspect_table": return introspectTable(args)
536
+ case "introspect_function": return introspectFunction(args)
537
+ case "execute_script": return executeScript(args)
538
+ default:
539
+ return {
540
+ success: false,
541
+ error: `Unknown operation: ${operation}`,
542
+ hint: "Valid operations: apply_migration, list_migrations, introspect_schema, introspect_table, introspect_function, execute_script.",
543
+ }
544
+ }
545
+ },
546
+ }