@ema.co/mcp-toolkit 1.5.2 → 1.7.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,609 @@
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
+ import { generateSchemaBundle, } from "./action-schema-parser.js";
12
+ import * as fs from "fs";
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Utilities
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ /**
17
+ * Simple hash function for fingerprinting (not cryptographically secure)
18
+ * Used for detecting schema changes, not for security.
19
+ */
20
+ function simpleHash(str) {
21
+ let hash = 0;
22
+ for (let i = 0; i < str.length; i++) {
23
+ const char = str.charCodeAt(i);
24
+ hash = ((hash << 5) - hash) + char;
25
+ hash = hash & hash; // Convert to 32-bit integer
26
+ }
27
+ // Convert to hex and pad to 8 chars
28
+ return (hash >>> 0).toString(16).padStart(8, "0");
29
+ }
30
+ export class APISchemaRegistry {
31
+ actions = new Map();
32
+ templates = new Map();
33
+ loaded = false;
34
+ _metadata = {
35
+ source: "api",
36
+ version: "unknown",
37
+ generatedAt: new Date().toISOString(),
38
+ };
39
+ /**
40
+ * Get schema metadata (source, version, etc.)
41
+ */
42
+ get metadata() {
43
+ return this._metadata;
44
+ }
45
+ /**
46
+ * Load schemas from API (primary source - reflects deployed state)
47
+ */
48
+ async load(client) {
49
+ const [actionDTOs, templateDTOs] = await Promise.all([
50
+ client.listActions().catch(() => []),
51
+ client.getPersonaTemplates().catch(() => []),
52
+ ]);
53
+ // Parse actions
54
+ for (const dto of actionDTOs) {
55
+ const schema = this.parseActionDTO(dto);
56
+ if (schema) {
57
+ this.actions.set(schema.name, schema);
58
+ }
59
+ }
60
+ // Parse templates
61
+ for (const dto of templateDTOs) {
62
+ const schema = this.parseTemplateDTO(dto);
63
+ if (schema) {
64
+ this.templates.set(schema.id, schema);
65
+ }
66
+ }
67
+ this._metadata = {
68
+ source: "api",
69
+ version: new Date().toISOString().split("T")[0],
70
+ generatedAt: new Date().toISOString(),
71
+ };
72
+ this.loaded = true;
73
+ }
74
+ /**
75
+ * Load schemas from ema repo source code (for development/offline use)
76
+ * @param config - Configuration for ema repo paths
77
+ */
78
+ loadFromEmaRepo(config) {
79
+ const bundle = generateSchemaBundle(config);
80
+ this.loadFromBundle(bundle);
81
+ this._metadata.source = "code";
82
+ this._metadata.sourcePath = config.basePath;
83
+ }
84
+ /**
85
+ * Load schemas from a pre-generated bundle (JSON file)
86
+ * @param bundleOrPath - Bundle object or path to JSON file
87
+ */
88
+ loadFromBundle(bundleOrPath) {
89
+ const bundle = typeof bundleOrPath === "string"
90
+ ? JSON.parse(fs.readFileSync(bundleOrPath, "utf-8"))
91
+ : bundleOrPath;
92
+ // Convert ParsedAction to ActionSchema
93
+ for (const [actionName, versions] of Object.entries(bundle.actions)) {
94
+ // Use the latest version (last in array)
95
+ const action = versions[versions.length - 1];
96
+ const schema = this.parsedActionToSchema(action);
97
+ this.actions.set(actionName, schema);
98
+ }
99
+ this._metadata = {
100
+ source: bundle.source,
101
+ version: bundle.version,
102
+ generatedAt: bundle.generatedAt,
103
+ sourcePath: bundle.sourcePath,
104
+ };
105
+ this.loaded = true;
106
+ }
107
+ /**
108
+ * Load with fallback strategy: API -> Code -> Bundle
109
+ */
110
+ async loadWithFallback(options) {
111
+ // 1. Try live API first (most accurate for deployed state)
112
+ if (options.client) {
113
+ try {
114
+ await this.load(options.client);
115
+ this._metadata.environment = options.environment;
116
+ console.log(`[SchemaRegistry] Loaded ${this.actions.size} actions from API`);
117
+ return;
118
+ }
119
+ catch (e) {
120
+ console.warn(`[SchemaRegistry] API load failed, trying fallback:`, e);
121
+ }
122
+ }
123
+ // 2. Try parsing ema repo (for development)
124
+ if (options.emaRepoPath) {
125
+ try {
126
+ this.loadFromEmaRepo({ basePath: options.emaRepoPath });
127
+ console.log(`[SchemaRegistry] Loaded ${this.actions.size} actions from ema repo`);
128
+ return;
129
+ }
130
+ catch (e) {
131
+ console.warn(`[SchemaRegistry] Ema repo load failed, trying fallback:`, e);
132
+ }
133
+ }
134
+ // 3. Use bundled schema (offline/CI)
135
+ if (options.bundlePath) {
136
+ try {
137
+ this.loadFromBundle(options.bundlePath);
138
+ console.log(`[SchemaRegistry] Loaded ${this.actions.size} actions from bundle`);
139
+ return;
140
+ }
141
+ catch (e) {
142
+ console.warn(`[SchemaRegistry] Bundle load failed:`, e);
143
+ }
144
+ }
145
+ console.warn("[SchemaRegistry] No schema source available - validation will be limited");
146
+ }
147
+ /**
148
+ * Convert ParsedAction to ActionSchema
149
+ */
150
+ parsedActionToSchema(action) {
151
+ const inputs = new Map();
152
+ const outputs = new Map();
153
+ for (const input of action.inputs) {
154
+ inputs.set(input.name, {
155
+ name: input.name,
156
+ type: input.argType.wellKnownType ?? input.argType.typeParameter ?? "unknown",
157
+ required: !input.isOptional,
158
+ description: input.description ?? input.displayName,
159
+ });
160
+ }
161
+ for (const output of action.outputs) {
162
+ outputs.set(output.name, {
163
+ name: output.name,
164
+ type: output.argType.wellKnownType ?? output.argType.typeParameter ?? "unknown",
165
+ description: output.displayName,
166
+ });
167
+ }
168
+ return {
169
+ name: action.name,
170
+ displayName: action.displayName,
171
+ description: action.description,
172
+ category: action.category,
173
+ version: action.version,
174
+ inputs,
175
+ outputs,
176
+ documentation: action.documentation, // Raw docs for LLM context
177
+ };
178
+ }
179
+ isLoaded() {
180
+ return this.loaded;
181
+ }
182
+ getAction(name) {
183
+ return this.actions.get(name);
184
+ }
185
+ getTemplate(id) {
186
+ return this.templates.get(id);
187
+ }
188
+ getAllActions() {
189
+ return Array.from(this.actions.values());
190
+ }
191
+ getAllTemplates() {
192
+ return Array.from(this.templates.values());
193
+ }
194
+ /**
195
+ * Compute a fingerprint hash of all action definitions.
196
+ * Used for detecting skew between API and code schemas.
197
+ */
198
+ computeFingerprint() {
199
+ const actions = this.getAllActions().sort((a, b) => a.name.localeCompare(b.name));
200
+ const data = actions.map(a => `${a.name}:${a.version}:${a.inputs.size}:${a.outputs.size}`).join("|");
201
+ return simpleHash(data);
202
+ }
203
+ /**
204
+ * Compare this registry against another to detect schema skew.
205
+ * Useful for detecting when API differs from code definitions.
206
+ */
207
+ compareWith(other) {
208
+ const thisActions = new Map(this.actions);
209
+ const otherActions = new Map(other.actions);
210
+ const addedInOther = [];
211
+ const removedFromOther = [];
212
+ const versionDiff = [];
213
+ // Check for actions in "other" but not in "this"
214
+ for (const [name, action] of otherActions) {
215
+ if (!thisActions.has(name)) {
216
+ addedInOther.push(name);
217
+ }
218
+ else {
219
+ const thisAction = thisActions.get(name);
220
+ if (thisAction.version !== action.version) {
221
+ versionDiff.push({
222
+ name,
223
+ apiVersion: otherActions === this.actions ? thisAction.version : action.version,
224
+ codeVersion: otherActions === this.actions ? action.version : thisAction.version,
225
+ });
226
+ }
227
+ }
228
+ }
229
+ // Check for actions in "this" but not in "other"
230
+ for (const name of thisActions.keys()) {
231
+ if (!otherActions.has(name)) {
232
+ removedFromOther.push(name);
233
+ }
234
+ }
235
+ return {
236
+ hasSkew: addedInOther.length > 0 || removedFromOther.length > 0 || versionDiff.length > 0,
237
+ addedInApi: addedInOther,
238
+ removedFromApi: removedFromOther,
239
+ versionDiff,
240
+ };
241
+ }
242
+ /**
243
+ * Parse ActionDTO from ListActions API into ActionSchema
244
+ */
245
+ parseActionDTO(dto) {
246
+ try {
247
+ const typeName = dto.typeName;
248
+ const name = typeName?.name?.name;
249
+ if (!name)
250
+ return null;
251
+ const inputs = new Map();
252
+ const outputs = new Map();
253
+ // Parse inputs from dto.inputs.inputs
254
+ const inputsObj = dto.inputs?.inputs;
255
+ if (inputsObj && typeof inputsObj === "object") {
256
+ for (const [inputName, inputDef] of Object.entries(inputsObj)) {
257
+ const def = inputDef;
258
+ inputs.set(inputName, {
259
+ name: inputName,
260
+ type: def.type?.wellKnownType ?? "unknown",
261
+ required: !def.isOptional,
262
+ description: def.description ?? def.displayName,
263
+ });
264
+ }
265
+ }
266
+ // Parse outputs from dto.outputs.outputs
267
+ const outputsObj = dto.outputs?.outputs;
268
+ if (outputsObj && typeof outputsObj === "object") {
269
+ for (const [outputName, outputDef] of Object.entries(outputsObj)) {
270
+ const def = outputDef;
271
+ outputs.set(outputName, {
272
+ name: outputName,
273
+ type: def.type?.wellKnownType ?? "unknown",
274
+ description: def.description,
275
+ });
276
+ }
277
+ }
278
+ // Get displayName - might be string or object with nested value
279
+ const displayName = typeof dto.displayName === "string"
280
+ ? dto.displayName
281
+ : dto.name ?? name;
282
+ // Get description - might be string or object
283
+ const description = typeof dto.description === "string"
284
+ ? dto.description
285
+ : "";
286
+ return {
287
+ name,
288
+ displayName: displayName,
289
+ description,
290
+ category: dto.category ?? "unknown",
291
+ version: typeName?.version ?? "v1",
292
+ inputs,
293
+ outputs,
294
+ };
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ /**
301
+ * Parse PersonaTemplateDTO from GetPersonaTemplates API
302
+ */
303
+ parseTemplateDTO(dto) {
304
+ try {
305
+ if (!dto.id)
306
+ return null;
307
+ // Determine type from template name or ID
308
+ let type = "chat";
309
+ const nameLower = (dto.name ?? "").toLowerCase();
310
+ if (nameLower.includes("voice"))
311
+ type = "voice";
312
+ else if (nameLower.includes("dashboard"))
313
+ type = "dashboard";
314
+ // Extract widget names from proto_config
315
+ const defaultWidgets = [];
316
+ const protoConfig = dto.proto_config;
317
+ if (protoConfig?.widgets) {
318
+ for (const widget of protoConfig.widgets) {
319
+ if (widget.name)
320
+ defaultWidgets.push(widget.name);
321
+ }
322
+ }
323
+ return {
324
+ id: dto.id,
325
+ name: dto.name ?? dto.id,
326
+ type,
327
+ description: dto.description,
328
+ defaultWidgets,
329
+ };
330
+ }
331
+ catch {
332
+ return null;
333
+ }
334
+ }
335
+ }
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+ // Workflow Validator
338
+ // ─────────────────────────────────────────────────────────────────────────────
339
+ /**
340
+ * Validate a WorkflowSpec against API schemas
341
+ */
342
+ export function validateWorkflowSpec(spec, registry) {
343
+ const errors = [];
344
+ const warnings = [];
345
+ const usedActions = [];
346
+ const unknownActions = [];
347
+ // Build node map for reference checking
348
+ const nodeMap = new Map();
349
+ for (const node of spec.nodes) {
350
+ nodeMap.set(node.id, node);
351
+ }
352
+ // Check if registry actually has data - if not, skip action-specific validation
353
+ // (API may be unavailable or mocked)
354
+ const registryHasData = registry.isLoaded() && registry.getAllActions().length > 0;
355
+ // 1. Check for trigger node
356
+ const triggerTypes = ["chat_trigger", "document_trigger", "voice_trigger"];
357
+ const hasTrigger = spec.nodes.some(n => triggerTypes.includes(n.actionType));
358
+ if (!hasTrigger) {
359
+ errors.push({
360
+ type: "missing_trigger",
361
+ message: "Workflow must have a trigger node (chat_trigger, document_trigger)",
362
+ suggestion: "Add a trigger node as the first node in your workflow",
363
+ });
364
+ }
365
+ // 2. Validate each node
366
+ for (const node of spec.nodes) {
367
+ const actionSchema = registry.getAction(node.actionType);
368
+ // Schema-dependent validation (only when registry has data)
369
+ if (!actionSchema && registryHasData) {
370
+ // Action not found in API (only flag if we have data to check against)
371
+ unknownActions.push(node.actionType);
372
+ errors.push({
373
+ type: "missing_action",
374
+ node_id: node.id,
375
+ message: `Action "${node.actionType}" not found in API`,
376
+ suggestion: `Check ListActions for available actions. Similar: ${findSimilarActions(node.actionType, registry)}`,
377
+ });
378
+ }
379
+ else if (actionSchema) {
380
+ usedActions.push(node.actionType);
381
+ // Validate required inputs
382
+ for (const [inputName, inputSchema] of actionSchema.inputs) {
383
+ if (inputSchema.required && !node.inputs?.[inputName]) {
384
+ errors.push({
385
+ type: "missing_input",
386
+ node_id: node.id,
387
+ field: inputName,
388
+ message: `Required input "${inputName}" missing on node "${node.id}" (${node.actionType})`,
389
+ suggestion: `Add input "${inputName}" of type ${inputSchema.type}`,
390
+ });
391
+ }
392
+ }
393
+ // Schema-aware input validation
394
+ if (node.inputs) {
395
+ for (const [inputName, binding] of Object.entries(node.inputs)) {
396
+ const inputSchema = actionSchema.inputs.get(inputName);
397
+ // Check if input exists on action
398
+ if (!inputSchema && actionSchema.inputs.size > 0) {
399
+ warnings.push({
400
+ type: "suboptimal_wiring",
401
+ node_id: node.id,
402
+ message: `Input "${inputName}" may not exist on action "${node.actionType}"`,
403
+ suggestion: `Valid inputs: ${Array.from(actionSchema.inputs.keys()).join(", ")}`,
404
+ });
405
+ }
406
+ }
407
+ }
408
+ }
409
+ // Schema-INDEPENDENT validation (ALWAYS runs - checks node references)
410
+ if (node.inputs) {
411
+ for (const [inputName, binding] of Object.entries(node.inputs)) {
412
+ // Validate action_output references - doesn't need schema
413
+ if (binding.type === "action_output" && binding.actionName) {
414
+ const sourceNode = nodeMap.get(binding.actionName);
415
+ if (!sourceNode) {
416
+ errors.push({
417
+ type: "invalid_wiring",
418
+ node_id: node.id,
419
+ field: inputName,
420
+ message: `Input "${inputName}" references non-existent node "${binding.actionName}"`,
421
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
422
+ });
423
+ }
424
+ else if (actionSchema) {
425
+ // Output validation only if we have schema
426
+ const sourceSchema = registry.getAction(sourceNode.actionType);
427
+ if (sourceSchema && binding.output && !sourceSchema.outputs.has(binding.output)) {
428
+ warnings.push({
429
+ type: "suboptimal_wiring",
430
+ node_id: node.id,
431
+ message: `Output "${binding.output}" may not exist on action "${sourceNode.actionType}"`,
432
+ suggestion: `Valid outputs: ${Array.from(sourceSchema.outputs.keys()).join(", ")}`,
433
+ });
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+ // Validate runIf references - also schema-independent
440
+ if (node.runIf && "actionName" in node.runIf) {
441
+ const runIfNode = nodeMap.get(node.runIf.actionName);
442
+ if (!runIfNode) {
443
+ errors.push({
444
+ type: "invalid_wiring",
445
+ node_id: node.id,
446
+ message: `runIf references non-existent node "${node.runIf.actionName}"`,
447
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
448
+ });
449
+ }
450
+ }
451
+ }
452
+ // 3. Validate result mappings
453
+ for (const mapping of spec.resultMappings) {
454
+ if (!nodeMap.has(mapping.nodeId)) {
455
+ errors.push({
456
+ type: "invalid_structure",
457
+ message: `Result mapping references non-existent node "${mapping.nodeId}"`,
458
+ suggestion: `Valid nodes: ${Array.from(nodeMap.keys()).join(", ")}`,
459
+ });
460
+ }
461
+ }
462
+ return {
463
+ valid: errors.length === 0,
464
+ errors,
465
+ warnings,
466
+ action_coverage: {
467
+ used: [...new Set(usedActions)],
468
+ available: registry.getAllActions().map(a => a.name),
469
+ unknown: unknownActions,
470
+ },
471
+ };
472
+ }
473
+ /**
474
+ * Find similar action names for suggestions
475
+ */
476
+ function findSimilarActions(name, registry) {
477
+ const allActions = registry.getAllActions();
478
+ const similar = allActions
479
+ .filter(a => {
480
+ const aLower = a.name.toLowerCase();
481
+ const nLower = name.toLowerCase();
482
+ return aLower.includes(nLower) || nLower.includes(aLower) ||
483
+ levenshteinDistance(aLower, nLower) <= 3;
484
+ })
485
+ .slice(0, 3)
486
+ .map(a => a.name);
487
+ return similar.length > 0 ? similar.join(", ") : "none found";
488
+ }
489
+ /**
490
+ * Simple Levenshtein distance for fuzzy matching
491
+ */
492
+ function levenshteinDistance(a, b) {
493
+ if (a.length === 0)
494
+ return b.length;
495
+ if (b.length === 0)
496
+ return a.length;
497
+ const matrix = [];
498
+ for (let i = 0; i <= b.length; i++) {
499
+ matrix[i] = [i];
500
+ }
501
+ for (let j = 0; j <= a.length; j++) {
502
+ matrix[0][j] = j;
503
+ }
504
+ for (let i = 1; i <= b.length; i++) {
505
+ for (let j = 1; j <= a.length; j++) {
506
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
507
+ matrix[i][j] = matrix[i - 1][j - 1];
508
+ }
509
+ else {
510
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
511
+ }
512
+ }
513
+ }
514
+ return matrix[b.length][a.length];
515
+ }
516
+ // ─────────────────────────────────────────────────────────────────────────────
517
+ // LLM Context Generation
518
+ // ─────────────────────────────────────────────────────────────────────────────
519
+ /**
520
+ * Generate action catalog for LLM context
521
+ */
522
+ export function generateActionCatalogForLLM(registry) {
523
+ const sections = [];
524
+ sections.push("# Available Actions (from API)\n");
525
+ // Group by category
526
+ const byCategory = new Map();
527
+ for (const action of registry.getAllActions()) {
528
+ const cat = action.category || "other";
529
+ if (!byCategory.has(cat))
530
+ byCategory.set(cat, []);
531
+ byCategory.get(cat).push(action);
532
+ }
533
+ for (const [category, actions] of byCategory) {
534
+ sections.push(`## ${category}\n`);
535
+ for (const action of actions) {
536
+ const inputs = Array.from(action.inputs.values())
537
+ .map(i => `${i.name}${i.required ? "*" : ""}: ${i.type}`)
538
+ .join(", ");
539
+ const outputs = Array.from(action.outputs.keys()).join(", ");
540
+ sections.push(`### ${action.name} (${action.version})`);
541
+ sections.push(`${action.description}`);
542
+ sections.push(`- Inputs: ${inputs || "none"}`);
543
+ sections.push(`- Outputs: ${outputs || "none"}\n`);
544
+ }
545
+ }
546
+ return sections.join("\n");
547
+ }
548
+ /**
549
+ * Generate template catalog for LLM context
550
+ */
551
+ export function generateTemplateCatalogForLLM(registry) {
552
+ const sections = [];
553
+ sections.push("# Available Templates (from API)\n");
554
+ for (const template of registry.getAllTemplates()) {
555
+ sections.push(`## ${template.name} (${template.type})`);
556
+ sections.push(`ID: ${template.id}`);
557
+ if (template.description)
558
+ sections.push(template.description);
559
+ sections.push(`Default widgets: ${template.defaultWidgets.join(", ") || "none"}\n`);
560
+ }
561
+ return sections.join("\n");
562
+ }
563
+ // ─────────────────────────────────────────────────────────────────────────────
564
+ // Singleton Registry
565
+ // ─────────────────────────────────────────────────────────────────────────────
566
+ let _schemaRegistry = null;
567
+ /**
568
+ * Ensure schema registry is loaded with layered fallback:
569
+ * 1. API (live data from ListActions)
570
+ * 2. Bundled schema (resources/action-schema.json)
571
+ */
572
+ export async function ensureSchemaRegistry(client) {
573
+ if (!_schemaRegistry) {
574
+ _schemaRegistry = new APISchemaRegistry();
575
+ // Use layered loading: API first, then bundled schema fallback
576
+ const bundlePath = resolveBundlePath();
577
+ await _schemaRegistry.loadWithFallback({
578
+ client,
579
+ bundlePath,
580
+ });
581
+ }
582
+ return _schemaRegistry;
583
+ }
584
+ /**
585
+ * Resolve the path to the bundled action schema.
586
+ * Works both in development (src/) and production (dist/).
587
+ */
588
+ function resolveBundlePath() {
589
+ const paths = [
590
+ // Development: relative to src/sdk/
591
+ new URL("../../resources/action-schema.json", import.meta.url).pathname,
592
+ // Production: relative to dist/sdk/
593
+ new URL("../../resources/action-schema.json", import.meta.url).pathname,
594
+ ];
595
+ for (const p of paths) {
596
+ if (fs.existsSync(p))
597
+ return p;
598
+ }
599
+ return undefined;
600
+ }
601
+ export function getSchemaRegistry() {
602
+ return _schemaRegistry;
603
+ }
604
+ /**
605
+ * Reset the schema registry (for testing)
606
+ */
607
+ export function resetSchemaRegistry() {
608
+ _schemaRegistry = null;
609
+ }