@ema.co/mcp-toolkit 2026.1.26-4 → 2026.1.27

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.

Potentially problematic release.


This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.

@@ -1,386 +1,110 @@
1
1
  /**
2
2
  * Quality Gates - Pre-Deploy Validation
3
3
  *
4
- * Ensures workflows meet minimum quality standards before deployment.
5
- * These are the "guardrails" that prevent broken workflows from going live.
4
+ * DEPRECATED: This module used detectWorkflowIssues which has been removed.
5
+ *
6
+ * The LLM should analyze workflows using rules from:
7
+ * - ema://rules/anti-patterns
8
+ * - ema://rules/input-sources
9
+ * - ema://rules/optimizations
10
+ *
11
+ * Backend validation happens when workflow is deployed.
12
+ * These quality gates are kept for backwards compatibility but are minimal.
6
13
  */
7
- import { detectWorkflowIssues } from "./knowledge.js";
8
14
  // ─────────────────────────────────────────────────────────────────────────────
9
- // Quality Gate Definitions
15
+ // Minimal Quality Gates (structural only)
10
16
  // ─────────────────────────────────────────────────────────────────────────────
11
- const QUALITY_GATES = [
12
- // ═══════════════════════════════════════════════════════════════════════════
13
- // CRITICAL GATES (Blocking)
14
- // ═══════════════════════════════════════════════════════════════════════════
15
- {
16
- id: "has_trigger",
17
- name: "Has Entry Point",
18
- description: "Workflow must have a trigger node as entry point",
19
- blocking: true,
20
- severity: "critical",
21
- check: (workflow) => {
22
- const actions = (workflow.actions || []);
23
- const hasTrigger = actions.some(a => {
24
- const actionName = a.action?.name;
25
- const name = (actionName?.name || "").toLowerCase();
26
- return name.includes("trigger");
27
- });
28
- return {
29
- passed: hasTrigger,
30
- message: hasTrigger
31
- ? "Workflow has a valid trigger"
32
- : "Missing trigger node - workflow has no entry point",
33
- };
34
- },
35
- },
36
- {
37
- id: "has_output",
38
- name: "Has Workflow Output",
39
- description: "Workflow must have at least one WORKFLOW_OUTPUT defined",
40
- blocking: true,
41
- severity: "critical",
42
- check: (workflow) => {
43
- const results = workflow.results;
44
- const hasOutput = results && Object.keys(results).length > 0;
45
- return {
46
- passed: !!hasOutput,
47
- message: hasOutput
48
- ? `Workflow has ${Object.keys(results).length} output(s) defined`
49
- : "No WORKFLOW_OUTPUT defined - responses won't reach users",
50
- };
51
- },
52
- },
53
- {
54
- id: "no_critical_issues",
55
- name: "No Critical Issues",
56
- description: "Workflow must not have any critical structural issues",
57
- blocking: true,
58
- severity: "critical",
59
- check: (workflow) => {
60
- const issues = detectWorkflowIssues(workflow);
61
- const criticalIssues = issues.filter((i) => i.severity === "critical");
62
- return {
63
- passed: criticalIssues.length === 0,
64
- message: criticalIssues.length === 0
65
- ? "No critical issues detected"
66
- : `${criticalIssues.length} critical issue(s) found`,
67
- details: criticalIssues.map((i) => `${i.type}: ${i.reason}`),
68
- };
69
- },
70
- },
17
+ export const QUALITY_GATES = [
71
18
  {
72
- id: "all_paths_reach_output",
73
- name: "All Paths Reach Output",
74
- description: "Every branch from trigger must eventually reach WORKFLOW_OUTPUT",
19
+ id: "has_actions",
20
+ name: "Has Actions",
21
+ description: "Workflow must have at least one action",
75
22
  blocking: true,
76
23
  severity: "critical",
77
24
  check: (workflow) => {
78
25
  const actions = (workflow.actions || []);
79
- const results = workflow.results;
80
- if (!results || Object.keys(results).length === 0) {
81
- return { passed: false, message: "No outputs to check" };
82
- }
83
- // Get all output nodes
84
- const outputNodes = new Set(Object.values(results).map((r) => r.actionName));
85
- // Build reverse adjacency (who feeds into whom)
86
- const feedsInto = new Map();
87
- for (const action of actions) {
88
- const name = action.name;
89
- feedsInto.set(name, new Set());
90
- const inputs = action.inputs;
91
- if (inputs) {
92
- for (const binding of Object.values(inputs)) {
93
- const bindingObj = binding;
94
- const actionOutput = bindingObj?.actionOutput;
95
- if (actionOutput?.actionName) {
96
- const source = actionOutput.actionName;
97
- if (!feedsInto.has(source)) {
98
- feedsInto.set(source, new Set());
99
- }
100
- feedsInto.get(source).add(name);
101
- }
102
- }
103
- }
104
- }
105
- // Check if each output node is reachable from trigger
106
- const reachableFromTrigger = new Set();
107
- const queue = ["trigger"];
108
- while (queue.length > 0) {
109
- const current = queue.shift();
110
- if (reachableFromTrigger.has(current))
111
- continue;
112
- reachableFromTrigger.add(current);
113
- const children = feedsInto.get(current) || new Set();
114
- for (const child of children) {
115
- if (!reachableFromTrigger.has(child)) {
116
- queue.push(child);
117
- }
118
- }
119
- }
120
- const unreachableOutputs = Array.from(outputNodes).filter(n => !reachableFromTrigger.has(n));
121
- return {
122
- passed: unreachableOutputs.length === 0,
123
- message: unreachableOutputs.length === 0
124
- ? "All output nodes are reachable from trigger"
125
- : `${unreachableOutputs.length} output node(s) unreachable from trigger`,
126
- details: unreachableOutputs.length > 0
127
- ? [`Unreachable: ${unreachableOutputs.join(", ")}`]
128
- : undefined,
129
- };
130
- },
131
- },
132
- // ═══════════════════════════════════════════════════════════════════════════
133
- // WARNING GATES (Non-blocking but important)
134
- // ═══════════════════════════════════════════════════════════════════════════
135
- {
136
- id: "hitl_for_side_effects",
137
- name: "HITL for Side Effects",
138
- description: "Actions with external side effects should have human approval",
139
- blocking: false,
140
- severity: "warning",
141
- check: (workflow) => {
142
- const actions = (workflow.actions || []);
143
- const sideEffectNodes = actions.filter(a => {
144
- const actionName = a.action?.name;
145
- const name = (actionName?.name || "").toLowerCase();
146
- return name.includes("email") ||
147
- name.includes("external") ||
148
- name.includes("create_") ||
149
- name.includes("update_") ||
150
- name.includes("delete_");
151
- });
152
- const hitlNodes = new Set(actions
153
- .filter(a => {
154
- const actionName = a.action?.name;
155
- const name = (actionName?.name || "").toLowerCase();
156
- return name.includes("hitl");
157
- })
158
- .map(a => a.name));
159
- // Check if side effect nodes have HITL upstream
160
- const unprotected = sideEffectNodes.filter(a => {
161
- const inputs = a.inputs;
162
- if (!inputs)
163
- return true;
164
- // Check if any input comes from a HITL node
165
- for (const binding of Object.values(inputs)) {
166
- const bindingObj = binding;
167
- const actionOutput = bindingObj?.actionOutput;
168
- if (actionOutput?.actionName && hitlNodes.has(actionOutput.actionName)) {
169
- return false;
170
- }
171
- }
172
- return true;
173
- });
174
- return {
175
- passed: unprotected.length === 0,
176
- message: unprotected.length === 0
177
- ? "All side-effect actions have HITL protection"
178
- : `${unprotected.length} side-effect action(s) without HITL approval`,
179
- details: unprotected.map(a => `${a.name}: Consider adding human approval before this action`),
180
- };
181
- },
182
- },
183
- {
184
- id: "categorizer_has_fallback",
185
- name: "Categorizer Has Fallback",
186
- description: "Intent categorizers should have a Fallback category",
187
- blocking: false,
188
- severity: "warning",
189
- check: (workflow) => {
190
- const enumTypes = (workflow.enumTypes || []);
191
- const missingFallback = enumTypes.filter(e => {
192
- if (!e.values)
193
- return false;
194
- return !e.values.some(v => v.name.toLowerCase() === "fallback");
195
- });
196
26
  return {
197
- passed: missingFallback.length === 0,
198
- message: missingFallback.length === 0
199
- ? "All categorizers have Fallback category"
200
- : `${missingFallback.length} categorizer(s) missing Fallback`,
201
- details: missingFallback.map(e => `${e.name}: Add Fallback category for unmatched intents`),
27
+ passed: actions.length > 0,
28
+ message: actions.length > 0
29
+ ? `Workflow has ${actions.length} action(s)`
30
+ : "Workflow has no actions",
202
31
  };
203
32
  },
204
33
  },
205
34
  {
206
- id: "no_orphan_nodes",
207
- name: "No Orphan Nodes",
208
- description: "All nodes should be connected to the workflow",
35
+ id: "has_results",
36
+ name: "Has Results Mapping",
37
+ description: "Voice AI workflows must have results mapping",
209
38
  blocking: false,
210
39
  severity: "warning",
211
40
  check: (workflow) => {
212
- const issues = detectWorkflowIssues(workflow);
213
- const orphans = issues.filter((i) => i.type === "orphan");
214
- return {
215
- passed: orphans.length === 0,
216
- message: orphans.length === 0
217
- ? "No orphan nodes detected"
218
- : `${orphans.length} orphan node(s) found`,
219
- details: orphans.map((i) => `${i.node}: ${i.reason}`),
220
- };
221
- },
222
- },
223
- // ═══════════════════════════════════════════════════════════════════════════
224
- // INFO GATES (Best practices)
225
- // ═══════════════════════════════════════════════════════════════════════════
226
- {
227
- id: "reasonable_complexity",
228
- name: "Reasonable Complexity",
229
- description: "Workflow should not be overly complex",
230
- blocking: false,
231
- severity: "info",
232
- check: (workflow) => {
233
- const actions = (workflow.actions || []);
234
- const nodeCount = actions.length;
235
- // Calculate branching factor
236
- let branches = 0;
237
- for (const action of actions) {
238
- const runIf = action.runIf;
239
- if (runIf)
240
- branches++;
241
- }
242
- const isComplex = nodeCount > 20 || branches > 10;
41
+ const results = workflow.results;
42
+ const hasResults = results && Object.keys(results).length > 0;
243
43
  return {
244
- passed: !isComplex,
245
- message: isComplex
246
- ? `High complexity: ${nodeCount} nodes, ${branches} conditional branches`
247
- : `Reasonable complexity: ${nodeCount} nodes, ${branches} branches`,
248
- details: isComplex
249
- ? ["Consider breaking into smaller sub-workflows for maintainability"]
250
- : undefined,
44
+ passed: hasResults || false,
45
+ message: hasResults
46
+ ? `Workflow has ${Object.keys(results).length} result mapping(s)`
47
+ : "No results mapping found (may be OK for chat workflows)",
251
48
  };
252
49
  },
253
50
  },
254
51
  ];
255
52
  // ─────────────────────────────────────────────────────────────────────────────
256
- // Main Entry Point
53
+ // Quality Gate Runner
257
54
  // ─────────────────────────────────────────────────────────────────────────────
258
- /**
259
- * Run all quality gates on a workflow
260
- */
261
- export function runQualityGates(workflow) {
262
- const blockingFailures = [];
263
- const warnings = [];
264
- const passed = [];
265
- for (const gate of QUALITY_GATES) {
55
+ export function runQualityGates(workflow, gates = QUALITY_GATES) {
56
+ const results = [];
57
+ let blockingFailures = 0;
58
+ let warnings = 0;
59
+ for (const gate of gates) {
266
60
  const result = gate.check(workflow);
267
- const report = {
61
+ results.push({
268
62
  gate_id: gate.id,
269
63
  gate_name: gate.name,
270
64
  result,
65
+ blocking: gate.blocking,
271
66
  severity: gate.severity,
272
- };
67
+ });
273
68
  if (!result.passed) {
274
69
  if (gate.blocking) {
275
- blockingFailures.push(report);
70
+ blockingFailures++;
276
71
  }
277
- else {
278
- warnings.push(report);
72
+ else if (gate.severity === "warning") {
73
+ warnings++;
279
74
  }
280
75
  }
281
- else {
282
- passed.push(report);
283
- }
284
76
  }
285
- const overallStatus = blockingFailures.length > 0
286
- ? "fail"
287
- : warnings.length > 0
288
- ? "warning"
289
- : "pass";
290
- const summary = generateSummary(blockingFailures, warnings, passed);
77
+ const deploymentAllowed = blockingFailures === 0;
291
78
  return {
292
- overall_status: overallStatus,
79
+ overall_status: blockingFailures > 0 ? "fail" : warnings > 0 ? "warning" : "pass",
80
+ gates: results,
293
81
  blocking_failures: blockingFailures,
294
82
  warnings,
295
- passed,
296
- summary,
297
- deploy_allowed: blockingFailures.length === 0,
83
+ deployment_allowed: deploymentAllowed,
298
84
  };
299
85
  }
300
- /**
301
- * Quick check if a workflow passes all blocking gates
302
- */
303
- export function canDeploy(workflow) {
304
- for (const gate of QUALITY_GATES) {
305
- if (gate.blocking) {
306
- const result = gate.check(workflow);
307
- if (!result.passed) {
308
- return false;
309
- }
310
- }
311
- }
312
- return true;
313
- }
314
- /**
315
- * Get list of all quality gates
316
- */
317
- export function getQualityGates() {
318
- return [...QUALITY_GATES];
319
- }
320
86
  // ─────────────────────────────────────────────────────────────────────────────
321
- // Helpers
87
+ // Utility Functions
322
88
  // ─────────────────────────────────────────────────────────────────────────────
323
- function generateSummary(failures, warnings, passed) {
324
- const parts = [];
325
- if (failures.length > 0) {
326
- parts.push(`🔴 ${failures.length} BLOCKING failure(s): ${failures.map(f => f.gate_name).join(", ")}`);
327
- }
328
- if (warnings.length > 0) {
329
- parts.push(`🟡 ${warnings.length} warning(s): ${warnings.map(w => w.gate_name).join(", ")}`);
330
- }
331
- if (passed.length > 0) {
332
- parts.push(`🟢 ${passed.length} check(s) passed`);
333
- }
334
- if (failures.length === 0) {
335
- parts.push("✅ Workflow is ready for deployment");
336
- }
337
- else {
338
- parts.push("❌ Deployment blocked - fix critical issues first");
339
- }
340
- return parts.join("\n");
89
+ export function isDeploymentAllowed(report) {
90
+ return report.deployment_allowed;
91
+ }
92
+ export function getBlockingIssues(report) {
93
+ return report.gates
94
+ .filter(g => g.blocking && !g.result.passed)
95
+ .map(g => `${g.gate_name}: ${g.result.message}`);
341
96
  }
342
- /**
343
- * Get a human-readable quality report
344
- */
345
97
  export function formatQualityReport(report) {
346
- const lines = [];
347
- lines.push("## Quality Gate Report");
348
- lines.push("");
349
- lines.push(`**Status**: ${report.overall_status.toUpperCase()}`);
350
- lines.push(`**Deploy Allowed**: ${report.deploy_allowed ? "Yes ✅" : "No ❌"}`);
351
- lines.push("");
352
- if (report.blocking_failures.length > 0) {
353
- lines.push("### 🔴 Blocking Failures");
354
- for (const failure of report.blocking_failures) {
355
- lines.push(`- **${failure.gate_name}**: ${failure.result.message}`);
356
- if (failure.result.details) {
357
- for (const detail of failure.result.details) {
358
- lines.push(` - ${detail}`);
359
- }
98
+ let output = `Quality Report: ${report.overall_status.toUpperCase()}\n`;
99
+ output += `Deployment Allowed: ${report.deployment_allowed ? "YES" : "NO"}\n\n`;
100
+ for (const gate of report.gates) {
101
+ const status = gate.result.passed ? "✓" : gate.blocking ? "✗" : "⚠";
102
+ output += `${status} ${gate.gate_name}: ${gate.result.message}\n`;
103
+ if (gate.result.details) {
104
+ for (const detail of gate.result.details) {
105
+ output += ` - ${detail}\n`;
360
106
  }
361
107
  }
362
- lines.push("");
363
- }
364
- if (report.warnings.length > 0) {
365
- lines.push("### 🟡 Warnings");
366
- for (const warning of report.warnings) {
367
- lines.push(`- **${warning.gate_name}**: ${warning.result.message}`);
368
- if (warning.result.details) {
369
- for (const detail of warning.result.details) {
370
- lines.push(` - ${detail}`);
371
- }
372
- }
373
- }
374
- lines.push("");
375
- }
376
- if (report.passed.length > 0) {
377
- lines.push("### 🟢 Passed");
378
- for (const pass of report.passed) {
379
- lines.push(`- ${pass.gate_name}`);
380
- }
381
108
  }
382
- lines.push("");
383
- lines.push("---");
384
- lines.push(report.summary);
385
- return lines.join("\n");
109
+ return output;
386
110
  }
@@ -255,6 +255,39 @@ export const ANTI_PATTERNS = [
255
255
  },
256
256
  severity: "critical",
257
257
  },
258
+ {
259
+ id: "stateless-response-node",
260
+ name: "Stateless Response Node",
261
+ pattern: "Response node receives only user_query without chat_conversation",
262
+ problem: "The final LLM cannot see the conversation history, only the latest message. " +
263
+ "This causes repeated questions - when user answers 'My DOB is Jan 1, 1980', the LLM " +
264
+ "doesn't see it asked the question, so it asks again. Multi-turn context is lost.",
265
+ solution: "Wire trigger.chat_conversation into the response node's named_inputs or history input. " +
266
+ "The LLM needs to see the full conversation transcript to maintain context.",
267
+ detection: {
268
+ issueType: "stateless_response",
269
+ condition: "Response node (respond_for_external_actions, respond_with_sources, call_llm as responder) " +
270
+ "receives trigger.user_query but NOT trigger.chat_conversation",
271
+ },
272
+ severity: "warning",
273
+ },
274
+ {
275
+ id: "search-without-data-sources",
276
+ name: "Search Without Data Sources",
277
+ pattern: "Workflow has search node but no data sources uploaded",
278
+ problem: "The workflow includes a search/v2 node to query knowledge base, but no documents have been " +
279
+ "uploaded to the persona. Search will return empty results, making the RAG workflow useless. " +
280
+ "The AI will have no context to answer questions.",
281
+ solution: "Upload data sources BEFORE deploying or testing the workflow:\n" +
282
+ "1. persona(id='...', data={method:'upload', path:'/path/to/document.pdf'})\n" +
283
+ "2. Enable embedding if not already: persona(id='...', data={method:'embedding', enabled:true})\n" +
284
+ "3. Wait for indexing to complete: persona(id='...', data={method:'stats'}) → check 'success' count",
285
+ detection: {
286
+ issueType: "search_without_data",
287
+ condition: "Workflow contains search/v2 node AND persona has 0 data sources uploaded (check via data.stats)",
288
+ },
289
+ severity: "critical",
290
+ },
258
291
  ];
259
292
  export const OPTIMIZATION_RULES = [
260
293
  {