@dypai-ai/mcp 1.5.18 → 1.5.22
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 +2 -2
- package/src/index.js +42 -20
- package/src/lib/workflow-placeholder-contract.d.ts +50 -0
- package/src/lib/workflow-placeholder-contract.js +364 -0
- package/src/tools/project-artifacts.js +627 -0
- package/src/tools/sync/validate.js +151 -8
- package/src/tools/capability-kits.js +0 -830
|
@@ -19,6 +19,11 @@ import { join, resolve as resolvePath } from "path"
|
|
|
19
19
|
import { fetchRemoteState, readLocalState, readLocalConfig, readLocalRealtime } from "./planner.js"
|
|
20
20
|
import { proxyToolCall } from "../proxy.js"
|
|
21
21
|
import { dumpPublicSchema } from "./schema-dump.js"
|
|
22
|
+
import {
|
|
23
|
+
sqlProvesSingleRow,
|
|
24
|
+
outputSchemaPropertyNames,
|
|
25
|
+
workflowOutputKeys,
|
|
26
|
+
} from "../../lib/workflow-placeholder-contract.js"
|
|
22
27
|
|
|
23
28
|
/** schema.sql is considered "fresh" if it was written less than this ago.
|
|
24
29
|
* Within the freshness window, we trust it without re-checking the remote.
|
|
@@ -424,6 +429,10 @@ function nodeField(node, params, key) {
|
|
|
424
429
|
return node?.[key] ?? params?.[key]
|
|
425
430
|
}
|
|
426
431
|
|
|
432
|
+
function isUuidString(value) {
|
|
433
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
434
|
+
}
|
|
435
|
+
|
|
427
436
|
function lookupFileMapContent(fileMap, ref) {
|
|
428
437
|
if (!fileMap || typeof ref !== "string" || !ref.trim()) return ""
|
|
429
438
|
const key = ref.trim()
|
|
@@ -740,6 +749,11 @@ function inferSqlCardinality(sqlText) {
|
|
|
740
749
|
const sql = stripSqlForInference(sqlText)
|
|
741
750
|
if (!sql) return null
|
|
742
751
|
if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single"
|
|
752
|
+
// INSERT ... RETURNING is one row unless the caller uses bulk mode.
|
|
753
|
+
// UPDATE/DELETE ... RETURNING may affect many existing rows, so keep those as "many".
|
|
754
|
+
if (/\breturning\b/i.test(sql) && /^insert\b/i.test(sql.trim())) {
|
|
755
|
+
return "single"
|
|
756
|
+
}
|
|
743
757
|
const startsLikeRead = /^(select|with)\b/i.test(sql)
|
|
744
758
|
if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many"
|
|
745
759
|
const outerSelectList = findOuterSelectList(sql)
|
|
@@ -785,9 +799,14 @@ function buildNodeOutputContracts(nodes, fileMap, ctx) {
|
|
|
785
799
|
if (op === "query" || op === "custom_query") {
|
|
786
800
|
const query = nodeField(node, params, "query") || lookupFileMapContent(fileMap, nodeField(node, params, "query_file"))
|
|
787
801
|
const inferred = inferSelectOutputColumns(query)
|
|
802
|
+
const declared = nodeField(node, params, "output_cardinality") || node.output_cardinality
|
|
803
|
+
const cardinality =
|
|
804
|
+
declared === "single" || declared === "many"
|
|
805
|
+
? declared
|
|
806
|
+
: inferSqlCardinality(query)
|
|
788
807
|
contracts.set(node.id, {
|
|
789
808
|
type: "array",
|
|
790
|
-
cardinality
|
|
809
|
+
cardinality,
|
|
791
810
|
properties: inferred.properties,
|
|
792
811
|
strict: inferred.strict,
|
|
793
812
|
})
|
|
@@ -895,6 +914,33 @@ function parseNodeOutputRef(path) {
|
|
|
895
914
|
return { nodeId, indexed, prop }
|
|
896
915
|
}
|
|
897
916
|
|
|
917
|
+
function buildNodeTypeMap(nodes) {
|
|
918
|
+
const map = new Map()
|
|
919
|
+
for (const node of nodes || []) {
|
|
920
|
+
if (!node || typeof node !== "object" || !node.id) continue
|
|
921
|
+
map.set(node.id, node.type ?? node.node_type)
|
|
922
|
+
}
|
|
923
|
+
return map
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function validateStripeNodePlaceholder({ expr, path, nodeId, nodeTypes, endpoint, file, loc }) {
|
|
927
|
+
const nodeType = nodeTypes.get(nodeId)
|
|
928
|
+
if (nodeType !== "stripe") return null
|
|
929
|
+
const prefix = `nodes.${nodeId}.`
|
|
930
|
+
if (!path.startsWith(prefix)) return null
|
|
931
|
+
const rest = path.slice(prefix.length)
|
|
932
|
+
if (!rest || rest.startsWith("output.")) return null
|
|
933
|
+
return {
|
|
934
|
+
severity: "error",
|
|
935
|
+
rule: "stripe_output_wrong_path",
|
|
936
|
+
endpoint, file, loc,
|
|
937
|
+
message: `\${${expr}} is not the Stripe node output shape. Stripe returns { success, output: { metadata: { id, url, ... } } }.`,
|
|
938
|
+
fix_hint:
|
|
939
|
+
`Use \${nodes.${nodeId}.output.metadata.url} for checkout URL and \${nodes.${nodeId}.output.metadata.id} for session id. ` +
|
|
940
|
+
`See search_docs('stripe payments').`,
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
898
944
|
function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
|
|
899
945
|
const ref = parseNodeOutputRef(path)
|
|
900
946
|
if (!ref || !ref.prop || !contract) return null
|
|
@@ -919,7 +965,9 @@ function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
|
|
|
919
965
|
rule: "node_output_many_direct_property",
|
|
920
966
|
endpoint, file, loc,
|
|
921
967
|
message: `\${${expr}} reads property '${ref.prop}' directly from node '${ref.nodeId}', but that node is inferred to return many rows.`,
|
|
922
|
-
fix_hint:
|
|
968
|
+
fix_hint:
|
|
969
|
+
`For multi-row query results, use \${nodes.${ref.nodeId}[0].${ref.prop}} for the first row, \${nodes.${ref.nodeId}} for the full array, or a set_fields / javascript_code node. ` +
|
|
970
|
+
`For INSERT ... RETURNING (single row), prefer \${nodes.${ref.nodeId}.${ref.prop}} without [0].`,
|
|
923
971
|
}
|
|
924
972
|
}
|
|
925
973
|
|
|
@@ -949,8 +997,10 @@ function validateEndpoint(entry, ctx) {
|
|
|
949
997
|
const file = ctx.fileByName[name] || `endpoints/${name}.yaml`
|
|
950
998
|
|
|
951
999
|
const inputProps = doc.input?.properties || {}
|
|
952
|
-
const
|
|
953
|
-
const
|
|
1000
|
+
const workflowNodes = doc.workflow?.nodes || []
|
|
1001
|
+
const nodeIds = new Set(workflowNodes.map(n => n.id))
|
|
1002
|
+
const nodeTypes = buildNodeTypeMap(workflowNodes)
|
|
1003
|
+
const outputContracts = buildNodeOutputContracts(workflowNodes, fileMap, ctx)
|
|
954
1004
|
|
|
955
1005
|
const jwt = ruleUsesJwt(doc.trigger)
|
|
956
1006
|
|
|
@@ -1038,6 +1088,17 @@ function validateEndpoint(entry, ctx) {
|
|
|
1038
1088
|
missingNodeRefs.set(nodeId, { loc, expr })
|
|
1039
1089
|
}
|
|
1040
1090
|
} else {
|
|
1091
|
+
const stripeDiag = validateStripeNodePlaceholder({
|
|
1092
|
+
expr,
|
|
1093
|
+
path: e,
|
|
1094
|
+
nodeId,
|
|
1095
|
+
nodeTypes,
|
|
1096
|
+
endpoint: name,
|
|
1097
|
+
file,
|
|
1098
|
+
loc,
|
|
1099
|
+
})
|
|
1100
|
+
if (stripeDiag) diagnostics.push(stripeDiag)
|
|
1101
|
+
|
|
1041
1102
|
const outputDiag = validateNodeOutputRef({
|
|
1042
1103
|
expr,
|
|
1043
1104
|
path: e,
|
|
@@ -1311,6 +1372,45 @@ function validateEndpoint(entry, ctx) {
|
|
|
1311
1372
|
}
|
|
1312
1373
|
}
|
|
1313
1374
|
}
|
|
1375
|
+
if (node.tool_ids !== undefined && node.tools === undefined) {
|
|
1376
|
+
const toolIds = Array.isArray(node.tool_ids) ? node.tool_ids : []
|
|
1377
|
+
const legacyUuidToolIds = toolIds.length === 0 || toolIds.every(isUuidString)
|
|
1378
|
+
diagnostics.push({
|
|
1379
|
+
severity: legacyUuidToolIds ? "warn" : "error",
|
|
1380
|
+
rule: "tool_ids_used",
|
|
1381
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
1382
|
+
message: legacyUuidToolIds
|
|
1383
|
+
? `Node '${node.id}' uses legacy 'tool_ids' in YAML. Prefer 'tools: [<endpoint-name>, ...]' for portable project files.`
|
|
1384
|
+
: `Node '${node.id}' uses 'tool_ids' with non-UUID values in YAML. The codec only resolves endpoint names via 'tools'.`,
|
|
1385
|
+
fix_hint: legacyUuidToolIds
|
|
1386
|
+
? `This is allowed for compatibility with older/manual projects, but tools: is the canonical YAML field. See placeholder-cheatsheet.md.`
|
|
1387
|
+
: `Replace tool_ids with tools: [<endpoint-name>, ...]. See placeholder-cheatsheet.md.`,
|
|
1388
|
+
example_fix: "tools: [search-patients, book-appointment]",
|
|
1389
|
+
})
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const declaredCardinality =
|
|
1393
|
+
nodeField(node, nodeParams(node), "output_cardinality") || node.output_cardinality
|
|
1394
|
+
if (
|
|
1395
|
+
nodeType === "dypai_database" &&
|
|
1396
|
+
(nodeField(node, nodeParams(node), "operation") === "query" ||
|
|
1397
|
+
nodeField(node, nodeParams(node), "operation") === "custom_query") &&
|
|
1398
|
+
declaredCardinality === "single"
|
|
1399
|
+
) {
|
|
1400
|
+
const query =
|
|
1401
|
+
nodeField(node, nodeParams(node), "query") ||
|
|
1402
|
+
lookupFileMapContent(ctx.fileMap || {}, nodeField(node, nodeParams(node), "query_file"))
|
|
1403
|
+
if (query && !sqlProvesSingleRow(query)) {
|
|
1404
|
+
diagnostics.push({
|
|
1405
|
+
severity: "warn",
|
|
1406
|
+
rule: "output_cardinality_unproven",
|
|
1407
|
+
endpoint: name, file, loc: `workflow.nodes[${node.id}]`,
|
|
1408
|
+
message: `Node '${node.id}' declares output_cardinality: single but SQL does not prove a single row (add LIMIT 1, aggregate without GROUP BY, or INSERT ... RETURNING).`,
|
|
1409
|
+
fix_hint: "Add LIMIT 1 to the query or remove output_cardinality if the query can return multiple rows.",
|
|
1410
|
+
})
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1314
1414
|
// node_type exists in catalog?
|
|
1315
1415
|
if (ctx.knownTypes.size && !ctx.knownTypes.has(nodeType)) {
|
|
1316
1416
|
const suggestions = [...ctx.knownTypes].filter(t => levenshteinSmall(t, nodeType) <= 2).slice(0, 3)
|
|
@@ -1564,6 +1664,19 @@ function validateEndpoint(entry, ctx) {
|
|
|
1564
1664
|
}
|
|
1565
1665
|
}
|
|
1566
1666
|
|
|
1667
|
+
const webhookCred = doc.trigger?.webhook?.credential
|
|
1668
|
+
if (webhookCred && !ctx.remoteCredentials.has(webhookCred)) {
|
|
1669
|
+
diagnostics.push({
|
|
1670
|
+
severity: "error",
|
|
1671
|
+
rule: "credential_not_found",
|
|
1672
|
+
endpoint: name, file, loc: "trigger.webhook.credential",
|
|
1673
|
+
message: `credential '${webhookCred}' is not defined remotely.`,
|
|
1674
|
+
fix_hint: ctx.remoteCredentials.size
|
|
1675
|
+
? `Available: ${[...ctx.remoteCredentials].join(", ")}. For Stripe webhooks use type stripe_webhook, recommended name stripe-webhook.`
|
|
1676
|
+
: "Create stripe-webhook (type stripe_webhook) in the dashboard. See search_docs('stripe payments').",
|
|
1677
|
+
})
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1567
1680
|
// ── Edge sanity: catch typos in workflow.edges before runtime ────────────
|
|
1568
1681
|
// The engine silently skips edges whose `from`/`to` doesn't resolve to a
|
|
1569
1682
|
// node id, which manifests as "node never ran" — extremely hard to debug.
|
|
@@ -1604,12 +1717,40 @@ function validateEndpoint(entry, ctx) {
|
|
|
1604
1717
|
// that don't reconverge), "last to finish" is non-deterministic and
|
|
1605
1718
|
// almost certainly not what the author intended.
|
|
1606
1719
|
const allNodes = doc.workflow?.nodes || []
|
|
1720
|
+
const triggerKeys = Object.keys(doc.trigger || {})
|
|
1721
|
+
const NEEDS_RESPONSE = new Set(["http_api", "webhook"])
|
|
1722
|
+
const needsResponse = triggerKeys.some(k => NEEDS_RESPONSE.has(k))
|
|
1723
|
+
const hasReturn = allNodes.some(n => n?.return === true || n?.is_return === true)
|
|
1724
|
+
|
|
1725
|
+
if (needsResponse && allNodes.length > 0 && !hasReturn) {
|
|
1726
|
+
diagnostics.push({
|
|
1727
|
+
severity: "warn",
|
|
1728
|
+
rule: "endpoint_missing_return",
|
|
1729
|
+
endpoint: name, file, loc: "workflow.nodes",
|
|
1730
|
+
message:
|
|
1731
|
+
"HTTP/webhook endpoint has no node marked 'return: true'. The engine may return an unintended node output.",
|
|
1732
|
+
fix_hint: "Mark exactly one terminal node with 'return: true' (usually your set_fields or response node).",
|
|
1733
|
+
})
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const declaredOutputProps = outputSchemaPropertyNames(doc.output)
|
|
1737
|
+
const mappedOutputKeys = workflowOutputKeys(doc.workflow?.output)
|
|
1738
|
+
if (declaredOutputProps.size > 0 && mappedOutputKeys.size > 0) {
|
|
1739
|
+
const unknownKeys = [...mappedOutputKeys].filter(k => !declaredOutputProps.has(k))
|
|
1740
|
+
if (unknownKeys.length > 0) {
|
|
1741
|
+
diagnostics.push({
|
|
1742
|
+
severity: "warn",
|
|
1743
|
+
rule: "endpoint_output_mismatch",
|
|
1744
|
+
endpoint: name, file, loc: "workflow.output",
|
|
1745
|
+
message:
|
|
1746
|
+
`workflow.output declares keys not present in the endpoint output schema: ${unknownKeys.join(", ")}.`,
|
|
1747
|
+
fix_hint: `Add them to output.properties or rename workflow.output keys. Declared: ${[...declaredOutputProps].join(", ")}.`,
|
|
1748
|
+
})
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1607
1752
|
if (allNodes.length > 1) {
|
|
1608
|
-
const hasReturn = allNodes.some(n => n?.return === true || n?.is_return === true)
|
|
1609
1753
|
if (!hasReturn) {
|
|
1610
|
-
const triggerKeys = Object.keys(doc.trigger || {})
|
|
1611
|
-
const NEEDS_RESPONSE = new Set(["http_api", "webhook"])
|
|
1612
|
-
const needsResponse = triggerKeys.some(k => NEEDS_RESPONSE.has(k))
|
|
1613
1754
|
if (needsResponse) {
|
|
1614
1755
|
// Find terminal nodes (no outgoing edges).
|
|
1615
1756
|
const edgeList = Array.isArray(doc.workflow?.edges) ? doc.workflow.edges : []
|
|
@@ -1960,11 +2101,13 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1960
2101
|
|
|
1961
2102
|
export const __testing = {
|
|
1962
2103
|
buildNodeOutputContracts,
|
|
2104
|
+
buildNodeTypeMap,
|
|
1963
2105
|
extractRuntimePaths,
|
|
1964
2106
|
inferSelectOutputColumns,
|
|
1965
2107
|
inferSqlCardinality,
|
|
1966
2108
|
unsupportedRuntimePlaceholder,
|
|
1967
2109
|
validateNodeOutputRef,
|
|
2110
|
+
validateStripeNodePlaceholder,
|
|
1968
2111
|
}
|
|
1969
2112
|
|
|
1970
2113
|
export const dypaiValidateTool = {
|