@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.5.18",
3
+ "version": "1.5.22",
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",
@@ -22,7 +22,7 @@
22
22
  "license": "MIT",
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "https://github.com/DYPAI-SOLUTIONS/dypai-mcp"
25
+ "url": "git+https://github.com/DYPAI-SOLUTIONS/dypai-mcp.git"
26
26
  },
27
27
  "homepage": "https://dypai.ai",
28
28
  "dependencies": {
package/src/index.js CHANGED
@@ -35,10 +35,7 @@ import { manageFrontendTool } from "./tools/frontend.js"
35
35
  // import { scaffoldTool } from "./tools/scaffold.js"
36
36
  import { manageDomainTool } from "./tools/domains.js"
37
37
  import { bulkUpsertTool } from "./tools/bulk-upsert.js"
38
- import {
39
- searchCapabilityKitsTool,
40
- manageCapabilityKitTool,
41
- } from "./tools/capability-kits.js"
38
+ import { manageProjectArtifactTool } from "./tools/project-artifacts.js"
42
39
  import { uploadFile } from "./tools/storage.js"
43
40
  // dypaiTestTool (YAML test-suite runner) is intentionally not imported — deferred to v2.
44
41
  // The format works but needs fixtures/auto-rollback/scaffolder + proper docs before being surfaced.
@@ -86,9 +83,8 @@ const LOCAL_TOOLS = [
86
83
  manageDomainTool,
87
84
  // ── Data ──────────────────────────────────────────────────────────────────
88
85
  bulkUpsertTool,
89
- // ── Capability kits (local source installer) ──────────────────────────────
90
- searchCapabilityKitsTool,
91
- manageCapabilityKitTool,
86
+ // ── Project artifacts (install via cloud GitHub fetch + local workspace copy) ──
87
+ manageProjectArtifactTool,
92
88
  // ── Git-first source of truth ─────────────────────────────────────────────
93
89
  // dypai_describe was merged into dypai_pull (now returns an `overview` block).
94
90
  dypaiPullTool,
@@ -494,6 +490,34 @@ endpoint YAML and \`dypai_push\`. This tool does NOT modify the definition.`,
494
490
 
495
491
  // ── Knowledge ─────────────────────────────────────────────────────────────
496
492
  { name: "search_docs", description: "Search DYPAI documentation. Use this when unsure about SDK usage, auth patterns, workflow nodes, or platform features. Returns relevant documentation chunks.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What you want to learn about" } }, required: ["query"] } },
493
+ {
494
+ name: "search_project_artifacts",
495
+ description: "Search installable project shells and feature modules from the unified dytemplates catalog (semantic). Returns shells, vertical features, and reusable modules (often kit-* slugs). Use before install via manage_project_artifact.",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ query: { type: "string", description: "Natural language need, e.g. 'calendario de reservas para hotel'." },
500
+ surface: { type: "string", enum: ["public", "private", "mixed", "agnostic"], description: "Filter by surface; agnostic modules always match when set." },
501
+ kind: { type: "string", enum: ["shell", "feature"], description: "Restrict to shells or feature modules." },
502
+ compatible_shell: { type: "string", description: "Shell slug; returns features compatible with it." },
503
+ limit: { type: "integer", default: 20, minimum: 1, maximum: 50 },
504
+ },
505
+ required: ["query"],
506
+ },
507
+ },
508
+ {
509
+ name: "fetch_project_artifact",
510
+ description: "Download artifact source from GitHub (server-side). Requires source_repo, source_path, source_ref from search_project_artifacts. Used internally by manage_project_artifact; agents normally call manage_project_artifact only.",
511
+ inputSchema: {
512
+ type: "object",
513
+ properties: {
514
+ source_repo: { type: "string", description: "e.g. dyapps-codes/artifacts" },
515
+ source_path: { type: "string", description: "e.g. kits/pricing-stripe" },
516
+ source_ref: { type: "string", default: "main" },
517
+ },
518
+ required: ["source_repo"],
519
+ },
520
+ },
497
521
  { name: "search_design_patterns", description: "Search compact DYPAI UI/design recipes. Use before designing substantial screens.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Design need, with starter/domain/screen/style context when known." }, starter_slug: { type: "string", description: "Optional: private-admin, user-accounts, landing-admin, or blank." }, app_type: { type: "string", description: "Optional domain/app type." }, screen_type: { type: "string", description: "Optional screen/workflow." }, visual_style: { type: "string", description: "Optional style." }, category: { type: "string", description: "Optional category." }, limit: { type: "integer", default: 3, minimum: 1, maximum: 4 } }, required: ["query"] } },
