@ema.co/mcp-toolkit 1.6.0 → 1.7.1

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.

@@ -25,6 +25,7 @@ import * as path from "path";
25
25
  import { AGENT_CATALOG, WORKFLOW_PATTERNS, WIDGET_CATALOG } from "../sdk/knowledge.js";
26
26
  import { INPUT_SOURCE_RULES, ANTI_PATTERNS, OPTIMIZATION_RULES } from "../sdk/validation-rules.js";
27
27
  import { EmaClient } from "../sdk/client.js";
28
+ import { APISchemaRegistry } from "../sdk/workflow-validator.js";
28
29
  import { loadConfigFromJsonEnv, loadConfigOptional, resolveBearerToken, getEnvByName, getMasterEnv, } from "../sdk/config.js";
29
30
  // ─────────────────────────────────────────────────────────────────────────────
30
31
  // Security Utilities
@@ -269,6 +270,62 @@ const DYNAMIC_RESOURCES = [
269
270
  return md;
270
271
  },
271
272
  },
273
+ // Action Schema - Complete action definitions from ema repo
274
+ {
275
+ uri: "ema://schema/actions",
276
+ name: "schema/actions",
277
+ description: "Complete action schema: all workflow actions with inputs, outputs, types, and documentation from ema_backend/grpc",
278
+ mimeType: "application/json",
279
+ generate: async (ctx) => {
280
+ const schema = await getDynamicActionSchema({ env: ctx.env });
281
+ return JSON.stringify(schema, null, 2);
282
+ },
283
+ },
284
+ {
285
+ uri: "ema://schema/actions-summary",
286
+ name: "schema/actions-summary",
287
+ description: "Action schema summary: action names, versions, and descriptions for quick reference",
288
+ mimeType: "text/markdown",
289
+ generate: async (ctx) => {
290
+ const schema = await getDynamicActionSchema({ env: ctx.env });
291
+ if (schema.actions.length === 0) {
292
+ return "# Action Schema\n\n> No actions loaded. Check API connectivity or bundled schema.\n";
293
+ }
294
+ let md = "# Action Schema\n\n";
295
+ md += `> ${schema.actions.length} actions loaded (source: ${schema.source})\n`;
296
+ md += `> Version: ${schema.version}\n\n`;
297
+ const byCategory = new Map();
298
+ for (const action of schema.actions) {
299
+ const cat = action.category || "OTHER";
300
+ if (!byCategory.has(cat))
301
+ byCategory.set(cat, []);
302
+ byCategory.get(cat).push(action);
303
+ }
304
+ for (const [category, actions] of byCategory) {
305
+ md += `## ${category.replace("ACTION_CATEGORY_", "")}\n\n`;
306
+ md += "| Action | Version | Description | Inputs | Outputs |\n";
307
+ md += "|--------|---------|-------------|--------|--------|\n";
308
+ for (const action of actions) {
309
+ const inputs = action.inputs?.size || 0;
310
+ const outputs = action.outputs?.size || 0;
311
+ const desc = action.description?.slice(0, 50) || "-";
312
+ md += `| \`${action.name}\` | ${action.version || "v0"} | ${desc}... | ${inputs} | ${outputs} |\n`;
313
+ }
314
+ md += "\n";
315
+ }
316
+ return md;
317
+ },
318
+ },
319
+ {
320
+ uri: "ema://schema/action/:name",
321
+ name: "schema/action",
322
+ description: "Individual action schema by name (e.g., ema://schema/action/call_llm)",
323
+ mimeType: "application/json",
324
+ generate: async (ctx) => {
325
+ // This is a template - actual resolution happens in getResource
326
+ return JSON.stringify({ error: "Use specific action name in URI" }, null, 2);
327
+ },
328
+ },
272
329
  ];
273
330
  // ─────────────────────────────────────────────────────────────────────────────
274
331
  // Dynamic fetching helpers (Ema API as source of truth when available)
@@ -374,6 +431,56 @@ async function getDynamicPersonaTemplates(opts) {
374
431
  return [];
375
432
  }
376
433
  }
