@dypai-ai/mcp 1.5.11 → 1.5.13

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.5.11",
3
+ "version": "1.5.13",
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
@@ -1121,7 +1121,7 @@ async function handleRequest(msg) {
1121
1121
  return makeResponse(id, {
1122
1122
  protocolVersion: "2024-11-05",
1123
1123
  capabilities: { tools: {} },
1124
- serverInfo: { name: "dypai", version: "1.3.1" },
1124
+ serverInfo: { name: "dypai", version: "1.5.13" },
1125
1125
  instructions: SERVER_INSTRUCTIONS,
1126
1126
  })
1127
1127
  }
@@ -252,7 +252,10 @@ const RUNTIME_PLACEHOLDER_OPERATOR_RE = /(\|\||&&|\?\?|[?:+*/=<>!()]|\s+\b(?:or|
252
252
 
253
253
  function unsupportedRuntimePlaceholder(expr) {
254
254
  const clean = expr.trim()
255
- return expr !== clean || RUNTIME_PLACEHOLDER_OPERATOR_RE.test(clean) || !RUNTIME_PLACEHOLDER_PATH_RE.test(clean)
255
+ if (expr !== clean) return true
256
+ if (RUNTIME_PLACEHOLDER_PATH_RE.test(clean)) return false
257
+ if (!RUNTIME_PLACEHOLDER_OPERATOR_RE.test(clean)) return true
258
+ return extractRuntimePaths(clean).length === 0
256
259
  }
257
260
 
258
261
  /** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
@@ -287,6 +290,31 @@ async function readSchemaTables(rootDir) {
287
290
  }
288
291
  }
289
292
 
293
+ /** Parse schema.sql to extract public table columns when available. */
294
+ async function readSchemaColumns(rootDir) {
295
+ try {
296
+ const raw = await readFile(join(rootDir, "schema.sql"), "utf8")
297
+ const tables = {}
298
+ const tableRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?public\.(?:"([^"]+)"|([A-Za-z_]\w*))\s*\(([\s\S]*?)\);/gi
299
+ let m
300
+ while ((m = tableRe.exec(raw)) !== null) {
301
+ const tableName = m[1] || m[2]
302
+ const body = m[3] || ""
303
+ const cols = new Set()
304
+ for (const rawLine of body.split("\n")) {
305
+ const line = rawLine.trim().replace(/,$/, "")
306
+ if (!line || /^(CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|EXCLUDE)\b/i.test(line)) continue
307
+ const col = /^"?([A-Za-z_]\w*)"?\s+/.exec(line)?.[1]
308
+ if (col) cols.add(col)
309
+ }
310
+ tables[tableName] = cols
311
+ }
312
+ return tables
313
+ } catch {
314
+ return {}
315
+ }
316
+ }
317
+
290
318
  /** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
291
319
  function extractSqlTables(sql) {
292
320
  const tables = new Set()
@@ -353,6 +381,502 @@ function lookupFileMapContent(fileMap, ref) {
353
381
  return fileMap[key] || fileMap[key.replace(/^dypai\//, "")] || fileMap[`dypai/${key}`] || ""
354
382
  }
355
383
 
384
+ function stripSqlForInference(sql) {
385
+ return String(sql || "")
386
+ .replace(/--[^\n]*/g, " ")
387
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
388
+ .replace(/'(?:[^']|'')*'/g, "''")
389
+ .replace(/\s+/g, " ")
390
+ .trim()
391
+ }
392
+
393
+ function isWordAt(sql, index, word) {
394
+ if (sql.slice(index, index + word.length).toLowerCase() !== word) return false
395
+ const before = index === 0 ? "" : sql[index - 1]
396
+ const after = sql[index + word.length] || ""
397
+ return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after)
398
+ }
399
+
400
+ function skipDoubleQuotedIdentifier(sql, index) {
401
+ if (sql[index] !== "\"") return index
402
+ for (let i = index + 1; i < sql.length; i++) {
403
+ if (sql[i] !== "\"") continue
404
+ if (sql[i + 1] === "\"") {
405
+ i++
406
+ continue
407
+ }
408
+ return i
409
+ }
410
+ return sql.length - 1
411
+ }
412
+
413
+ function skipSqlSpaces(sql, index) {
414
+ let i = index
415
+ while (i < sql.length && /\s/.test(sql[i])) i++
416
+ return i
417
+ }
418
+
419
+ function findMatchingParen(sql, openIndex) {
420
+ if (sql[openIndex] !== "(") return -1
421
+ let depth = 0
422
+ for (let i = openIndex; i < sql.length; i++) {
423
+ const ch = sql[i]
424
+ if (ch === "\"") {
425
+ i = skipDoubleQuotedIdentifier(sql, i)
426
+ continue
427
+ }
428
+ if (ch === "(") {
429
+ depth++
430
+ continue
431
+ }
432
+ if (ch === ")") {
433
+ depth--
434
+ if (depth === 0) return i
435
+ }
436
+ }
437
+ return -1
438
+ }
439
+
440
+ function findOuterSelectList(sqlText) {
441
+ const sql = stripSqlForInference(sqlText)
442
+ if (!sql) return null
443
+ let depth = 0
444
+ let selectStart = -1
445
+ for (let i = 0; i < sql.length; i++) {
446
+ const ch = sql[i]
447
+ if (ch === "\"") {
448
+ i = skipDoubleQuotedIdentifier(sql, i)
449
+ continue
450
+ }
451
+ if (ch === "(") depth++
452
+ else if (ch === ")") depth = Math.max(0, depth - 1)
453
+
454
+ if (depth !== 0) continue
455
+ if (selectStart < 0 && isWordAt(sql, i, "select")) {
456
+ selectStart = i + "select".length
457
+ i += "select".length - 1
458
+ continue
459
+ }
460
+ if (selectStart >= 0 && isWordAt(sql, i, "from")) {
461
+ return sql.slice(selectStart, i).trim()
462
+ }
463
+ }
464
+ if (selectStart >= 0) return sql.slice(selectStart).trim()
465
+ return null
466
+ }
467
+
468
+ function hasTopLevelLimitOne(sqlText) {
469
+ const sql = stripSqlForInference(sqlText)
470
+ let depth = 0
471
+ for (let i = 0; i < sql.length; i++) {
472
+ const ch = sql[i]
473
+ if (ch === "\"") {
474
+ i = skipDoubleQuotedIdentifier(sql, i)
475
+ continue
476
+ }
477
+ if (ch === "(") {
478
+ depth++
479
+ continue
480
+ }
481
+ if (ch === ")") {
482
+ depth = Math.max(0, depth - 1)
483
+ continue
484
+ }
485
+ if (depth !== 0 || !isWordAt(sql, i, "limit")) continue
486
+
487
+ const tail = sql.slice(skipSqlSpaces(sql, i + "limit".length))
488
+ return /^1(?:\D|$)/.test(tail)
489
+ }
490
+ return false
491
+ }
492
+
493
+ function hasTopLevelFetchOne(sqlText) {
494
+ const sql = stripSqlForInference(sqlText)
495
+ let depth = 0
496
+ for (let i = 0; i < sql.length; i++) {
497
+ const ch = sql[i]
498
+ if (ch === "\"") {
499
+ i = skipDoubleQuotedIdentifier(sql, i)
500
+ continue
501
+ }
502
+ if (ch === "(") {
503
+ depth++
504
+ continue
505
+ }
506
+ if (ch === ")") {
507
+ depth = Math.max(0, depth - 1)
508
+ continue
509
+ }
510
+ if (depth !== 0 || !isWordAt(sql, i, "fetch")) continue
511
+
512
+ let cursor = skipSqlSpaces(sql, i + "fetch".length)
513
+ if (isWordAt(sql, cursor, "first")) cursor += "first".length
514
+ else if (isWordAt(sql, cursor, "next")) cursor += "next".length
515
+ else continue
516
+ cursor = skipSqlSpaces(sql, cursor)
517
+ return /^1(?:\D|$)/.test(sql.slice(cursor))
518
+ }
519
+ return false
520
+ }
521
+
522
+ function hasTopLevelGroupBy(sqlText) {
523
+ const sql = stripSqlForInference(sqlText)
524
+ let depth = 0
525
+ for (let i = 0; i < sql.length; i++) {
526
+ const ch = sql[i]
527
+ if (ch === "\"") {
528
+ i = skipDoubleQuotedIdentifier(sql, i)
529
+ continue
530
+ }
531
+ if (ch === "(") {
532
+ depth++
533
+ continue
534
+ }
535
+ if (ch === ")") {
536
+ depth = Math.max(0, depth - 1)
537
+ continue
538
+ }
539
+ if (depth !== 0 || !isWordAt(sql, i, "group")) continue
540
+
541
+ const restStart = i + "group".length
542
+ const rest = sql.slice(restStart)
543
+ const byOffset = rest.search(/\S/)
544
+ if (byOffset >= 0 && isWordAt(sql, restStart + byOffset, "by")) return true
545
+ }
546
+ return false
547
+ }
548
+
549
+ function hasTopLevelSetOperation(sqlText) {
550
+ const sql = stripSqlForInference(sqlText)
551
+ let depth = 0
552
+ for (let i = 0; i < sql.length; i++) {
553
+ const ch = sql[i]
554
+ if (ch === "\"") {
555
+ i = skipDoubleQuotedIdentifier(sql, i)
556
+ continue
557
+ }
558
+ if (ch === "(") {
559
+ depth++
560
+ continue
561
+ }
562
+ if (ch === ")") {
563
+ depth = Math.max(0, depth - 1)
564
+ continue
565
+ }
566
+ if (depth !== 0) continue
567
+ if (isWordAt(sql, i, "union") || isWordAt(sql, i, "intersect") || isWordAt(sql, i, "except")) {
568
+ return true
569
+ }
570
+ }
571
+ return false
572
+ }
573
+
574
+ const AGGREGATE_FUNCTIONS = ["count", "sum", "avg", "min", "max", "json_agg", "jsonb_agg", "array_agg", "string_agg"]
575
+
576
+ function isWindowAggregateCall(sql, openIndex) {
577
+ const closeIndex = findMatchingParen(sql, openIndex)
578
+ if (closeIndex < 0) return false
579
+
580
+ let cursor = skipSqlSpaces(sql, closeIndex + 1)
581
+ if (isWordAt(sql, cursor, "filter")) {
582
+ cursor = skipSqlSpaces(sql, cursor + "filter".length)
583
+ if (sql[cursor] === "(") {
584
+ const filterClose = findMatchingParen(sql, cursor)
585
+ if (filterClose >= 0) cursor = skipSqlSpaces(sql, filterClose + 1)
586
+ }
587
+ }
588
+
589
+ return isWordAt(sql, cursor, "over")
590
+ }
591
+
592
+ function hasOuterAggregateFunction(selectList) {
593
+ const sql = String(selectList || "")
594
+ let depth = 0
595
+ const activeSubqueryDepths = []
596
+
597
+ const insideSubquery = () => activeSubqueryDepths.some(d => d <= depth)
598
+
599
+ for (let i = 0; i < sql.length; i++) {
600
+ const ch = sql[i]
601
+ if (ch === "\"") {
602
+ i = skipDoubleQuotedIdentifier(sql, i)
603
+ continue
604
+ }
605
+ if (ch === "(") {
606
+ depth++
607
+ continue
608
+ }
609
+ if (ch === ")") {
610
+ activeSubqueryDepths.splice(0, activeSubqueryDepths.length, ...activeSubqueryDepths.filter(d => d < depth))
611
+ depth = Math.max(0, depth - 1)
612
+ continue
613
+ }
614
+
615
+ if (depth > 0 && isWordAt(sql, i, "select")) {
616
+ activeSubqueryDepths.push(depth)
617
+ i += "select".length - 1
618
+ continue
619
+ }
620
+
621
+ for (const fn of AGGREGATE_FUNCTIONS) {
622
+ if (!isWordAt(sql, i, fn)) continue
623
+ const openIndex = skipSqlSpaces(sql, i + fn.length)
624
+ if (sql[openIndex] === "(" && !insideSubquery() && !isWindowAggregateCall(sql, openIndex)) return true
625
+ }
626
+ }
627
+
628
+ return false
629
+ }
630
+
631
+ function splitTopLevelComma(list) {
632
+ const parts = []
633
+ let depth = 0
634
+ let start = 0
635
+ for (let i = 0; i < list.length; i++) {
636
+ const ch = list[i]
637
+ if (ch === "\"") {
638
+ i = skipDoubleQuotedIdentifier(list, i)
639
+ continue
640
+ }
641
+ if (ch === "(") depth++
642
+ else if (ch === ")") depth = Math.max(0, depth - 1)
643
+ else if (ch === "," && depth === 0) {
644
+ parts.push(list.slice(start, i).trim())
645
+ start = i + 1
646
+ }
647
+ }
648
+ const tail = list.slice(start).trim()
649
+ if (tail) parts.push(tail)
650
+ return parts
651
+ }
652
+
653
+ function inferSelectOutputColumns(sqlText) {
654
+ const selectList = findOuterSelectList(sqlText)
655
+ if (!selectList) return { properties: null, strict: false }
656
+
657
+ const props = new Set()
658
+ let strict = true
659
+ for (const itemRaw of splitTopLevelComma(selectList)) {
660
+ const item = itemRaw.trim()
661
+ if (!item || item === "*" || /\.\*$/.test(item)) {
662
+ strict = false
663
+ continue
664
+ }
665
+
666
+ const asAlias = /\bas\s+"?([A-Za-z_]\w*)"?$/i.exec(item)?.[1]
667
+ if (asAlias) {
668
+ props.add(asAlias)
669
+ continue
670
+ }
671
+
672
+ const simpleColumn = /^(?:(?:"?[A-Za-z_]\w*"?\.)?)"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
673
+ if (simpleColumn) {
674
+ props.add(simpleColumn)
675
+ continue
676
+ }
677
+
678
+ const trailingAlias = /\s+"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
679
+ if (trailingAlias && !SQL_KEYWORDS_AFTER_FROM.has(trailingAlias.toUpperCase())) {
680
+ props.add(trailingAlias)
681
+ continue
682
+ }
683
+
684
+ strict = false
685
+ }
686
+
687
+ return { properties: props.size > 0 ? props : null, strict }
688
+ }
689
+
690
+ function inferSqlCardinality(sqlText) {
691
+ const sql = stripSqlForInference(sqlText)
692
+ if (!sql) return null
693
+ if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single"
694
+ const startsLikeRead = /^(select|with)\b/i.test(sql)
695
+ if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many"
696
+ const outerSelectList = findOuterSelectList(sql)
697
+ const hasAggregate = outerSelectList ? hasOuterAggregateFunction(outerSelectList) : false
698
+ const hasGroupBy = hasTopLevelGroupBy(sql)
699
+ if (startsLikeRead && hasAggregate && !hasGroupBy) return "single"
700
+ return "many"
701
+ }
702
+
703
+ function returningColumns(returning, table, schemaColumns) {
704
+ if (Array.isArray(returning)) return new Set(returning.filter(v => typeof v === "string" && v !== "*"))
705
+ if (typeof returning === "string" && returning.trim() && returning.trim() !== "*") {
706
+ return new Set(returning.split(",").map(s => s.trim()).filter(Boolean))
707
+ }
708
+ if (table && schemaColumns?.[table]?.size) return new Set(schemaColumns[table])
709
+ return null
710
+ }
711
+
712
+ function buildNodeOutputContracts(nodes, fileMap, ctx) {
713
+ const contracts = new Map()
714
+ for (const node of nodes || []) {
715
+ if (!node || typeof node !== "object" || !node.id) continue
716
+ const nodeType = node.type ?? node.node_type
717
+ const params = nodeParams(node)
718
+
719
+ if (nodeType === "set_fields") {
720
+ const op = nodeField(node, params, "operation") || "set"
721
+ const fields = nodeField(node, params, "fields")
722
+ if (fields && typeof fields === "object" && !Array.isArray(fields)) {
723
+ contracts.set(node.id, {
724
+ type: "object",
725
+ properties: new Set(Object.keys(fields).map(k => k.split(".")[0])),
726
+ strict: op === "compose",
727
+ })
728
+ }
729
+ continue
730
+ }
731
+
732
+ if (nodeType !== "dypai_database") continue
733
+ const op = nodeField(node, params, "operation")
734
+ const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
735
+
736
+ if (op === "query" || op === "custom_query") {
737
+ const query = nodeField(node, params, "query") || lookupFileMapContent(fileMap, nodeField(node, params, "query_file"))
738
+ const inferred = inferSelectOutputColumns(query)
739
+ contracts.set(node.id, {
740
+ type: "array",
741
+ cardinality: inferSqlCardinality(query),
742
+ properties: inferred.properties,
743
+ strict: inferred.strict,
744
+ })
745
+ continue
746
+ }
747
+
748
+ if (op === "select") {
749
+ contracts.set(node.id, {
750
+ type: "array",
751
+ cardinality: "many",
752
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
753
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
754
+ })
755
+ continue
756
+ }
757
+
758
+ if (op === "aggregate") {
759
+ contracts.set(node.id, { type: "object", properties: new Set(["value"]), strict: true })
760
+ continue
761
+ }
762
+
763
+ if (op === "insert") {
764
+ const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(nodeField(node, params, "data"))
765
+ contracts.set(node.id, {
766
+ type: bulk ? "array" : "object",
767
+ cardinality: bulk ? "many" : "single",
768
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
769
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
770
+ })
771
+ continue
772
+ }
773
+
774
+ if (op === "upsert") {
775
+ contracts.set(node.id, {
776
+ type: "object",
777
+ cardinality: "single",
778
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
779
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
780
+ })
781
+ continue
782
+ }
783
+
784
+ if (op === "update" || op === "delete" || op === "copy_to") {
785
+ contracts.set(node.id, {
786
+ type: "array",
787
+ cardinality: "many",
788
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
789
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
790
+ })
791
+ continue
792
+ }
793
+
794
+ if (op === "mutation") {
795
+ const insertValue = nodeField(node, params, "insert")
796
+ const updateValue = nodeField(node, params, "update")
797
+ const deleteValue = nodeField(node, params, "delete")
798
+ const returning = nodeField(node, params, "returning") ?? "*"
799
+ const props = returningColumns(returning, table, ctx.schemaColumns)
800
+ if (insertValue !== undefined && insertValue !== null) {
801
+ const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(insertValue)
802
+ const hasConflict = nodeField(node, params, "on_conflict") !== undefined
803
+ contracts.set(node.id, {
804
+ type: bulk || hasConflict ? "array" : "object",
805
+ cardinality: bulk ? "many" : hasConflict ? "zero_or_one" : "single",
806
+ properties: props,
807
+ strict: Boolean(props),
808
+ })
809
+ } else if (updateValue !== undefined || deleteValue === true) {
810
+ contracts.set(node.id, {
811
+ type: "array",
812
+ cardinality: "many",
813
+ properties: props,
814
+ strict: Boolean(props),
815
+ })
816
+ }
817
+ }
818
+ }
819
+ return contracts
820
+ }
821
+
822
+ function extractRuntimePaths(expr) {
823
+ const clean = String(expr || "").replace(/'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"/g, " ")
824
+ const paths = []
825
+ const re = /\b(?:input|nodes|vars|current_user)\.[A-Za-z_][A-Za-z0-9_-]*(?:\[[0-9]+\]|\.[A-Za-z_][A-Za-z0-9_-]*)*|\bcurrent_user_id\b|\bcurrent_user_role\b/g
826
+ let m
827
+ while ((m = re.exec(clean)) !== null) paths.push(m[0])
828
+ if (paths.length === 0 && RUNTIME_PLACEHOLDER_PATH_RE.test(expr.trim())) paths.push(expr.trim())
829
+ return [...new Set(paths)]
830
+ }
831
+
832
+ function parseNodeOutputRef(path) {
833
+ const m = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)(.*)$/.exec(path)
834
+ if (!m) return null
835
+ const nodeId = m[1]
836
+ let rest = m[2] || ""
837
+ let indexed = false
838
+ if (rest.startsWith("[")) {
839
+ const idx = /^\[[0-9]+\]/.exec(rest)?.[0]
840
+ if (idx) {
841
+ indexed = true
842
+ rest = rest.slice(idx.length)
843
+ }
844
+ }
845
+ const prop = rest.startsWith(".") ? /^\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(rest)?.[1] : null
846
+ return { nodeId, indexed, prop }
847
+ }
848
+
849
+ function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
850
+ const ref = parseNodeOutputRef(path)
851
+ if (!ref || !ref.prop || !contract) return null
852
+
853
+ if (contract.strict && contract.properties && !contract.properties.has(ref.prop)) {
854
+ const known = [...contract.properties]
855
+ const suggestions = known.filter(k => levenshteinSmall(k, ref.prop) <= 2).slice(0, 3)
856
+ return {
857
+ severity: "warn",
858
+ rule: "node_output_property_unknown",
859
+ endpoint, file, loc,
860
+ message: `\${${expr}} references '${ref.prop}', but node '${ref.nodeId}' is inferred to output: ${known.join(", ") || "(no known properties)"}.`,
861
+ fix_hint: suggestions.length
862
+ ? `Did you mean: ${suggestions.join(", ")}?`
863
+ : `Use one of: ${known.join(", ") || "(none)"}. If this is a dynamic SQL shape, verify with dypai_test_endpoint.`,
864
+ }
865
+ }
866
+
867
+ if (contract.type === "array" && contract.cardinality === "many" && !ref.indexed) {
868
+ return {
869
+ severity: "warn",
870
+ rule: "node_output_many_direct_property",
871
+ endpoint, file, loc,
872
+ message: `\${${expr}} reads property '${ref.prop}' directly from node '${ref.nodeId}', but that node is inferred to return many rows.`,
873
+ fix_hint: `Use \${nodes.${ref.nodeId}} for the full array, or \${nodes.${ref.nodeId}[0].${ref.prop}} if you intentionally want the first row.`,
874
+ }
875
+ }
876
+
877
+ return null
878
+ }
879
+
356
880
  function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
