@elastic.io/mcp 1.0.1-dev2 → 1.0.1-dev21

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.
Files changed (108) hide show
  1. package/README.md +94 -89
  2. package/dist/api/client.d.ts.map +1 -1
  3. package/dist/api/client.js +8 -3
  4. package/dist/api/client.js.map +1 -1
  5. package/dist/api/jsonapi.d.ts +2 -9
  6. package/dist/api/jsonapi.d.ts.map +1 -1
  7. package/dist/api/jsonapi.js +1 -14
  8. package/dist/api/jsonapi.js.map +1 -1
  9. package/dist/api/logger.d.ts +0 -1
  10. package/dist/api/logger.d.ts.map +1 -1
  11. package/dist/api/logger.js +2 -5
  12. package/dist/api/logger.js.map +1 -1
  13. package/dist/api/samples.d.ts +0 -6
  14. package/dist/api/samples.d.ts.map +1 -1
  15. package/dist/api/samples.js +8 -8
  16. package/dist/api/samples.js.map +1 -1
  17. package/dist/api/verification.d.ts +6 -0
  18. package/dist/api/verification.d.ts.map +1 -0
  19. package/dist/api/verification.js +71 -0
  20. package/dist/api/verification.js.map +1 -0
  21. package/dist/cli.d.ts +3 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +9 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/index.js +8 -11
  27. package/dist/index.js.map +1 -1
  28. package/dist/paths.d.ts +0 -1
  29. package/dist/paths.d.ts.map +1 -1
  30. package/dist/paths.js +1 -1
  31. package/dist/paths.js.map +1 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +24 -0
  34. package/dist/server.js.map +1 -1
  35. package/dist/setup.d.ts.map +1 -1
  36. package/dist/setup.js +220 -21
  37. package/dist/setup.js.map +1 -1
  38. package/dist/tools/confirmation.d.ts +0 -4
  39. package/dist/tools/confirmation.d.ts.map +1 -1
  40. package/dist/tools/confirmation.js +1 -1
  41. package/dist/tools/confirmation.js.map +1 -1
  42. package/dist/tools/constants.d.ts +6 -9
  43. package/dist/tools/constants.d.ts.map +1 -1
  44. package/dist/tools/constants.js +48 -41
  45. package/dist/tools/constants.js.map +1 -1
  46. package/dist/tools/definitions.d.ts.map +1 -1
  47. package/dist/tools/definitions.js +35 -3
  48. package/dist/tools/definitions.js.map +1 -1
  49. package/dist/tools/flow-build.d.ts +18 -0
  50. package/dist/tools/flow-build.d.ts.map +1 -0
  51. package/dist/tools/flow-build.js +295 -0
  52. package/dist/tools/flow-build.js.map +1 -0
  53. package/dist/tools/flow-evidence.d.ts +62 -0
  54. package/dist/tools/flow-evidence.d.ts.map +1 -0
  55. package/dist/tools/flow-evidence.js +128 -0
  56. package/dist/tools/flow-evidence.js.map +1 -0
  57. package/dist/tools/flow-state-machine.d.ts +69 -0
  58. package/dist/tools/flow-state-machine.d.ts.map +1 -0
  59. package/dist/tools/flow-state-machine.js +292 -0
  60. package/dist/tools/flow-state-machine.js.map +1 -0
  61. package/dist/tools/handlers/auth.d.ts.map +1 -1
  62. package/dist/tools/handlers/auth.js +18 -148
  63. package/dist/tools/handlers/auth.js.map +1 -1
  64. package/dist/tools/handlers/component.d.ts.map +1 -1
  65. package/dist/tools/handlers/component.js +28 -8
  66. package/dist/tools/handlers/component.js.map +1 -1
  67. package/dist/tools/handlers/data-sample.d.ts.map +1 -1
  68. package/dist/tools/handlers/data-sample.js +37 -5
  69. package/dist/tools/handlers/data-sample.js.map +1 -1
  70. package/dist/tools/handlers/dynamic.d.ts.map +1 -1
  71. package/dist/tools/handlers/dynamic.js +43 -14
  72. package/dist/tools/handlers/dynamic.js.map +1 -1
  73. package/dist/tools/handlers/flow.d.ts.map +1 -1
  74. package/dist/tools/handlers/flow.js +147 -619
  75. package/dist/tools/handlers/flow.js.map +1 -1
  76. package/dist/tools/handlers/log.d.ts.map +1 -1
  77. package/dist/tools/handlers/log.js.map +1 -1
  78. package/dist/tools/handlers/raw.d.ts.map +1 -1
  79. package/dist/tools/handlers/raw.js +5 -2
  80. package/dist/tools/handlers/raw.js.map +1 -1
  81. package/dist/tools/handlers/search.d.ts.map +1 -1
  82. package/dist/tools/handlers/search.js +31 -0
  83. package/dist/tools/handlers/search.js.map +1 -1
  84. package/dist/tools/handlers/session.d.ts.map +1 -1
  85. package/dist/tools/handlers/session.js +8 -1
  86. package/dist/tools/handlers/session.js.map +1 -1
  87. package/dist/tools/handlers.d.ts.map +1 -1
  88. package/dist/tools/handlers.js +7 -2
  89. package/dist/tools/handlers.js.map +1 -1
  90. package/dist/tools/helpers.d.ts +0 -1
  91. package/dist/tools/helpers.d.ts.map +1 -1
  92. package/dist/tools/helpers.js +2 -2
  93. package/dist/tools/helpers.js.map +1 -1
  94. package/dist/tools/types.d.ts +8 -1
  95. package/dist/tools/types.d.ts.map +1 -1
  96. package/dist/tools/validation.d.ts +16 -5
  97. package/dist/tools/validation.d.ts.map +1 -1
  98. package/dist/tools/validation.js +220 -168
  99. package/dist/tools/validation.js.map +1 -1
  100. package/dist/vectorstore/qdrant.js +1 -1
  101. package/dist/vectorstore/qdrant.js.map +1 -1
  102. package/package.json +16 -4
  103. package/resources/docs/complex-flow-scenarios-2.md +120 -0
  104. package/resources/docs/flow-building-guide.md +18 -2
  105. package/resources/docs/flow-creation-stages.md +195 -0
  106. package/resources/docs/flow-state-machine-audit.md +162 -0
  107. package/resources/docs/rest-api-pagination.md +58 -0
  108. package/resources/docs/system-instructions.md +11 -1