498
522
  { name: "search_workflow_templates", description: "Search workflow templates by description. Returns ready-to-use workflow code for common patterns: CRUD operations, payment gateways, email sending, AI chatbots, data pipelines, etc.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What the workflow should do (e.g. 'send email', 'stripe payment')" }, category: { type: "string", description: "Optional: AI, Database, Payments, Communication, Logic, Storage" } }, required: ["query"] } },
499
523
  { name: "search_project_templates", description: "Search visible DYPAI Studio project templates by description. Returns only the Studio catalog plus compact template briefs and selection_guidance: recommended_slug, confidence, fallback_slug, and agent_rule.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What kind of project starter you need, in the user's language (e.g. 'app para barberia con reservas', 'private admin dashboard', 'landing plus admin')" }, category: { type: "string", description: "Optional category/type filter." }, limit: { type: "integer", default: 5, minimum: 1, maximum: 10 }, include_drafts: { type: "boolean", default: false, description: "Internal/local testing only. Include draft Studio catalog templates." } }, required: ["query"] } },
@@ -544,16 +568,13 @@ forms, calendars, or domain-specific screens), use \`search_design_patterns\`
544
568
  with the app/starter/screen/style context. It returns curated recipes; adapt
545
569
  them to the project instead of inventing generic starter UI.
546
570
 
547
- For complex reusable capabilities (calendar booking, interactive maps, CRUD
548
- tables, Kanban boards, upload/document flows, dashboards, rich editors), also
549
- use \`search_capability_kits\`. If a strong kit matches, inspect it with
550
- \`manage_capability_kit(operation: "inspect")\` and install it with
551
- \`manage_capability_kit(operation: "apply")\` instead of building that complex
552
- component from scratch. Installed kit code is editable workspace source; after
553
- install, if the tool returns dependencies, add any missing packages to the
554
- target workspace package.json and install them locally (never globally or in
555
- the MCP server). Then wire the kit into the app, apply SQL if needed, validate
556
- endpoints, and verify the frontend.
571
+ For shells, vertical features, and reusable modules (calendar, maps, CRUD tables,
572
+ Kanban, uploads, dashboards, editors), use \`search_project_artifacts\` first.
573
+ If an artifact fits, pass the exact \`slug\` from the search hit into
574
+ \`manage_project_artifact(operation: "inspect")\` then \`operation: "apply"\`
575
+ instead of building that complex slice from scratch. Installed artifact code is
576
+ editable workspace source; add missing deps to package.json locally, apply SQL
577
+ with execute_sql when needed, validate endpoints, and verify the frontend.
557
578
 
558
579
  **The template system exists to save time when the fit is obvious, not to force-match every request.** When in doubt → use the safest returned Studio base. Iterating up from a smaller base is cheaper than deleting 80% of a mismatched template.
559
580
 
@@ -836,9 +857,10 @@ synonym.
836
857
  - \`"auth defaults"\` — what auth_mode to pick when the user doesn't specify
837
858
 
838
859
  ### Integrations
839
- - \`"integrations guide"\` — step-by-step integration recipes (Stripe has full coverage; Telegram, Slack, WhatsApp, Resend, Google Sheets are referenced inside this doc)
840
- - \`"credentials reference"\` — what fields each provider needs
841
- - To find a specific provider: \`search_docs("integrations guide stripe")\` (combine the topic + the provider name) semantic search will pick up the provider's section
860
+ - \`"stripe payments"\` — **start here for any Stripe work** (credentials, checkout URL placeholders, one-time + webhook checklist)
861
+ - \`"integrations guide"\` — step-by-step integration recipes (Stripe subscriptions; other providers referenced inside)
862
+ - \`"credentials reference"\` what fields each provider needs (includes stripe + stripe_webhook)
863
+ - To find Stripe: \`search_docs("stripe payments")\` or \`search_docs("integrations guide stripe")\`
842
864
 
843
865
  ### Realtime
844
866
  - \`"realtime channels"\` — client API for WebSocket subscriptions
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Canonical workflow placeholder / SQL cardinality helpers.
3
+ * Copy into workspace/engine — do not import across service deploy boundaries.
4
+ */
5
+
6
+ export type SqlCardinality = "single" | "many" | null;
7
+
8
+ export type PlaceholderDiagnostic = {
9
+ rule: string;
10
+ message: string;
11
+ fix_hint: string;
12
+ example_fix?: string;
13
+ };
14
+
15
+ export function stripSqlForInference(sqlText: string): string;
16
+
17
+ export function hasTopLevelLimitOne(sqlText: string): boolean;
18
+
19
+ export function inferSqlCardinality(sqlText: string): SqlCardinality;
20
+
21
+ /** Alias: true when inferSqlCardinality returns "single". */
22
+ export function sqlProvesSingleRow(sqlText: string): boolean;
23
+
24
+ /**
25
+ * Parse node id from nodes.* expression; supports bracket index.
26
+ * nodes.foo[0].bar → "foo"
27
+ */
28
+ export function referencedNodeIdFromNodesExpression(expr: string): string | null;
29
+
30
+ export function stripePlaceholderIssue(
31
+ placeholder: string,
32
+ nodeId: string,
33
+ nodeType: string,
34
+ ): PlaceholderDiagnostic | null;
35
+
36
+ export function databaseManyRowPlaceholderIssue(
37
+ placeholder: string,
38
+ nodeId: string,
39
+ cardinality: SqlCardinality | "single" | "many",
40
+ ): PlaceholderDiagnostic | null;
41
+
42
+ export function outputSchemaPropertyNames(outputSchema: unknown): Set<string>;
43
+
44
+ export function workflowOutputKeys(workflowOutput: unknown): Set<string>;
45
+
46
+ /** Keys present in workflow.output but missing from output.properties. */
47
+ export function workflowOutputMismatchKeys(
48
+ outputSchema: unknown,
49
+ workflowOutput: unknown,
50
+ ): string[];
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Canonical workflow placeholder / SQL cardinality helpers for DYPAI validators.
3
+ *
4
+ * Source of truth for MCP (`dypai_validate`). Copy into other services (workspace,
5
+ * engine-side tooling) — do NOT import this file across service boundaries at runtime.
6
+ *
7
+ * @see test-fixtures/workflow-contract/ for golden parity checks
8
+ */
9
+
10
+ const AGGREGATE_FUNCTIONS = [
11
+ "count",
12
+ "sum",
13
+ "avg",
14
+ "min",
15
+ "max",
16
+ "json_agg",
17
+ "jsonb_agg",
18
+ "array_agg",
19
+ "string_agg",
20
+ ];
21
+
22
+ export function stripSqlForInference(sqlText) {
23
+ return String(sqlText || "")
24
+ .replace(/--[^\n]*/g, " ")
25
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
26
+ .replace(/'(?:[^']|'')*'/g, "''")
27
+ .replace(/\s+/g, " ")
28
+ .trim();
29
+ }
30
+
31
+ function isWordAt(sql, index, word) {
32
+ if (sql.slice(index, index + word.length).toLowerCase() !== word) return false;
33
+ const before = index === 0 ? "" : sql[index - 1];
34
+ const after = sql[index + word.length] || "";
35
+ return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after);
36
+ }
37
+
38
+ function skipDoubleQuotedIdentifier(sql, index) {
39
+ if (sql[index] !== '"') return index;
40
+ for (let i = index + 1; i < sql.length; i++) {
41
+ if (sql[i] !== '"') continue;
42
+ if (sql[i + 1] === '"') {
43
+ i++;
44
+ continue;
45
+ }
46
+ return i;
47
+ }
48
+ return sql.length - 1;
49
+ }
50
+
51
+ function skipSqlSpaces(sql, index) {
52
+ let i = index;
53
+ while (i < sql.length && /\s/.test(sql[i])) i++;
54
+ return i;
55
+ }
56
+
57
+ function findMatchingParen(sql, openIndex) {
58
+ if (sql[openIndex] !== "(") return -1;
59
+ let depth = 0;
60
+ for (let i = openIndex; i < sql.length; i++) {
61
+ const ch = sql[i];
62
+ if (ch === '"') {
63
+ i = skipDoubleQuotedIdentifier(sql, i);
64
+ continue;
65
+ }
66
+ if (ch === "(") {
67
+ depth++;
68
+ continue;
69
+ }
70
+ if (ch === ")") {
71
+ depth--;
72
+ if (depth === 0) return i;
73
+ }
74
+ }
75
+ return -1;
76
+ }
77
+
78
+ function findOuterSelectList(sqlText) {
79
+ const sql = stripSqlForInference(sqlText);
80
+ if (!sql) return null;
81
+ let depth = 0;
82
+ let selectStart = -1;
83
+ for (let i = 0; i < sql.length; i++) {
84
+ const ch = sql[i];
85
+ if (ch === '"') {
86
+ i = skipDoubleQuotedIdentifier(sql, i);
87
+ continue;
88
+ }
89
+ if (ch === "(") depth++;
90
+ else if (ch === ")") depth = Math.max(0, depth - 1);
91
+
92
+ if (depth !== 0) continue;
93
+ if (selectStart < 0 && isWordAt(sql, i, "select")) {
94
+ selectStart = i + "select".length;
95
+ i += "select".length - 1;
96
+ continue;
97
+ }
98
+ if (selectStart >= 0 && isWordAt(sql, i, "from")) {
99
+ return sql.slice(selectStart, i).trim();
100
+ }
101
+ }
102
+ if (selectStart >= 0) return sql.slice(selectStart).trim();
103
+ return null;
104
+ }
105
+
106
+ export function hasTopLevelLimitOne(sqlText) {
107
+ const sql = stripSqlForInference(sqlText);
108
+ let depth = 0;
109
+ for (let i = 0; i < sql.length; i++) {
110
+ const ch = sql[i];
111
+ if (ch === '"') {
112
+ i = skipDoubleQuotedIdentifier(sql, i);
113
+ continue;
114
+ }
115
+ if (ch === "(") {
116
+ depth++;
117
+ continue;
118
+ }
119
+ if (ch === ")") {
120
+ depth = Math.max(0, depth - 1);
121
+ continue;
122
+ }
123
+ if (depth !== 0 || !isWordAt(sql, i, "limit")) continue;
124
+ const tail = sql.slice(skipSqlSpaces(sql, i + "limit".length));
125
+ if (/^1(?:\D|$)/.test(tail)) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ function hasTopLevelFetchOne(sqlText) {
131
+ const sql = stripSqlForInference(sqlText);
132
+ let depth = 0;
133
+ for (let i = 0; i < sql.length; i++) {
134
+ const ch = sql[i];
135
+ if (ch === '"') {
136
+ i = skipDoubleQuotedIdentifier(sql, i);
137
+ continue;
138
+ }
139
+ if (ch === "(") {
140
+ depth++;
141
+ continue;
142
+ }
143
+ if (ch === ")") {
144
+ depth = Math.max(0, depth - 1);
145
+ continue;
146
+ }
147
+ if (depth !== 0 || !isWordAt(sql, i, "fetch")) continue;
148
+
149
+ let cursor = skipSqlSpaces(sql, i + "fetch".length);
150
+ if (isWordAt(sql, cursor, "first")) cursor += "first".length;
151
+ else if (isWordAt(sql, cursor, "next")) cursor += "next".length;
152
+ else continue;
153
+ cursor = skipSqlSpaces(sql, cursor);
154
+ if (/^1(?:\D|$)/.test(sql.slice(cursor))) return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ function hasTopLevelGroupBy(sqlText) {
160
+ const sql = stripSqlForInference(sqlText);
161
+ let depth = 0;
162
+ for (let i = 0; i < sql.length; i++) {
163
+ const ch = sql[i];
164
+ if (ch === '"') {
165
+ i = skipDoubleQuotedIdentifier(sql, i);
166
+ continue;
167
+ }
168
+ if (ch === "(") {
169
+ depth++;
170
+ continue;
171
+ }
172
+ if (ch === ")") {
173
+ depth = Math.max(0, depth - 1);
174
+ continue;
175
+ }
176
+ if (depth !== 0 || !isWordAt(sql, i, "group")) continue;
177
+
178
+ const restStart = i + "group".length;
179
+ const rest = sql.slice(restStart);
180
+ const byOffset = rest.search(/\S/);
181
+ if (byOffset >= 0 && isWordAt(sql, restStart + byOffset, "by")) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ function hasTopLevelSetOperation(sqlText) {
187
+ const sql = stripSqlForInference(sqlText);
188
+ let depth = 0;
189
+ for (let i = 0; i < sql.length; i++) {
190
+ const ch = sql[i];
191
+ if (ch === '"') {
192
+ i = skipDoubleQuotedIdentifier(sql, i);
193
+ continue;
194
+ }
195
+ if (ch === "(") {
196
+ depth++;
197
+ continue;
198
+ }
199
+ if (ch === ")") {
200
+ depth = Math.max(0, depth - 1);
201
+ continue;
202
+ }
203
+ if (depth !== 0) continue;
204
+ if (isWordAt(sql, i, "union") || isWordAt(sql, i, "intersect") || isWordAt(sql, i, "except")) {
205
+ return true;
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+
211
+ function isWindowAggregateCall(sql, openIndex) {
212
+ const closeIndex = findMatchingParen(sql, openIndex);
213
+ if (closeIndex < 0) return false;
214
+
215
+ let cursor = skipSqlSpaces(sql, closeIndex + 1);
216
+ if (isWordAt(sql, cursor, "filter")) {
217
+ cursor = skipSqlSpaces(sql, cursor + "filter".length);
218
+ if (sql[cursor] === "(") {
219
+ const filterClose = findMatchingParen(sql, cursor);
220
+ if (filterClose >= 0) cursor = skipSqlSpaces(sql, filterClose + 1);
221
+ }
222
+ }
223
+
224
+ return isWordAt(sql, cursor, "over");
225
+ }
226
+
227
+ function hasOuterAggregateFunction(selectList) {
228
+ const sql = String(selectList || "");
229
+ let depth = 0;
230
+ const activeSubqueryDepths = [];
231
+
232
+ const insideSubquery = () => activeSubqueryDepths.some((d) => d <= depth);
233
+
234
+ for (let i = 0; i < sql.length; i++) {
235
+ const ch = sql[i];
236
+ if (ch === '"') {
237
+ i = skipDoubleQuotedIdentifier(sql, i);
238
+ continue;
239
+ }
240
+ if (ch === "(") {
241
+ depth++;
242
+ continue;
243
+ }
244
+ if (ch === ")") {
245
+ activeSubqueryDepths.splice(
246
+ 0,
247
+ activeSubqueryDepths.length,
248
+ ...activeSubqueryDepths.filter((d) => d < depth),
249
+ );
250
+ depth = Math.max(0, depth - 1);
251
+ continue;
252
+ }
253
+
254
+ if (depth > 0 && isWordAt(sql, i, "select")) {
255
+ activeSubqueryDepths.push(depth);
256
+ i += "select".length - 1;
257
+ continue;
258
+ }
259
+
260
+ for (const fn of AGGREGATE_FUNCTIONS) {
261
+ if (!isWordAt(sql, i, fn)) continue;
262
+ const openIndex = skipSqlSpaces(sql, i + fn.length);
263
+ if (sql[openIndex] === "(" && !insideSubquery() && !isWindowAggregateCall(sql, openIndex)) {
264
+ return true;
265
+ }
266
+ }
267
+ }
268
+
269
+ return false;
270
+ }
271
+
272
+ /**
273
+ * Infer whether a SQL query returns a single row or many rows.
274
+ * Conservative rules: INSERT ... RETURNING → single; UPDATE/DELETE ... RETURNING → many.
275
+ */
276
+ export function inferSqlCardinality(sqlText) {
277
+ const sql = stripSqlForInference(sqlText);
278
+ if (!sql) return null;
279
+
280
+ if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single";
281
+
282
+ if (/\breturning\b/i.test(sql) && /^insert\b/i.test(sql.trim())) return "single";
283
+
284
+ const startsLikeRead = /^(select|with)\b/i.test(sql);
285
+ if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many";
286
+
287
+ const outerSelectList = findOuterSelectList(sql);
288
+ const hasAggregate = outerSelectList ? hasOuterAggregateFunction(outerSelectList) : false;
289
+ const hasGroupBy = hasTopLevelGroupBy(sql);
290
+ if (startsLikeRead && hasAggregate && !hasGroupBy) return "single";
291
+
292
+ return "many";
293
+ }
294
+
295
+ /**
296
+ * True when SQL clearly proves at most one row (for output_cardinality: single validation).
297
+ */
298
+ export function sqlProvesSingleRow(sqlText) {
299
+ return inferSqlCardinality(sqlText) === "single";
300
+ }
301
+
302
+ /**
303
+ * Parse node id from a nodes.* placeholder expression (supports bracket index).
304
+ * e.g. nodes.create_payment[0].id → create_payment
305
+ */
306
+ export function referencedNodeIdFromNodesExpression(expr) {
307
+ const trimmed = String(expr || "").trim();
308
+ const match = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(trimmed);
309
+ return match?.[1] ?? null;
310
+ }
311
+
312
+ export function stripePlaceholderIssue(placeholder, nodeId, nodeType) {
313
+ if (nodeType !== "stripe") return null;
314
+ const prefix = `nodes.${nodeId}.`;
315
+ if (!String(placeholder).startsWith(prefix)) return null;
316
+ const rest = String(placeholder).slice(prefix.length);
317
+ if (!rest || rest.startsWith("output.")) return null;
318
+ return {
319
+ rule: "stripe_output_wrong_path",
320
+ message: `Placeholder \${${placeholder}} is not the Stripe node output shape. Use output.metadata.*.`,
321
+ fix_hint: `Use \${nodes.${nodeId}.output.metadata.url} for checkout URL and \${nodes.${nodeId}.output.metadata.id} for session id.`,
322
+ example_fix: `\${nodes.${nodeId}.output.metadata.url}`,
323
+ };
324
+ }
325
+
326
+ export function databaseManyRowPlaceholderIssue(placeholder, nodeId, cardinality) {
327
+ if (cardinality !== "many") return null;
328
+ if (/\[\d+\]/.test(placeholder)) return null;
329
+ const match = /^nodes\.[A-Za-z_][A-Za-z0-9_-]*\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(String(placeholder));
330
+ if (!match) return null;
331
+ const prop = match[1];
332
+ return {
333
+ rule: "node_output_many_direct_property",
334
+ message: `Placeholder \${${placeholder}} reads '${prop}' directly from node '${nodeId}', but that node returns many rows.`,
335
+ fix_hint: `Use \${nodes.${nodeId}[0].${prop}} or process \${nodes.${nodeId}} in javascript_code. For INSERT ... RETURNING (single row), use \${nodes.${nodeId}.${prop}} without [0].`,
336
+ example_fix: `\${nodes.${nodeId}[0].${prop}}`,
337
+ };
338
+ }
339
+
340
+ /** Top-level keys from endpoint output schema (`type: object` + `properties`). */
341
+ export function outputSchemaPropertyNames(outputSchema) {
342
+ if (!outputSchema || typeof outputSchema !== "object" || Array.isArray(outputSchema)) {
343
+ return new Set();
344
+ }
345
+ const props = outputSchema.properties;
346
+ if (!props || typeof props !== "object" || Array.isArray(props)) return new Set();
347
+ return new Set(Object.keys(props));
348
+ }
349
+
350
+ /** Keys on workflow.output mapping object. */
351
+ export function workflowOutputKeys(workflowOutput) {
352
+ if (!workflowOutput || typeof workflowOutput !== "object" || Array.isArray(workflowOutput)) {
353
+ return new Set();
354
+ }
355
+ return new Set(Object.keys(workflowOutput));
356
+ }
357
+
358
+ /** Compare workflow.output keys against declared output.properties (extras = mismatch). */
359
+ export function workflowOutputMismatchKeys(outputSchema, workflowOutput) {
360
+ const declared = outputSchemaPropertyNames(outputSchema);
361
+ const mapped = workflowOutputKeys(workflowOutput);
362
+ if (declared.size === 0 || mapped.size === 0) return [];
363
+ return [...mapped].filter((key) => !declared.has(key));
364
+ }