357
881
  if (!ctx.schemaTables) return
358
882
  for (const table of referencedTables) {
@@ -377,6 +901,7 @@ function validateEndpoint(entry, ctx) {
377
901
 
378
902
  const inputProps = doc.input?.properties || {}
379
903
  const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
904
+ const outputContracts = buildNodeOutputContracts(doc.workflow?.nodes || [], fileMap, ctx)
380
905
 
381
906
  const jwt = ruleUsesJwt(doc.trigger)
382
907
 
@@ -409,6 +934,7 @@ function validateEndpoint(entry, ctx) {
409
934
  for (const { source, loc, value } of sources) {
410
935
  // --- Placeholder checks ---
411
936
  for (const expr of extractPlaceholders(value)) {
937
+ const runtimePaths = extractRuntimePaths(expr)
412
938
  if (unsupportedRuntimePlaceholder(expr)) {
413
939
  diagnostics.push({
414
940
  severity: "error",
@@ -423,10 +949,10 @@ function validateEndpoint(entry, ctx) {
423
949
  continue
424
950
  }
425
951
 
426
- // Normalize to the runtime path after rejecting expression syntax above.
427
- // This keeps the downstream schema checks focused on the referenced
428
- // input/node path instead of trying to interpret template logic.
429
- const e = stripExprTail(expr)
952
+ // Normalize every runtime path we can find in this placeholder. Expressions
953
+ // such as `${nodes.x.stock >= input.qty}` are valid at runtime, so validate
954
+ // each referenced path instead of rejecting the whole expression.
955
+ for (const e of runtimePaths) {
430
956
 
431
957
  // ${input.X} or ${input.X.Y}
432
958
  // Only validate against the input schema if one is declared; DYPAI allows
@@ -462,6 +988,16 @@ function validateEndpoint(entry, ctx) {
462
988
  if (!missingNodeRefs.has(nodeId)) {
463
989
  missingNodeRefs.set(nodeId, { loc, expr })
464
990
  }
991
+ } else {
992
+ const outputDiag = validateNodeOutputRef({
993
+ expr,
994
+ path: e,
995
+ contract: outputContracts.get(nodeId),
996
+ endpoint: name,
997
+ file,
998
+ loc,
999
+ })
1000
+ if (outputDiag) diagnostics.push(outputDiag)
465
1001
  }
466
1002
  }
467
1003
 
@@ -477,6 +1013,7 @@ function validateEndpoint(entry, ctx) {
477
1013
  })
478
1014
  }
479
1015
  }
1016
+ }
480
1017
  }
481
1018
 
482
1019
  // NOTE: SQL table extraction used to live here (anywhere a string looked
@@ -1241,10 +1778,11 @@ export async function runValidation(rootDir, projectId) {
1241
1778
  const config = await readLocalConfig(rootDir)
1242
1779
  const targetProjectId = projectId || config?.project_id || null
1243
1780
 
1244
- const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
1781
+ const [local, remote, schemaTables, schemaColumns, nodeCatalog] = await Promise.all([
1245
1782
  readLocalState(rootDir),
1246
1783
  fetchRemoteState(targetProjectId),
1247
1784
  readSchemaTables(rootDir),
1785
+ readSchemaColumns(rootDir),
1248
1786
  loadNodeCatalog(rootDir),
1249
1787
  ])
1250
1788
 
@@ -1261,6 +1799,7 @@ export async function runValidation(rootDir, projectId) {
1261
1799
  remoteCredentials,
1262
1800
  toolEndpoints,
1263
1801
  schemaTables,
1802
+ schemaColumns,
1264
1803
  catalog: nodeCatalog.schemas,
1265
1804
  knownTypes: nodeCatalog.knownTypes,
1266
1805
  fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.file || `${e.doc.name}.yaml`}`])),
@@ -1370,6 +1909,15 @@ export async function runValidation(rootDir, projectId) {
1370
1909
  }
1371
1910
  }
1372
1911
 
1912
+ export const __testing = {
1913
+ buildNodeOutputContracts,
1914
+ extractRuntimePaths,
1915
+ inferSelectOutputColumns,
1916
+ inferSqlCardinality,
1917
+ unsupportedRuntimePlaceholder,
1918
+ validateNodeOutputRef,
1919
+ }
1920
+
1373
1921
  export const dypaiValidateTool = {
1374
1922
  name: "dypai_validate",
1375
1923
  description: