@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.
- package/package.json +1 -1
- package/src/api.js +14 -2
- package/src/auto-update.js +44 -1
- package/src/index.js +185 -17
- package/src/tools/deploy.js +49 -1
- package/src/tools/frontend.js +59 -6
- package/src/tools/scaffold.js +6 -2
- package/src/tools/sync/diff.js +88 -7
- package/src/tools/sync/pull.js +75 -8
- package/src/tools/sync/push.js +129 -96
- package/src/tools/sync/test-endpoint.js +217 -73
- package/src/tools/sync/validate.js +415 -48
- package/src/tools/sync.js +85 -13
- package/src/tools/status.js +0 -94
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dypai_test_endpoint — test an endpoint by name
|
|
2
|
+
* dypai_test_endpoint — test an endpoint by name from one of three sources.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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:
|
|
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
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
299
|
+
const remote = await fetchRemoteState(targetProjectId)
|
|
300
|
+
mapsCtx = remote?.mapsCtx
|
|
147
301
|
} catch (e) {
|
|
148
302
|
return {
|
|
149
303
|
success: false,
|
|
150
|
-
error: `
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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
|
|
357
|
+
// we test the resolved source, regardless of what's currently deployed).
|
|
228
358
|
const execArgs = {
|
|
229
359
|
project_id: targetProjectId,
|
|
230
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|