@dypai-ai/mcp 1.0.9 → 1.1.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,411 @@
1
+ /**
2
+ * dypai_push — apply the local ./dypai/ state to the remote project.
3
+ *
4
+ * Reads local YAML + referenced files, computes a plan against remote,
5
+ * and applies it via create_endpoint / update_endpoint / delete_endpoint.
6
+ *
7
+ * By default refuses to delete remote endpoints that aren't in local (safe).
8
+ * Pass delete_orphans: true to opt in.
9
+ */
10
+
11
+ import { resolve as resolvePath } from "path"
12
+ import { proxyToolCall } from "../proxy.js"
13
+ import {
14
+ fetchRemoteState,
15
+ readLocalState,
16
+ readLocalStateSnapshot,
17
+ readLocalConfig,
18
+ readLocalRealtime,
19
+ fetchRemoteRealtime,
20
+ computePlan,
21
+ localToCanonical,
22
+ } from "./planner.js"
23
+ import { runValidation } from "./validate.js"
24
+ import { regenerateTypes } from "../codegen.js"
25
+
26
+ // ─── Apply helpers ─────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Run an async worker over an array with bounded concurrency. Workers never
30
+ * throw (they handle their own errors via the push errors[] accumulator),
31
+ * so we can await them all safely without Promise.all propagating rejections.
32
+ */
33
+ async function runWithConcurrency(items, concurrency, worker) {
34
+ if (!items.length) return
35
+ const queue = items.slice()
36
+ const pumps = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
37
+ while (queue.length) {
38
+ const item = queue.shift()
39
+ await worker(item)
40
+ }
41
+ })
42
+ await Promise.all(pumps)
43
+ }
44
+
45
+ function endpointPayload(row) {
46
+ // The remote schemas reject null values for optional fields — only include
47
+ // what's actually set. workflow_code is always required.
48
+ const p = {
49
+ name: row.name,
50
+ method: row.method,
51
+ workflow_code: row.workflow_code,
52
+ is_tool: !!row.is_tool,
53
+ allowed_roles: row.allowed_roles || [],
54
+ }
55
+ if (row.description) p.description = row.description
56
+ if (row.tool_description) p.tool_description = row.tool_description
57
+ if (row.group_id) p.group_id = row.group_id
58
+ if (row.input) p.input = row.input
59
+ if (row.output) p.output = row.output
60
+ return p
61
+ }
62
+
63
+ async function applyCreate(canonicalRow, projectId) {
64
+ return proxyToolCall("create_endpoint", {
65
+ ...(projectId ? { project_id: projectId } : {}),
66
+ ...endpointPayload(canonicalRow),
67
+ })
68
+ }
69
+
70
+ async function applyUpdate(canonicalRow, endpointId, projectId) {
71
+ return proxyToolCall("update_endpoint", {
72
+ ...(projectId ? { project_id: projectId } : {}),
73
+ endpoint_id: endpointId,
74
+ updates: endpointPayload(canonicalRow),
75
+ })
76
+ }
77
+
78
+ async function applyDelete(endpointId, projectId) {
79
+ return proxyToolCall("delete_endpoint", {
80
+ ...(projectId ? { project_id: projectId } : {}),
81
+ endpoint_id: endpointId,
82
+ })
83
+ }
84
+
85
+ // ─── Realtime policies sync ────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Reconcile local realtime.yaml with system.realtime_policies remotely.
89
+ * Returns a summary of what was applied (or would be applied in dry_run).
90
+ */
91
+ async function syncRealtimePolicies(projectId, rootDir, dryRun = false) {
92
+ const local = await readLocalRealtime(rootDir)
93
+ if (!local) return { skipped: true, reason: "no realtime.yaml found" }
94
+
95
+ const remote = await fetchRemoteRealtime(projectId)
96
+ if (remote === null) {
97
+ return { skipped: true, reason: "engine has no system.realtime_policies table yet (backward compat)" }
98
+ }
99
+
100
+ const key = (p) => `${p.target_type}::${p.target_name}`
101
+ const remoteMap = Object.fromEntries(remote.map(p => [key(p), p]))
102
+ const localMap = Object.fromEntries(local.rows.map(p => [key(p), p]))
103
+
104
+ const toUpsert = []
105
+ const toDelete = []
106
+ for (const k of Object.keys(localMap)) {
107
+ const l = localMap[k]
108
+ const r = remoteMap[k]
109
+ if (!r || !policiesEqual(l, r)) toUpsert.push(l)
110
+ }
111
+ for (const k of Object.keys(remoteMap)) {
112
+ if (!localMap[k]) toDelete.push(remoteMap[k])
113
+ }
114
+
115
+ if (dryRun) {
116
+ return { upsert: toUpsert.length, delete: toDelete.length, dry_run: true }
117
+ }
118
+
119
+ // execute_sql on the remote is parameterless (only takes a raw `sql` string).
120
+ // To avoid hand-escaping values, we embed the rows as JSON and let Postgres
121
+ // parse them via jsonb_to_recordset — JSON.stringify handles quote escaping
122
+ // correctly, and we only need to double single-quotes for the SQL literal.
123
+ if (toUpsert.length > 0) {
124
+ const rowsJson = JSON.stringify(toUpsert.map(p => ({
125
+ target_type: p.target_type,
126
+ target_name: p.target_name,
127
+ subscribe_filter: p.subscribe_filter,
128
+ events: p.events,
129
+ required_role: p.required_role,
130
+ auth_required: p.auth_required,
131
+ }))).replace(/'/g, "''") // escape for SQL string literal
132
+
133
+ await proxyToolCall("execute_sql", {
134
+ project_id: projectId,
135
+ sql: `
136
+ INSERT INTO system.realtime_policies
137
+ (target_type, target_name, subscribe_filter, events, required_role, auth_required, updated_at)
138
+ SELECT target_type, target_name, subscribe_filter, events, required_role, auth_required, now()
139
+ FROM jsonb_to_recordset('${rowsJson}'::jsonb) AS r(
140
+ target_type text,
141
+ target_name text,
142
+ subscribe_filter text,
143
+ events text[],
144
+ required_role text,
145
+ auth_required boolean
146
+ )
147
+ ON CONFLICT (target_type, target_name) DO UPDATE SET
148
+ subscribe_filter = EXCLUDED.subscribe_filter,
149
+ events = EXCLUDED.events,
150
+ required_role = EXCLUDED.required_role,
151
+ auth_required = EXCLUDED.auth_required,
152
+ updated_at = now()
153
+ `,
154
+ })
155
+ }
156
+
157
+ if (toDelete.length > 0) {
158
+ // Build a safe tuple list. target_type is always 'table' or 'channel', target_name is user-provided.
159
+ const pairs = toDelete.map(p =>
160
+ `('${p.target_type}', '${String(p.target_name).replace(/'/g, "''")}')`
161
+ ).join(",")
162
+ await proxyToolCall("execute_sql", {
163
+ project_id: projectId,
164
+ sql: `
165
+ DELETE FROM system.realtime_policies
166
+ WHERE (target_type, target_name) IN (${pairs})
167
+ `,
168
+ })
169
+ }
170
+
171
+ return { upsert: toUpsert.length, delete: toDelete.length }
172
+ }
173
+
174
+ function policiesEqual(a, b) {
175
+ return a.target_type === b.target_type
176
+ && a.target_name === b.target_name
177
+ && (a.subscribe_filter || null) === (b.subscribe_filter || null)
178
+ && JSON.stringify(a.events || []) === JSON.stringify(b.events || [])
179
+ && (a.required_role || null) === (b.required_role || null)
180
+ && !!a.auth_required === !!b.auth_required
181
+ }
182
+
183
+ // ─── Tool ──────────────────────────────────────────────────────────────────
184
+
185
+ export const dypaiPushTool = {
186
+ name: "dypai_push",
187
+ description:
188
+ "Apply the local ./dypai/ state to the remote project: creates, updates, and optionally deletes endpoints. " +
189
+ "ALWAYS run dypai_diff first to preview the plan. This tool mutates remote state. " +
190
+ "By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to delete them.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ project_id: {
195
+ type: "string",
196
+ description: "Project UUID. Optional if your token is project-scoped.",
197
+ },
198
+ root_dir: {
199
+ type: "string",
200
+ description: "Root of the dypai/ folder (default: ./dypai).",
201
+ default: "./dypai",
202
+ },
203
+ delete_orphans: {
204
+ type: "boolean",
205
+ description: "If true, endpoints in remote but missing locally get deleted. Default: false.",
206
+ default: false,
207
+ },
208
+ dry_run: {
209
+ type: "boolean",
210
+ description: "If true, compute the plan and return it without applying. Same as dypai_diff.",
211
+ default: false,
212
+ },
213
+ force: {
214
+ type: "boolean",
215
+ description: "Skip conflict detection. Use only if you know the remote changes you'd overwrite. Default: false.",
216
+ default: false,
217
+ },
218
+ skip_validation: {
219
+ type: "boolean",
220
+ description: "Skip the dypai_validate pre-flight check. Use only when you know the validator is wrong. Default: false.",
221
+ default: false,
222
+ },
223
+ },
224
+ },
225
+
226
+ async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false } = {}) {
227
+ const rootDir = resolvePath(process.cwd(), root_dir)
228
+
229
+ // Resolve the target project via dypai.config.yaml if the caller didn't
230
+ // pass an explicit project_id. If both are provided and disagree, refuse —
231
+ // this is the primary guard against an agent hallucinating a project_id.
232
+ const config = await readLocalConfig(rootDir)
233
+ const configProjectId = config?.project_id || null
234
+ const targetProjectId = project_id || configProjectId
235
+
236
+ // Pre-flight validation (lint). Blocks the push unless skip_validation is set.
237
+ if (!skip_validation && !dry_run) {
238
+ try {
239
+ const validation = await runValidation(rootDir, project_id || configProjectId)
240
+ if (!validation.success) {
241
+ return {
242
+ success: false,
243
+ applied: false,
244
+ reason: "validation_failed",
245
+ summary: validation.summary,
246
+ diagnostics: validation.diagnostics.filter(d => d.severity === "error").slice(0, 20),
247
+ hint: `${validation.summary.errors} error(s) would cause runtime failures. Fix them, or retry with skip_validation: true to override.`,
248
+ }
249
+ }
250
+ } catch (e) {
251
+ // Don't block the push on a validator crash — just warn
252
+ console.error(`[dypai_push] validator crashed: ${e.message}`)
253
+ }
254
+ }
255
+
256
+ if (project_id && configProjectId && project_id !== configProjectId) {
257
+ return {
258
+ success: false,
259
+ applied: false,
260
+ reason: "project_id_mismatch",
261
+ expected_project_id: configProjectId,
262
+ provided_project_id: project_id,
263
+ hint:
264
+ `This dypai/ folder is pinned to project ${configProjectId} (${config?.project_name || "unknown"}). ` +
265
+ `You passed ${project_id}. Refusing to push to a different project. ` +
266
+ `If retargeting is intentional, edit dypai.config.yaml first.`,
267
+ }
268
+ }
269
+
270
+ const [local, remote, stateSnapshot] = await Promise.all([
271
+ readLocalState(rootDir),
272
+ fetchRemoteState(targetProjectId),
273
+ readLocalStateSnapshot(rootDir),
274
+ ])
275
+
276
+ if (local.errors.length) {
277
+ return {
278
+ success: false,
279
+ phase: "read_local",
280
+ errors: local.errors,
281
+ }
282
+ }
283
+
284
+ const plan = computePlan(local, remote, remote.mapsCtx, {
285
+ deleteOrphans: delete_orphans,
286
+ stateSnapshot,
287
+ })
288
+ const totalChanges = plan.create.length + plan.update.length + plan.delete.length
289
+
290
+ // Block push on conflicts unless forced
291
+ const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
292
+ if (conflicts.length && !force) {
293
+ return {
294
+ success: false,
295
+ applied: false,
296
+ reason: "conflicts_detected",
297
+ conflicts,
298
+ hint: "Run dypai_pull to refresh local state, resolve any overlap, then push again. To override, pass force=true.",
299
+ }
300
+ }
301
+
302
+ if (dry_run || totalChanges === 0) {
303
+ return {
304
+ success: true,
305
+ applied: false,
306
+ reason: totalChanges === 0 ? "no_changes" : "dry_run",
307
+ summary: summaryFromPlan(plan),
308
+ plan,
309
+ }
310
+ }
311
+
312
+ // Apply in order: creates → updates → deletes.
313
+ // Within each phase we run with bounded concurrency (5) so pushing 50
314
+ // endpoints doesn't take 50 * 200ms = 10s — drops to ~2s on typical RTT.
315
+ const CONCURRENCY = 5
316
+ const applied = { created: [], updated: [], deleted: [] }
317
+ const errors = []
318
+
319
+ await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
320
+ try {
321
+ const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
322
+ await applyCreate(canonical, targetProjectId)
323
+ applied.created.push(item.name)
324
+ } catch (e) {
325
+ errors.push({ op: "create", endpoint: item.name, error: e.message })
326
+ }
327
+ })
328
+
329
+ await runWithConcurrency(plan.update, CONCURRENCY, async (item) => {
330
+ try {
331
+ const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
332
+ const endpointId = remote.byName[item.name]?.id
333
+ if (!endpointId) throw new Error("endpoint_id missing from remote state")
334
+ await applyUpdate(canonical, endpointId, targetProjectId)
335
+ applied.updated.push({ name: item.name, changed_fields: item.changedFields })
336
+ } catch (e) {
337
+ errors.push({ op: "update", endpoint: item.name, error: e.message })
338
+ }
339
+ })
340
+
341
+ await runWithConcurrency(plan.delete, CONCURRENCY, async (item) => {
342
+ try {
343
+ const endpointId = remote.byName[item.name]?.id
344
+ if (!endpointId) throw new Error("endpoint_id missing from remote state")
345
+ await applyDelete(endpointId, targetProjectId)
346
+ applied.deleted.push(item.name)
347
+ } catch (e) {
348
+ errors.push({ op: "delete", endpoint: item.name, error: e.message })
349
+ }
350
+ })
351
+
352
+ // Also reconcile realtime policies if realtime.yaml exists
353
+ let realtime = null
354
+ try {
355
+ realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
356
+ } catch (e) {
357
+ errors.push({ op: "realtime", error: e.message })
358
+ }
359
+
360
+ const changedNames = [
361
+ ...applied.created,
362
+ ...applied.updated.map(u => u.name),
363
+ ]
364
+
365
+ // Regenerate TS types if endpoints changed (input/output schemas may have shifted).
366
+ let codegen
367
+ if (changedNames.length > 0) {
368
+ try { codegen = await regenerateTypes(rootDir) }
369
+ catch (e) { codegen = { skipped: true, reason: e.message } }
370
+ }
371
+
372
+ return {
373
+ success: errors.length === 0,
374
+ applied: true,
375
+ summary: {
376
+ created: applied.created.length,
377
+ updated: applied.updated.length,
378
+ deleted: applied.deleted.length,
379
+ unchanged: plan.unchanged.length,
380
+ errors: errors.length,
381
+ realtime: realtime || { skipped: true, reason: "no realtime.yaml" },
382
+ codegen: codegen?.regenerated
383
+ ? { files: codegen.files_written.length }
384
+ : (codegen?.reason || "no changes to regenerate"),
385
+ },
386
+ details: applied,
387
+ errors: errors.length ? errors : undefined,
388
+ // Only one next_step — and only when it's non-obvious
389
+ next_step: errors.length
390
+ ? "Fix the offending YAMLs and push again."
391
+ : changedNames.length
392
+ ? `Test changed endpoint(s) with test_workflow: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
393
+ : undefined,
394
+ hint: errors.some(e => /credential/i.test(e.error))
395
+ ? "A referenced credential doesn't exist remotely. Create it in the dashboard (same name), then retry."
396
+ : errors.some(e => /validation/i.test(e.error))
397
+ ? "Field shape mismatch. Inspect the YAML of the failed endpoint."
398
+ : undefined,
399
+ }
400
+ },
401
+ }
402
+
403
+ function summaryFromPlan(plan) {
404
+ return {
405
+ create: plan.create.length,
406
+ update: plan.update.length,
407
+ delete: plan.delete.length,
408
+ unchanged: plan.unchanged.length,
409
+ orphans_ignored: plan.orphansIgnored?.length || 0,
410
+ }
411
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Dump of the public schema as DDL-looking SQL (CREATE TABLE statements with
3
+ * columns + PKs + FK comments). Shared by dypai_pull and the auto-refresh
4
+ * triggered by execute_sql when it detects schema-affecting DDL.
5
+ */
6
+
7
+ import { proxyToolCall } from "../proxy.js"
8
+
9
+ async function execSql(projectId, sql) {
10
+ const args = projectId ? { project_id: projectId, sql } : { sql }
11
+ const result = await proxyToolCall("execute_sql", args)
12
+ if (result?.error) throw new Error(`SQL error: ${result.error}`)
13
+ if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
14
+ return result.rows
15
+ }
16
+
17
+ function formatColumnType(c) {
18
+ if (c.data_type === "character varying") {
19
+ return c.character_maximum_length ? `varchar(${c.character_maximum_length})` : "varchar"
20
+ }
21
+ if (c.data_type === "USER-DEFINED") return c.udt_name || "user_defined"
22
+ if (c.data_type === "ARRAY") return `${c.udt_name || "text"}`.replace(/^_/, "") + "[]"
23
+ return c.data_type
24
+ }
25
+
26
+ export async function dumpPublicSchema(projectId) {
27
+ const [columns, pks, fks] = await Promise.all([
28
+ execSql(projectId, `
29
+ SELECT table_name, column_name, data_type, is_nullable, column_default,
30
+ character_maximum_length, udt_name, ordinal_position
31
+ FROM information_schema.columns
32
+ WHERE table_schema = 'public'
33
+ ORDER BY table_name, ordinal_position
34
+ `),
35
+ execSql(projectId, `
36
+ SELECT tc.table_name, kcu.column_name
37
+ FROM information_schema.table_constraints tc
38
+ JOIN information_schema.key_column_usage kcu
39
+ ON tc.constraint_schema = kcu.constraint_schema
40
+ AND tc.constraint_name = kcu.constraint_name
41
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
42
+ `),
43
+ execSql(projectId, `
44
+ SELECT tc.table_name, kcu.column_name,
45
+ ccu.table_name AS ref_table, ccu.column_name AS ref_column
46
+ FROM information_schema.table_constraints tc
47
+ JOIN information_schema.key_column_usage kcu
48
+ ON tc.constraint_schema = kcu.constraint_schema
49
+ AND tc.constraint_name = kcu.constraint_name
50
+ JOIN information_schema.constraint_column_usage ccu
51
+ ON tc.constraint_schema = ccu.constraint_schema
52
+ AND tc.constraint_name = ccu.constraint_name
53
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
54
+ `),
55
+ ])
56
+
57
+ const byTable = {}
58
+ for (const c of columns) {
59
+ if (!byTable[c.table_name]) byTable[c.table_name] = []
60
+ byTable[c.table_name].push(c)
61
+ }
62
+ const pkSet = new Set(pks.map(p => `${p.table_name}.${p.column_name}`))
63
+ const fksByTable = {}
64
+ for (const f of fks) {
65
+ if (!fksByTable[f.table_name]) fksByTable[f.table_name] = []
66
+ fksByTable[f.table_name].push(f)
67
+ }
68
+
69
+ const lines = [
70
+ "-- Auto-generated — do NOT edit by hand.",
71
+ "-- Regenerated on every dypai_pull and after schema-affecting execute_sql (CREATE/ALTER/DROP/RENAME TABLE).",
72
+ "",
73
+ ]
74
+
75
+ const tableNames = Object.keys(byTable).sort()
76
+ for (const table of tableNames) {
77
+ const cols = byTable[table]
78
+ lines.push(`CREATE TABLE public.${table} (`)
79
+ const colLines = cols.map(c => {
80
+ const type = formatColumnType(c)
81
+ const nullable = c.is_nullable === "NO" ? " NOT NULL" : ""
82
+ const pk = pkSet.has(`${table}.${c.column_name}`) ? " PRIMARY KEY" : ""
83
+ const dflt = c.column_default ? ` DEFAULT ${c.column_default}` : ""
84
+ return ` ${c.column_name} ${type}${nullable}${pk}${dflt}`
85
+ })
86
+ lines.push(colLines.join(",\n"))
87
+ lines.push(");")
88
+
89
+ const tableFks = fksByTable[table] || []
90
+ for (const fk of tableFks) {
91
+ lines.push(`-- FK: ${fk.column_name} -> public.${fk.ref_table}(${fk.ref_column})`)
92
+ }
93
+ lines.push("")
94
+ }
95
+ return lines.join("\n")
96
+ }