@dypai-ai/mcp 1.5.11 → 1.5.12

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.12",
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.12" },
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,288 @@ 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 findOuterSelectList(sqlText) {
401
+ const sql = stripSqlForInference(sqlText)
402
+ if (!sql) return null
403
+ let depth = 0
404
+ let selectStart = -1
405
+ for (let i = 0; i < sql.length; i++) {
406
+ const ch = sql[i]
407
+ if (ch === "(") depth++
408
+ else if (ch === ")") depth = Math.max(0, depth - 1)
409
+
410
+ if (depth !== 0) continue
411
+ if (selectStart < 0 && isWordAt(sql, i, "select")) {
412
+ selectStart = i + "select".length
413
+ i += "select".length - 1
414
+ continue
415
+ }
416
+ if (selectStart >= 0 && isWordAt(sql, i, "from")) {
417
+ return sql.slice(selectStart, i).trim()
418
+ }
419
+ }
420
+ return null
421
+ }
422
+
423
+ function splitTopLevelComma(list) {
424
+ const parts = []
425
+ let depth = 0
426
+ let start = 0
427
+ for (let i = 0; i < list.length; i++) {
428
+ const ch = list[i]
429
+ if (ch === "(") depth++
430
+ else if (ch === ")") depth = Math.max(0, depth - 1)
431
+ else if (ch === "," && depth === 0) {
432
+ parts.push(list.slice(start, i).trim())
433
+ start = i + 1
434
+ }
435
+ }
436
+ const tail = list.slice(start).trim()
437
+ if (tail) parts.push(tail)
438
+ return parts
439
+ }
440
+
441
+ function inferSelectOutputColumns(sqlText) {
442
+ const selectList = findOuterSelectList(sqlText)
443
+ if (!selectList) return { properties: null, strict: false }
444
+
445
+ const props = new Set()
446
+ let strict = true
447
+ for (const itemRaw of splitTopLevelComma(selectList)) {
448
+ const item = itemRaw.trim()
449
+ if (!item || item === "*" || /\.\*$/.test(item)) {
450
+ strict = false
451
+ continue
452
+ }
453
+
454
+ const asAlias = /\bas\s+"?([A-Za-z_]\w*)"?$/i.exec(item)?.[1]
455
+ if (asAlias) {
456
+ props.add(asAlias)
457
+ continue
458
+ }
459
+
460
+ const simpleColumn = /^(?:(?:"?[A-Za-z_]\w*"?\.)?)"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
461
+ if (simpleColumn) {
462
+ props.add(simpleColumn)
463
+ continue
464
+ }
465
+
466
+ const trailingAlias = /\s+"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
467
+ if (trailingAlias && !SQL_KEYWORDS_AFTER_FROM.has(trailingAlias.toUpperCase())) {
468
+ props.add(trailingAlias)
469
+ continue
470
+ }
471
+
472
+ strict = false
473
+ }
474
+
475
+ return { properties: props.size > 0 ? props : null, strict }
476
+ }
477
+
478
+ function inferSqlCardinality(sqlText) {
479
+ const sql = stripSqlForInference(sqlText)
480
+ if (!sql) return null
481
+ if (/\blimit\s+1(?:\D|$)/i.test(sql)) return "single"
482
+ const startsLikeRead = /^(select|with)\b/i.test(sql)
483
+ const hasAggregate = /\b(count|sum|avg|min|max|json_agg|jsonb_agg|array_agg|string_agg)\s*\(/i.test(sql)
484
+ const hasGroupBy = /\bgroup\s+by\b/i.test(sql)
485
+ if (startsLikeRead && hasAggregate && !hasGroupBy) return "single"
486
+ return "many"
487
+ }
488
+
489
+ function returningColumns(returning, table, schemaColumns) {
490
+ if (Array.isArray(returning)) return new Set(returning.filter(v => typeof v === "string" && v !== "*"))
491
+ if (typeof returning === "string" && returning.trim() && returning.trim() !== "*") {
492
+ return new Set(returning.split(",").map(s => s.trim()).filter(Boolean))
493
+ }
494
+ if (table && schemaColumns?.[table]?.size) return new Set(schemaColumns[table])
495
+ return null
496
+ }
497
+
498
+ function buildNodeOutputContracts(nodes, fileMap, ctx) {
499
+ const contracts = new Map()
500
+ for (const node of nodes || []) {
501
+ if (!node || typeof node !== "object" || !node.id) continue
502
+ const nodeType = node.type ?? node.node_type
503
+ const params = nodeParams(node)
504
+
505
+ if (nodeType === "set_fields") {
506
+ const op = nodeField(node, params, "operation") || "set"
507
+ const fields = nodeField(node, params, "fields")
508
+ if (fields && typeof fields === "object" && !Array.isArray(fields)) {
509
+ contracts.set(node.id, {
510
+ type: "object",
511
+ properties: new Set(Object.keys(fields).map(k => k.split(".")[0])),
512
+ strict: op === "compose",
513
+ })
514
+ }
515
+ continue
516
+ }
517
+
518
+ if (nodeType !== "dypai_database") continue
519
+ const op = nodeField(node, params, "operation")
520
+ const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
521
+
522
+ if (op === "query" || op === "custom_query") {
523
+ const query = nodeField(node, params, "query") || lookupFileMapContent(fileMap, nodeField(node, params, "query_file"))
524
+ const inferred = inferSelectOutputColumns(query)
525
+ contracts.set(node.id, {
526
+ type: "array",
527
+ cardinality: inferSqlCardinality(query),
528
+ properties: inferred.properties,
529
+ strict: inferred.strict,
530
+ })
531
+ continue
532
+ }
533
+
534
+ if (op === "select") {
535
+ contracts.set(node.id, {
536
+ type: "array",
537
+ cardinality: "many",
538
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
539
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
540
+ })
541
+ continue
542
+ }
543
+
544
+ if (op === "aggregate") {
545
+ contracts.set(node.id, { type: "object", properties: new Set(["value"]), strict: true })
546
+ continue
547
+ }
548
+
549
+ if (op === "insert") {
550
+ const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(nodeField(node, params, "data"))
551
+ contracts.set(node.id, {
552
+ type: bulk ? "array" : "object",
553
+ cardinality: bulk ? "many" : "single",
554
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
555
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
556
+ })
557
+ continue
558
+ }
559
+
560
+ if (op === "upsert") {
561
+ contracts.set(node.id, {
562
+ type: "object",
563
+ cardinality: "single",
564
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
565
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
566
+ })
567
+ continue
568
+ }
569
+
570
+ if (op === "update" || op === "delete" || op === "copy_to") {
571
+ contracts.set(node.id, {
572
+ type: "array",
573
+ cardinality: "many",
574
+ properties: table ? ctx.schemaColumns?.[table] ?? null : null,
575
+ strict: Boolean(table && ctx.schemaColumns?.[table]),
576
+ })
577
+ continue
578
+ }
579
+
580
+ if (op === "mutation") {
581
+ const insertValue = nodeField(node, params, "insert")
582
+ const updateValue = nodeField(node, params, "update")
583
+ const deleteValue = nodeField(node, params, "delete")
584
+ const returning = nodeField(node, params, "returning") ?? "*"
585
+ const props = returningColumns(returning, table, ctx.schemaColumns)
586
+ if (insertValue !== undefined && insertValue !== null) {
587
+ const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(insertValue)
588
+ const hasConflict = nodeField(node, params, "on_conflict") !== undefined
589
+ contracts.set(node.id, {
590
+ type: bulk || hasConflict ? "array" : "object",
591
+ cardinality: bulk ? "many" : hasConflict ? "zero_or_one" : "single",
592
+ properties: props,
593
+ strict: Boolean(props),
594
+ })
595
+ } else if (updateValue !== undefined || deleteValue === true) {
596
+ contracts.set(node.id, {
597
+ type: "array",
598
+ cardinality: "many",
599
+ properties: props,
600
+ strict: Boolean(props),
601
+ })
602
+ }
603
+ }
604
+ }
605
+ return contracts
606
+ }
607
+
608
+ function extractRuntimePaths(expr) {
609
+ const clean = String(expr || "").replace(/'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"/g, " ")
610
+ const paths = []
611
+ 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
612
+ let m
613
+ while ((m = re.exec(clean)) !== null) paths.push(m[0])
614
+ if (paths.length === 0 && RUNTIME_PLACEHOLDER_PATH_RE.test(expr.trim())) paths.push(expr.trim())
615
+ return [...new Set(paths)]
616
+ }
617
+
618
+ function parseNodeOutputRef(path) {
619
+ const m = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)(.*)$/.exec(path)
620
+ if (!m) return null
621
+ const nodeId = m[1]
622
+ let rest = m[2] || ""
623
+ let indexed = false
624
+ if (rest.startsWith("[")) {
625
+ const idx = /^\[[0-9]+\]/.exec(rest)?.[0]
626
+ if (idx) {
627
+ indexed = true
628
+ rest = rest.slice(idx.length)
629
+ }
630
+ }
631
+ const prop = rest.startsWith(".") ? /^\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(rest)?.[1] : null
632
+ return { nodeId, indexed, prop }
633
+ }
634
+
635
+ function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
636
+ const ref = parseNodeOutputRef(path)
637
+ if (!ref || !ref.prop || !contract) return null
638
+
639
+ if (contract.strict && contract.properties && !contract.properties.has(ref.prop)) {
640
+ const known = [...contract.properties]
641
+ const suggestions = known.filter(k => levenshteinSmall(k, ref.prop) <= 2).slice(0, 3)
642
+ return {
643
+ severity: "warn",
644
+ rule: "node_output_property_unknown",
645
+ endpoint, file, loc,
646
+ message: `\${${expr}} references '${ref.prop}', but node '${ref.nodeId}' is inferred to output: ${known.join(", ") || "(no known properties)"}.`,
647
+ fix_hint: suggestions.length
648
+ ? `Did you mean: ${suggestions.join(", ")}?`
649
+ : `Use one of: ${known.join(", ") || "(none)"}. If this is a dynamic SQL shape, verify with dypai_test_endpoint.`,
650
+ }
651
+ }
652
+
653
+ if (contract.type === "array" && contract.cardinality === "many" && !ref.indexed) {
654
+ return {
655
+ severity: "warn",
656
+ rule: "node_output_many_direct_property",
657
+ endpoint, file, loc,
658
+ message: `\${${expr}} reads property '${ref.prop}' directly from node '${ref.nodeId}', but that node is inferred to return many rows.`,
659
+ fix_hint: `Use \${nodes.${ref.nodeId}} for the full array, or \${nodes.${ref.nodeId}[0].${ref.prop}} if you intentionally want the first row.`,
660
+ }
661
+ }
662
+
663
+ return null
664
+ }
665
+
356
666
  function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