434
+ const actionSchemaCache = new Map();
435
+ /**
436
+ * Get action schema with layered loading:
437
+ * 1. Try bundled schema (resources/action-schema.json)
438
+ * 2. Augment with live API data if available
439
+ */
440
+ async function getDynamicActionSchema(opts) {
441
+ const cacheKey = opts.env ?? "";
442
+ const now = Date.now();
443
+ const cached = actionSchemaCache.get(cacheKey);
444
+ if (cached && now - cached.ts < 60_000)
445
+ return cached.data;
446
+ const registry = new APISchemaRegistry();
447
+ // Try to load from bundled schema first (code source of truth)
448
+ const bundlePath = path.resolve(__dirname, "../../resources/action-schema.json");
449
+ try {
450
+ registry.loadFromBundle(bundlePath);
451
+ }
452
+ catch {
453
+ // Bundle not available - try API
454
+ const client = getClientForEnvName(opts.env);
455
+ if (client) {
456
+ try {
457
+ await registry.load(client);
458
+ }
459
+ catch {
460
+ // Neither bundle nor API available
461
+ }
462
+ }
463
+ }
464
+ const response = {
465
+ version: registry.metadata.version ?? "unknown",
466
+ source: registry.metadata.source ?? "bundle",
467
+ actions: registry.getAllActions(),
468
+ };
469
+ actionSchemaCache.set(cacheKey, { ts: now, data: response });
470
+ return response;
471
+ }
472
+ /**
473
+ * Get a specific action by name from the schema.
474
+ */
475
+ async function getActionByName(name, env) {
476
+ const schema = await getDynamicActionSchema({ env });
477
+ // Find latest version of action
478
+ const matching = schema.actions.filter(a => a.name === name);
479
+ if (matching.length === 0)
480
+ return null;
481
+ // Return latest version (sort by version desc)
482
+ return matching.sort((a, b) => (b.version || "v0").localeCompare(a.version || "v0"))[0];
483
+ }
377
484
  /**
378
485
  * Convert PersonaTemplateDTO to a simplified format for MCP resources.
379
486
  */
@@ -483,6 +590,23 @@ export class ResourceRegistry {
483
590
  text: await dynamicResource.generate({ env: envFromQuery }),
484
591
  };
485
592
  }
593
+ // Handle pattern URIs (e.g., ema://schema/action/:name)
594
+ const actionMatch = baseUri.match(/^ema:\/\/schema\/action\/(.+)$/);
595
+ if (actionMatch) {
596
+ const actionName = actionMatch[1];
597
+ const action = await getActionByName(actionName, envFromQuery);
598
+ if (!action) {
599
+ return {
600
+ code: "NOT_FOUND",
601
+ message: `Action not found: ${actionName}. Use ema://schema/actions-summary to list available actions.`,
602
+ };
603
+ }
604
+ return {
605
+ uri: baseUri,
606
+ mimeType: "application/json",
607
+ text: JSON.stringify(action, null, 2),
608
+ };
609
+ }
486
610
  // Look up in file-backed allowlist
487
611
  const resourceConfig = RESOURCE_MAP[baseUri];
488
612
  if (!resourceConfig) {
@@ -14,9 +14,19 @@
14
14
  * - Default environment is set via EMA_ENV_NAME or first in config
15
15
  * - Available environments come from sync config (EMA_AGENT_SYNC_CONFIG)
16
16
  */
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { dirname, join } from "node:path";
17
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
21
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
22
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
+ // Read package.json for version info
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ const packageJsonPath = join(__dirname, "..", "..", "package.json");
27
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
28
+ export const TOOLKIT_NAME = packageJson.name;
29
+ export const TOOLKIT_VERSION = packageJson.version;
20
30
  // Prompts and Resources
21
31
  import { PromptRegistry, isPromptError } from "./prompts.js";
22
32
  import { ResourceRegistry, isResourceError } from "./resources.js";
@@ -4692,7 +4702,7 @@ const toolHandlers = {
4692
4702
  return handleEnv({}, () => getAvailableEnvironments().map(e => ({
4693
4703
  name: e.name,
4694
4704
  isDefault: e.name === getDefaultEnvName(),
4695
- })));
4705
+ })), { name: TOOLKIT_NAME, version: TOOLKIT_VERSION });
4696
4706
  },
