@dypai-ai/mcp 1.2.2 → 1.2.4
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 +10 -1
- package/src/tools/bulk-upsert.js +69 -12
- package/src/tools/proxy.js +42 -20
- package/src/tools/sync/codec.js +67 -9
- package/src/tools/sync/diff.js +8 -0
- package/src/tools/sync/planner.js +10 -1
- package/src/tools/sync/push.js +37 -3
- package/src/tools/sync/test-endpoint.js +8 -1
- package/src/tools/sync/transforms.js +5 -0
- package/src/tools/sync/validate.js +43 -15
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -390,8 +390,17 @@ async function handleRequest(msg) {
|
|
|
390
390
|
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }],
|
|
391
391
|
})
|
|
392
392
|
} catch (e) {
|
|
393
|
+
// Return a structured error so the agent can parse `success`/`error`
|
|
394
|
+
// consistently with our other "soft failure" responses (validate,
|
|
395
|
+
// test_endpoint, etc.). Still flag isError=true at the MCP level.
|
|
396
|
+
const payload = {
|
|
397
|
+
success: false,
|
|
398
|
+
error: e.message || String(e),
|
|
399
|
+
// First few stack frames help debug which file blew up.
|
|
400
|
+
stack: (e.stack || "").split("\n").slice(0, 4).join("\n"),
|
|
401
|
+
}
|
|
393
402
|
return makeResponse(id, {
|
|
394
|
-
content: [{ type: "text", text:
|
|
403
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
395
404
|
isError: true,
|
|
396
405
|
})
|
|
397
406
|
}
|
package/src/tools/bulk-upsert.js
CHANGED
|
@@ -11,6 +11,59 @@ import { readFileSync, existsSync } from "fs"
|
|
|
11
11
|
import { basename } from "path"
|
|
12
12
|
import { proxyToolCall } from "./proxy.js"
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Minimal RFC 4180-compliant CSV parser.
|
|
16
|
+
* Handles: quoted fields, embedded commas, escaped quotes (""), CRLF, embedded newlines in quoted fields.
|
|
17
|
+
* Returns an array of rows, each row an array of string fields.
|
|
18
|
+
*
|
|
19
|
+
* The previous implementation did `line.split(",")` which silently corrupted
|
|
20
|
+
* any CSV with quoted commas (e.g. `"Smith, Jr.",100`).
|
|
21
|
+
*/
|
|
22
|
+
function parseCsv(text) {
|
|
23
|
+
const rows = []
|
|
24
|
+
let row = []
|
|
25
|
+
let field = ""
|
|
26
|
+
let i = 0
|
|
27
|
+
let inQuotes = false
|
|
28
|
+
while (i < text.length) {
|
|
29
|
+
const c = text[i]
|
|
30
|
+
if (inQuotes) {
|
|
31
|
+
if (c === '"') {
|
|
32
|
+
if (text[i + 1] === '"') { field += '"'; i += 2; continue }
|
|
33
|
+
inQuotes = false
|
|
34
|
+
i++
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
field += c
|
|
38
|
+
i++
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (c === '"') { inQuotes = true; i++; continue }
|
|
42
|
+
if (c === ",") { row.push(field); field = ""; i++; continue }
|
|
43
|
+
if (c === "\r") {
|
|
44
|
+
// Treat CRLF as one line break
|
|
45
|
+
if (text[i + 1] === "\n") i++
|
|
46
|
+
row.push(field); rows.push(row); row = []; field = ""
|
|
47
|
+
i++
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (c === "\n") {
|
|
51
|
+
row.push(field); rows.push(row); row = []; field = ""
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
field += c
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
// Tail row (no trailing newline)
|
|
59
|
+
if (field.length > 0 || row.length > 0) {
|
|
60
|
+
row.push(field); rows.push(row)
|
|
61
|
+
}
|
|
62
|
+
// Drop trailing empty rows (common when the file ends with \n)
|
|
63
|
+
while (rows.length && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0] === "") rows.pop()
|
|
64
|
+
return rows
|
|
65
|
+
}
|
|
66
|
+
|
|
14
67
|
export const bulkUpsertTool = {
|
|
15
68
|
name: "bulk_upsert",
|
|
16
69
|
description: `Import data from a local CSV or JSON file into a database table.
|
|
@@ -63,21 +116,20 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
63
116
|
// Read and parse data
|
|
64
117
|
let rows
|
|
65
118
|
try {
|
|
66
|
-
|
|
119
|
+
// Strip UTF-8 BOM if present — otherwise the first header column ends up
|
|
120
|
+
// as "\uFEFFname" instead of "name" and silently misaligns everything.
|
|
121
|
+
const raw = readFileSync(file_path, "utf-8").replace(/^\uFEFF/, "")
|
|
67
122
|
|
|
68
123
|
if (ext === "json") {
|
|
69
124
|
const parsed = JSON.parse(raw)
|
|
70
125
|
rows = Array.isArray(parsed) ? parsed : [parsed]
|
|
71
126
|
} else {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const headers = lines[0].split(",").map(h => h.trim().replace(/^"|"$/g, ""))
|
|
77
|
-
rows = lines.slice(1).map(line => {
|
|
78
|
-
const values = line.split(",").map(v => v.trim().replace(/^"|"$/g, ""))
|
|
127
|
+
const records = parseCsv(raw)
|
|
128
|
+
if (records.length < 2) return { error: "CSV must have a header row and at least one data row" }
|
|
129
|
+
const headers = records[0].map(h => h.trim())
|
|
130
|
+
rows = records.slice(1).map(values => {
|
|
79
131
|
const obj = {}
|
|
80
|
-
headers.forEach((h, i) => { obj[h] = values[i] || null })
|
|
132
|
+
headers.forEach((h, i) => { obj[h] = (values[i] === undefined || values[i] === "") ? null : values[i] })
|
|
81
133
|
return obj
|
|
82
134
|
})
|
|
83
135
|
}
|
|
@@ -110,11 +162,16 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
110
162
|
let inserted = 0
|
|
111
163
|
let errors = []
|
|
112
164
|
|
|
165
|
+
// Quote a Postgres identifier safely. Doubles any embedded quotes.
|
|
166
|
+
const qIdent = (s) => `"${String(s).replace(/"/g, '""')}"`
|
|
167
|
+
const qTable = qIdent(table)
|
|
168
|
+
|
|
113
169
|
// Batch in chunks of 50 rows
|
|
114
170
|
const BATCH_SIZE = 50
|
|
115
171
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
116
172
|
const batch = rows.slice(i, i + BATCH_SIZE)
|
|
117
173
|
const batchCols = Object.keys(batch[0])
|
|
174
|
+
const quotedCols = batchCols.map(qIdent).join(", ")
|
|
118
175
|
const batchValues = batch.map(row =>
|
|
119
176
|
`(${batchCols.map(col => {
|
|
120
177
|
const v = row[col]
|
|
@@ -126,10 +183,10 @@ Great for seeding initial data: products, categories, config, etc.`,
|
|
|
126
183
|
|
|
127
184
|
let batchSql
|
|
128
185
|
if (upsert_key && batchCols.includes(upsert_key)) {
|
|
129
|
-
const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${c} = EXCLUDED.${c}`).join(", ")
|
|
130
|
-
batchSql = `INSERT INTO ${
|
|
186
|
+
const updateCols = batchCols.filter(c => c !== upsert_key).map(c => `${qIdent(c)} = EXCLUDED.${qIdent(c)}`).join(", ")
|
|
187
|
+
batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}\nON CONFLICT (${qIdent(upsert_key)}) DO UPDATE SET ${updateCols}`
|
|
131
188
|
} else {
|
|
132
|
-
batchSql = `INSERT INTO ${
|
|
189
|
+
batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}`
|
|
133
190
|
}
|
|
134
191
|
|
|
135
192
|
try {
|
package/src/tools/proxy.js
CHANGED
|
@@ -76,28 +76,41 @@ function mcpRequest(body) {
|
|
|
76
76
|
})
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Single in-flight init promise so concurrent first-calls (e.g. push with
|
|
80
|
+
// concurrency=5) don't all race to initialize and clobber each other's
|
|
81
|
+
// session id mid-handshake.
|
|
82
|
+
let initPromise = null
|
|
83
|
+
|
|
79
84
|
async function ensureInitialized() {
|
|
80
85
|
if (sessionId) return
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
86
|
+
if (initPromise) return initPromise
|
|
87
|
+
initPromise = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
await mcpRequest({
|
|
90
|
+
jsonrpc: "2.0",
|
|
91
|
+
id: "init-proxy",
|
|
92
|
+
method: "initialize",
|
|
93
|
+
params: {
|
|
94
|
+
protocolVersion: "2024-11-05",
|
|
95
|
+
capabilities: {},
|
|
96
|
+
clientInfo: { name: "dypai-mcp-local", version: "1.0.0" },
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
// Send initialized notification
|
|
100
|
+
await mcpRequest({
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
method: "notifications/initialized",
|
|
103
|
+
})
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// Don't swallow silently — calls right after will appear to "work"
|
|
106
|
+
// returning empty results and we'd never know init failed.
|
|
107
|
+
process.stderr.write(`MCP proxy init failed: ${e.message}\n`)
|
|
108
|
+
// Reset so a future call gets a chance to retry.
|
|
109
|
+
initPromise = null
|
|
110
|
+
throw e
|
|
111
|
+
}
|
|
112
|
+
})()
|
|
113
|
+
return initPromise
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
export async function proxyToolCall(toolName, args) {
|
|
@@ -129,6 +142,15 @@ export async function proxyToolCall(toolName, args) {
|
|
|
129
142
|
if (typeof parsed === "string" && /^(Error:|Input validation error:|You don't have)/i.test(parsed)) {
|
|
130
143
|
throw new Error(parsed)
|
|
131
144
|
}
|
|
145
|
+
// Other remote errors come as a JSON object with success:false but no isError
|
|
146
|
+
// flag. The push pipeline was treating these as successes — promote them to
|
|
147
|
+
// throws here so the caller's try/catch sees them.
|
|
148
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
149
|
+
if (parsed.success === false || parsed.ok === false) {
|
|
150
|
+
const msg = parsed.error || parsed.message || parsed.detail || JSON.stringify(parsed).slice(0, 300)
|
|
151
|
+
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
132
154
|
return parsed
|
|
133
155
|
}
|
|
134
156
|
return response.result
|
package/src/tools/sync/codec.js
CHANGED
|
@@ -43,14 +43,19 @@ function triggersToDb(trigger = {}) {
|
|
|
43
43
|
http_api: { enabled: true, auth_mode: "jwt" },
|
|
44
44
|
schedule: { enabled: false },
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Accept `http_api: true` (shorthand) by treating it as `http_api: {}`.
|
|
47
|
+
// Accept `auth_mode` or `mode` for forward/backward compat with older docs.
|
|
48
|
+
const httpApi = trigger.http_api === true ? {} : trigger.http_api
|
|
49
|
+
if (httpApi) {
|
|
50
|
+
const authMode = httpApi.auth_mode || httpApi.mode || "jwt"
|
|
51
|
+
out.http_api = { enabled: true, auth_mode: authMode }
|
|
48
52
|
} else if (trigger.webhook || trigger.schedule || trigger.manual) {
|
|
49
53
|
// Non-HTTP trigger: disable http_api
|
|
50
54
|
out.http_api = { enabled: false, auth_mode: "jwt" }
|
|
51
55
|
}
|
|
52
56
|
if (trigger.webhook) {
|
|
53
|
-
|
|
57
|
+
const wh = trigger.webhook === true ? {} : trigger.webhook
|
|
58
|
+
out.webhook = { path: wh.path || "", enabled: true }
|
|
54
59
|
}
|
|
55
60
|
if (trigger.schedule) {
|
|
56
61
|
out.schedule = { ...trigger.schedule, enabled: true }
|
|
@@ -74,13 +79,25 @@ function edgesToYaml(edges = []) {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
function edgesToDb(edges = []) {
|
|
77
|
-
return edges.map(e => {
|
|
78
|
-
|
|
82
|
+
return (edges || []).map(e => {
|
|
83
|
+
if (!e || typeof e !== "object") return null
|
|
84
|
+
// Tolerate both YAML-canonical (`from`/`to`) and engine-style (`source`/`target`).
|
|
85
|
+
// Without this, an agent that wrote `source/target` in YAML would silently
|
|
86
|
+
// produce edges with `source: undefined`, breaking workflow execution.
|
|
87
|
+
const source = e.from ?? e.source
|
|
88
|
+
const target = e.to ?? e.target
|
|
89
|
+
if (!source || !target) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Edge is missing source/target: ${JSON.stringify(e).slice(0, 120)}. ` +
|
|
92
|
+
`Edges must have either 'from'/'to' (canonical) or 'source'/'target' (engine-style).`
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const out = { source, target }
|
|
79
96
|
if (e.condition) out.condition = e.condition
|
|
80
97
|
if (e.source_handle) out.source_handle = e.source_handle
|
|
81
98
|
if (e.target_handle) out.target_handle = e.target_handle
|
|
82
99
|
return out
|
|
83
|
-
})
|
|
100
|
+
}).filter(Boolean)
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
// ─── Public codec ───────────────────────────────────────────────────────────
|
|
@@ -147,7 +164,44 @@ export function deserializeEndpoint(doc, mapsCtx) {
|
|
|
147
164
|
const readFile = mapsCtx.readFile || (() => "")
|
|
148
165
|
|
|
149
166
|
const nodes = (doc.workflow?.nodes || []).map(clean => {
|
|
150
|
-
|
|
167
|
+
// Tolerate both YAML-canonical `type` and engine-style `node_type`. The
|
|
168
|
+
// canonical form is `type`, but agents writing YAML by hand sometimes use
|
|
169
|
+
// `node_type` because that's what the engine returns. We accept either,
|
|
170
|
+
// but FAIL LOUD if neither is present — silently sending undefined to the
|
|
171
|
+
// engine causes accepted-but-not-created ghost endpoints (the bug from 1.2.2).
|
|
172
|
+
if (!clean || typeof clean !== "object") {
|
|
173
|
+
throw new Error(`workflow.nodes contains a non-object entry in endpoint '${doc.name}'.`)
|
|
174
|
+
}
|
|
175
|
+
// Tolerate both YAML-canonical (`type`, `return`) and engine-style
|
|
176
|
+
// (`node_type`, `is_return`) for the keys that have a "renamed" form.
|
|
177
|
+
// Anything still in `rest` after this is treated as a node parameter
|
|
178
|
+
// (inline form). This is the layer that prevents silent data loss when
|
|
179
|
+
// an agent mixes the two conventions.
|
|
180
|
+
const {
|
|
181
|
+
id,
|
|
182
|
+
type: typeA, node_type: typeB,
|
|
183
|
+
variable,
|
|
184
|
+
return: isReturnA, is_return: isReturnB,
|
|
185
|
+
parameters: explicitParams,
|
|
186
|
+
...rest
|
|
187
|
+
} = clean
|
|
188
|
+
const type = typeA || typeB
|
|
189
|
+
const isReturn = isReturnA || isReturnB
|
|
190
|
+
if (!id) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`A node in endpoint '${doc.name}' is missing 'id'. Every node needs a unique id within the workflow.`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (!type) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Node '${id}' in endpoint '${doc.name}' is missing 'type'. ` +
|
|
198
|
+
`Each node in workflow.nodes must declare a node type, e.g. \`type: dypai_database\`. ` +
|
|
199
|
+
`(Both 'type' and 'node_type' are accepted.)`
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
// Params: prefer an explicit `parameters:` object, otherwise fall back to
|
|
203
|
+
// the inline shape (everything that isn't id/type/variable/return is a param).
|
|
204
|
+
const params = explicitParams && typeof explicitParams === "object" ? explicitParams : rest
|
|
151
205
|
const nodeCtx = {
|
|
152
206
|
...mapsCtx,
|
|
153
207
|
endpointName: doc.name,
|
|
@@ -172,13 +226,17 @@ export function deserializeEndpoint(doc, mapsCtx) {
|
|
|
172
226
|
|
|
173
227
|
return {
|
|
174
228
|
name: doc.name,
|
|
175
|
-
|
|
229
|
+
// Engine expects uppercase HTTP methods. Normalize so YAML can use any case.
|
|
230
|
+
method: String(doc.method || "GET").toUpperCase(),
|
|
176
231
|
description: doc.description || null,
|
|
177
232
|
workflow_code,
|
|
178
233
|
input: doc.input || null,
|
|
179
234
|
output: doc.output || null,
|
|
180
235
|
allowed_roles: doc.allowed_roles || [],
|
|
181
|
-
is_tool:
|
|
236
|
+
// Accept both `tool: true` (canonical) and `is_tool: true` (engine-style).
|
|
237
|
+
// Without this, an agent that wrote `is_tool` in YAML would silently get
|
|
238
|
+
// a non-tool endpoint and `allowed_roles` would not be enforced as expected.
|
|
239
|
+
is_tool: !!(doc.tool || doc.is_tool),
|
|
182
240
|
tool_description: doc.tool_description || null,
|
|
183
241
|
group_id: doc.group && mapsCtx.groupNameToId ? (mapsCtx.groupNameToId[doc.group] || null) : null,
|
|
184
242
|
}
|
package/src/tools/sync/diff.js
CHANGED
|
@@ -52,6 +52,14 @@ export const dypaiDiffTool = {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
if (!remote || !remote.mapsCtx) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
phase: "fetch_remote",
|
|
59
|
+
error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
56
64
|
deleteOrphans: delete_orphans,
|
|
57
65
|
stateSnapshot,
|
|
@@ -232,8 +232,17 @@ export async function readLocalState(rootDir) {
|
|
|
232
232
|
* This is what we'd send to update_endpoint.
|
|
233
233
|
*/
|
|
234
234
|
export function localToCanonical(entry, mapsCtx) {
|
|
235
|
+
if (!entry || !entry.doc) {
|
|
236
|
+
throw new Error("localToCanonical: entry must have a `doc` field (parsed YAML).")
|
|
237
|
+
}
|
|
238
|
+
if (!mapsCtx || typeof mapsCtx !== "object") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"localToCanonical: mapsCtx required to resolve credential/group/tool references. " +
|
|
241
|
+
"If you see this, fetchRemoteState probably returned an empty/failed response."
|
|
242
|
+
)
|
|
243
|
+
}
|
|
235
244
|
const { doc, fileMap } = entry
|
|
236
|
-
const ctx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
|
|
245
|
+
const ctx = { ...mapsCtx, readFile: (p) => (fileMap || {})[p] ?? "" }
|
|
237
246
|
return deserializeEndpoint(doc, ctx)
|
|
238
247
|
}
|
|
239
248
|
|
package/src/tools/sync/push.js
CHANGED
|
@@ -61,26 +61,52 @@ function endpointPayload(row) {
|
|
|
61
61
|
return p
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Treat a remote tool response as a definite success only when it has at
|
|
66
|
+
* least one of the markers we expect from a real mutation. Anything else
|
|
67
|
+
* (empty object, error string masquerading as data, missing id) becomes
|
|
68
|
+
* an error so the push doesn't lie to the agent.
|
|
69
|
+
*/
|
|
70
|
+
function assertMutationOK(result, op, name) {
|
|
71
|
+
if (!result || typeof result !== "object") {
|
|
72
|
+
throw new Error(`${op} ${name}: empty response from remote (got ${JSON.stringify(result).slice(0, 120)})`)
|
|
73
|
+
}
|
|
74
|
+
if (result.error) {
|
|
75
|
+
throw new Error(`${op} ${name}: ${typeof result.error === "string" ? result.error : JSON.stringify(result.error)}`)
|
|
76
|
+
}
|
|
77
|
+
// Successful create returns { id, name } or { ok: true }; update/delete return
|
|
78
|
+
// { message } or { ok: true }. If none of these markers are present the call
|
|
79
|
+
// probably failed silently.
|
|
80
|
+
const hasMarker = result.id || result.ok === true || result.message || result.endpoint_id || result.success === true
|
|
81
|
+
if (!hasMarker) {
|
|
82
|
+
throw new Error(`${op} ${name}: response missing success markers — ${JSON.stringify(result).slice(0, 200)}`)
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
async function applyCreate(canonicalRow, projectId) {
|
|
65
|
-
|
|
88
|
+
const res = await proxyToolCall("create_endpoint", {
|
|
66
89
|
...(projectId ? { project_id: projectId } : {}),
|
|
67
90
|
...endpointPayload(canonicalRow),
|
|
68
91
|
})
|
|
92
|
+
return assertMutationOK(res, "create", canonicalRow.name)
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
async function applyUpdate(canonicalRow, endpointId, projectId) {
|
|
72
|
-
|
|
96
|
+
const res = await proxyToolCall("update_endpoint", {
|
|
73
97
|
...(projectId ? { project_id: projectId } : {}),
|
|
74
98
|
endpoint_id: endpointId,
|
|
75
99
|
updates: endpointPayload(canonicalRow),
|
|
76
100
|
})
|
|
101
|
+
return assertMutationOK(res, "update", canonicalRow.name)
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
async function applyDelete(endpointId, projectId) {
|
|
80
|
-
|
|
105
|
+
const res = await proxyToolCall("delete_endpoint", {
|
|
81
106
|
...(projectId ? { project_id: projectId } : {}),
|
|
82
107
|
endpoint_id: endpointId,
|
|
83
108
|
})
|
|
109
|
+
return assertMutationOK(res, "delete", endpointId)
|
|
84
110
|
}
|
|
85
111
|
|
|
86
112
|
// ─── Realtime policies sync ────────────────────────────────────────────────
|
|
@@ -282,6 +308,14 @@ export const dypaiPushTool = {
|
|
|
282
308
|
}
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
if (!remote || !remote.mapsCtx) {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
phase: "fetch_remote",
|
|
315
|
+
error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
285
319
|
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
286
320
|
deleteOrphans: delete_orphans,
|
|
287
321
|
stateSnapshot,
|
|
@@ -157,13 +157,20 @@ export const dypaiTestEndpointTool = {
|
|
|
157
157
|
let mapsCtx
|
|
158
158
|
try {
|
|
159
159
|
const remote = await fetchRemoteState(targetProjectId)
|
|
160
|
-
mapsCtx = remote
|
|
160
|
+
mapsCtx = remote?.mapsCtx
|
|
161
161
|
} catch (e) {
|
|
162
162
|
return {
|
|
163
163
|
success: false,
|
|
164
164
|
error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
if (!mapsCtx) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: "Remote state returned without mapsCtx — cannot resolve credential/tool references.",
|
|
171
|
+
hint: "Check your DYPAI_TOKEN and that the project_id is correct.",
|
|
172
|
+
}
|
|
173
|
+
}
|
|
167
174
|
|
|
168
175
|
// Deserialize the local YAML to the engine-shaped workflow_code
|
|
169
176
|
const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
|
|
@@ -142,6 +142,11 @@ export const NODE_FIELD_TRANSFORMS = [
|
|
|
142
142
|
// ─── Engine ────────────────────────────────────────────────────────────────
|
|
143
143
|
|
|
144
144
|
function applyTransforms(input, direction, ctx, nodeType) {
|
|
145
|
+
// Defensive: an agent passing null parameters or non-objects shouldn't crash
|
|
146
|
+
// 6 functions deep. Treat as empty params instead.
|
|
147
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
148
|
+
return {}
|
|
149
|
+
}
|
|
145
150
|
let result = { ...input }
|
|
146
151
|
for (const t of NODE_FIELD_TRANSFORMS) {
|
|
147
152
|
if (t.appliesWhen && !t.appliesWhen(nodeType)) continue
|
|
@@ -133,13 +133,15 @@ async function loadNodeCatalog(rootDir) {
|
|
|
133
133
|
const parsed = JSON.parse(raw)
|
|
134
134
|
const schemas = {}
|
|
135
135
|
const knownTypes = new Set()
|
|
136
|
+
const isNonEmptyObj = (o) => o && typeof o === "object" && !Array.isArray(o) && Object.keys(o).length > 0
|
|
136
137
|
for (const [nodeType, data] of Object.entries(parsed.nodes || {})) {
|
|
138
|
+
if (!data || typeof data !== "object") continue
|
|
137
139
|
knownTypes.add(nodeType)
|
|
138
140
|
schemas[nodeType] = {
|
|
139
141
|
label: data.label,
|
|
140
142
|
description: data.description,
|
|
141
|
-
inputs:
|
|
142
|
-
outputs:
|
|
143
|
+
inputs: isNonEmptyObj(data.inputs) ? data.inputs : null,
|
|
144
|
+
outputs: isNonEmptyObj(data.outputs) ? data.outputs : null,
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
return { schemas, knownTypes, missing: false }
|
|
@@ -341,14 +343,27 @@ function validateEndpoint(entry, ctx) {
|
|
|
341
343
|
|
|
342
344
|
// --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
|
|
343
345
|
for (const node of doc.workflow?.nodes || []) {
|
|
346
|
+
if (!node || typeof node !== "object") continue
|
|
347
|
+
// Tolerate both `type` and `node_type` (the codec accepts either).
|
|
348
|
+
const nodeType = node.type ?? node.node_type
|
|
349
|
+
if (!nodeType) {
|
|
350
|
+
diagnostics.push({
|
|
351
|
+
severity: "error",
|
|
352
|
+
rule: "missing_node_type",
|
|
353
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id || "?"}]`,
|
|
354
|
+
message: `Node '${node.id || "(no id)"}' is missing 'type'.`,
|
|
355
|
+
fix_hint: "Add `type: <node_type>` to this node. Use `dypai_pull` to refresh `node-catalog.json` for the list of valid types.",
|
|
356
|
+
})
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
344
359
|
// node_type exists in catalog?
|
|
345
|
-
if (ctx.knownTypes.size && !ctx.knownTypes.has(
|
|
346
|
-
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t,
|
|
360
|
+
if (ctx.knownTypes.size && !ctx.knownTypes.has(nodeType)) {
|
|
361
|
+
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, nodeType) <= 2).slice(0, 3)
|
|
347
362
|
diagnostics.push({
|
|
348
363
|
severity: "error",
|
|
349
364
|
rule: "unknown_node_type",
|
|
350
365
|
endpoint: name, file, loc: `workflow.nodes[${node.id}].type`,
|
|
351
|
-
message: `Node type '${
|
|
366
|
+
message: `Node type '${nodeType}' is not registered.`,
|
|
352
367
|
fix_hint: suggestions.length
|
|
353
368
|
? `Did you mean: ${suggestions.join(", ")}?`
|
|
354
369
|
: `Run dypai_pull to refresh node-catalog.json. Or call search_nodes to discover.`,
|
|
@@ -357,7 +372,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
357
372
|
}
|
|
358
373
|
|
|
359
374
|
// Schema-based parameter validation (only when we have a schema for this node_type)
|
|
360
|
-
const schema = ctx.catalog[
|
|
375
|
+
const schema = ctx.catalog[nodeType]
|
|
361
376
|
if (schema?.inputs?.properties) {
|
|
362
377
|
const { properties, required = [] } = schema.inputs
|
|
363
378
|
// Ignore node metadata keys (id, type, variable, return, credential, *_file) — they're not "params"
|
|
@@ -377,7 +392,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
377
392
|
severity: "warn",
|
|
378
393
|
rule: "missing_required_param",
|
|
379
394
|
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
380
|
-
message: `Node '${node.id}' (type '${
|
|
395
|
+
message: `Node '${node.id}' (type '${nodeType}') may be missing parameter '${req}'.`,
|
|
381
396
|
fix_hint: `Schema lists required: [${required.join(", ")}]. Verify this param is actually needed for your operation.`,
|
|
382
397
|
})
|
|
383
398
|
}
|
|
@@ -393,7 +408,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
393
408
|
severity: "warn",
|
|
394
409
|
rule: "unknown_param",
|
|
395
410
|
endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
|
|
396
|
-
message: `Node '${node.id}' (type '${
|
|
411
|
+
message: `Node '${node.id}' (type '${nodeType}') has unknown parameter '${key}'.`,
|
|
397
412
|
fix_hint: suggestions.length
|
|
398
413
|
? `Did you mean: ${suggestions.join(", ")}?`
|
|
399
414
|
: `Valid params: ${knownKeys.slice(0, 8).join(", ")}${knownKeys.length > 8 ? "…" : ""}`,
|
|
@@ -438,6 +453,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
438
453
|
|
|
439
454
|
// --- Credential references ---
|
|
440
455
|
for (const node of doc.workflow?.nodes || []) {
|
|
456
|
+
if (!node || typeof node !== "object") continue
|
|
441
457
|
const cred = node.credential
|
|
442
458
|
if (cred && !ctx.remoteCredentials.has(cred)) {
|
|
443
459
|
diagnostics.push({
|
|
@@ -452,7 +468,8 @@ function validateEndpoint(entry, ctx) {
|
|
|
452
468
|
}
|
|
453
469
|
|
|
454
470
|
// Agent tool references
|
|
455
|
-
|
|
471
|
+
const nodeType = node.type ?? node.node_type
|
|
472
|
+
if (nodeType === "agent" && Array.isArray(node.tools)) {
|
|
456
473
|
for (const toolName of node.tools) {
|
|
457
474
|
if (!ctx.toolEndpoints.has(toolName)) {
|
|
458
475
|
diagnostics.push({
|
|
@@ -556,12 +573,23 @@ export const dypaiValidateTool = {
|
|
|
556
573
|
},
|
|
557
574
|
async execute({ project_id, root_dir = "./dypai" } = {}) {
|
|
558
575
|
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
576
|
+
try {
|
|
577
|
+
const result = await runValidation(rootDir, project_id)
|
|
578
|
+
return {
|
|
579
|
+
...result,
|
|
580
|
+
hint: result.success
|
|
581
|
+
? undefined
|
|
582
|
+
: `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
|
|
583
|
+
}
|
|
584
|
+
} catch (e) {
|
|
585
|
+
// Surface the real stack so failures during validation don't look like
|
|
586
|
+
// a generic "undefined.length" mystery to the agent.
|
|
587
|
+
return {
|
|
588
|
+
success: false,
|
|
589
|
+
error: `Validation crashed: ${e.message}`,
|
|
590
|
+
stack: (e.stack || "").split("\n").slice(0, 5).join("\n"),
|
|
591
|
+
hint: "This is a bug in the local MCP. Report the stack above; in the meantime you can pass skip_validation: true to dypai_push to bypass.",
|
|
592
|
+
}
|
|
565
593
|
}
|
|
566
594
|
},
|
|
567
595
|
}
|