357
667
  if (!ctx.schemaTables) return
358
668
  for (const table of referencedTables) {
@@ -377,6 +687,7 @@ function validateEndpoint(entry, ctx) {
377
687
 
378
688
  const inputProps = doc.input?.properties || {}
379
689
  const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
690
+ const outputContracts = buildNodeOutputContracts(doc.workflow?.nodes || [], fileMap, ctx)
380
691
 
381
692
  const jwt = ruleUsesJwt(doc.trigger)
382
693
 
@@ -409,6 +720,7 @@ function validateEndpoint(entry, ctx) {
409
720
  for (const { source, loc, value } of sources) {
410
721
  // --- Placeholder checks ---
411
722
  for (const expr of extractPlaceholders(value)) {
723
+ const runtimePaths = extractRuntimePaths(expr)
412
724
  if (unsupportedRuntimePlaceholder(expr)) {
413
725
  diagnostics.push({
414
726
  severity: "error",
@@ -423,10 +735,10 @@ function validateEndpoint(entry, ctx) {
423
735
  continue
424
736
  }
425
737
 
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)
738
+ // Normalize every runtime path we can find in this placeholder. Expressions
739
+ // such as `${nodes.x.stock >= input.qty}` are valid at runtime, so validate
740
+ // each referenced path instead of rejecting the whole expression.
741
+ for (const e of runtimePaths) {
430
742
 
431
743
  // ${input.X} or ${input.X.Y}
432
744
  // Only validate against the input schema if one is declared; DYPAI allows
@@ -462,6 +774,16 @@ function validateEndpoint(entry, ctx) {
462
774
  if (!missingNodeRefs.has(nodeId)) {
463
775
  missingNodeRefs.set(nodeId, { loc, expr })
464
776
  }
777
+ } else {
778
+ const outputDiag = validateNodeOutputRef({
779
+ expr,
780
+ path: e,
781
+ contract: outputContracts.get(nodeId),
782
+ endpoint: name,
783
+ file,
784
+ loc,
785
+ })
786
+ if (outputDiag) diagnostics.push(outputDiag)
465
787
  }
466
788
  }
467
789
 
@@ -477,6 +799,7 @@ function validateEndpoint(entry, ctx) {
477
799
  })
478
800
  }
479
801
  }
802
+ }
480
803
  }
481
804
 
482
805
  // NOTE: SQL table extraction used to live here (anywhere a string looked
@@ -1241,10 +1564,11 @@ export async function runValidation(rootDir, projectId) {
1241
1564
  const config = await readLocalConfig(rootDir)
1242
1565
  const targetProjectId = projectId || config?.project_id || null
1243
1566
 
1244
- const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
1567
+ const [local, remote, schemaTables, schemaColumns, nodeCatalog] = await Promise.all([
1245
1568
  readLocalState(rootDir),
1246
1569
  fetchRemoteState(targetProjectId),
1247
1570
  readSchemaTables(rootDir),
1571
+ readSchemaColumns(rootDir),
1248
1572
  loadNodeCatalog(rootDir),
1249
1573
  ])
1250
1574
 
@@ -1261,6 +1585,7 @@ export async function runValidation(rootDir, projectId) {
1261
1585
  remoteCredentials,
1262
1586
  toolEndpoints,
1263
1587
  schemaTables,
1588
+ schemaColumns,
1264
1589
  catalog: nodeCatalog.schemas,
1265
1590
  knownTypes: nodeCatalog.knownTypes,
1266
1591
  fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.file || `${e.doc.name}.yaml`}`])),
@@ -1370,6 +1695,15 @@ export async function runValidation(rootDir, projectId) {
1370
1695
  }
1371
1696
  }
1372
1697
 
1698
+ export const __testing = {
1699
+ buildNodeOutputContracts,
1700
+ extractRuntimePaths,
1701
+ inferSelectOutputColumns,
1702
+ inferSqlCardinality,
1703
+ unsupportedRuntimePlaceholder,
1704
+ validateNodeOutputRef,
1705
+ }
1706
+
1373
1707
  export const dypaiValidateTool = {
1374
1708
  name: "dypai_validate",
1375
1709
  description: