@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,567 @@
1
+ /**
2
+ * dypai_validate — static analysis for DYPAI projects (like ESLint for endpoints).
3
+ *
4
+ * Catches entire classes of runtime errors BEFORE push:
5
+ * - ${input.X} references that don't match the input schema
6
+ * - ${nodes.X.Y} references to nodes that don't exist in this endpoint
7
+ * - ${current_user_id} / ${current_user_role} used without jwt auth
8
+ * - `credential: foo` pointing to a credential that doesn't exist remotely
9
+ * - Agent `tools: [foo]` pointing to endpoints that aren't tools
10
+ * - SQL queries referencing tables that don't exist in schema.sql
11
+ *
12
+ * Returns a list of diagnostics with severity + fix_hint. Non-zero errors
13
+ * mean push will refuse (unless skip_validation is passed).
14
+ */
15
+
16
+ import { readFile } from "fs/promises"
17
+ import { join, resolve as resolvePath } from "path"
18
+ import { fetchRemoteState, readLocalState, readLocalConfig, readLocalRealtime } from "./planner.js"
19
+
20
+ /**
21
+ * Validate dypai/realtime.yaml against known schemas/tables. Rules:
22
+ * rt_table_not_found — references a table that doesn't exist in schema.sql
23
+ * rt_invalid_placeholder — uses a placeholder name outside the allowed set
24
+ * rt_invalid_events — events array contains values other than INSERT/UPDATE/DELETE
25
+ * rt_empty_target — no tables and no channels declared but realtime.yaml exists
26
+ */
27
+ async function validateRealtime(rootDir, ctx) {
28
+ const local = await readLocalRealtime(rootDir)
29
+ if (!local) return []
30
+
31
+ const diagnostics = []
32
+ const ALLOWED_PLACEHOLDERS = new Set(["current_user_id", "current_user_role", "channel_param"])
33
+ const ALLOWED_EVENTS = new Set(["INSERT", "UPDATE", "DELETE"])
34
+
35
+ if (local.rows.length === 0) {
36
+ diagnostics.push({
37
+ severity: "warn",
38
+ rule: "rt_empty_target",
39
+ file: "realtime.yaml",
40
+ message: "realtime.yaml exists but declares no tables or channels.",
41
+ fix_hint: "Add at least one entry, or delete the file to go back to deny-by-default.",
42
+ })
43
+ }
44
+
45
+ for (const p of local.rows) {
46
+ const tag = `${p.target_type}:${p.target_name}`
47
+
48
+ // Table must exist in schema.sql
49
+ if (p.target_type === "table" && ctx.schemaTables && !ctx.schemaTables.has(p.target_name)) {
50
+ diagnostics.push({
51
+ severity: "error",
52
+ rule: "rt_table_not_found",
53
+ file: "realtime.yaml",
54
+ loc: `tables.${p.target_name}`,
55
+ message: `realtime.yaml declares table '${p.target_name}' but it's not in schema.sql.`,
56
+ fix_hint: `Create the table first (CREATE TABLE public.${p.target_name} ...) or fix the typo.`,
57
+ })
58
+ }
59
+
60
+ // subscribe_filter placeholders
61
+ if (p.subscribe_filter) {
62
+ const placeholderRe = /\$\{([^}]+)\}/g
63
+ let m
64
+ while ((m = placeholderRe.exec(p.subscribe_filter)) !== null) {
65
+ const name = m[1].trim()
66
+ if (!ALLOWED_PLACEHOLDERS.has(name)) {
67
+ diagnostics.push({
68
+ severity: "error",
69
+ rule: "rt_invalid_placeholder",
70
+ file: "realtime.yaml",
71
+ loc: tag,
72
+ message: `Unknown placeholder \${${name}} in subscribe_filter.`,
73
+ fix_hint: `Allowed: ${[...ALLOWED_PLACEHOLDERS].map(p => "${" + p + "}").join(", ")}`,
74
+ })
75
+ }
76
+ // channel_param only makes sense for channels with wildcards
77
+ if (name === "channel_param" && (p.target_type !== "channel" || !p.target_name.includes("%"))) {
78
+ diagnostics.push({
79
+ severity: "error",
80
+ rule: "rt_invalid_placeholder",
81
+ file: "realtime.yaml",
82
+ loc: tag,
83
+ message: `\${channel_param} only resolves on channels with a '%' wildcard in target_name.`,
84
+ fix_hint: `Use a pattern like 'chat:%' so channel_param captures the variable part.`,
85
+ })
86
+ }
87
+ }
88
+ }
89
+
90
+ // events validation
91
+ if (Array.isArray(p.events)) {
92
+ for (const e of p.events) {
93
+ if (!ALLOWED_EVENTS.has(e)) {
94
+ diagnostics.push({
95
+ severity: "error",
96
+ rule: "rt_invalid_events",
97
+ file: "realtime.yaml",
98
+ loc: tag,
99
+ message: `Invalid event '${e}' in events array.`,
100
+ fix_hint: `Allowed values: INSERT, UPDATE, DELETE.`,
101
+ })
102
+ }
103
+ }
104
+ }
105
+
106
+ // Perf warning: complex filters (subqueries, JOINs, EXISTS) fall off the
107
+ // in-memory fast path and hit the DB on every notification. Nudge toward
108
+ // flat filters when possible.
109
+ if (p.subscribe_filter && /\b(SELECT|JOIN|EXISTS|UNION|WITH)\b/i.test(p.subscribe_filter)) {
110
+ diagnostics.push({
111
+ severity: "warn",
112
+ rule: "rt_complex_filter",
113
+ file: "realtime.yaml",
114
+ loc: tag,
115
+ message: `Subscribe filter has subqueries/JOINs — each notification will hit the DB.`,
116
+ fix_hint: `For high-throughput tables, prefer a flat filter (e.g. 'user_id = \${current_user_id}'). If complex is unavoidable, expect ~1-5ms added latency per event.`,
117
+ })
118
+ }
119
+ }
120
+
121
+ return diagnostics
122
+ }
123
+
124
+ /**
125
+ * Load the node catalog that dypai_pull cached to `dypai/node-catalog.json`.
126
+ * The catalog is the single source of truth (dumped from public.node_catalog
127
+ * in the central control plane). If it's missing, the validator falls back
128
+ * to basic rules — no curated drift-prone JSON here.
129
+ */
130
+ async function loadNodeCatalog(rootDir) {
131
+ try {
132
+ const raw = await readFile(join(rootDir, "node-catalog.json"), "utf8")
133
+ const parsed = JSON.parse(raw)
134
+ const schemas = {}
135
+ const knownTypes = new Set()
136
+ for (const [nodeType, data] of Object.entries(parsed.nodes || {})) {
137
+ knownTypes.add(nodeType)
138
+ schemas[nodeType] = {
139
+ label: data.label,
140
+ 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
+ }
144
+ }
145
+ return { schemas, knownTypes, missing: false }
146
+ } catch {
147
+ return { schemas: {}, knownTypes: new Set(), missing: true }
148
+ }
149
+ }
150
+
151
+ // ─── Helpers ────────────────────────────────────────────────────────────────
152
+
153
+ /** Walk an object, yielding every string value with its JSON path. */
154
+ function* walkStrings(node, path = "") {
155
+ if (node == null) return
156
+ if (typeof node === "string") {
157
+ yield { path, value: node }
158
+ return
159
+ }
160
+ if (Array.isArray(node)) {
161
+ for (let i = 0; i < node.length; i++) {
162
+ yield* walkStrings(node[i], `${path}[${i}]`)
163
+ }
164
+ return
165
+ }
166
+ if (typeof node === "object") {
167
+ for (const [k, v] of Object.entries(node)) {
168
+ yield* walkStrings(v, path ? `${path}.${k}` : k)
169
+ }
170
+ }
171
+ }
172
+
173
+ /** Extract all ${...} placeholder expressions from a string. */
174
+ function extractPlaceholders(s) {
175
+ const out = []
176
+ const re = /\$\{([^}]+)\}/g
177
+ let m
178
+ while ((m = re.exec(s)) !== null) out.push(m[1])
179
+ return out
180
+ }
181
+
182
+ /** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
183
+ function levenshteinSmall(a, b) {
184
+ if (a === b) return 0
185
+ if (Math.abs(a.length - b.length) > 3) return 99
186
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
187
+ for (let j = 0; j <= b.length; j++) m[0][j] = j
188
+ for (let i = 1; i <= a.length; i++) {
189
+ for (let j = 1; j <= b.length; j++) {
190
+ m[i][j] = Math.min(
191
+ m[i-1][j] + 1,
192
+ m[i][j-1] + 1,
193
+ m[i-1][j-1] + (a[i-1] === b[j-1] ? 0 : 1),
194
+ )
195
+ }
196
+ }
197
+ return m[a.length][b.length]
198
+ }
199
+
200
+ /** Parse schema.sql to extract the set of public.<table> names. */
201
+ async function readSchemaTables(rootDir) {
202
+ try {
203
+ const raw = await readFile(join(rootDir, "schema.sql"), "utf8")
204
+ const tables = new Set()
205
+ const re = /CREATE\s+TABLE\s+public\.(\w+)/gi
206
+ let m
207
+ while ((m = re.exec(raw)) !== null) tables.add(m[1])
208
+ return tables
209
+ } catch {
210
+ return null // schema.sql missing — skip SQL checks
211
+ }
212
+ }
213
+
214
+ /** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
215
+ function extractSqlTables(sql) {
216
+ const tables = new Set()
217
+ const re = /(?:FROM|JOIN|INTO|UPDATE)\s+public\.(\w+)/gi
218
+ let m
219
+ while ((m = re.exec(sql)) !== null) tables.add(m[1])
220
+ return tables
221
+ }
222
+
223
+ // ─── Rules ──────────────────────────────────────────────────────────────────
224
+
225
+ function ruleUsesJwt(trigger) {
226
+ // current_user_* requires http_api + jwt
227
+ return trigger?.http_api?.auth_mode === "jwt"
228
+ }
229
+
230
+ function validateEndpoint(entry, ctx) {
231
+ const { doc, fileMap } = entry
232
+ const diagnostics = []
233
+ const name = doc.name
234
+ const file = ctx.fileByName[name] || `endpoints/${name}.yaml`
235
+
236
+ const inputProps = doc.input?.properties || {}
237
+ const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
238
+
239
+ const jwt = ruleUsesJwt(doc.trigger)
240
+
241
+ // Collect all strings INCLUDING file contents (query_file, system_prompt_file, code_file)
242
+ const sources = []
243
+ for (const { path, value } of walkStrings(doc)) {
244
+ sources.push({ source: "yaml", loc: path, value })
245
+ }
246
+ for (const [filePath, content] of Object.entries(fileMap || {})) {
247
+ sources.push({ source: "file", loc: filePath, value: content })
248
+ }
249
+
250
+ // Collect SQL tables referenced (before checking each individually)
251
+ const referencedTables = new Set()
252
+
253
+ for (const { source, loc, value } of sources) {
254
+ // --- Placeholder checks ---
255
+ for (const expr of extractPlaceholders(value)) {
256
+ // Strip leading/trailing whitespace in the expression
257
+ const e = expr.trim()
258
+
259
+ // ${input.X} or ${input.X.Y}
260
+ // Only validate against the input schema if one is declared; DYPAI allows
261
+ // schemaless input (placeholders pass through at runtime), so missing
262
+ // schema is not an error by itself — at most a WARNING.
263
+ if (e.startsWith("input.")) {
264
+ const first = e.slice(6).split(/[.\[]/)[0]
265
+ const hasSchema = Object.keys(inputProps).length > 0
266
+ if (hasSchema && !inputProps[first]) {
267
+ diagnostics.push({
268
+ severity: "error",
269
+ rule: "input_placeholder_missing",
270
+ endpoint: name, file, loc,
271
+ message: `\${${expr}} references input.${first}, but the endpoint's input schema has no '${first}' property.`,
272
+ fix_hint: `Valid properties: ${Object.keys(inputProps).join(", ")}`,
273
+ })
274
+ } else if (!hasSchema) {
275
+ // One warning per endpoint max — accumulate in a set
276
+ ctx.schemaless ??= new Set()
277
+ if (!ctx.schemaless.has(name)) {
278
+ ctx.schemaless.add(name)
279
+ diagnostics.push({
280
+ severity: "warn",
281
+ rule: "no_input_schema",
282
+ endpoint: name, file,
283
+ message: `This endpoint uses \${input.*} but has no input schema declared.`,
284
+ fix_hint: `Add an input schema at top level so typos in placeholders can be caught: input: { type: object, properties: { ... } }`,
285
+ })
286
+ }
287
+ }
288
+ }
289
+
290
+ // ${nodes.X.Y}
291
+ else if (e.startsWith("nodes.")) {
292
+ const nodeId = e.slice(6).split(/[.\[]/)[0]
293
+ if (!nodeIds.has(nodeId)) {
294
+ diagnostics.push({
295
+ severity: "error",
296
+ rule: "node_ref_missing",
297
+ endpoint: name, file, loc,
298
+ message: `\${${expr}} references node '${nodeId}' but that node is not declared in this workflow.`,
299
+ fix_hint: nodeIds.size
300
+ ? `Known nodes: ${[...nodeIds].join(", ")}`
301
+ : "This endpoint has no nodes yet.",
302
+ })
303
+ }
304
+ }
305
+
306
+ // ${current_user_id} / ${current_user_role}
307
+ else if (e === "current_user_id" || e === "current_user_role") {
308
+ if (!jwt) {
309
+ diagnostics.push({
310
+ severity: "error",
311
+ rule: "current_user_without_jwt",
312
+ endpoint: name, file, loc,
313
+ message: `\${${e}} is only available when the endpoint uses http_api with auth_mode: jwt.`,
314
+ fix_hint: "Add `trigger: { http_api: { auth_mode: jwt } }` or remove the placeholder.",
315
+ })
316
+ }
317
+ }
318
+ }
319
+
320
+ // --- SQL: collect referenced tables for later comparison against schema.sql ---
321
+ // Heuristic: look like SQL (contains SELECT/INSERT/UPDATE/DELETE/WITH)
322
+ if (/\b(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(value)) {
323
+ for (const t of extractSqlTables(value)) referencedTables.add(t)
324
+ }
325
+ }
326
+
327
+ // --- SQL table existence (if schema.sql available) ---
328
+ if (ctx.schemaTables) {
329
+ for (const table of referencedTables) {
330
+ if (!ctx.schemaTables.has(table)) {
331
+ diagnostics.push({
332
+ severity: "error",
333
+ rule: "sql_table_not_found",
334
+ endpoint: name, file,
335
+ message: `SQL references public.${table}, but that table does not exist in schema.sql.`,
336
+ fix_hint: `Check typos, or create the table first with execute_sql. Known tables: ${[...ctx.schemaTables].slice(0, 8).join(", ")}${ctx.schemaTables.size > 8 ? "…" : ""}`,
337
+ })
338
+ }
339
+ }
340
+ }
341
+
342
+ // --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
343
+ for (const node of doc.workflow?.nodes || []) {
344
+ // 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)
347
+ diagnostics.push({
348
+ severity: "error",
349
+ rule: "unknown_node_type",
350
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].type`,
351
+ message: `Node type '${node.type}' is not registered.`,
352
+ fix_hint: suggestions.length
353
+ ? `Did you mean: ${suggestions.join(", ")}?`
354
+ : `Run dypai_pull to refresh node-catalog.json. Or call search_nodes to discover.`,
355
+ })
356
+ continue // no point checking params of an unknown type
357
+ }
358
+
359
+ // Schema-based parameter validation (only when we have a schema for this node_type)
360
+ const schema = ctx.catalog[node.type]
361
+ if (schema?.inputs?.properties) {
362
+ const { properties, required = [] } = schema.inputs
363
+ // Ignore node metadata keys (id, type, variable, return, credential, *_file) — they're not "params"
364
+ const META_KEYS = new Set(["id", "type", "variable", "return", "credential", "query_file", "code_file", "system_prompt_file"])
365
+ const paramKeys = Object.keys(node).filter(k => !META_KEYS.has(k))
366
+
367
+ // Required params present? Severity: warn (not error) because current
368
+ // engine node_catalog schemas list all conditional params as "required"
369
+ // (e.g. dypai_database flags `data` AND `query` both required regardless
370
+ // of `operation`). Once schemas model conditional required properly
371
+ // (oneOf / dependentRequired), we can bump this back to error.
372
+ for (const req of required) {
373
+ if (!paramKeys.includes(req)) {
374
+ const hasFileEquivalent = META_KEYS.has(`${req}_file`) && node[`${req}_file`]
375
+ if (!hasFileEquivalent) {
376
+ diagnostics.push({
377
+ severity: "warn",
378
+ rule: "missing_required_param",
379
+ endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
380
+ message: `Node '${node.id}' (type '${node.type}') may be missing parameter '${req}'.`,
381
+ fix_hint: `Schema lists required: [${required.join(", ")}]. Verify this param is actually needed for your operation.`,
382
+ })
383
+ }
384
+ }
385
+ }
386
+
387
+ // Unknown/typo params?
388
+ for (const key of paramKeys) {
389
+ if (!properties[key]) {
390
+ const knownKeys = Object.keys(properties)
391
+ const suggestions = knownKeys.filter(k => levenshteinSmall(k, key) <= 2).slice(0, 2)
392
+ diagnostics.push({
393
+ severity: "warn",
394
+ rule: "unknown_param",
395
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
396
+ message: `Node '${node.id}' (type '${node.type}') has unknown parameter '${key}'.`,
397
+ fix_hint: suggestions.length
398
+ ? `Did you mean: ${suggestions.join(", ")}?`
399
+ : `Valid params: ${knownKeys.slice(0, 8).join(", ")}${knownKeys.length > 8 ? "…" : ""}`,
400
+ })
401
+ } else {
402
+ // Enum / range checks for primitive values
403
+ const prop = properties[key]
404
+ const v = node[key]
405
+ if (prop.enum && typeof v === "string" && !prop.enum.includes(v)) {
406
+ diagnostics.push({
407
+ severity: "error",
408
+ rule: "param_enum_violation",
409
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
410
+ message: `Node '${node.id}' parameter '${key}' = '${v}' is not one of: ${prop.enum.join(", ")}.`,
411
+ fix_hint: `Allowed values: ${prop.enum.join(", ")}`,
412
+ })
413
+ }
414
+ if (prop.type === "number" || prop.type === "integer") {
415
+ if (typeof v === "number") {
416
+ if (prop.min != null && v < prop.min) {
417
+ diagnostics.push({
418
+ severity: "error",
419
+ rule: "param_range_violation",
420
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
421
+ message: `Node '${node.id}' parameter '${key}' = ${v} is below the minimum ${prop.min}.`,
422
+ })
423
+ }
424
+ if (prop.max != null && v > prop.max) {
425
+ diagnostics.push({
426
+ severity: "error",
427
+ rule: "param_range_violation",
428
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].${key}`,
429
+ message: `Node '${node.id}' parameter '${key}' = ${v} is above the maximum ${prop.max}.`,
430
+ })
431
+ }
432
+ }
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ // --- Credential references ---
440
+ for (const node of doc.workflow?.nodes || []) {
441
+ const cred = node.credential
442
+ if (cred && !ctx.remoteCredentials.has(cred)) {
443
+ diagnostics.push({
444
+ severity: "error",
445
+ rule: "credential_not_found",
446
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].credential`,
447
+ message: `credential '${cred}' is not defined remotely.`,
448
+ fix_hint: ctx.remoteCredentials.size
449
+ ? `Available: ${[...ctx.remoteCredentials].join(", ")}. Create '${cred}' in the dashboard first.`
450
+ : "No credentials configured yet. Create one in the dashboard with this exact name.",
451
+ })
452
+ }
453
+
454
+ // Agent tool references
455
+ if (node.type === "agent" && Array.isArray(node.tools)) {
456
+ for (const toolName of node.tools) {
457
+ if (!ctx.toolEndpoints.has(toolName)) {
458
+ diagnostics.push({
459
+ severity: "error",
460
+ rule: "agent_tool_not_found",
461
+ endpoint: name, file, loc: `workflow.nodes[${node.id}].tools`,
462
+ message: `agent references tool '${toolName}' but no endpoint with that name is marked is_tool: true.`,
463
+ fix_hint: ctx.toolEndpoints.size
464
+ ? `Available tool endpoints: ${[...ctx.toolEndpoints].join(", ")}`
465
+ : "No tool endpoints exist yet. Set `tool: true` on the YAML of an existing endpoint to expose it.",
466
+ })
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ return diagnostics
473
+ }
474
+
475
+ // ─── Tool ───────────────────────────────────────────────────────────────────
476
+
477
+ export async function runValidation(rootDir, projectId) {
478
+ // Load everything in parallel
479
+ const config = await readLocalConfig(rootDir)
480
+ const targetProjectId = projectId || config?.project_id || null
481
+
482
+ const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
483
+ readLocalState(rootDir),
484
+ fetchRemoteState(targetProjectId),
485
+ readSchemaTables(rootDir),
486
+ loadNodeCatalog(rootDir),
487
+ ])
488
+
489
+ // Build context for the rules
490
+ const remoteCredentials = new Set(Object.keys(remote.mapsCtx.credNameToId || {}))
491
+ // Tool endpoints come from LOCAL YAMLs — the ones marked `tool: true`
492
+ const toolEndpoints = new Set(
493
+ Object.values(local.byName)
494
+ .filter(e => e.doc?.tool === true)
495
+ .map(e => e.doc.name)
496
+ )
497
+
498
+ const ctx = {
499
+ remoteCredentials,
500
+ toolEndpoints,
501
+ schemaTables,
502
+ catalog: nodeCatalog.schemas,
503
+ knownTypes: nodeCatalog.knownTypes,
504
+ fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
505
+ }
506
+
507
+ const diagnostics = []
508
+ for (const entry of Object.values(local.byName)) {
509
+ diagnostics.push(...validateEndpoint(entry, ctx))
510
+ }
511
+
512
+ // Realtime YAML rules
513
+ diagnostics.push(...await validateRealtime(rootDir, ctx))
514
+
515
+ // Surface any file-read errors too
516
+ for (const err of local.errors || []) {
517
+ diagnostics.push({
518
+ severity: "error",
519
+ rule: "file_read_error",
520
+ file: err.file,
521
+ message: err.error,
522
+ })
523
+ }
524
+
525
+ const errors = diagnostics.filter(d => d.severity === "error")
526
+ const warnings = diagnostics.filter(d => d.severity === "warn")
527
+
528
+ return {
529
+ success: errors.length === 0,
530
+ summary: {
531
+ total: diagnostics.length,
532
+ errors: errors.length,
533
+ warnings: warnings.length,
534
+ endpoints_checked: Object.keys(local.byName).length,
535
+ schema_sql_available: schemaTables !== null,
536
+ node_catalog_nodes: nodeCatalog.knownTypes.size,
537
+ node_catalog_missing: nodeCatalog.missing,
538
+ node_catalog_has_schemas: Object.values(nodeCatalog.schemas).some(s => s?.inputs?.properties),
539
+ },
540
+ diagnostics,
541
+ }
542
+ }
543
+
544
+ export const dypaiValidateTool = {
545
+ name: "dypai_validate",
546
+ description:
547
+ "Lint the local dypai/ folder BEFORE pushing. Catches ${input.x} / ${nodes.x.y} / ${current_user_*} refs that don't resolve, " +
548
+ "SQL tables not in schema.sql, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
549
+ "Run this after editing YAMLs to catch typos pre-flight. Push already calls it by default — pass skip_validation:true to override.",
550
+ inputSchema: {
551
+ type: "object",
552
+ properties: {
553
+ project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml if omitted." },
554
+ root_dir: { type: "string", default: "./dypai" },
555
+ },
556
+ },
557
+ async execute({ project_id, root_dir = "./dypai" } = {}) {
558
+ 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).`,
565
+ }
566
+ },
567
+ }