@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,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
+ }