@dypai-ai/mcp 1.4.3 → 1.4.5

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.
@@ -1,12 +1,19 @@
1
1
  /**
2
- * dypai_test_endpoint — test an endpoint by name using its LOCAL YAML.
2
+ * dypai_test_endpoint — test an endpoint by name from one of three sources.
3
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.
4
+ * Modes:
5
+ * - 'local' (default): read & deserialize the LOCAL YAML on disk. Tight
6
+ * iteration loop test edits BEFORE dypai_push.
7
+ * - 'draft': fetch the pending draft from the engine (via manage_drafts(list))
8
+ * and run it. Use this AFTER dypai_push to verify exactly what
9
+ * manage_drafts(operation:'publish') will ship.
10
+ * - 'live': fetch the currently-deployed workflow_code from system.endpoints.
11
+ * Use this to repro a live bug or sanity-check what's serving traffic.
7
12
  *
8
- * The big win: you're testing the EDITED version before pushing. Tight
9
- * iteration loop without round-tripping through dypai_push + remote lookup.
13
+ * All three modes funnel into the same remote test_workflow (workflow_code
14
+ * passed inline) so impersonation, trace summarization, and trace_mode work
15
+ * uniformly. mode:'draft' and mode:'live' skip pre-flight validation — the
16
+ * source isn't local YAML, so there's nothing to lint.
10
17
  */
11
18
 
12
19
  import { readFile, readdir } from "fs/promises"
