@dypai-ai/mcp 1.4.6 → 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.
- package/package.json +1 -1
- package/src/index.js +196 -427
- package/src/tools/introspect.js +311 -0
- package/src/tools/manage-database.js +546 -0
- package/src/tools/run-migration.js +269 -0
- package/src/tools/sql-guard.js +164 -0
- package/src/tools/sync/codec.js +2 -1
- package/src/tools/sync/pull.js +19 -3
- package/src/tools/sync/transforms.js +10 -2
|
@@ -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
|
+
}
|