@@ -1,8 +1,11 @@
1
+ import { verifyCredentials } from '../api/verification.js';
2
+ import { credentialKey, descriptorFor, evidence, nodeCredentialArgs, nodeMetadataKey, operation } from './flow-evidence.js';
1
3
  import { logger } from '../api/logger.js';
4
+ import { deserializeJsonApi } from '../api/jsonapi.js';
2
5
  import { resolveCredentialId } from './helpers.js';
3
- import { DEPRECATED_COMPONENTS, DEPRECATED_ACTIONS, BARE_FIELD_NAMES, UNSUPPORTED_ROUTER_JSONATA_FUNCTIONS, REST_API_READER_FIELDS, PLACEHOLDER_FIELD_VALUES, } from './constants.js';
4
- export { DEPRECATED_COMPONENTS, DEPRECATED_ACTIONS } from './constants.js';
5
- export function checkFlowLifecycle(flowId, discoveryState, existingFlow, operation) {
6
+ import { DEPRECATED_COMPONENTS, getDeprecatedActionReplacement, BARE_FIELD_NAMES, UNSUPPORTED_ROUTER_JSONATA_FUNCTIONS, REST_API_READER_FIELDS, PLACEHOLDER_FIELD_VALUES, } from './constants.js';
7
+ export { DEPRECATED_COMPONENTS, DEPRECATED_ACTIONS, DEPRECATED_COMPONENT_ACTIONS, getDeprecatedActionReplacement } from './constants.js';
8
+ export function checkFlowLifecycle(flowId, discoveryState, existingFlow, _operation) {
6
9
  const errors = [];
7
10
  if (!discoveryState.inspectedFlows.has(flowId)) {
8
11
  errors.push(`You have not inspected flow '${flowId}' using 'get_flow' in this session. ` +
@@ -29,7 +32,7 @@ export function checkFlowBuildingGuideRead(discoveryState) {
29
32
  }
30
33
  return [];
31
34
  }
32
- export function stripStringLiterals(str) {
35
+ function stripStringLiterals(str) {
33
36
  let result = '';
34
37
  let inDoubleQuote = false;
35
38
  let inSingleQuote = false;
@@ -123,6 +126,15 @@ function validateNodes(nodes, nodeMap, errors, warnings) {
123
126
  errors.push("Node definition is missing the 'id' field.");
124
127
  continue;
125
128
  }
129
+ const stepIdRegex = /^step_[1-9]\d*$/;
130
+ if (!stepIdRegex.test(node.id)) {
131
+ errors.push(`Node ID '${node.id}' has an invalid format. ` +
132
+ 'Every step_id must follow the format "step_N" where N is a positive integer starting from 1 ' +
133
+ '(e.g., "step_1", "step_2", "step_3"). ' +
134
+ 'Do not use formats like "step1", "Step_1", "step_0", "step_01", or custom names like "step_lk_from".');
135
+ }
136
+ if (nodeMap.has(node.id))
137
+ errors.push(`Duplicate node ID '${node.id}'.`);
126
138
  nodeMap.set(node.id, node);
127
139
  const command = node.command;
128
140
  if (!command) {
@@ -132,14 +144,14 @@ function validateNodes(nodes, nodeMap, errors, warnings) {
132
144
  const formatRegex = /^[^/]+\/[^:]+:[^@]+@[^@]+$/;
133
145
  if (!formatRegex.test(command)) {
134
146
  errors.push(`Node '${node.id}' has an invalid command format: "${command}". ` +
135
- `It must be formatted as "{team_name}/{repo_name}:{action_or_trigger_id}@{latestVersionId}" ` +
136
- `(e.g., "elasticio/salesforce-v2:getUpdatedObjectsPolling@latestVersionId"). ` +
137
- `Retrieve this from the 'get_component' tool response.`);
147
+ 'It must be formatted as "{team_name}/{repo_name}:{action_or_trigger_id}@{latestVersionId}" ' +
148
+ '(e.g., "elasticio/salesforce-v2:getUpdatedObjectsPolling@latestVersionId"). ' +
149
+ 'Retrieve this from the \'get_component\' tool response.');
138
150
  }
139
151
  // Check for deprecated components by command prefix
140
152
  const prefixMatch = command.match(/^([^:]+):/);
141
- if (prefixMatch) {
142
- const commandPrefix = prefixMatch[1];
153
+ const commandPrefix = prefixMatch ? prefixMatch[1] : '';
154
+ if (commandPrefix) {
143
155
  const successor = DEPRECATED_COMPONENTS[commandPrefix];
144
156
  if (successor) {
145
157
  errors.push(`Node '${node.id}' uses deprecated component '${commandPrefix}'. ` +
@@ -147,11 +159,11 @@ function validateNodes(nodes, nodeMap, errors, warnings) {
147
159
  `Use '${successor}' instead. Call 'list_components' to find the replacement.`);
148
160
  }
149
161
  }
150
- // Check for deprecated actions/triggers by action ID
162
+ // Check for deprecated actions/triggers by action ID scoped to component
151
163
  const actionMatch = command.match(/^[^:]+:([^@]+)@/);
152
- if (actionMatch) {
164
+ if (actionMatch && commandPrefix) {
153
165
  const actionId = actionMatch[1];
154
- const replacement = DEPRECATED_ACTIONS[actionId];
166
+ const replacement = getDeprecatedActionReplacement(commandPrefix, actionId);
155
167
  if (replacement) {
156
168
  errors.push(`Node '${node.id}' uses deprecated action '${actionId}'. ` +
157
169
  `This action is marked as deprecated in the component descriptor and shows a "Deprecated" badge in the UI. ` +
@@ -173,7 +185,7 @@ function validateNodes(nodes, nodeMap, errors, warnings) {
173
185
  `(e.g., "$getPassthrough().step_1.${trimmedField}").`);
174
186
  }
175
187
  // Warn about deep $getPassthrough() paths in JSONata fields (e.g. Splitter expression).
176
- const deepPathMatch = trimmedField.match(/^\$getPassthrough\(\)\.\w+\.body\.[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)+$/);
188
+ const deepPathMatch = /^\$getPassthrough\(\)\.\w+\.body\.[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)+$/.exec(trimmedField);
177
189
  if (deepPathMatch) {
178
190
  warnings.push(`Node '${node.id}' field '${fieldKey}' uses a deep JSONata path "${trimmedField}". ` +
179
191
  `The UI form validator only accepts paths up to "$getPassthrough().step_X.body" — deeper paths ` +
@@ -263,25 +275,50 @@ function validateEdges(edges, nodeMap, errors, warnings) {
263
275
  function validateMapper(mapper, edgeId, errors) {
264
276
  const mapperValues = [];
265
277
  if (typeof mapper === 'string') {
266
- errors.push(`Edge '${edgeId}' mapper is a string, but the UI requires an object-format mapper ` +
267
- `with per-field JSONata expressions (e.g., {"fieldName": "$getPassthrough().step_1.body.value"}). ` +
268
- `A string mapper breaks the UI visual mapper. Convert to object format.`);
269
- mapperValues.push({ key: '_root', value: mapper });
278
+ const trimmed = mapper.trim();
279
+ // Allow root JSONata expressions: object constructors ({...}), blocks/parentheses ((...)), or array constructors ([...])
280
+ if (trimmed.startsWith('{') || trimmed.startsWith('(') || trimmed.startsWith('[')) {
281
+ mapperValues.push({ key: '_root', value: mapper });
282
+ }
283
+ else {
284
+ const hasOuterQuotes = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
285
+ (trimmed.startsWith("'") && trimmed.endsWith("'"));
286
+ if (!hasOuterQuotes) {
287
+ errors.push(`Edge '${edgeId}' mapper is a string but does not start with '{', '(', or '['. ` +
288
+ `A root JSONata mapper expression must evaluate to an object or array ` +
289
+ `(e.g., "{\\n \\"fieldName\\": $getPassthrough().step_1.body.value\\n}").`);
290
+ }
291
+ mapperValues.push({ key: '_root', value: mapper });
292
+ }
270
293
  }
271
294
  else if (typeof mapper === 'object' && mapper !== null) {
272
295
  for (const [key, val] of Object.entries(mapper)) {
273
296
  if (Array.isArray(val)) {
274
- errors.push(`Edge '${edgeId}' mapper field '${key}' is a native JSON array, but array-typed mapper fields MUST be ` +
275
- `JSONata expression strings that evaluate to an array. The UI cannot render native JSON arrays and will show empty fields. ` +
276
- `Convert it to a JSONata string, e.g.: "[{\\"role\\": \\"system\\", ...}]" instead of [{role: ...}].`);
297
+ for (let i = 0; i < val.length; i++) {
298
+ const item = val[i];
299
+ if (typeof item === 'string') {
300
+ mapperValues.push({ key: `${key}[${i}]`, value: item });
301
+ }
302
+ else if (item && typeof item === 'object') {
303
+ validateMapper(item, `${edgeId}.${key}[${i}]`, errors);
304
+ }
305
+ else if (typeof item === 'number' || typeof item === 'boolean') {
306
+ errors.push(`Edge '${edgeId}' mapper field '${key}[${i}]' must be a JSONata expression string.`);
307
+ }
308
+ }
277
309
  }
278
310
  else if (typeof val === 'number' || typeof val === 'boolean') {
279
- mapper[key] = String(val);
280
- mapperValues.push({ key, value: String(val) });
311
+ errors.push(`Edge '${edgeId}' mapper field '${key}' must be a JSONata expression string.`);
281
312
  }
282
313
  else if (typeof val === 'string') {
283
314
  mapperValues.push({ key, value: val });
284
315
  }
316
+ else if (val && typeof val === 'object') {
317
+ validateMapper(val, `${edgeId}.${key}`, errors);
318
+ }
319
+ else {
320
+ errors.push(`Edge '${edgeId}' mapper field '${key}' must contain a JSONata expression.`);
321
+ }
285
322
  }
286
323
  }
287
324
  for (const { key, value } of mapperValues) {
@@ -325,6 +362,66 @@ function validateRouterCondition(condition, edgeId, errors) {
325
362
  }
326
363
  }
327
364
  }
365
+ /** Validate graph topology: exactly one root, DAG (no cycles), all nodes reachable. */
366
+ function validateTopology(nodes, edges, nodeMap, errors) {
367
+ if (nodes.length === 0)
368
+ return;
369
+ // Find root nodes (not a target of any edge)
370
+ const targets = new Set(edges.map(e => e.target));
371
+ const roots = nodes.filter(n => !targets.has(n.id));
372
+ if (roots.length === 0) {
373
+ errors.push('Flow graph has no root node (every node is a target of some edge — possible cycle).');
374
+ return;
375
+ }
376
+ // Exactly one node should have first:true
377
+ const firstNodes = nodes.filter(n => n.first);
378
+ if (firstNodes.length > 1) {
379
+ errors.push(`Multiple nodes are marked as 'first': ${firstNodes.map(n => n.id).join(', ')}. Only one trigger node should be first.`);
380
+ }
381
+ // Check for cycles using DFS
382
+ const adjacency = new Map();
383
+ for (const node of nodes)
384
+ adjacency.set(node.id, []);
385
+ for (const edge of edges)
386
+ adjacency.get(edge.source)?.push(edge.target);
387
+ const visited = new Set();
388
+ const inStack = new Set();
389
+ const hasCycle = (id) => {
390
+ if (inStack.has(id))
391
+ return true;
392
+ if (visited.has(id))
393
+ return false;
394
+ visited.add(id);
395
+ inStack.add(id);
396
+ for (const next of adjacency.get(id) || []) {
397
+ if (hasCycle(next))
398
+ return true;
399
+ }
400
+ inStack.delete(id);
401
+ return false;
402
+ };
403
+ for (const node of nodes) {
404
+ if (hasCycle(node.id)) {
405
+ errors.push('Flow graph contains a cycle, which is not allowed.');
406
+ return;
407
+ }
408
+ }
409
+ // Check all nodes are reachable from roots
410
+ const reachable = new Set();
411
+ const queue = roots.map(r => r.id);
412
+ while (queue.length > 0) {
413
+ const id = queue.shift();
414
+ if (reachable.has(id))
415
+ continue;
416
+ reachable.add(id);
417
+ for (const next of adjacency.get(id) || [])
418
+ queue.push(next);
419
+ }
420
+ const unreachable = nodes.filter(n => !reachable.has(n.id));
421
+ if (unreachable.length > 0) {
422
+ errors.push(`Nodes not reachable from any root: ${unreachable.map(n => n.id).join(', ')}.`);
423
+ }
424
+ }
328
425
  export function validateFlowGraph(body) {
329
426
  const errors = [];
330
427
  const warnings = [];
@@ -334,24 +431,26 @@ export function validateFlowGraph(body) {
334
431
  }
335
432
  const nodes = graph.nodes || [];
336
433
  const edges = graph.edges || [];
434
+ if (!Array.isArray(nodes) || !Array.isArray(edges))
435
+ return { errors: ['graph.nodes and graph.edges must be arrays.'], warnings };
337
436
  const nodeMap = new Map();
338
437
  validateNodes(nodes, nodeMap, errors, warnings);
339
438
  validateEdges(edges, nodeMap, errors, warnings);
439
+ validateTopology(nodes, edges, nodeMap, errors);
340
440
  return { errors, warnings };
341
441
  }
342
442
  export function checkDiscoveryState(body, discoveryState, existingFlow) {
343
443
  const errors = [];
444
+ const warnings = [];
344
445
  const graph = body?.data?.attributes?.graph || body?.attributes?.graph || body?.graph;
345
446
  if (!graph)
346
- return errors;
447
+ return { errors, warnings };
347
448
  const nodes = graph.nodes || [];
348
- const existingNodeIds = new Set();
349
449
  const existingComponentIds = new Set();
350
450
  const existingCredSecretIds = new Set();
351
451
  if (existingFlow) {
352
452
  const existingNodes = existingFlow?.graph?.nodes || [];
353
453
  existingNodes.forEach((n) => {
354
- existingNodeIds.add(n.id);
355
454
  if (n.component_id)
356
455
  existingComponentIds.add(n.component_id);
357
456
  const credId = resolveCredentialId(n.credentials_id);
@@ -379,7 +478,9 @@ export function checkDiscoveryState(body, discoveryState, existingFlow) {
379
478
  errors.push(`Node '${node.id}' has invalid secret_id '${rawSecId}' — must be a 24-character hex string. ` +
380
479
  `Call 'list_secrets' to discover real secret IDs.`);
381
480
  }
382
- const isExistingNode = existingNodeIds.has(node.id);
481
+ const oldNode = existingFlow?.graph?.nodes?.find((n) => n.id === node.id);
482
+ const isExistingNode = oldNode && oldNode.component_id === node.component_id && oldNode.command === node.command
483
+ && resolveCredentialId(oldNode.credentials_id) === rawCredId && resolveCredentialId(oldNode.secret_id) === rawSecId;
383
484
  // Discovery checks only apply to new nodes
384
485
  if (!isExistingNode) {
385
486
  if (node.component_id && !existingComponentIds.has(node.component_id) && !discoveryState.components.has(node.component_id)) {
@@ -422,92 +523,59 @@ export function checkDiscoveryState(body, discoveryState, existingFlow) {
422
523
  `You MUST first find or create credentials/secrets using 'list_credentials'/'list_secrets' (or 'create_credentials'/'create_secret'), ` +
423
524
  `then assign them to the node before creating or updating the flow.`);
424
525
  }
425
- }
426
- return errors;
427
- }
428
- /**
429
- * Verify a credential/secret by calling the verify-credential endpoint and polling for the result.
430
- * Returns { ok: true } on success or { error: string } on failure.
431
- */
432
- async function verifyCredentialForHealthCheck(client, componentId, credOrSecretId, isSecret, workspaceId) {
433
- let verifyFields = {};
434
- if (isSecret && workspaceId) {
435
- const secretRes = await client.get(`/workspaces/${workspaceId}/secrets/${credOrSecretId}`);
436
- const rawSecretSubtype = secretRes.data?.data?.attributes?.type;
437
- const secretData = secretRes.data?.data?.attributes || {};
438
- const existingFields = secretData.credentials || {};
439
- const secretSubtype = rawSecretSubtype || secretData.type || 'noauth';
440
- if (secretSubtype === 'oauth2' && existingFields.undefined_params !== undefined) {
441
- const { undefined_params: existingParams = {}, ...existingOAuth } = existingFields;
442
- verifyFields = { oauth: { ...existingOAuth, undefined_params: existingParams } };
443
- }
444
- else {
445
- verifyFields = { auth: { type: secretSubtype, ...existingFields } };
526
+ // Warn when a Webhook step has no credentials — auth is optional but should always be proposed
527
+ if (node.command && typeof node.command === 'string' && /webhook.*:receive/.test(node.command)
528
+ && !resolveCredentialId(node.credentials_id) && !resolveCredentialId(node.secret_id)) {
529
+ warnings.push(`Node '${node.id}' is a Webhook trigger with no credentials attached. ` +
530
+ `Webhook authentication is optional but strongly recommended. Consider attaching credentials ` +
531
+ `to enable HMAC signature verification, API key validation, or basic auth. ` +
532
+ `Call 'list_credentials' for the Webhook component and ask the user which credential to attach.`);
446
533
  }
447
534
  }
448
- else {
449
- const credRes = await client.get(`/credentials/${credOrSecretId}`);
450
- const credData = credRes.data?.data?.attributes || credRes.data?.data || {};
451
- verifyFields = credData.keys || {};
452
- }
453
- const requestBody = {
454
- data: {
455
- type: 'verify-credential',
456
- attributes: { fields: verifyFields },
457
- relationships: {
458
- workspace: { data: { id: workspaceId, type: 'workspace' } }
459
- }
460
- }
461
- };
462
- const verifyRes = await client.post(`/components/${componentId}/versions/latest/verify-credential`, requestBody);
463
- const execId = verifyRes.data?.data?.id;
464
- if (!execId)
465
- return { ok: true }; // Can't poll, assume OK
466
- // Poll for result (max 3 attempts, 3s delay)
467
- const maxAttempts = 3;
468
- const delayMs = 3000;
469
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
470
- if (attempt > 1) {
471
- await new Promise(resolve => setTimeout(resolve, delayMs));
472
- }
473
- try {
474
- const resultRes = await client.get(`/exec/result/${execId}`);
475
- const resultData = resultRes.data?.data?.attributes || resultRes.data?.data || {};
476
- const isPending = typeof resultData.status === 'string' && resultData.status.toLowerCase().includes('pending');
477
- if (isPending)
535
+ return { errors, warnings };
536
+ }
537
+ export function checkMapperMetadataRetrieved(body, discoveryState, workspaceId = '') {
538
+ const graph = body?.data?.attributes?.graph || body?.attributes?.graph || body?.graph;
539
+ if (!graph)
540
+ return [];
541
+ const errors = [];
542
+ for (const edge of graph.edges || []) {
543
+ if (!edge.config?.mapper)
544
+ continue;
545
+ for (const id of [edge.source, edge.target]) {
546
+ const node = graph.nodes?.find((n) => n.id === id);
547
+ if (!node)
478
548
  continue;
479
- const result = resultData.result;
480
- if (result?.verified === false || result?.error || result?.status === 'error') {
481
- const reason = result.error || result.message || result.reason || JSON.stringify(result);
482
- return { error: `Reason: ${reason}` };
549
+ const op = operation(node);
550
+ const descriptor = descriptorFor(discoveryState, node);
551
+ const definition = descriptor?.actions?.[op.method] || descriptor?.triggers?.[op.method];
552
+ if (!definition)
553
+ errors.push(`Call get_component for ${op.componentId} at version ${op.version} before mapping step ${id}.`);
554
+ else if (definition.dynamicMetadata && evidence(discoveryState).metadata.get(nodeMetadataKey(workspaceId, node))?.status !== 'VERIFIED') {
555
+ errors.push(`Step ${id}: retrieve successful dynamic metadata for this exact version, connection and fields before mapping.`);
483
556
  }
484
- return { ok: true };
485
- }
486
- catch (err) {
487
- const isNotReady = err.message && (err.message.includes('409') || err.message.includes('not ready'));
488
- if (isNotReady)
489
- continue;
490
- throw err;
491
557
  }
492
558
  }
493
- // Timed out polling — don't block, just warn
494
- return { ok: true };
559
+ return [...new Set(errors)];
495
560
  }
496
561
  /**
497
562
  * Runtime health check for a flow — validates credentials, required fields, URLs, and field values.
498
563
  * Runs after create_flow/update_flow and before start_flow to catch config issues
499
564
  * that would show as red indicators in the UI.
500
565
  */
501
- export async function healthCheckFlow(flow, client) {
566
+ export async function healthCheckFlow(flow, client, discoveryState) {
502
567
  const errors = [];
503
568
  const warnings = [];
569
+ const stepStatuses = [];
504
570
  const nodes = flow?.graph?.nodes || [];
505
571
  if (nodes.length === 0) {
506
572
  errors.push('Flow has no steps configured');
507
- return { errors, warnings };
573
+ return { errors, warnings, stepStatuses };
508
574
  }
509
575
  for (const node of nodes) {
510
576
  const stepLabel = `Step '${node.id}' (${node.name || 'unnamed'})`;
577
+ const errCountBefore = errors.length;
578
+ const warnCountBefore = warnings.length;
511
579
  // 1. Verify credentials exist and are accessible
512
580
  const healthCredId = resolveCredentialId(node.credentials_id);
513
581
  if (healthCredId) {
@@ -542,8 +610,11 @@ export async function healthCheckFlow(flow, client) {
542
610
  const credOrSecretId = healthCredId || healthSecId;
543
611
  if (credOrSecretId) {
544
612
  try {
545
- const verifyResult = await verifyCredentialForHealthCheck(client, node.component_id, credOrSecretId, !!healthSecId, flow?.workspace?.id || flow?.workspace?.data?.id);
546
- if (verifyResult.error) {
613
+ const args = nodeCredentialArgs(flow?.workspace?.id || flow?.workspace?.data?.id, node);
614
+ const verifyResult = await verifyCredentials(client, args);
615
+ if (discoveryState)
616
+ evidence(discoveryState).credentials.set(credentialKey(args), verifyResult);
617
+ if (!verifyResult.ok) {
547
618
  errors.push(`${stepLabel}: credential verification FAILED for '${credOrSecretId}'. ` +
548
619
  `${verifyResult.error} ` +
549
620
  `The credential may be expired, revoked, or the user no longer has access. ` +
@@ -551,17 +622,31 @@ export async function healthCheckFlow(flow, client) {
551
622
  }
552
623
  }
553
624
  catch (err) {
554
- logger.warn(`[healthCheck] Credential verification skipped for '${credOrSecretId}': ${err instanceof Error ? err.message : String(err)}`);
625
+ errors.push(`${stepLabel}: credential verification could not complete: ${err instanceof Error ? err.message : String(err)}`);
555
626
  }
556
627
  }
557
628
  }
558
629
  // 2. Check component and required fields
559
630
  if (node.component_id) {
560
631
  try {
561
- const compRes = await client.get(`/components/${node.component_id}`);
562
- const comp = compRes.data?.data?.attributes || compRes.data?.data;
563
- if (comp) {
632
+ // Use the descriptor endpoint to get actions/triggers definitions
633
+ const compRes = await client.get(`/components/${operation(node).componentId}/versions/${operation(node).version}/descriptor`);
634
+ const comp = deserializeJsonApi(compRes.data);
635
+ if (comp && discoveryState)
636
+ evidence(discoveryState).descriptors.set(`${operation(node).componentId}@${operation(node).version}`, comp);
637
+ if (!comp) {
638
+ errors.push(`${stepLabel}: component '${node.component_id}' returned empty data — it may be unavailable`);
639
+ }
640
+ else {
564
641
  const actionOrTrigger = findActionOrTrigger(comp, node.command);
642
+ if (node.command && !actionOrTrigger) {
643
+ const commandMatch = node.command.match(/^[^:]+:([^@]+)@/);
644
+ const methodId = commandMatch ? commandMatch[1] : node.command;
645
+ const availableActions = Object.keys(comp.actions || {});
646
+ const availableTriggers = Object.keys(comp.triggers || {});
647
+ errors.push(`${stepLabel}: action/trigger '${methodId}' not found in component '${comp.name || node.component_id}'. ` +
648
+ `Available actions: [${availableActions.join(', ')}], triggers: [${availableTriggers.join(', ')}]`);
649
+ }
565
650
  if (actionOrTrigger?.fields) {
566
651
  for (const [fieldId, fieldDef] of Object.entries(actionOrTrigger.fields)) {
567
652
  // Check required fields
@@ -587,6 +672,13 @@ export async function healthCheckFlow(flow, client) {
587
672
  warnings.push(`${stepLabel}: component '${compName || compCommandPrefix}' is DEPRECATED. ` +
588
673
  `Use '${successor}' instead.`);
589
674
  }
675
+ if (actionOrTrigger?.deprecated) {
676
+ const commandMatch = node.command?.match(/^[^:]+:([^@]+)@/);
677
+ const methodId = commandMatch ? commandMatch[1] : (node.command || '');
678
+ const replacement = getDeprecatedActionReplacement(compCommandPrefix || compName, methodId);
679
+ const hint = replacement ? ` Use '${replacement}' instead.` : '';
680
+ warnings.push(`${stepLabel}: action/trigger '${methodId}' is marked as DEPRECATED in component '${comp.name || node.component_id}'.${hint}`);
681
+ }
590
682
  // Check if component requires auth but none is set
591
683
  const credFields = comp.credentials?.fields;
592
684
  const hasCredFields = credFields && Object.keys(credFields).length > 0;
@@ -596,6 +688,7 @@ export async function healthCheckFlow(flow, client) {
596
688
  }
597
689
  }
598
690
  catch (err) {
691
+ errors.push(`${stepLabel}: component '${node.component_id}' could not be fetched — it may have been deleted or is not accessible`);
599
692
  logger.warn(`[healthCheck] Could not fetch component '${node.component_id}': ${err instanceof Error ? err.message : String(err)}`);
600
693
  }
601
694
  }
@@ -631,6 +724,18 @@ export async function healthCheckFlow(flow, client) {
631
724
  }
632
725
  }
633
726
  }
727
+ // Collect per-step status
728
+ const stepErrors = errors.slice(errCountBefore);
729
+ const stepWarnings = warnings.slice(warnCountBefore);
730
+ const commandMatch = node.command?.match(/^([^:]+):([^@]+)@/);
731
+ const componentName = commandMatch ? commandMatch[1] : (node.component_id || 'unknown');
732
+ stepStatuses.push({
733
+ stepId: node.id,
734
+ name: node.name || 'unnamed',
735
+ component: componentName,
736
+ status: stepErrors.length > 0 ? 'ERROR' : stepWarnings.length > 0 ? 'WARNING' : 'OK',
737
+ issues: [...stepErrors, ...stepWarnings],
738
+ });
634
739
  }
635
740
  // 5. Check edges with missing mappers for non-first target steps.
636
741
  // Missing mappers cause empty/red fields in the UI.
@@ -646,77 +751,13 @@ export async function healthCheckFlow(flow, client) {
646
751
  }
647
752
  }
648
753
  }
649
- // 6. Validate mapper fields against component input schema types.
650
- // If a field is defined as type:"array" in the schema but the mapper value is a plain
651
- // string passthrough (not a JSONata array expression), warn that it needs to be an array expression.
652
- for (const edge of edges) {
653
- const edgeId = edge.id || `${edge.source}:${edge.target}`;
654
- const mapper = edge.config?.mapper;
655
- if (!mapper || typeof mapper !== 'object' || Array.isArray(mapper))
656
- continue;
657
- const targetNode = nodes.find((n) => n.id === edge.target);
658
- if (!targetNode?.component_id)
659
- continue;
660
- try {
661
- const compRes = await client.get(`/components/${targetNode.component_id}`);
662
- const comp = compRes.data?.data?.attributes || compRes.data?.data;
663
- if (!comp)
664
- continue;
665
- const actionOrTrigger = findActionOrTrigger(comp, targetNode.command);
666
- const inputSchema = actionOrTrigger?.metadata?.in?.properties;
667
- // For components with dynamicMetadata and no static input schema,
668
- // we can't verify field types statically. Warn if any mapper field name
669
- // contains common array-indicating patterns (e.g. "urls", "items", "files", "messages")
670
- // and the value is not a JSONata array expression.
671
- if (!inputSchema && actionOrTrigger?.dynamicMetadata === true) {
672
- const arrayFieldPatterns = /^(file_urls|urls|items|files|messages|recipients|attachments|records|entries|rows|contacts|emails)$/i;
673
- for (const [fieldKey, fieldValue] of Object.entries(mapper)) {
674
- if (typeof fieldValue === 'string' && arrayFieldPatterns.test(fieldKey)) {
675
- const trimmed = fieldValue.trim();
676
- if (!trimmed.startsWith('[')) {
677
- warnings.push(`Edge '${edgeId}' mapper field '${fieldKey}' looks like an array-typed field (dynamicMetadata component) ` +
678
- `but is mapped as a plain string "${trimmed}". The UI visual mapper cannot render array fields mapped ` +
679
- `as plain passthroughs — it shows "impossible to map properties not inside the array property". ` +
680
- `Use a JSONata array expression starting with "[" to construct the array inline, e.g.: ` +
681
- `"[{\\"file_url\\": $getPassthrough().step_X.body.file_url}]". ` +
682
- `The mapping works at runtime but causes red indicators in the UI.`);
683
- }
684
- }
685
- }
686
- continue;
687
- }
688
- if (!inputSchema)
689
- continue;
690
- for (const [fieldKey, fieldValue] of Object.entries(mapper)) {
691
- const schemaDef = inputSchema[fieldKey];
692
- // Check array-typed fields mapped as plain strings
693
- if (schemaDef?.type === 'array' && typeof fieldValue === 'string') {
694
- const trimmed = fieldValue.trim();
695
- // A valid array mapper should start with "[" (JSONata array expression)
696
- if (!trimmed.startsWith('[')) {
697
- const itemProps = schemaDef.items?.properties || schemaDef.items?.type;
698
- const hint = typeof itemProps === 'object'
699
- ? `Expected format: [{"${Object.keys(itemProps)[0]}": ...}]`
700
- : `Expected a JSONata expression that evaluates to an array`;
701
- errors.push(`Edge '${edgeId}' mapper field '${fieldKey}' is mapped as a plain string, but the component schema ` +
702
- `defines it as type "array". A plain string passthrough will cause a type mismatch (red field in the UI). ` +
703
- `${hint}. Wrap the value in a JSONata array expression, e.g.: ` +
704
- `"[{\\"field\\": $getPassthrough().step_X.body.value}]".`);
705
- }
706
- }
707
- }
708
- }
709
- catch {
710
- // Component fetch may have already been done above; skip on error
711
- }
712
- }
713
- return { errors, warnings };
754
+ return { errors, warnings, stepStatuses };
714
755
  }
715
756
  /** Find action or trigger definition by parsing the command string */
716
757
  function findActionOrTrigger(component, command) {
717
758
  if (!command)
718
759
  return null;
719
- const match = command.match(/^[^:]+:([^@]+)@/);
760
+ const match = /^[^:]+:([^@]+)@/.exec(command);
720
761
  if (!match)
721
762
  return null;
722
763
  const methodId = match[1];
@@ -724,10 +765,18 @@ function findActionOrTrigger(component, command) {
724
765
  }
725
766
  /** Format health check results as a readable string to append to tool response */
726
767
  export function formatHealthCheck(result) {
727
- if (result.errors.length === 0 && result.warnings.length === 0) {
728
- return '\n\nHealth check passed — no issues found.';
729
- }
730
768
  const parts = ['\n\n--- Flow Health Check ---'];
769
+ // Per-step status summary
770
+ if (result.stepStatuses && result.stepStatuses.length > 0) {
771
+ parts.push('\nStep Validation:');
772
+ for (const step of result.stepStatuses) {
773
+ const icon = step.status === 'OK' ? '[OK]' : step.status === 'WARNING' ? '[WARN]' : '[FAIL]';
774
+ parts.push(` ${icon} ${step.stepId} (${step.name}) — ${step.component}`);
775
+ for (const issue of step.issues) {
776
+ parts.push(` ${issue}`);
777
+ }
778
+ }
779
+ }
731
780
  if (result.errors.length > 0) {
732
781
  parts.push(`\nERRORS (${result.errors.length}):`);
733
782
  for (const err of result.errors) {
@@ -740,7 +789,10 @@ export function formatHealthCheck(result) {
740
789
  parts.push(` - ${warn}`);
741
790
  }
742
791
  }
743
- if (result.errors.length > 0) {
792
+ if (result.errors.length === 0 && result.warnings.length === 0) {
793
+ parts.push('\nAll steps validated successfully.');
794
+ }
795
+ else if (result.errors.length > 0) {
744
796
  parts.push('\nFix the errors above before starting the flow.');
745
797
  }
746
798
  return parts.join('\n');