@ema.co/mcp-toolkit 1.5.1 → 1.6.0

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.

@@ -0,0 +1,391 @@
1
+ /**
2
+ * API-Driven Workflow Validation
3
+ *
4
+ * Validates workflow specs against actual API definitions:
5
+ * - ListActions: Available actions with input/output schemas
6
+ * - GetPersonaTemplates: Available templates with configurations
7
+ * - OpenAPI spec: Endpoint contracts (optional, for payload validation)
8
+ *
9
+ * This ensures workflows are valid BEFORE deployment, catching errors early.
10
+ */
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Schema Registry - Caches API definitions
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ export class APISchemaRegistry {
15
+ actions = new Map();
16
+ templates = new Map();
17
+ loaded = false;
18
+ /**
19
+ * Load schemas from API
20
+ */
21
+ async load(client) {
22
+ const [actionDTOs, templateDTOs] = await Promise.all([
23
+ client.listActions().catch(() => []),
24
+ client.getPersonaTemplates().catch(() => []),
25
+ ]);
26
+ // Parse actions
27
+ for (const dto of actionDTOs) {
28
+ const schema = this.parseActionDTO(dto);
29
+ if (schema) {
30
+ this.actions.set(schema.name, schema);
31
+ }
32
+ }
33
+ // Parse templates
34
+ for (const dto of templateDTOs) {
35
+ const schema = this.parseTemplateDTO(dto);
36
+ if (schema) {
37
+ this.templates.set(schema.id, schema);
38
+ }
39
+ }
40
+ this.loaded = true;
41
+ }
42
+ isLoaded() {
43
+ return this.loaded;
44
+ }
45
+ getAction(name) {
46
+ return this.actions.get(name);
47
+ }
48
+ getTemplate(id) {
49
+ return this.templates.get(id);
50
+ }
51
+ getAllActions() {
52
+ return Array.from(this.actions.values());
53
+ }
54
+ getAllTemplates() {
55
+ return Array.from(this.templates.values());
56
+ }
57
+ /**
58
+ * Parse ActionDTO from ListActions API into ActionSchema
59
+ */
60
+ parseActionDTO(dto) {
61
+ try {
62
+ const typeName = dto.typeName;
63
+ const name = typeName?.name?.name;
64
+ if (!name)
65
+ return null;
66
+ const inputs = new Map();
67
+ const outputs = new Map();
68
+ // Parse inputs from dto.inputs.inputs
69
+ const inputsObj = dto.inputs?.inputs;
70
+ if (inputsObj && typeof inputsObj === "object") {
71
+ for (const [inputName, inputDef] of Object.entries(inputsObj)) {
72
+ const def = inputDef;
73
+ inputs.set(inputName, {
74
+ name: inputName,
75
+ type: def.type?.wellKnownType ?? "unknown",
76
+ required: !def.isOptional,
77
+ description: def.description ?? def.displayName,
78
+ });
79
+ }
80
+ }
81
+ // Parse outputs from dto.outputs.outputs
82
+ const outputsObj = dto.outputs?.outputs;
83
+ if (outputsObj && typeof outputsObj === "object") {
84
+ for (const [outputName, outputDef] of Object.entries(outputsObj)) {
85
+ const def = outputDef;
86
+ outputs.set(outputName, {
87
+ name: outputName,
88
+ type: def.type?.wellKnownType ?? "unknown",
89
+ description: def.description,
90
+ });
91
+ }
92
+ }
93
+ // Get displayName - might be string or object with nested value
94
+ const displayName = typeof dto.displayName === "string"
95
+ ? dto.displayName
96
+ : dto.name ?? name;
97
+ // Get description - might be string or object
98
+ const description = typeof dto.description === "string"
99
+ ? dto.description
100
+ : "";
101
+ return {
102
+ name,
103
+ displayName: displayName,
104
+ description,
105
+ category: dto.category ?? "unknown",
106
+ version: typeName?.version ?? "v1",
107
+ inputs,
108
+ outputs,
109
+ };
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ /**
116
+ * Parse PersonaTemplateDTO from GetPersonaTemplates API
117
+ */
118
+ parseTemplateDTO(dto) {
119
+ try {
120
+ if (!dto.id)
121
+ return null;
122
+ // Determine type from template name or ID
123
+ let type = "chat";
124
+ const nameLower = (dto.name ?? "").toLowerCase();
125
+ if (nameLower.includes("voice"))
126
+ type = "voice";
127
+ else if (nameLower.includes("dashboard"))
128
+ type = "dashboard";
129
+ // Extract widget names from proto_config
130
+ const defaultWidgets = [];
131
+ const protoConfig = dto.proto_config;
132
+ if (protoConfig?.widgets) {
133
+ for (const widget of protoConfig.widgets) {
134
+ if (widget.name)
135
+ defaultWidgets.push(widget.name);
136
+ }
137
+ }
138
+ return {
139
+ id: dto.id,
140
+ name: dto.name ?? dto.id,
141
+ type,
142
+ description: dto.description,
143
+ defaultWidgets,
144
+ };
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ }
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+ // Workflow Validator
153
+ // ─────────────────────────────────────────────────────────────────────────────
154
+ /**
155
+ * Validate a WorkflowSpec against API schemas
156
+ */
157
+ export function validateWorkflowSpec(spec, registry) {
158
+ const errors = [];
159
+ const warnings = [];
160
+ const usedActions = [];
161
+ const unknownActions = [];
162
+ // Build node map for reference checking
163
+ const nodeMap = new Map();
164
+ for (const node of spec.nodes) {
165
+ nodeMap.set(node.id, node);
166
+ }
167
+ // Check if registry actually has data - if not, skip action-specific validation
168
+ // (API may be unavailable or mocked)
169
+ const registryHasData = registry.isLoaded() && registry.getAllActions().length > 0;
170
+ // 1. Check for trigger node
171
+ const triggerTypes = ["chat_trigger", "document_trigger", "voice_trigger"];
172
+ const hasTrigger = spec.nodes.some(n => triggerTypes.includes(n.actionType));
173
+ if (!hasTrigger) {
174
+ errors.push({
175
+ type: "missing_trigger",
176
+ message: "Workflow must have a trigger node (chat_trigger, document_trigger)",
177
+ suggestion: "Add a trigger node as the first node in your workflow",
178
+ });
179
+ }
180
+ // 2. Validate each node
181
+ for (const node of spec.nodes) {
182
+ const actionSchema = registry.getAction(node.actionType);
183
+ // Schema-dependent validation (only when registry has data)
184
+ if (!actionSchema && registryHasData) {
185
+ // Action not found in API (only flag if we have data to check against)
186
+ unknownActions.push(node.actionType);
187
+ errors.push({
188
+ type: "missing_action",
189
+ node_id: node.id,
190
+ message: `Action "${node.actionType}" not found in API`,
191
+ suggestion: `Check ListActions for available actions. Similar: ${findSimilarActions(node.actionType, registry)}`,
192
+ });
193
+ }
194
+ else if (actionSchema) {
195
+ usedActions.push(node.actionType);
196
+ // Validate required inputs
197
+ for (const [inputName, inputSchema] of actionSchema.inputs) {
198
+ if (inputSchema.required && !node.inputs?.[inputName]) {
199
+ errors.push({
200
+ type: "missing_input",
201
+ node_id: node.id,
202
+ field: inputName,
203
+ message: `Required input "${inputName}" missing on node "${node.id}" (${node.actionType})`,
204
+ suggestion: `Add input "${inputName}" of type ${inputSchema.type}`,
205
+ });
206
+ }
207
+ }
208
+ // Schema-aware input validation
209
+ if (node.inputs) {
210
+ for (const [inputName, binding] of Object.entries(node.inputs)) {
211
+ const inputSchema = actionSchema.inputs.get(inputName);
212
+ // Check if input exists on action
213
+ if (!inputSchema && actionSchema.inputs.size > 0) {
214
+ warnings.push({
215
+ type: "suboptimal_wiring",
216
+ node_id: node.id,
217
+ message: `Input "${inputName}" may not exist on action "${node.actionType}"`,
218
+ suggestion: `Valid inputs: ${Array.from(actionSchema.inputs.keys()).join(", ")}`,
219
+ });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // Schema-INDEPENDENT validation (ALWAYS runs - checks node references)
225
+ if (node.inputs) {
226
+ for (const [inputName, binding] of Object.entries(node.inputs)) {
227
+ // Validate action_output references - doesn't need schema
228
+ if (binding.type === "action_output" && binding.actionName) {
229
+ const sourceNode = nodeMap.get(binding.actionName);
230
+ if (!sourceNode) {
231
+ errors.push({
232
+ type: "invalid_wiring",
233
+ node_id: node.id,
234
+ field: inputName,
235
+ message: `Input "${inputName}" references non-existent node "${binding.actionName}"`,
236
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
237
+ });
238
+ }
239
+ else if (actionSchema) {
240
+ // Output validation only if we have schema
241
+ const sourceSchema = registry.getAction(sourceNode.actionType);
242
+ if (sourceSchema && binding.output && !sourceSchema.outputs.has(binding.output)) {
243
+ warnings.push({
244
+ type: "suboptimal_wiring",
245
+ node_id: node.id,
246
+ message: `Output "${binding.output}" may not exist on action "${sourceNode.actionType}"`,
247
+ suggestion: `Valid outputs: ${Array.from(sourceSchema.outputs.keys()).join(", ")}`,
248
+ });
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
254
+ // Validate runIf references - also schema-independent
255
+ if (node.runIf && "actionName" in node.runIf) {
256
+ const runIfNode = nodeMap.get(node.runIf.actionName);
257
+ if (!runIfNode) {
258
+ errors.push({
259
+ type: "invalid_wiring",
260
+ node_id: node.id,
261
+ message: `runIf references non-existent node "${node.runIf.actionName}"`,
262
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
263
+ });
264
+ }
265
+ }
266
+ }
267
+ // 3. Validate result mappings
268
+ for (const mapping of spec.resultMappings) {
269
+ if (!nodeMap.has(mapping.nodeId)) {
270
+ errors.push({
271
+ type: "invalid_structure",
272
+ message: `Result mapping references non-existent node "${mapping.nodeId}"`,
273
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
274
+ });
275
+ }
276
+ }
277
+ return {
278
+ valid: errors.length === 0,
279
+ errors,
280
+ warnings,
281
+ action_coverage: {
282
+ used: [...new Set(usedActions)],
283
+ available: registry.getAllActions().map(a => a.name),
284
+ unknown: unknownActions,
285
+ },
286
+ };
287
+ }
288
+ /**
289
+ * Find similar action names for suggestions
290
+ */
291
+ function findSimilarActions(name, registry) {
292
+ const allActions = registry.getAllActions();
293
+ const similar = allActions
294
+ .filter(a => {
295
+ const aLower = a.name.toLowerCase();
296
+ const nLower = name.toLowerCase();
297
+ return aLower.includes(nLower) || nLower.includes(aLower) ||
298
+ levenshteinDistance(aLower, nLower) <= 3;
299
+ })
300
+ .slice(0, 3)
301
+ .map(a => a.name);
302
+ return similar.length > 0 ? similar.join(", ") : "none found";
303
+ }
304
+ /**
305
+ * Simple Levenshtein distance for fuzzy matching
306
+ */
307
+ function levenshteinDistance(a, b) {
308
+ if (a.length === 0)
309
+ return b.length;
310
+ if (b.length === 0)
311
+ return a.length;
312
+ const matrix = [];
313
+ for (let i = 0; i <= b.length; i++) {
314
+ matrix[i] = [i];
315
+ }
316
+ for (let j = 0; j <= a.length; j++) {
317
+ matrix[0][j] = j;
318
+ }
319
+ for (let i = 1; i <= b.length; i++) {
320
+ for (let j = 1; j <= a.length; j++) {
321
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
322
+ matrix[i][j] = matrix[i - 1][j - 1];
323
+ }
324
+ else {
325
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
326
+ }
327
+ }
328
+ }
329
+ return matrix[b.length][a.length];
330
+ }
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+ // LLM Context Generation
333
+ // ─────────────────────────────────────────────────────────────────────────────
334
+ /**
335
+ * Generate action catalog for LLM context
336
+ */
337
+ export function generateActionCatalogForLLM(registry) {
338
+ const sections = [];
339
+ sections.push("# Available Actions (from API)\n");
340
+ // Group by category
341
+ const byCategory = new Map();
342
+ for (const action of registry.getAllActions()) {
343
+ const cat = action.category || "other";
344
+ if (!byCategory.has(cat))
345
+ byCategory.set(cat, []);
346
+ byCategory.get(cat).push(action);
347
+ }
348
+ for (const [category, actions] of byCategory) {
349
+ sections.push(`## ${category}\n`);
350
+ for (const action of actions) {
351
+ const inputs = Array.from(action.inputs.values())
352
+ .map(i => `${i.name}${i.required ? "*" : ""}: ${i.type}`)
353
+ .join(", ");
354
+ const outputs = Array.from(action.outputs.keys()).join(", ");
355
+ sections.push(`### ${action.name} (${action.version})`);
356
+ sections.push(`${action.description}`);
357
+ sections.push(`- Inputs: ${inputs || "none"}`);
358
+ sections.push(`- Outputs: ${outputs || "none"}\n`);
359
+ }
360
+ }
361
+ return sections.join("\n");
362
+ }
363
+ /**
364
+ * Generate template catalog for LLM context
365
+ */
366
+ export function generateTemplateCatalogForLLM(registry) {
367
+ const sections = [];
368
+ sections.push("# Available Templates (from API)\n");
369
+ for (const template of registry.getAllTemplates()) {
370
+ sections.push(`## ${template.name} (${template.type})`);
371
+ sections.push(`ID: ${template.id}`);
372
+ if (template.description)
373
+ sections.push(template.description);
374
+ sections.push(`Default widgets: ${template.defaultWidgets.join(", ") || "none"}\n`);
375
+ }
376
+ return sections.join("\n");
377
+ }
378
+ // ─────────────────────────────────────────────────────────────────────────────
379
+ // Singleton Registry
380
+ // ─────────────────────────────────────────────────────────────────────────────
381
+ let _schemaRegistry = null;
382
+ export async function ensureSchemaRegistry(client) {
383
+ if (!_schemaRegistry) {
384
+ _schemaRegistry = new APISchemaRegistry();
385
+ await _schemaRegistry.load(client);
386
+ }
387
+ return _schemaRegistry;
388
+ }
389
+ export function getSchemaRegistry() {
390
+ return _schemaRegistry;
391
+ }