4697
4707
  persona: async (args) => {
4698
4708
  const targetEnv = args.env ?? getDefaultEnvName();
@@ -5278,7 +5288,7 @@ function generateEntityDocument(entityType, entity, related, tags) {
5278
5288
  const promptRegistry = new PromptRegistry();
5279
5289
  const resourceRegistry = new ResourceRegistry();
5280
5290
  export async function startMcpServer() {
5281
- const server = new Server({ name: "ema", version: "1.0.0" }, {
5291
+ const server = new Server({ name: TOOLKIT_NAME, version: TOOLKIT_VERSION }, {
5282
5292
  capabilities: {
5283
5293
  tools: {},
5284
5294
  prompts: {},
@@ -42,7 +42,7 @@ export function generateConsolidatedTools(envNames, defaultEnv) {
42
42
  // ═══════════════════════════════════════════════════════════════════════
43
43
  {
44
44
  name: "env",
45
- description: "List available Ema environments. Shows which is default.",
45
+ description: "List available Ema environments and toolkit info. Returns environments (with default marker) and toolkit name/version.",
46
46
  inputSchema: { type: "object", properties: {}, required: [] },
47
47
  },
48
48
  // ═══════════════════════════════════════════════════════════════════════
@@ -185,7 +185,9 @@ For complex workflows, send the prompt to an LLM and deploy:
185
185
  },
186
186
  template_id: { type: "string", description: "Specific template ID (usually auto-selected)" },
187
187
  clone_from: { type: "string", description: "Clone from existing persona ID" },
188
- clone_data: { type: "boolean", description: "Also clone knowledge base files" },
188
+ clone_data: { type: "boolean", description: "Also clone knowledge base files and dashboard rows (auto-enables persona for dashboard cloning)" },
189
+ sanitize: { type: "boolean", description: "Sanitize/obfuscate PII and sensitive data (for demo environments)" },
190
+ sanitize_examples: { type: "array", items: { type: "string" }, description: "Additional items to treat as sensitive (e.g., company names)" },
189
191
  enabled: { type: "boolean", description: "Enable/disable persona" },
190
192
  // === TEMPLATES ===
191
193
  templates: { type: "boolean", description: "List available templates" },
@@ -427,12 +429,20 @@ Ask the user these questions, then put answers into ONE workflow() call.
427
429
 
428
430
  **Attach data source to workflow node**:
429
431
  knowledge(persona_id="abc", mode="attach", node_name="knowledge_search_1")
430
- knowledge(persona_id="abc", mode="attach", node_name="knowledge_search_1", widget_name="fileUpload")`,
432
+ knowledge(persona_id="abc", mode="attach", node_name="knowledge_search_1", widget_name="fileUpload")
433
+
434
+ **Dashboard rows** (for Dashboard personas):
435
+ knowledge(persona_id="abc", mode="dashboard_rows")
436
+ knowledge(persona_id="abc", mode="dashboard_rows", limit=10)
437
+
438
+ **Clone dashboard data** (copy rows from source to target):
439
+ knowledge(persona_id="target", mode="dashboard_clone", source_persona_id="source")
440
+ knowledge(persona_id="target", mode="dashboard_clone", source_persona_id="source", sanitize=true)`,
431
441
  inputSchema: withEnv({
432
442
  persona_id: { type: "string", description: "AI Employee ID (required)" },
433
443
  mode: {
434
444
  type: "string",
435
- enum: ["list", "aggregates", "upload", "delete", "status", "toggle", "attach"],
445
+ enum: ["list", "aggregates", "upload", "delete", "status", "toggle", "attach", "dashboard_rows", "dashboard_clone"],
436
446
  description: "Operation. Default: 'list'"
437
447
  },
438
448
  // List flags
@@ -448,6 +458,10 @@ Ask the user these questions, then put answers into ONE workflow() call.
448
458
  enabled: { type: "boolean", description: "Enable/disable embedding" },
449
459
  // Attach flags
450
460
  node_name: { type: "string", description: "Workflow node name to attach data source to (e.g., 'knowledge_search_1')" },
461
+ // Dashboard clone flags
462
+ source_persona_id: { type: "string", description: "Source persona ID for dashboard clone" },
463
+ sanitize: { type: "boolean", description: "Sanitize/obfuscate data during clone" },
464
+ sanitize_examples: { type: "array", items: { type: "string" }, description: "Additional sensitive items to sanitize" },
451
465
  }, ["persona_id"]),
452
466
  },
453
467
  // ═══════════════════════════════════════════════════════════════════════
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Parser for Ema action definitions from .txtpb files
3
+ *
4
+ * This parses the Protocol Buffer text format used in ema_backend/grpc/workflow_actions/
5
+ * to extract action schemas for validation.
6
+ */
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ // ============================================================================
10
+ // Parser
11
+ // ============================================================================
12
+ /**
13
+ * Parse a .txtpb file content into a ParsedAction
14
+ */
15
+ export function parseTextproto(content, filePath) {
16
+ try {
17
+ // Extract type_name block using brace matching
18
+ const typeNameBlock = extractBlock(content, "type_name");
19
+ // Get the innermost name (action name, not the outer "name" block)
20
+ const nameMatches = [...typeNameBlock.matchAll(/name:\s*"([^"]+)"/g)];
21
+ const name = nameMatches.length > 0 ? nameMatches[nameMatches.length - 1][1] : undefined;
22
+ const version = extractField(typeNameBlock, /version:\s*"([^"]+)"/) || "v0";
23
+ const displayName = extractField(content, /display_name:\s*"([^"]+)"/);
24
+ const description = extractField(content, /description:\s*"([^"]+)"/) ||
25
+ extractMultilineField(content, /description:\s*\n\s*"([^"]+)"/);
26
+ // Extract directly from source - no hardcoded validation
27
+ const category = extractField(content, /category:\s*(\w+)/);
28
+ const lifecycle = extractField(content, /lifecycle:\s*(\w+)/);
29
+ if (!name) {
30
+ console.warn(`Could not parse action name from ${filePath}`);
31
+ return null;
32
+ }
33
+ // Parse inputs
34
+ const inputs = parseInputs(content);
35
+ // Parse outputs
36
+ const outputs = parseOutputs(content);
37
+ // Parse type parameters
38
+ const typeParameters = parseTypeParameters(content);
39
+ // Parse required model features
40
+ const requiredModelFeatures = parseRequiredModelFeatures(content);
41
+ return {
42
+ name,
43
+ version,
44
+ displayName: displayName || name,
45
+ description: description || "",
46
+ category,
47
+ lifecycle,
48
+ inputs,
49
+ outputs,
50
+ typeParameters,
51
+ requiredModelFeatures,
52
+ };
53
+ }
54
+ catch (error) {
55
+ console.error(`Error parsing ${filePath}:`, error);
56
+ return null;
57
+ }
58
+ }
59
+ function extractField(content, regex) {
60
+ const match = content.match(regex);
61
+ return match?.[1];
62
+ }
63
+ /**
64
+ * Extract a block by keyword using brace matching
65
+ */
66
+ function extractBlock(content, keyword) {
67
+ // Handle both "keyword {" and "keyword: {"
68
+ const regex = new RegExp(`${keyword}:?\\s*\\{`);
69
+ const match = content.match(regex);
70
+ if (!match || match.index === undefined)
71
+ return "";
72
+ const start = match.index;
73
+ let depth = 0;
74
+ let inBlock = false;
75
+ let blockEnd = start;
76
+ for (let i = start; i < content.length; i++) {
77
+ if (content[i] === "{") {
78
+ depth++;
79
+ inBlock = true;
80
+ }
81
+ if (content[i] === "}") {
82
+ depth--;
83
+ }
84
+ if (inBlock && depth === 0) {
85
+ blockEnd = i + 1;
86
+ break;
87
+ }
88
+ }
89
+ return content.slice(start, blockEnd);
90
+ }
91
+ function extractMultilineField(content, regex) {
92
+ const match = content.match(regex);
93
+ return match?.[1];
94
+ }
95
+ /**
96
+ * Parse required_model_features from content
97
+ */
98
+ function parseRequiredModelFeatures(content) {
99
+ const features = [];
100
+ const regex = /required_model_features:\s*(\w+)/g;
101
+ let match;
102
+ while ((match = regex.exec(content)) !== null) {
103
+ features.push(match[1]);
104
+ }
105
+ return features;
106
+ }
107
+ function parseInputs(content) {
108
+ const inputs = [];
109
+ // Find the inputs block
110
+ const inputsStart = content.indexOf("\ninputs {");
111
+ if (inputsStart === -1)
112
+ return inputs;
113
+ // Find the end of inputs block (next top-level section)
114
+ const outputsStart = content.indexOf("\noutputs {", inputsStart);
115
+ const inputsEnd = outputsStart !== -1 ? outputsStart : content.length;
116
+ const inputsBlock = content.slice(inputsStart, inputsEnd);
117
+ // Find each input definition by key
118
+ const keyMatches = [...inputsBlock.matchAll(/key:\s*"([^"]+)"/g)];
119
+ for (const keyMatch of keyMatches) {
120
+ const key = keyMatch[1];
121
+ const keyIndex = keyMatch.index;
122
+ // Find the value block after this key
123
+ const valueStart = inputsBlock.indexOf("value {", keyIndex);
124
+ if (valueStart === -1)
125
+ continue;
126
+ // Find matching closing brace for the value block
127
+ let valueDepth = 0;
128
+ let valueEnd = valueStart;
129
+ for (let i = valueStart; i < inputsBlock.length; i++) {
130
+ if (inputsBlock[i] === "{")
131
+ valueDepth++;
132
+ else if (inputsBlock[i] === "}") {
133
+ valueDepth--;
134
+ if (valueDepth === 0) {
135
+ valueEnd = i + 1;
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ const valueBlock = inputsBlock.slice(valueStart, valueEnd);
141
+ const input = {
142
+ name: key,
143
+ displayName: extractField(valueBlock, /display_name:\s*"([^"]+)"/) || key,
144
+ argType: parseArgType(valueBlock),
145
+ isOptional: /is_optional:\s*true/.test(valueBlock),
146
+ isAdvanced: /is_advanced:\s*true/.test(valueBlock),
147
+ isConfigDriven: /is_likely_config_driven:\s*true/.test(valueBlock),
148
+ isActionOutput: /is_likely_action_output:\s*true/.test(valueBlock),
149
+ description: extractField(valueBlock, /description:\s*"([^"]+)"/),
150
+ placeholderDisplayValue: extractField(valueBlock, /placeholder_display_value:\s*"([^"]+)"/),
151
+ };
152
+ inputs.push(input);
153
+ }
154
+ return inputs;
155
+ }
156
+ function parseOutputs(content) {
157
+ const outputs = [];
158
+ // Find the outputs block - more permissive matching
159
+ const outputsStart = content.indexOf("\noutputs {");
160
+ if (outputsStart === -1)
161
+ return outputs;
162
+ // Find the end of outputs block
163
+ let depth = 0;
164
+ let outputsEnd = outputsStart;
165
+ let started = false;
166
+ for (let i = outputsStart; i < content.length; i++) {
167
+ if (content[i] === "{") {
168
+ depth++;
169
+ started = true;
170
+ }
171
+ else if (content[i] === "}") {
172
+ depth--;
173
+ if (started && depth === 0) {
174
+ outputsEnd = i + 1;
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ const outputsBlock = content.slice(outputsStart, outputsEnd);
180
+ // Find each output definition - handle nested braces
181
+ const keyMatches = [...outputsBlock.matchAll(/key:\s*"([^"]+)"/g)];
182
+ for (const keyMatch of keyMatches) {
183
+ const key = keyMatch[1];
184
+ const keyIndex = keyMatch.index;
185
+ // Find the value block after this key
186
+ const valueStart = outputsBlock.indexOf("value {", keyIndex);
187
+ if (valueStart === -1)
188
+ continue;
189
+ // Find matching closing brace
190
+ let valueDepth = 0;
191
+ let valueEnd = valueStart;
192
+ for (let i = valueStart; i < outputsBlock.length; i++) {
193
+ if (outputsBlock[i] === "{")
194
+ valueDepth++;
195
+ else if (outputsBlock[i] === "}") {
196
+ valueDepth--;
197
+ if (valueDepth === 0) {
198
+ valueEnd = i + 1;
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ const valueBlock = outputsBlock.slice(valueStart, valueEnd);
204
+ const output = {
205
+ name: key,
206
+ displayName: extractField(valueBlock, /display_name:\s*"([^"]+)"/) || key,
207
+ argType: parseArgType(valueBlock, true),
208
+ };
209
+ outputs.push(output);
210
+ }
211
+ return outputs;
212
+ }
213
+ function parseArgType(block, isOutput = false) {
214
+ const argType = {};
215
+ // Check for type_parameter (enum types)
216
+ const typeParam = extractField(block, /type_parameter:\s*"([^"]+)"/);
217
+ if (typeParam) {
218
+ argType.typeParameter = typeParam;
219
+ return argType;
220
+ }
221
+ // Check for well_known_type directly
222
+ const wellKnownDirect = extractField(block, /well_known_type:\s*(\w+)/);
223
+ // Check for array_type { well_known_type: ... }
224
+ const arrayMatch = block.match(/array_type\s*\{[\s\S]*?well_known_type:\s*(\w+)/);
225
+ // Check for array_type { named_type { well_known_type: ... } }
226
+ const namedArrayMatch = block.match(/array_type\s*\{[\s\S]*?named_type\s*\{[\s\S]*?well_known_type:\s*(\w+)/);
227
+ if (namedArrayMatch) {
228
+ argType.wellKnownType = namedArrayMatch[1];
229
+ argType.isArray = true;
230
+ argType.isNamed = true;
231
+ }
232
+ else if (arrayMatch) {
233
+ argType.wellKnownType = arrayMatch[1];
234
+ argType.isArray = true;
235
+ }
236
+ else if (wellKnownDirect) {
237
+ argType.wellKnownType = wellKnownDirect;
238
+ }
239
+ return argType;
240
+ }
241
+ function parseTypeParameters(content) {
242
+ const params = [];
243
+ const typeParamsMatch = content.match(/type_parameters\s*\{([\s\S]*?)\n\}/);
244
+ if (!typeParamsMatch)
245
+ return params;
246
+ const keyRegex = /key:\s*"([^"]+)"/g;
247
+ let match;
248
+ while ((match = keyRegex.exec(typeParamsMatch[1])) !== null) {
249
+ params.push(match[1]);
250
+ }
251
+ return params;
252
+ }
253
+ // ============================================================================
254
+ // File System Helpers
255
+ // ============================================================================
256
+ /**
257
+ * Parse all .txtpb files from a directory
258
+ */
259
+ export function parseActionDirectory(dirPath) {
260
+ const actions = [];
261
+ if (!fs.existsSync(dirPath)) {
262
+ console.warn(`Directory not found: ${dirPath}`);
263
+ return actions;
264
+ }
265
+ const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".txtpb"));
266
+ for (const file of files) {
267
+ const filePath = path.join(dirPath, file);
268
+ const content = fs.readFileSync(filePath, "utf-8");
269
+ const parsed = parseTextproto(content, filePath);
270
+ if (parsed) {
271
+ actions.push(parsed);
272
+ }
273
+ }
274
+ return actions;
275
+ }
276
+ /**
277
+ * Load documentation for an action from .md files
278
+ */
279
+ export function loadDocumentation(docDir, actionName) {
280
+ if (!fs.existsSync(docDir))
281
+ return undefined;
282
+ // Try common naming patterns
283
+ const patterns = [
284
+ `${actionName}.md`,
285
+ `${actionName.replace(/_/g, " ")}.md`,
286
+ `${toTitleCase(actionName.replace(/_/g, " "))}.md`,
287
+ `${toTitleCase(actionName.replace(/_/g, " "))}_Agent.md`,
288
+ ];
289
+ for (const pattern of patterns) {
290
+ const filePath = path.join(docDir, pattern);
291
+ if (fs.existsSync(filePath)) {
292
+ return fs.readFileSync(filePath, "utf-8");
293
+ }
294
+ }
295
+ // Fallback: search for files containing the action name
296
+ const files = fs.readdirSync(docDir);
297
+ const searchName = actionName.replace(/_/g, "").toLowerCase();
298
+ for (const file of files) {
299
+ if (file.toLowerCase().replace(/_/g, "").includes(searchName)) {
300
+ return fs.readFileSync(path.join(docDir, file), "utf-8");
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+ function toTitleCase(str) {
306
+ return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
307
+ }
308
+ /**
309
+ * Generate a complete action schema bundle from the ema repo
310
+ */
311
+ export function generateSchemaBundle(config) {
312
+ const prodDir = path.join(config.basePath, config.prodDir || "system_agents/prod");
313
+ const devDir = path.join(config.basePath, config.devDir || "system_agents/dev");
314
+ const docsDir = path.join(config.basePath, config.docsDir || "documentation");
315
+ // Parse all actions
316
+ const prodActions = parseActionDirectory(prodDir);
317
+ const devActions = parseActionDirectory(devDir);
318
+ // Group by action name
319
+ const actionMap = {};
320
+ for (const action of [...prodActions, ...devActions]) {
321
+ if (!actionMap[action.name]) {
322
+ actionMap[action.name] = [];
323
+ }
324
+ // Add raw documentation - LLM will interpret at generation time
325
+ action.documentation = loadDocumentation(docsDir, action.name);
326
+ // Note: capabilities field is intentionally left undefined
327
+ // The LLM should analyze documentation in context, not pre-computed extraction
328
+ // Avoid duplicates (same name + version)
329
+ const exists = actionMap[action.name].some((a) => a.version === action.version);
330
+ if (!exists) {
331
+ actionMap[action.name].push(action);
332
+ }
333
+ }
334
+ return {
335
+ version: new Date().toISOString().split("T")[0], // YYYY-MM-DD
336
+ generatedAt: new Date().toISOString(),
337
+ source: "code",
338
+ sourcePath: config.basePath,
339
+ actions: actionMap,
340
+ };
341
+ }
342
+ // ============================================================================
343
+ // Type Compatibility Matrix
344
+ // ============================================================================
345
+ /**
346
+ * Type compatibility rules for workflow validation
347
+ */
348
+ export const TYPE_COMPATIBILITY = {
349
+ // What inputs can accept each type
350
+ WELL_KNOWN_TYPE_CHAT_CONVERSATION: ["conversation", "chat_conversation"],
351
+ WELL_KNOWN_TYPE_TEXT_WITH_SOURCES: ["query", "text", "user_query", "input"],
352
+ WELL_KNOWN_TYPE_SEARCH_RESULT: ["search_results", "results"],
353
+ WELL_KNOWN_TYPE_ANY: ["*"], // Accepts anything
354
+ WELL_KNOWN_TYPE_STRING: ["text", "query", "instructions", "input"],
355
+ WELL_KNOWN_TYPE_STRUCT: ["custom_data", "extracted_variables", "json"],
356
+ };
357
+ /**
358
+ * Check if an output type is compatible with an input type
359
+ */
360
+ export function isTypeCompatible(sourceType, targetType, targetInputName) {
361
+ // ANY accepts everything
362
+ if (targetType === "WELL_KNOWN_TYPE_ANY")
363
+ return true;
364
+ // Same type is always compatible
365
+ if (sourceType === targetType)
366
+ return true;
367
+ // named_inputs accepts anything
368
+ if (targetInputName?.startsWith("named_inputs"))
369
+ return true;
370
+ // Check explicit compatibility
371
+ if (sourceType && TYPE_COMPATIBILITY[sourceType]) {
372
+ const compatible = TYPE_COMPATIBILITY[sourceType];
373
+ if (compatible.includes("*"))
374
+ return true;
375
+ if (targetInputName && compatible.some((c) => targetInputName.includes(c)))
376
+ return true;
377
+ }
378
+ return false;
379
+ }