@dypai-ai/mcp 1.0.10 → 1.2.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,397 @@
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
+ // Codegen removed from v1 — see pull.js note.
25
+ // import { regenerateTypes } from "../codegen.js"
26
+
27
+ // ─── Apply helpers ─────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Run an async worker over an array with bounded concurrency. Workers never
31
+ * throw (they handle their own errors via the push errors[] accumulator),
32
+ * so we can await them all safely without Promise.all propagating rejections.
33
+ */
34
+ async function runWithConcurrency(items, concurrency, worker) {
35
+ if (!items.length) return
36
+ const queue = items.slice()
37
+ const pumps = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
38
+ while (queue.length) {
39
+ const item = queue.shift()
40
+ await worker(item)
41
+ }
42
+ })
43
+ await Promise.all(pumps)
44
+ }
45
+
46
+ function endpointPayload(row) {
47
+ // The remote schemas reject null values for optional fields — only include
48
+ // what's actually set. workflow_code is always required.
49
+ const p = {
50
+ name: row.name,
51
+ method: row.method,
52
+ workflow_code: row.workflow_code,
53
+ is_tool: !!row.is_tool,
54
+ allowed_roles: row.allowed_roles || [],
55
+ }
56
+ if (row.description) p.description = row.description
57
+ if (row.tool_description) p.tool_description = row.tool_description
58
+ if (row.group_id) p.group_id = row.group_id
59
+ if (row.input) p.input = row.input
60
+ if (row.output) p.output = row.output
61
+ return p
62
+ }
63
+
64
+ async function applyCreate(canonicalRow, projectId) {
65
+ return proxyToolCall("create_endpoint", {
66
+ ...(projectId ? { project_id: projectId } : {}),
67
+ ...endpointPayload(canonicalRow),
68
+ })
69
+ }
70
+
71
+ async function applyUpdate(canonicalRow, endpointId, projectId) {
72
+ return proxyToolCall("update_endpoint", {
73
+ ...(projectId ? { project_id: projectId } : {}),
74
+ endpoint_id: endpointId,
75
+ updates: endpointPayload(canonicalRow),
76
+ })
77
+ }
78
+
79
+ async function applyDelete(endpointId, projectId) {
80
+ return proxyToolCall("delete_endpoint", {
81
+ ...(projectId ? { project_id: projectId } : {}),
82
+ endpoint_id: endpointId,
83
+ })
84
+ }
85
+
86
+ // ─── Realtime policies sync ────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Reconcile local realtime.yaml with system.realtime_policies remotely.
90
+ * Returns a summary of what was applied (or would be applied in dry_run).
91
+ */
92
+ async function syncRealtimePolicies(projectId, rootDir, dryRun = false) {
93
+ const local = await readLocalRealtime(rootDir)
94
+ if (!local) return { skipped: true, reason: "no realtime.yaml found" }
95
+
96
+ const remote = await fetchRemoteRealtime(projectId)
97
+ if (remote === null) {
98
+ return { skipped: true, reason: "engine has no system.realtime_policies table yet (backward compat)" }
99
+ }
100
+
101
+ const key = (p) => `${p.target_type}::${p.target_name}`
102
+ const remoteMap = Object.fromEntries(remote.map(p => [key(p), p]))
103
+ const localMap = Object.fromEntries(local.rows.map(p => [key(p), p]))
104
+
105
+ const toUpsert = []
106
+ const toDelete = []
107
+ for (const k of Object.keys(localMap)) {
108
+ const l = localMap[k]
109
+ const r = remoteMap[k]
110
+ if (!r || !policiesEqual(l, r)) toUpsert.push(l)
111
+ }
112
+ for (const k of Object.keys(remoteMap)) {
113
+ if (!localMap[k]) toDelete.push(remoteMap[k])
114
+ }
115
+
116
+ if (dryRun) {
117
+ return { upsert: toUpsert.length, delete: toDelete.length, dry_run: true }
118
+ }
119
+
120
+ // execute_sql on the remote is parameterless (only takes a raw `sql` string).
121
+ // To avoid hand-escaping values, we embed the rows as JSON and let Postgres
122
+ // parse them via jsonb_to_recordset — JSON.stringify handles quote escaping
123
+ // correctly, and we only need to double single-quotes for the SQL literal.
124
+ if (toUpsert.length > 0) {
125
+ const rowsJson = JSON.stringify(toUpsert.map(p => ({
126
+ target_type: p.target_type,
127
+ target_name: p.target_name,
128
+ subscribe_filter: p.subscribe_filter,
129
+ events: p.events,
130
+ required_role: p.required_role,
131
+ auth_required: p.auth_required,
132
+ }))).replace(/'/g, "''") // escape for SQL string literal
133
+
134
+ await proxyToolCall("execute_sql", {
135
+ project_id: projectId,
136
+ sql: `
137
+ INSERT INTO system.realtime_policies
138
+ (target_type, target_name, subscribe_filter, events, required_role, auth_required, updated_at)
139
+ SELECT target_type, target_name, subscribe_filter, events, required_role, auth_required, now()
140
+ FROM jsonb_to_recordset('${rowsJson}'::jsonb) AS r(
141
+ target_type text,
142
+ target_name text,
143
+ subscribe_filter text,
144
+ events text[],
145
+ required_role text,
146
+ auth_required boolean
147
+ )
148
+ ON CONFLICT (target_type, target_name) DO UPDATE SET
149
+ subscribe_filter = EXCLUDED.subscribe_filter,
150
+ events = EXCLUDED.events,
151
+ required_role = EXCLUDED.required_role,
152
+ auth_required = EXCLUDED.auth_required,
153
+ updated_at = now()
154
+ `,
155
+ })
156
+ }
157
+
158
+ if (toDelete.length > 0) {
159
+ // Build a safe tuple list. target_type is always 'table' or 'channel', target_name is user-provided.
160
+ const pairs = toDelete.map(p =>
161
+ `('${p.target_type}', '${String(p.target_name).replace(/'/g, "''")}')`
162
+ ).join(",")
163
+ await proxyToolCall("execute_sql", {
164
+ project_id: projectId,
165
+ sql: `
166
+ DELETE FROM system.realtime_policies
167
+ WHERE (target_type, target_name) IN (${pairs})
168
+ `,
169
+ })
170
+ }
171
+
172
+ return { upsert: toUpsert.length, delete: toDelete.length }
173
+ }
174
+
175
+ function policiesEqual(a, b) {
176
+ return a.target_type === b.target_type
177
+ && a.target_name === b.target_name
178
+ && (a.subscribe_filter || null) === (b.subscribe_filter || null)
179
+ && JSON.stringify(a.events || []) === JSON.stringify(b.events || [])
180
+ && (a.required_role || null) === (b.required_role || null)
181
+ && !!a.auth_required === !!b.auth_required
182
+ }
183
+
184
+ // ─── Tool ──────────────────────────────────────────────────────────────────
185
+
186
+ export const dypaiPushTool = {
187
+ name: "dypai_push",
188
+ description:
189
+ "Apply the local ./dypai/ state to the remote project: creates, updates, and optionally deletes endpoints. " +
190
+ "ALWAYS run dypai_diff first to preview the plan. This tool mutates remote state. " +
191
+ "By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to delete them.",
192
+ inputSchema: {
193
+ type: "object",
194
+ properties: {
195
+ project_id: {
196
+ type: "string",
197
+ description: "Project UUID. Optional if your token is project-scoped.",
198
+ },
199
+ root_dir: {
200
+ type: "string",
201
+ description: "Root of the dypai/ folder (default: ./dypai).",
202
+ default: "./dypai",
203
+ },
204
+ delete_orphans: {
205
+ type: "boolean",
206
+ description: "If true, endpoints in remote but missing locally get deleted. Default: false.",
207
+ default: false,
208
+ },
209
+ dry_run: {
210
+ type: "boolean",
211
+ description: "If true, compute the plan and return it without applying. Same as dypai_diff.",
212
+ default: false,
213
+ },
214
+ force: {
215
+ type: "boolean",
216
+ description: "Skip conflict detection. Use only if you know the remote changes you'd overwrite. Default: false.",
217
+ default: false,
218
+ },
219
+ skip_validation: {
220
+ type: "boolean",
221
+ description: "Skip the dypai_validate pre-flight check. Use only when you know the validator is wrong. Default: false.",
222
+ default: false,
223
+ },
224
+ },
225
+ },
226
+
227
+ async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false } = {}) {
228
+ const rootDir = resolvePath(process.cwd(), root_dir)
229
+
230
+ // Resolve the target project via dypai.config.yaml if the caller didn't
231
+ // pass an explicit project_id. If both are provided and disagree, refuse —
232
+ // this is the primary guard against an agent hallucinating a project_id.
233
+ const config = await readLocalConfig(rootDir)
234
+ const configProjectId = config?.project_id || null
235
+ const targetProjectId = project_id || configProjectId
236
+
237
+ // Pre-flight validation (lint). Blocks the push unless skip_validation is set.
238
+ if (!skip_validation && !dry_run) {
239
+ try {
240
+ const validation = await runValidation(rootDir, project_id || configProjectId)
241
+ if (!validation.success) {
242
+ return {
243
+ success: false,
244
+ applied: false,
245
+ reason: "validation_failed",
246
+ summary: validation.summary,
247
+ diagnostics: validation.diagnostics.filter(d => d.severity === "error").slice(0, 20),
248
+ hint: `${validation.summary.errors} error(s) would cause runtime failures. Fix them, or retry with skip_validation: true to override.`,
249
+ }
250
+ }
251
+ } catch (e) {
252
+ // Don't block the push on a validator crash — just warn
253
+ console.error(`[dypai_push] validator crashed: ${e.message}`)
254
+ }
255
+ }
256
+
257
+ if (project_id && configProjectId && project_id !== configProjectId) {
258
+ return {
259
+ success: false,
260
+ applied: false,
261
+ reason: "project_id_mismatch",
262
+ expected_project_id: configProjectId,
263
+ provided_project_id: project_id,
264
+ hint:
265
+ `This dypai/ folder is pinned to project ${configProjectId} (${config?.project_name || "unknown"}). ` +
266
+ `You passed ${project_id}. Refusing to push to a different project. ` +
267
+ `If retargeting is intentional, edit dypai.config.yaml first.`,
268
+ }
269
+ }
270
+
271
+ const [local, remote, stateSnapshot] = await Promise.all([
272
+ readLocalState(rootDir),
273
+ fetchRemoteState(targetProjectId),
274
+ readLocalStateSnapshot(rootDir),
275
+ ])
276
+
277
+ if (local.errors.length) {
278
+ return {
279
+ success: false,
280
+ phase: "read_local",
281
+ errors: local.errors,
282
+ }
283
+ }
284
+
285
+ const plan = computePlan(local, remote, remote.mapsCtx, {
286
+ deleteOrphans: delete_orphans,
287
+ stateSnapshot,
288
+ })
289
+ const totalChanges = plan.create.length + plan.update.length + plan.delete.length
290
+
291
+ // Block push on conflicts unless forced
292
+ const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
293
+ if (conflicts.length && !force) {
294
+ return {
295
+ success: false,
296
+ applied: false,
297
+ reason: "conflicts_detected",
298
+ conflicts,
299
+ hint: "Run dypai_pull to refresh local state, resolve any overlap, then push again. To override, pass force=true.",
300
+ }
301
+ }
302
+
303
+ if (dry_run || totalChanges === 0) {
304
+ return {
305
+ success: true,
306
+ applied: false,
307
+ reason: totalChanges === 0 ? "no_changes" : "dry_run",
308
+ summary: summaryFromPlan(plan),
309
+ plan,
310
+ }
311
+ }
312
+
313
+ // Apply in order: creates → updates → deletes.
314
+ // Within each phase we run with bounded concurrency (5) so pushing 50
315
+ // endpoints doesn't take 50 * 200ms = 10s — drops to ~2s on typical RTT.
316
+ const CONCURRENCY = 5
317
+ const applied = { created: [], updated: [], deleted: [] }
318
+ const errors = []
319
+
320
+ await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
321
+ try {
322
+ const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
323
+ await applyCreate(canonical, targetProjectId)
324
+ applied.created.push(item.name)
325
+ } catch (e) {
326
+ errors.push({ op: "create", endpoint: item.name, error: e.message })
327
+ }
328
+ })
329
+
330
+ await runWithConcurrency(plan.update, CONCURRENCY, async (item) => {
331
+ try {
332
+ const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
333
+ const endpointId = remote.byName[item.name]?.id
334
+ if (!endpointId) throw new Error("endpoint_id missing from remote state")
335
+ await applyUpdate(canonical, endpointId, targetProjectId)
336
+ applied.updated.push({ name: item.name, changed_fields: item.changedFields })
337
+ } catch (e) {
338
+ errors.push({ op: "update", endpoint: item.name, error: e.message })
339
+ }
340
+ })
341
+
342
+ await runWithConcurrency(plan.delete, CONCURRENCY, async (item) => {
343
+ try {
344
+ const endpointId = remote.byName[item.name]?.id
345
+ if (!endpointId) throw new Error("endpoint_id missing from remote state")
346
+ await applyDelete(endpointId, targetProjectId)
347
+ applied.deleted.push(item.name)
348
+ } catch (e) {
349
+ errors.push({ op: "delete", endpoint: item.name, error: e.message })
350
+ }
351
+ })
352
+
353
+ // Also reconcile realtime policies if realtime.yaml exists
354
+ let realtime = null
355
+ try {
356
+ realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
357
+ } catch (e) {
358
+ errors.push({ op: "realtime", error: e.message })
359
+ }
360
+
361
+ return {
362
+ success: errors.length === 0,
363
+ applied: true,
364
+ summary: {
365
+ created: applied.created.length,
366
+ updated: applied.updated.length,
367
+ deleted: applied.deleted.length,
368
+ unchanged: plan.unchanged.length,
369
+ errors: errors.length,
370
+ realtime: realtime || { skipped: true, reason: "no realtime.yaml" },
371
+ },
372
+ details: applied,
373
+ errors: errors.length ? errors : undefined,
374
+ // Only one next_step — and only when it's non-obvious
375
+ next_step: errors.length
376
+ ? "Fix the offending YAMLs and push again."
377
+ : changedNames.length
378
+ ? `Test changed endpoint(s) with test_workflow: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
379
+ : undefined,
380
+ hint: errors.some(e => /credential/i.test(e.error))
381
+ ? "A referenced credential doesn't exist remotely. Create it in the dashboard (same name), then retry."
382
+ : errors.some(e => /validation/i.test(e.error))
383
+ ? "Field shape mismatch. Inspect the YAML of the failed endpoint."
384
+ : undefined,
385
+ }
386
+ },
387
+ }
388
+
389
+ function summaryFromPlan(plan) {
390
+ return {
391
+ create: plan.create.length,
392
+ update: plan.update.length,
393
+ delete: plan.delete.length,
394
+ unchanged: plan.unchanged.length,
395
+ orphans_ignored: plan.orphansIgnored?.length || 0,
396
+ }
397
+ }
@@ -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
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * dypai_test_endpoint — test an endpoint by name using its LOCAL YAML.
3
+ *
4
+ * Pre-git-first world: the agent had to pass workflow_code or endpoint_id to
5
+ * the remote test_workflow. Now the YAML lives on disk — so we just take a
6
+ * name + input, read the file, deserialize via the codec, and execute.
7
+ *
8
+ * The big win: you're testing the EDITED version before pushing. Tight
9
+ * iteration loop without round-tripping through dypai_push + remote lookup.
10
+ */
11
+
12
+ import { readFile, readdir } from "fs/promises"
13
+ import { join, resolve as resolvePath } from "path"
14
+ import YAML from "yaml"
15
+ import { proxyToolCall } from "../proxy.js"
16
+ import { deserializeEndpoint } from "./codec.js"
17
+ import { readLocalConfig, fetchRemoteState } from "./planner.js"
18
+ import { summarizeTestWorkflowResponse } from "../trace-summarize.js"
19
+
20
+ // ─── Local endpoint file discovery ──────────────────────────────────────────
21
+
22
+ function collectFileRefs(doc) {
23
+ const refs = new Set()
24
+ const walk = (node) => {
25
+ if (!node || typeof node !== "object") return
26
+ if (Array.isArray(node)) return node.forEach(walk)
27
+ for (const [k, v] of Object.entries(node)) {
28
+ if (typeof v === "string" && k.endsWith("_file")) refs.add(v)
29
+ else walk(v)
30
+ }
31
+ }
32
+ walk(doc)
33
+ return Array.from(refs)
34
+ }
35
+
36
+ async function findEndpointByName(rootDir, name) {
37
+ const endpointsDir = join(rootDir, "endpoints")
38
+ const candidates = []
39
+
40
+ async function walk(dir) {
41
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
42
+ for (const e of entries) {
43
+ const full = join(dir, e.name)
44
+ if (e.isDirectory()) await walk(full)
45
+ else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
46
+ candidates.push(full)
47
+ }
48
+ }
49
+ }
50
+ await walk(endpointsDir)
51
+
52
+ // 1. Exact doc.name match (authoritative)
53
+ for (const full of candidates) {
54
+ try {
55
+ const raw = await readFile(full, "utf8")
56
+ const doc = YAML.parse(raw)
57
+ if (doc?.name === name) return { full, doc }
58
+ } catch { /* skip malformed */ }
59
+ }
60
+
61
+ // 2. Fallback: filename match (handles case where the agent guessed the file)
62
+ const filenameMatch = candidates.find(p => p.endsWith(`/${name}.yaml`) || p.endsWith(`/${name}.yml`))
63
+ if (filenameMatch) {
64
+ const raw = await readFile(filenameMatch, "utf8")
65
+ const doc = YAML.parse(raw)
66
+ return { full: filenameMatch, doc }
67
+ }
68
+
69
+ return null
70
+ }
71
+
72
+ // ─── Tool ───────────────────────────────────────────────────────────────────
73
+
74
+ export const dypaiTestEndpointTool = {
75
+ name: "dypai_test_endpoint",
76
+ description:
77
+ "Test an endpoint using its LOCAL YAML (dypai/endpoints/<name>.yaml, any group). " +
78
+ "You only pass the endpoint name + input — the tool reads the YAML, inlines referenced SQL/code/prompt files, " +
79
+ "and runs a debug execution against the engine. Use this to iterate BEFORE dypai_push. " +
80
+ "For jwt endpoints, pass as_user with the UUID to impersonate. " +
81
+ "Returns a summarized per-node trace by default; pass trace_mode: 'full' for the unabbreviated view.",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ endpoint: {
86
+ type: "string",
87
+ description: "Endpoint name as declared in its YAML (e.g. 'create-order'). Looked up in dypai/endpoints/** by matching doc.name.",
88
+ },
89
+ input: {
90
+ type: "object",
91
+ description: "Input body for the execution. Maps to ${input.*} placeholders inside the workflow.",
92
+ },
93
+ as_user: {
94
+ type: "string",
95
+ description: "User UUID to impersonate. Required for jwt endpoints that read ${current_user_id}.",
96
+ },
97
+ trace_mode: {
98
+ type: "string",
99
+ enum: ["smart", "full", "minimal"],
100
+ description: "How to summarize the returned trace. 'smart' (default) surfaces the failing node in detail; 'full' returns everything; 'minimal' returns only status + duration.",
101
+ default: "smart",
102
+ },
103
+ root_dir: {
104
+ type: "string",
105
+ description: "Path to the dypai/ folder (default: ./dypai).",
106
+ default: "./dypai",
107
+ },
108
+ project_id: {
109
+ type: "string",
110
+ description: "Project UUID. Auto-resolved from dypai.config.yaml.",
111
+ },
112
+ },
113
+ required: ["endpoint"],
114
+ },
115
+
116
+ async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id } = {}) {
117
+ const rootDir = resolvePath(process.cwd(), root_dir)
118
+
119
+ if (!endpoint) {
120
+ return { success: false, error: "endpoint name is required." }
121
+ }
122
+
123
+ const found = await findEndpointByName(rootDir, endpoint)
124
+ if (!found) {
125
+ return {
126
+ success: false,
127
+ error: `Endpoint '${endpoint}' not found under dypai/endpoints/.`,
128
+ hint: "Run dypai_pull to refresh, or check that the YAML's top-level `name` matches.",
129
+ }
130
+ }
131
+
132
+ // Pre-read any *_file references so the codec can inline them
133
+ const refs = collectFileRefs(found.doc)
134
+ const fileMap = {}
135
+ for (const ref of refs) {
136
+ try {
137
+ fileMap[ref] = await readFile(join(rootDir, ref), "utf8")
138
+ } catch (e) {
139
+ return {
140
+ success: false,
141
+ error: `Referenced file not readable: ${ref} (${e.message})`,
142
+ hint: "The YAML points to a *_file that doesn't exist on disk. Run dypai_pull or create the missing file.",
143
+ }
144
+ }
145
+ }
146
+
147
+ // Resolve project + credential/tool UUID maps for the codec
148
+ const config = await readLocalConfig(rootDir)
149
+ const targetProjectId = project_id || config?.project_id
150
+ if (!targetProjectId) {
151
+ return {
152
+ success: false,
153
+ error: "project_id required (set it in dypai.config.yaml or pass it explicitly).",
154
+ }
155
+ }
156
+
157
+ let mapsCtx
158
+ try {
159
+ const remote = await fetchRemoteState(targetProjectId)
160
+ mapsCtx = remote.mapsCtx
161
+ } catch (e) {
162
+ return {
163
+ success: false,
164
+ error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
165
+ }
166
+ }
167
+
168
+ // Deserialize the local YAML to the engine-shaped workflow_code
169
+ const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
170
+ let row
171
+ try {
172
+ row = deserializeEndpoint(found.doc, deserCtx)
173
+ } catch (e) {
174
+ return {
175
+ success: false,
176
+ error: `Failed to deserialize '${endpoint}' from its YAML: ${e.message}`,
177
+ hint: "Run dypai_validate to surface the specific problem.",
178
+ }
179
+ }
180
+
181
+ // Build the call to the remote test_workflow (pass workflow_code inline so
182
+ // we test the LOCAL version, not whatever is currently deployed).
183
+ const execArgs = {
184
+ project_id: targetProjectId,
185
+ workflow_code: row.workflow_code,
186
+ data: input,
187
+ trace_mode, // used by the local MCP enrichment layer
188
+ }
189
+ if (as_user) execArgs.impersonated_user_id = as_user
190
+
191
+ try {
192
+ const result = await proxyToolCall("test_workflow", execArgs)
193
+ // Summarize the trace just like the direct test_workflow path.
194
+ const summarized = summarizeTestWorkflowResponse(result, trace_mode)
195
+ return {
196
+ endpoint,
197
+ file: found.full.replace(rootDir + "/", ""),
198
+ as_user: as_user || null,
199
+ ...summarized,
200
+ }
201
+ } catch (e) {
202
+ return {
203
+ success: false,
204
+ error: `Execution failed: ${e.message}`,
205
+ endpoint,
206
+ hint: "If the error is cryptic, try trace_mode: 'full' or use dypai_trace with the execution_id from the response.",
207
+ }
208
+ }
209
+ },
210
+ }