@dypai-ai/mcp 1.2.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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: `Error: ${e.message}` }],
403
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
395
404
  isError: true,
396
405
  })
397
406
  }
@@ -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
- const raw = readFileSync(file_path, "utf-8")
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
- // Parse CSV
73
- const lines = raw.trim().split("\n")
74
- if (lines.length < 2) return { error: "CSV must have a header row and at least one data row" }
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 ${table} (${batchCols.join(", ")}) VALUES\n${batchValues}\nON CONFLICT (${upsert_key}) DO UPDATE SET ${updateCols}`
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 ${table} (${batchCols.join(", ")}) VALUES\n${batchValues}`
189
+ batchSql = `INSERT INTO ${qTable} (${quotedCols}) VALUES\n${batchValues}`
133
190
  }
134
191
 
135
192
  try {
@@ -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
- try {
82
- await mcpRequest({
83
- jsonrpc: "2.0",
84
- id: "init-proxy",
85
- method: "initialize",
86
- params: {
87
- protocolVersion: "2024-11-05",
88
- capabilities: {},
89
- clientInfo: { name: "dypai-mcp-local", version: "1.0.0" },
90
- },
91
- })
92
- // Send initialized notification
93
- await mcpRequest({
94
- jsonrpc: "2.0",
95
- method: "notifications/initialized",
96
- })
97
- } catch (e) {
98
- // Non-fatal — some servers don't require init
99
- process.stderr.write(`MCP proxy init warning: ${e.message}\n`)
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) {
@@ -43,14 +43,19 @@ function triggersToDb(trigger = {}) {
43
43
  http_api: { enabled: true, auth_mode: "jwt" },
44
44
  schedule: { enabled: false },
45
45
  }
46
- if (trigger.http_api) {
47
- out.http_api = { enabled: true, auth_mode: trigger.http_api.auth_mode || "jwt" }
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
- out.webhook = { path: trigger.webhook.path || "", enabled: true }
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
- const out = { source: e.from, target: e.to }
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
- const { id, type, variable, return: isReturn, ...params } = clean
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
- method: doc.method || "GET",
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: !!doc.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
  }
@@ -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
 
@@ -308,6 +308,14 @@ export const dypaiPushTool = {
308
308
  }
309
309
  }
310
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
+
311
319
  const plan = computePlan(local, remote, remote.mapsCtx, {
312
320
  deleteOrphans: delete_orphans,
313
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.mapsCtx
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: data.inputs && Object.keys(data.inputs).length ? data.inputs : null,
142
- outputs: data.outputs && Object.keys(data.outputs).length ? data.outputs : null,
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(node.type)) {
346
- const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, node.type) <= 2).slice(0, 3)
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 '${node.type}' is not registered.`,
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[node.type]
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 '${node.type}') may be missing parameter '${req}'.`,
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 '${node.type}') has unknown parameter '${key}'.`,
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
- if (node.type === "agent" && Array.isArray(node.tools)) {
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
- const result = await runValidation(rootDir, project_id)
560
- return {
561
- ...result,
562
- hint: result.success
563
- ? undefined
564
- : `${result.summary.errors} error(s) would cause runtime failures. Fix them, or push with skip_validation: true to override (not recommended).`,
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
  }