@@ -75,20 +82,160 @@ async function findEndpointByName(rootDir, name) {
75
82
 
76
83
  // ─── Tool ───────────────────────────────────────────────────────────────────
77
84
 
85
+ // ─── Source resolvers (one per mode) ────────────────────────────────────────
86
+ //
87
+ // Each resolver returns either:
88
+ // { workflow_code, source: { mode, ...metadata } }
89
+ // or:
90
+ // { error, hint?, ...debug } (caller short-circuits with success:false)
91
+
92
+ async function resolveLocal(rootDir, endpoint, mapsCtx) {
93
+ const found = await findEndpointByName(rootDir, endpoint)
94
+ if (!found) {
95
+ return {
96
+ error: `Endpoint '${endpoint}' not found under dypai/endpoints/.`,
97
+ hint: "Run dypai_pull to refresh, or check that the YAML's top-level `name` matches. " +
98
+ "If you want to test a draft or live version that isn't on disk, pass mode:'draft' or mode:'live'.",
99
+ }
100
+ }
101
+
102
+ const refs = collectFileRefs(found.doc)
103
+ const fileMap = {}
104
+ for (const ref of refs) {
105
+ try {
106
+ fileMap[ref] = await readFile(join(rootDir, ref), "utf8")
107
+ } catch (e) {
108
+ return {
109
+ error: `Referenced file not readable: ${ref} (${e.message})`,
110
+ hint: "The YAML points to a *_file that doesn't exist on disk. Run dypai_pull or create the missing file.",
111
+ }
112
+ }
113
+ }
114
+
115
+ const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
116
+ let row
117
+ try {
118
+ row = deserializeEndpoint(found.doc, deserCtx)
119
+ } catch (e) {
120
+ return {
121
+ error: `Failed to deserialize '${endpoint}' from its YAML: ${e.message}`,
122
+ hint: "Run dypai_validate to surface the specific problem.",
123
+ }
124
+ }
125
+
126
+ return {
127
+ workflow_code: row.workflow_code,
128
+ source: {
129
+ mode: "local",
130
+ file: found.full.replace(rootDir + "/", ""),
131
+ doc: found.doc,
132
+ },
133
+ }
134
+ }
135
+
136
+ async function resolveDraft(projectId, endpoint) {
137
+ const result = await proxyToolCall("manage_drafts", { operation: "list", project_id: projectId })
138
+ if (result?.error) {
139
+ return { error: `manage_drafts(list) failed: ${result.error}` }
140
+ }
141
+ const drafts = Array.isArray(result?.drafts) ? result.drafts : []
142
+ const match = drafts.find(d => d?.resource_type === "endpoint" && d?.resource_name === endpoint)
143
+ if (!match) {
144
+ const otherEndpointDrafts = drafts.filter(d => d?.resource_type === "endpoint").map(d => d.resource_name)
145
+ return {
146
+ error: `No pending draft for endpoint '${endpoint}'.`,
147
+ hint: otherEndpointDrafts.length
148
+ ? `Pending endpoint drafts: ${otherEndpointDrafts.join(", ")}. ` +
149
+ `If you wanted to test the LOCAL YAML use mode:'local' (default), or mode:'live' for the deployed version.`
150
+ : "No endpoint drafts at all. Use mode:'local' to test your YAML or mode:'live' for the deployed version.",
151
+ }
152
+ }
153
+ if (match.pending_deletion) {
154
+ return {
155
+ error: `Draft for '${endpoint}' is a deletion (pending_deletion=true) — there's no workflow_code to test.`,
156
+ hint: "If you want to test what's currently live, use mode:'live'.",
157
+ }
158
+ }
159
+ const payload = match.payload || {}
160
+ const workflow_code = payload.workflow_code
161
+ if (!workflow_code || typeof workflow_code !== "object") {
162
+ return {
163
+ error: `Draft for '${endpoint}' has no workflow_code in its payload — cannot test.`,
164
+ raw_draft: match,
165
+ }
166
+ }
167
+ return {
168
+ workflow_code,
169
+ source: {
170
+ mode: "draft",
171
+ draft_id: match.id,
172
+ created_at: match.created_at,
173
+ created_by: match.created_by,
174
+ },
175
+ }
176
+ }
177
+
178
+ async function resolveLive(projectId, endpoint) {
179
+ // Look up the deployed row directly. system.endpoints.workflow_code is jsonb.
180
+ const sql = `SELECT id, name, workflow_code, updated_at
181
+ FROM system.endpoints
182
+ WHERE name = '${endpoint.replace(/'/g, "''")}'
183
+ LIMIT 1`
184
+ const result = await proxyToolCall("execute_sql", { project_id: projectId, sql })
185
+ if (result?.error) {
186
+ return { error: `execute_sql failed: ${result.error}` }
187
+ }
188
+ const rows = Array.isArray(result?.rows) ? result.rows : []
189
+ if (rows.length === 0) {
190
+ return {
191
+ error: `No live endpoint named '${endpoint}' is deployed.`,
192
+ hint: "If it only exists locally, use mode:'local'. If it was just pushed it may still be a pending draft — try mode:'draft' (then publish with manage_drafts).",
193
+ }
194
+ }
195
+ const row = rows[0]
196
+ let workflow_code = row.workflow_code
197
+ if (typeof workflow_code === "string") {
198
+ try { workflow_code = JSON.parse(workflow_code) } catch { /* leave as-is */ }
199
+ }
200
+ if (!workflow_code || typeof workflow_code !== "object") {
201
+ return {
202
+ error: `Live endpoint '${endpoint}' has empty/malformed workflow_code.`,
203
+ raw_row: row,
204
+ }
205
+ }
206
+ return {
207
+ workflow_code,
208
+ source: {
209
+ mode: "live",
210
+ endpoint_id: row.id,
211
+ updated_at: row.updated_at,
212
+ },
213
+ }
214
+ }
215
+
216
+ // ─── Tool ───────────────────────────────────────────────────────────────────
217
+
78
218
  export const dypaiTestEndpointTool = {
79
219
  name: "dypai_test_endpoint",
80
220
  description:
81
- "Test an endpoint using its LOCAL YAML (dypai/endpoints/<name>.yaml, any group). " +
82
- "You only pass the endpoint name + input the tool reads the YAML, inlines referenced SQL/code/prompt files, " +
83
- "and runs a debug execution against the engine. Use this to iterate BEFORE dypai_push. " +
84
- "For jwt endpoints, pass as_user with the UUID to impersonate. " +
85
- "Returns a summarized per-node trace by default; pass trace_mode: 'full' for the unabbreviated view.",
221
+ "Test an endpoint by name. Three sources via `mode`: " +
222
+ "'local' (default) reads dypai/endpoints/<name>.yaml from disk and inlines referenced SQL/code/prompt files — use BEFORE dypai_push to iterate; " +
223
+ "'draft' fetches the pending draft staged by dypai_push use AFTER dypai_push to verify exactly what manage_drafts(operation:'publish') will ship; " +
224
+ "'live' fetches the currently-deployed workflow_code from system.endpoints use to repro a live bug or check what's serving traffic. " +
225
+ "All modes execute via the engine's debug runner with full impersonation (as_user for jwt) and trace summarization. " +
226
+ "Pre-flight YAML validation only runs in mode:'local'.",
86
227
  inputSchema: {
87
228
  type: "object",
88
229
  properties: {
89
230
  endpoint: {
90
231
  type: "string",
91
- description: "Endpoint name as declared in its YAML (e.g. 'create-order'). Looked up in dypai/endpoints/** by matching doc.name.",
232
+ description: "Endpoint name (e.g. 'create-order'). For mode:'local' looked up by doc.name in dypai/endpoints/**; for 'draft'/'live' looked up by name on the engine.",
233
+ },
234
+ mode: {
235
+ type: "string",
236
+ enum: ["local", "draft", "live"],
237
+ description: "Source of the workflow_code: 'local' (default, reads YAML on disk), 'draft' (pending changes staged by dypai_push), or 'live' (currently deployed version).",
238
+ default: "local",
92
239
  },
93
240
  input: {
94
241
  type: "object",
@@ -106,7 +253,7 @@ export const dypaiTestEndpointTool = {
106
253
  },
107
254
  root_dir: {
108
255
  type: "string",
109
- description: "Path to the dypai/ folder (default: ./dypai).",
256
+ description: "Path to the dypai/ folder (default: ./dypai). Only used in mode:'local'.",
110
257
  default: "./dypai",
111
258
  },
112
259
  project_id: {
@@ -115,89 +262,85 @@ export const dypaiTestEndpointTool = {
115
262
  },
116
263
  skip_validation: {
117
264
  type: "boolean",
118
- description: "Skip the pre-flight validate pass. Default false — useful only when you intentionally want to send a malformed YAML to the engine to inspect its raw error.",
265
+ description: "Skip the pre-flight validate pass (mode:'local' only). Default false — useful only when you intentionally want to send a malformed YAML to the engine to inspect its raw error.",
119
266
  default: false,
120
267
  },
121
268
  },
122
269
  required: ["endpoint"],
123
270
  },
124
271
 
125
- async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id, skip_validation = false } = {}) {
272
+ async execute({ endpoint, mode = "local", input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id, skip_validation = false } = {}) {
126
273
  const rootDir = resolvePath(process.cwd(), root_dir)
127
274
 
128
275
  if (!endpoint) {
129
276
  return { success: false, error: "endpoint name is required." }
130
277
  }
278
+ if (!["local", "draft", "live"].includes(mode)) {
279
+ return { success: false, error: `Unknown mode '${mode}'. Use 'local', 'draft' or 'live'.` }
280
+ }
131
281
 
132
- const found = await findEndpointByName(rootDir, endpoint)
133
- if (!found) {
282
+ // Resolve project_id (needed by all three modes — local needs it to look
283
+ // up credential/tool UUID maps for the codec).
284
+ const config = await readLocalConfig(rootDir)
285
+ const targetProjectId = project_id || config?.project_id
286
+ if (!targetProjectId) {
134
287
  return {
135
288
  success: false,
136
- error: `Endpoint '${endpoint}' not found under dypai/endpoints/.`,
137
- hint: "Run dypai_pull to refresh, or check that the YAML's top-level `name` matches.",
289
+ error: "project_id required (set it in dypai.config.yaml or pass it explicitly).",
138
290
  }
139
291
  }
140
292
 
141
- // Pre-read any *_file references so the codec can inline them
142
- const refs = collectFileRefs(found.doc)
143
- const fileMap = {}
144
- for (const ref of refs) {
293
+ // ── Source resolution per mode ──────────────────────────────────────────
294
+ let resolved
295
+ if (mode === "local") {
296
+ // Local needs the codec context (credential/tool name UUID).
297
+ let mapsCtx
145
298
  try {
146
- fileMap[ref] = await readFile(join(rootDir, ref), "utf8")
299
+ const remote = await fetchRemoteState(targetProjectId)
300
+ mapsCtx = remote?.mapsCtx
147
301
  } catch (e) {
148
302
  return {
149
303
  success: false,
150
- error: `Referenced file not readable: ${ref} (${e.message})`,
151
- hint: "The YAML points to a *_file that doesn't exist on disk. Run dypai_pull or create the missing file.",
304
+ error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
152
305
  }
153
306
  }
154
- }
155
-
156
- // Resolve project + credential/tool UUID maps for the codec
157
- const config = await readLocalConfig(rootDir)
158
- const targetProjectId = project_id || config?.project_id
159
- if (!targetProjectId) {
160
- return {
161
- success: false,
162
- error: "project_id required (set it in dypai.config.yaml or pass it explicitly).",
307
+ if (!mapsCtx) {
308
+ return {
309
+ success: false,
310
+ error: "Remote state returned without mapsCtx — cannot resolve credential/tool references.",
311
+ hint: "Check your DYPAI_TOKEN and that the project_id is correct.",
312
+ }
163
313
  }
314
+ resolved = await resolveLocal(rootDir, endpoint, mapsCtx)
315
+ } else if (mode === "draft") {
316
+ resolved = await resolveDraft(targetProjectId, endpoint)
317
+ } else {
318
+ resolved = await resolveLive(targetProjectId, endpoint)
164
319
  }
165
320
 
166
- let mapsCtx
167
- try {
168
- const remote = await fetchRemoteState(targetProjectId)
169
- mapsCtx = remote?.mapsCtx
170
- } catch (e) {
171
- return {
172
- success: false,
173
- error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
174
- }
175
- }
176
- if (!mapsCtx) {
177
- return {
178
- success: false,
179
- error: "Remote state returned without mapsCtx — cannot resolve credential/tool references.",
180
- hint: "Check your DYPAI_TOKEN and that the project_id is correct.",
181
- }
321
+ if (resolved.error) {
322
+ return { success: false, mode, endpoint, ...resolved }
182
323
  }
183
324
 
184
- // ── Pre-flight validation (skippable) ────────────────────────────────────
325
+ // ── Pre-flight validation (mode:'local' only, skippable) ────────────────
185
326
  // Run the same linter dypai_push uses, but filter to errors that affect THIS
186
327
  // endpoint only — warnings and other-endpoint diagnostics shouldn't block
187
328
  // a focused test. Catches things like mutation+query_file, missing where,
188
329
  // unknown node types, placeholder typos, etc. before the engine sees them.
189
- if (!skip_validation) {
330
+ // Skipped for 'draft'/'live': source is the engine itself, nothing to lint.
331
+ if (mode === "local" && !skip_validation) {
190
332
  try {
191
333
  const valResult = await runValidation(rootDir, targetProjectId)
192
334
  const relevantErrors = (valResult.diagnostics || []).filter(d =>
193
- d.severity === "error" && d.endpoint === found.doc.name
335
+ d.severity === "error" && d.endpoint === resolved.source.doc?.name
194
336
  )
195
337
  if (relevantErrors.length > 0) {
196
338
  return {
197
339
  success: false,
198
340
  phase: "pre_flight_validation",
341
+ mode,
199
342
  endpoint,
200
- file: found.full.replace(rootDir + "/", ""),
343
+ file: resolved.source.file,
201
344
  error: `${relevantErrors.length} validation error(s) in this endpoint — fix them before testing.`,
202
345
  errors: relevantErrors,
203
346
  hint: "Each error includes a fix_hint. Address them and re-run dypai_test_endpoint. Pass skip_validation: true if you want to bypass this and see the raw engine response.",
@@ -210,29 +353,29 @@ export const dypaiTestEndpointTool = {
210
353
  }
211
354
  }
212
355
 
213
- // Deserialize the local YAML to the engine-shaped workflow_code
214
- const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
215
- let row
216
- try {
217
- row = deserializeEndpoint(found.doc, deserCtx)
218
- } catch (e) {
219
- return {
220
- success: false,
221
- error: `Failed to deserialize '${endpoint}' from its YAML: ${e.message}`,
222
- hint: "Run dypai_validate to surface the specific problem.",
223
- }
224
- }
225
-
226
356
  // Build the call to the remote test_workflow (pass workflow_code inline so
227
- // we test the LOCAL version, not whatever is currently deployed).
357
+ // we test the resolved source, regardless of what's currently deployed).
228
358
  const execArgs = {
229
359
  project_id: targetProjectId,
230
- workflow_code: row.workflow_code,
360
+ workflow_code: resolved.workflow_code,
231
361
  data: input,
232
362
  trace_mode, // used by the local MCP enrichment layer
233
363
  }
234
364
  if (as_user) execArgs.impersonated_user_id = as_user
235
365
 
366
+ // Build a compact source-meta block for the response so the agent can see
367
+ // which version was actually executed (file path, draft id, or endpoint id).
368
+ const sourceMeta = { mode }
369
+ if (mode === "local") sourceMeta.file = resolved.source.file
370
+ else if (mode === "draft") {
371
+ sourceMeta.draft_id = resolved.source.draft_id
372
+ sourceMeta.created_at = resolved.source.created_at
373
+ if (resolved.source.created_by) sourceMeta.created_by = resolved.source.created_by
374
+ } else {
375
+ sourceMeta.endpoint_id = resolved.source.endpoint_id
376
+ sourceMeta.updated_at = resolved.source.updated_at
377
+ }
378
+
236
379
  try {
237
380
  const result = await proxyToolCall("test_workflow", execArgs)
238
381
 
@@ -245,7 +388,7 @@ export const dypaiTestEndpointTool = {
245
388
  return {
246
389
  success: false,
247
390
  endpoint,
248
- file: found.full.replace(rootDir + "/", ""),
391
+ source: sourceMeta,
249
392
  as_user: as_user || null,
250
393
  error: result.length > 2000 ? result.slice(0, 2000) + "...[truncated]" : result,
251
394
  hint: "The remote returned a raw error string (no per-node trace available). Read the error above for the root cause.",
@@ -255,7 +398,7 @@ export const dypaiTestEndpointTool = {
255
398
  return {
256
399
  success: false,
257
400
  endpoint,
258
- file: found.full.replace(rootDir + "/", ""),
401
+ source: sourceMeta,
259
402
  as_user: as_user || null,
260
403
  error: `Unexpected response type from remote test_workflow: ${typeof result}`,
261
404
  raw_response: result,
@@ -267,7 +410,7 @@ export const dypaiTestEndpointTool = {
267
410
  const safeSummary = (summarized && typeof summarized === "object" && !Array.isArray(summarized)) ? summarized : { result: summarized }
268
411
  return {
269
412
  endpoint,
270
- file: found.full.replace(rootDir + "/", ""),
413
+ source: sourceMeta,
271
414
  as_user: as_user || null,
272
415
  ...safeSummary,
273
416
  }
@@ -276,6 +419,7 @@ export const dypaiTestEndpointTool = {
276
419
  success: false,
277
420
  error: `Execution failed: ${e.message}`,
278
421
  endpoint,
422
+ source: sourceMeta,
279
423
  hint: "If the error is cryptic, try trace_mode: 'full' or use dypai_trace with the execution_id from the response.",
280
424
  }
281
425
  }