@ema.co/mcp-toolkit 2026.1.30-1 → 2026.2.5
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.
- package/README.md +6 -2
- package/dist/mcp/autobuilder.js +1 -1
- package/dist/mcp/domain/dashboard.js +224 -0
- package/dist/mcp/domain/validation-rules.js +67 -5
- package/dist/mcp/guidance.js +31 -26
- package/dist/mcp/handlers/data/dashboard-clone.js +41 -55
- package/dist/mcp/handlers/data/index.js +150 -60
- package/dist/mcp/handlers/persona/delete.js +46 -1
- package/dist/mcp/handlers/persona/update.js +66 -1
- package/dist/mcp/handlers/workflow/deploy.js +4 -0
- package/dist/mcp/handlers/workflow/generate.js +1 -1
- package/dist/mcp/handlers/workflow/index.js +11 -4
- package/dist/mcp/handlers/workflow/utils.js +1 -1
- package/dist/mcp/handlers/workflow/validate.js +2 -2
- package/dist/mcp/handlers/workflow/validation.js +87 -12
- package/dist/mcp/knowledge.js +164 -41
- package/dist/mcp/prompts.js +4 -3
- package/dist/mcp/resources.js +513 -5
- package/dist/mcp/server.js +177 -23
- package/dist/mcp/tools.js +31 -3
- package/dist/sdk/ema-client.js +76 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -225,13 +225,17 @@ persona(input="IT helpdesk with KB search", type="chat", name="IT Support", prev
|
|
|
225
225
|
```typescript
|
|
226
226
|
// Step 1: Get current workflow
|
|
227
227
|
workflow(mode="get", persona_id="abc-123")
|
|
228
|
-
// Returns: workflow_def, schema, guidance
|
|
228
|
+
// Returns: workflow_def, schema, guidance, fingerprint
|
|
229
229
|
|
|
230
230
|
// Step 2: LLM modifies the workflow_def JSON
|
|
231
231
|
// (add nodes, remove nodes, rewire connections)
|
|
232
232
|
|
|
233
233
|
// Step 3: Deploy modified workflow
|
|
234
|
-
|
|
234
|
+
// IMPORTANT: pass base_fingerprint from the get() response to prevent overwriting out-of-band changes
|
|
235
|
+
workflow(mode="deploy", persona_id="abc-123", base_fingerprint="<fingerprint>", workflow_def={...})
|
|
236
|
+
|
|
237
|
+
// If the workflow_def is large (near transport limits), deploy from a file instead:
|
|
238
|
+
// workflow(mode="deploy", persona_id="abc-123", base_fingerprint="<fingerprint>", workflow_def_path="/path/to/wf.json")
|
|
235
239
|
```
|
|
236
240
|
|
|
237
241
|
**Get reference info:**
|
package/dist/mcp/autobuilder.js
CHANGED
|
@@ -274,7 +274,7 @@ export function generateAutobuilderPrompt(description, personaType) {
|
|
|
274
274
|
prompt += "- Use external_action_caller or send_email_agent as needed\n";
|
|
275
275
|
}
|
|
276
276
|
if (hasHITL) {
|
|
277
|
-
prompt += "-
|
|
277
|
+
prompt += "- Enable HITL flag on agents that need approval (do NOT add standalone general_hitl nodes)\n";
|
|
278
278
|
}
|
|
279
279
|
prompt += `- ${config.outputNote}\n`;
|
|
280
280
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Domain Logic
|
|
3
|
+
*
|
|
4
|
+
* Business logic for dashboard operations including:
|
|
5
|
+
* - Sanitization of PII in column values
|
|
6
|
+
* - Nested data transformation with sanitization
|
|
7
|
+
* - Validation helpers
|
|
8
|
+
*
|
|
9
|
+
* Note: Pure format translation (without sanitization) is in SDK's columnValueToInput().
|
|
10
|
+
*/
|
|
11
|
+
import { columnValueToInput } from "../../sdk/ema-client.js";
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Sanitization
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Sanitization session for PII replacement.
|
|
17
|
+
* Maintains consistent replacements across multiple values.
|
|
18
|
+
*/
|
|
19
|
+
export class SanitizationSession {
|
|
20
|
+
replacements = new Map();
|
|
21
|
+
counters = {};
|
|
22
|
+
/**
|
|
23
|
+
* Get or create a replacement for a detected PII value.
|
|
24
|
+
* Returns consistent replacements for the same value.
|
|
25
|
+
*/
|
|
26
|
+
getOrCreateReplacement(value, type) {
|
|
27
|
+
const existing = this.replacements.get(value);
|
|
28
|
+
if (existing)
|
|
29
|
+
return existing;
|
|
30
|
+
this.counters[type] = (this.counters[type] ?? 0) + 1;
|
|
31
|
+
const replacement = `[${type.toUpperCase()}_${this.counters[type]}]`;
|
|
32
|
+
this.replacements.set(value, replacement);
|
|
33
|
+
return replacement;
|
|
34
|
+
}
|
|
35
|
+
/** Get all replacements made in this session */
|
|
36
|
+
getReplacements() {
|
|
37
|
+
return new Map(this.replacements);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Detect PII patterns in text using regex.
|
|
42
|
+
*/
|
|
43
|
+
export function detectPiiPatterns(text) {
|
|
44
|
+
const detected = [];
|
|
45
|
+
// Email pattern
|
|
46
|
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
47
|
+
for (const match of text.matchAll(emailRegex)) {
|
|
48
|
+
detected.push({ value: match[0], type: "email" });
|
|
49
|
+
}
|
|
50
|
+
// Phone pattern (various formats)
|
|
51
|
+
const phoneRegex = /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g;
|
|
52
|
+
for (const match of text.matchAll(phoneRegex)) {
|
|
53
|
+
detected.push({ value: match[0], type: "phone" });
|
|
54
|
+
}
|
|
55
|
+
return detected;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sanitize a string value by replacing detected PII.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeString(value, session, additionalPatterns) {
|
|
61
|
+
let result = value;
|
|
62
|
+
// Pattern-based sanitization
|
|
63
|
+
const detected = detectPiiPatterns(result);
|
|
64
|
+
for (const entity of detected) {
|
|
65
|
+
const replacement = session.getOrCreateReplacement(entity.value, entity.type);
|
|
66
|
+
result = result.split(entity.value).join(replacement);
|
|
67
|
+
}
|
|
68
|
+
// User-provided patterns
|
|
69
|
+
if (additionalPatterns) {
|
|
70
|
+
for (const pattern of additionalPatterns) {
|
|
71
|
+
if (result.includes(pattern)) {
|
|
72
|
+
const replacement = session.getOrCreateReplacement(pattern, "custom");
|
|
73
|
+
result = result.split(pattern).join(replacement);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// Column Value Conversion with Sanitization
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Convert a column value to DashboardInput with optional sanitization.
|
|
84
|
+
*
|
|
85
|
+
* This wraps the SDK's pure conversion function and adds sanitization.
|
|
86
|
+
* Use this in MCP handlers when sanitization is needed.
|
|
87
|
+
*
|
|
88
|
+
* @param name - The column/field name
|
|
89
|
+
* @param value - The column value from API response
|
|
90
|
+
* @param session - Sanitization session (null to skip sanitization)
|
|
91
|
+
* @param additionalPatterns - Additional strings to sanitize
|
|
92
|
+
* @returns DashboardInput for upload, or null if value is empty/unsupported
|
|
93
|
+
*/
|
|
94
|
+
export function columnValueToInputWithSanitization(name, value, session, additionalPatterns) {
|
|
95
|
+
if (!value)
|
|
96
|
+
return null;
|
|
97
|
+
// If no sanitization needed, delegate to SDK
|
|
98
|
+
if (!session) {
|
|
99
|
+
return columnValueToInput(name, value);
|
|
100
|
+
}
|
|
101
|
+
// Handle string value with sanitization
|
|
102
|
+
if (value.stringValue !== undefined) {
|
|
103
|
+
const sanitized = sanitizeString(value.stringValue, session, additionalPatterns);
|
|
104
|
+
return { name, string_value: sanitized };
|
|
105
|
+
}
|
|
106
|
+
// Handle number value (no sanitization needed)
|
|
107
|
+
if (value.numberValue !== undefined) {
|
|
108
|
+
return { name, number_value: value.numberValue };
|
|
109
|
+
}
|
|
110
|
+
// Handle boolean value (no sanitization needed)
|
|
111
|
+
if (value.booleanValue !== undefined) {
|
|
112
|
+
return { name, boolean_value: value.booleanValue };
|
|
113
|
+
}
|
|
114
|
+
// Handle array value (recursively with sanitization)
|
|
115
|
+
if (value.arrayValue) {
|
|
116
|
+
const elements = value.arrayValue.arrayValues ?? [];
|
|
117
|
+
if (elements.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
const arrayInputs = [];
|
|
120
|
+
for (const elem of elements) {
|
|
121
|
+
const converted = columnValueToInputWithSanitization("element", elem, session, additionalPatterns);
|
|
122
|
+
if (converted)
|
|
123
|
+
arrayInputs.push(converted);
|
|
124
|
+
}
|
|
125
|
+
return arrayInputs.length > 0 ? { name, array_value: arrayInputs } : null;
|
|
126
|
+
}
|
|
127
|
+
// Handle object value (recursively with sanitization)
|
|
128
|
+
if (value.objectValue) {
|
|
129
|
+
const objectVals = value.objectValue.objectValues ?? {};
|
|
130
|
+
const keys = Object.keys(objectVals);
|
|
131
|
+
if (keys.length === 0)
|
|
132
|
+
return null;
|
|
133
|
+
const objectInput = {};
|
|
134
|
+
for (const key of keys) {
|
|
135
|
+
const converted = columnValueToInputWithSanitization(key, objectVals[key], session, additionalPatterns);
|
|
136
|
+
if (converted)
|
|
137
|
+
objectInput[key] = converted;
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(objectInput).length > 0 ? { name, object_value: objectInput } : null;
|
|
140
|
+
}
|
|
141
|
+
// Unsupported types - delegate to SDK (will return null)
|
|
142
|
+
return columnValueToInput(name, value);
|
|
143
|
+
}
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
// Nested Data Validation
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
/** Maximum array elements to prevent oversized payloads */
|
|
148
|
+
export const MAX_ARRAY_ELEMENTS = 1000;
|
|
149
|
+
/** Maximum nesting depth for arrays/objects */
|
|
150
|
+
export const MAX_NESTING_DEPTH = 3;
|
|
151
|
+
/**
|
|
152
|
+
* Validate nested data structure.
|
|
153
|
+
* Returns errors/warnings for issues found.
|
|
154
|
+
*/
|
|
155
|
+
export function validateNestedData(value, schemaType, path = "", depth = 0) {
|
|
156
|
+
const errors = [];
|
|
157
|
+
// Check nesting depth
|
|
158
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
159
|
+
errors.push({
|
|
160
|
+
path,
|
|
161
|
+
message: `Nesting depth exceeds maximum of ${MAX_NESTING_DEPTH}`,
|
|
162
|
+
severity: "error",
|
|
163
|
+
});
|
|
164
|
+
return errors;
|
|
165
|
+
}
|
|
166
|
+
// Validate based on schema type
|
|
167
|
+
if (schemaType === "COLUMN_TYPE_ARRAY" || schemaType === "ARRAY") {
|
|
168
|
+
if (!Array.isArray(value)) {
|
|
169
|
+
errors.push({
|
|
170
|
+
path,
|
|
171
|
+
message: `Expected array for ARRAY column, got ${typeof value}`,
|
|
172
|
+
severity: "error",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else if (value.length > MAX_ARRAY_ELEMENTS) {
|
|
176
|
+
errors.push({
|
|
177
|
+
path,
|
|
178
|
+
message: `Array has ${value.length} elements, exceeds maximum of ${MAX_ARRAY_ELEMENTS}`,
|
|
179
|
+
severity: "error",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (schemaType === "COLUMN_TYPE_OBJECT" || schemaType === "OBJECT") {
|
|
184
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
185
|
+
errors.push({
|
|
186
|
+
path,
|
|
187
|
+
message: `Expected object for OBJECT column, got ${Array.isArray(value) ? "array" : typeof value}`,
|
|
188
|
+
severity: "error",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (schemaType === "COLUMN_TYPE_STRING" || schemaType === "STRING") {
|
|
193
|
+
if (typeof value !== "string") {
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
errors.push({
|
|
196
|
+
path,
|
|
197
|
+
message: `Column expects scalar STRING value, got array. Did you mean to use an ARRAY column?`,
|
|
198
|
+
severity: "error",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (typeof value === "object" && value !== null) {
|
|
202
|
+
errors.push({
|
|
203
|
+
path,
|
|
204
|
+
message: `Column expects scalar STRING value, got object. Did you mean to use an OBJECT column?`,
|
|
205
|
+
severity: "error",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return errors;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Check if a value contains nested data (array or object).
|
|
214
|
+
*/
|
|
215
|
+
export function hasNestedData(value) {
|
|
216
|
+
if (Array.isArray(value))
|
|
217
|
+
return true;
|
|
218
|
+
if (typeof value === "object" && value !== null) {
|
|
219
|
+
// Check if it looks like a nested structure vs a simple object
|
|
220
|
+
const keys = Object.keys(value);
|
|
221
|
+
return keys.some(k => !["name", "string_value", "number_value", "boolean_value", "document_value"].includes(k));
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
@@ -132,12 +132,13 @@ export const ANTI_PATTERNS = [
|
|
|
132
132
|
{
|
|
133
133
|
id: "email-without-validation",
|
|
134
134
|
name: "Email Without Input Validation",
|
|
135
|
-
pattern: "send_email_agent without entity_extraction
|
|
135
|
+
pattern: "send_email_agent without entity_extraction to validate recipient data",
|
|
136
136
|
problem: "Sending emails without extracting and validating recipient data risks sending to wrong people or with wrong content. Emails are high-impact actions with external side effects.",
|
|
137
|
-
solution: "Always: 1) Extract required fields (email_address, subject) via entity_extraction, 2) Validate completeness via categorizer, 3) Ask user if missing
|
|
137
|
+
solution: "Always: 1) Extract required fields (email_address, subject) via entity_extraction, 2) Validate completeness via categorizer, 3) Ask user if missing. " +
|
|
138
|
+
"For approval: enable the HITL flag on send_email_agent (do NOT add a standalone general_hitl node).",
|
|
138
139
|
detection: {
|
|
139
|
-
issueType: "
|
|
140
|
-
condition: "send_email_agent without preceding entity_extraction
|
|
140
|
+
issueType: "incomplete_email_validation",
|
|
141
|
+
condition: "send_email_agent without preceding entity_extraction node to extract/validate recipient data",
|
|
141
142
|
},
|
|
142
143
|
severity: "critical",
|
|
143
144
|
},
|
|
@@ -194,12 +195,15 @@ export const ANTI_PATTERNS = [
|
|
|
194
195
|
name: "Incomplete HITL Paths",
|
|
195
196
|
pattern: "HITL with only success path",
|
|
196
197
|
problem: "Rejected requests have no handling, leaving users without response.",
|
|
197
|
-
solution: "ALWAYS implement both success AND failure paths
|
|
198
|
+
solution: "If workflow already has general_hitl node: ALWAYS implement both success AND failure paths. For NEW workflows: use HITL flag on the agent instead of standalone general_hitl nodes (see hitl-patterns guidance).",
|
|
198
199
|
detection: {
|
|
199
200
|
issueType: "incomplete_hitl",
|
|
200
201
|
condition: "HITL node missing 'hitl_status_HITL Success' or 'hitl_status_HITL Failure' edge (note: space, not underscore)",
|
|
201
202
|
},
|
|
202
203
|
severity: "critical",
|
|
204
|
+
// TODO: Revisit if general_hitl is re-enabled as a standalone node.
|
|
205
|
+
// Currently HITL is a flag on agents (send_email_agent, external_action_caller).
|
|
206
|
+
// This anti-pattern still fires for existing workflows that use general_hitl.
|
|
203
207
|
},
|
|
204
208
|
{
|
|
205
209
|
id: "orphan-nodes",
|
|
@@ -432,6 +436,64 @@ export const ANTI_PATTERNS = [
|
|
|
432
436
|
},
|
|
433
437
|
severity: "critical",
|
|
434
438
|
},
|
|
439
|
+
{
|
|
440
|
+
id: "chat-conversation-without-chat-response",
|
|
441
|
+
name: "Chat Conversation Not Wired to Chat Response Node",
|
|
442
|
+
pattern: "Chat workflow where chat_conversation is consumed by processing nodes but never wired to a chat-aware response node (respond_with_sources or respond_for_external_actions)",
|
|
443
|
+
problem: "When chat_conversation is processed by nodes like entity_extraction, call_llm, or search, " +
|
|
444
|
+
"but the final response is generated by a generic call_llm (not a chat-aware response node), " +
|
|
445
|
+
"the response node lacks conversation history context. This causes:\n" +
|
|
446
|
+
"1. Duplicate/repetitive responses — the LLM re-asks questions already answered in conversation\n" +
|
|
447
|
+
"2. Lost context — follow-up messages lose prior context\n" +
|
|
448
|
+
"3. Hallucinated greetings — the LLM generates its own greeting instead of continuing the conversation\n\n" +
|
|
449
|
+
"The chat-aware response nodes (respond_with_sources, respond_for_external_actions) are specifically " +
|
|
450
|
+
"designed to handle conversation history and produce contextually appropriate responses.",
|
|
451
|
+
solution: "Wire chat_conversation through to a chat-aware response node:\n" +
|
|
452
|
+
"• For search-based responses: use respond_with_sources (takes query + search_results)\n" +
|
|
453
|
+
"• For tool/action results: use respond_for_external_actions (takes query + external_action_result)\n" +
|
|
454
|
+
"• If using call_llm as responder: wire chat_conversation into named_inputs so the LLM sees full history\n\n" +
|
|
455
|
+
"Correct patterns:\n" +
|
|
456
|
+
" chat_trigger → search → respond_with_sources → WORKFLOW_OUTPUT\n" +
|
|
457
|
+
" chat_trigger → external_action_caller → respond_for_external_actions → WORKFLOW_OUTPUT\n" +
|
|
458
|
+
" chat_trigger → call_llm(named_inputs includes chat_conversation) → WORKFLOW_OUTPUT\n\n" +
|
|
459
|
+
"Anti-patterns:\n" +
|
|
460
|
+
" ❌ chat_trigger → search → call_llm (no conversation context) → WORKFLOW_OUTPUT\n" +
|
|
461
|
+
" ❌ chat_trigger → entity_extraction → call_llm (stateless) → WORKFLOW_OUTPUT",
|
|
462
|
+
detection: {
|
|
463
|
+
issueType: "chat_conversation_not_wired_to_response",
|
|
464
|
+
condition: "Chat workflow (chat_trigger) where terminal response node is call_llm without chat_conversation in named_inputs, " +
|
|
465
|
+
"and respond_with_sources / respond_for_external_actions are not used",
|
|
466
|
+
},
|
|
467
|
+
severity: "critical",
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: "entity-extraction-direct-to-email",
|
|
471
|
+
name: "Entity Extraction Wired Directly to Email Inputs",
|
|
472
|
+
pattern: "entity_extraction outputs connected directly to send_email_agent.email_to, email_subject, or email_body",
|
|
473
|
+
problem: "entity_extraction outputs are typed as WELL_KNOWN_TYPE_ANY (structured extraction results), " +
|
|
474
|
+
"but send_email_agent inputs (email_to, email_subject, email_body) require WELL_KNOWN_TYPE_TEXT_WITH_SOURCES. " +
|
|
475
|
+
"Direct wiring causes type mismatch errors or corrupted email fields.\n\n" +
|
|
476
|
+
"Additionally, entity_extraction outputs may contain multiple extracted values in a structured format — " +
|
|
477
|
+
"wiring the entire output to email_to would send an object/JSON as the recipient address, not a clean email string.",
|
|
478
|
+
solution: "Use intermediary nodes to extract and format individual fields:\n\n" +
|
|
479
|
+
"Pattern A — json_mapper + fixed_response (recommended for multiple fields):\n" +
|
|
480
|
+
" entity_extraction → json_mapper (extract 'to', 'subject', 'body') → fixed_response({{to}}) → send_email_agent.email_to\n" +
|
|
481
|
+
" Same json_mapper → fixed_response({{subject}}) → send_email_agent.email_subject\n" +
|
|
482
|
+
" Same json_mapper → fixed_response({{body}}) → send_email_agent.email_body\n\n" +
|
|
483
|
+
"Pattern B — custom_agent with output_fields (simpler for LLM-generated content):\n" +
|
|
484
|
+
" custom_agent(output_fields=[To,Subject,Body]) → send_email_agent\n" +
|
|
485
|
+
" (custom_agent with output_fields produces individual TEXT_WITH_SOURCES outputs)\n\n" +
|
|
486
|
+
"Pattern C — fixed_response with template variables (for simple extraction):\n" +
|
|
487
|
+
" entity_extraction → fixed_response(template='{{email_address}}', custom_data=entity_extraction) → send_email_agent.email_to\n\n" +
|
|
488
|
+
"Key principle: send_email_agent inputs need TEXT_WITH_SOURCES — always use fixed_response or " +
|
|
489
|
+
"custom_agent(output_fields) as the adapter between extraction results and email fields.",
|
|
490
|
+
detection: {
|
|
491
|
+
issueType: "entity_extraction_direct_to_email",
|
|
492
|
+
condition: "entity_extraction output connected directly to send_email_agent.email_to, email_subject, or email_body " +
|
|
493
|
+
"without an intermediary json_mapper/fixed_response/custom_agent node",
|
|
494
|
+
},
|
|
495
|
+
severity: "critical",
|
|
496
|
+
},
|
|
435
497
|
];
|
|
436
498
|
export const OPTIMIZATION_RULES = [
|
|
437
499
|
{
|
package/dist/mcp/guidance.js
CHANGED
|
@@ -25,36 +25,38 @@ export const GUIDANCE_RULES = [
|
|
|
25
25
|
category: "workflow",
|
|
26
26
|
title: "Get Workflow Before Modifying",
|
|
27
27
|
applies: "Before any workflow modification",
|
|
28
|
-
description: "Always fetch the current workflow_def before making changes.",
|
|
29
|
-
do: "Call workflow(mode='get', persona_id='...') to get the current workflow_def and schema.",
|
|
30
|
-
dont: "Jump straight to deploy without understanding the current state.",
|
|
28
|
+
description: "Always fetch the current workflow_def before making changes. Use the returned workflow_def as your canonical format reference; do not reverse-engineer from local JSON files.",
|
|
29
|
+
do: "Call workflow(mode='get', persona_id='...') to get the current workflow_def and schema. Use the returned workflow_def as format reference. For extraction_columns see ema://rules/extraction-column-format.",
|
|
30
|
+
dont: "Jump straight to deploy without understanding the current state. Do not compare local JSON files in Python/scripts; use MCP get as format reference.",
|
|
31
31
|
example: `// Good - get first, then modify, then deploy
|
|
32
32
|
const data = await workflow({ mode: "get", persona_id: "abc" });
|
|
33
33
|
const wf = data.workflow_def;
|
|
34
34
|
// LLM modifies wf...
|
|
35
35
|
wf.actions.push(newNode);
|
|
36
|
-
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: wf });`,
|
|
36
|
+
await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: wf });`,
|
|
37
37
|
antiExample: `// Bad - deploying without getting current state
|
|
38
|
-
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: guessedDef })
|
|
38
|
+
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: guessedDef }); // Will be blocked (missing base_fingerprint)`,
|
|
39
39
|
related: ["preview-before-deploy"],
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
id: "preview-before-deploy",
|
|
43
43
|
level: "critical",
|
|
44
44
|
category: "safety",
|
|
45
|
-
title: "
|
|
45
|
+
title: "Validate Before Deploying",
|
|
46
46
|
applies: "Before any deployment",
|
|
47
|
-
description: "Always
|
|
48
|
-
do: "Use preview=true to
|
|
49
|
-
dont: "Deploy directly without reviewing
|
|
50
|
-
example: `// Good
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
await persona({
|
|
47
|
+
description: "Always validate and review changes before deploying to catch errors early.",
|
|
48
|
+
do: "Use workflow(mode='validate') before workflow(mode='deploy'). For persona workflow_spec updates, you can use preview=true to review changes before applying.",
|
|
49
|
+
dont: "Deploy directly without validating/reviewing.",
|
|
50
|
+
example: `// Good (workflow deploy)
|
|
51
|
+
const data = await workflow({ mode: "get", persona_id: "abc" });
|
|
52
|
+
// LLM modifies data.workflow_def...
|
|
53
|
+
await workflow({ mode: "validate", workflow_def: data.workflow_def });
|
|
54
|
+
await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: data.workflow_def });
|
|
55
|
+
|
|
56
|
+
// Good (persona workflow_spec preview)
|
|
57
|
+
const preview = await persona({ method: "update", id: "abc", workflow_spec: spec, preview: true });
|
|
58
|
+
// Review preview.changes, then apply:
|
|
59
|
+
await persona({ method: "update", id: "abc", workflow_spec: spec, preview: false });`,
|
|
58
60
|
related: ["get-before-modify"],
|
|
59
61
|
},
|
|
60
62
|
{
|
|
@@ -93,7 +95,8 @@ const stats = await persona({ id: "abc", data: { method: "stats" } });
|
|
|
93
95
|
// stats.success should be > 0
|
|
94
96
|
|
|
95
97
|
// Then deploy workflow with search
|
|
96
|
-
await workflow({ mode: "
|
|
98
|
+
const wfData = await workflow({ mode: "get", persona_id: "abc" });
|
|
99
|
+
await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: wfData.fingerprint, workflow_def: workflowWithSearch });`,
|
|
97
100
|
antiExample: `// BAD: Deploy search workflow with no documents
|
|
98
101
|
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: workflowWithSearch });
|
|
99
102
|
// Search will return empty results!`,
|
|
@@ -106,11 +109,13 @@ await workflow({ mode: "deploy", persona_id: "abc", workflow_def: workflowWithSe
|
|
|
106
109
|
title: "Workflow Modification Flow",
|
|
107
110
|
applies: "When modifying workflows",
|
|
108
111
|
description: "All workflow modifications follow a 3-step flow: get → modify → deploy. " +
|
|
112
|
+
"Use the returned workflow_def as your format reference; do not reverse-engineer from local files. " +
|
|
109
113
|
"The LLM generates the full workflow_def, MCP just deploys it.",
|
|
110
|
-
do: "1) Get workflow: workflow(mode='get', persona_id='...') " +
|
|
111
|
-
"2) LLM modifies the workflow_def JSON " +
|
|
112
|
-
"3) Deploy: workflow(mode='deploy', persona_id='...', workflow_def={...})"
|
|
113
|
-
|
|
114
|
+
do: "1) Get workflow: workflow(mode='get', persona_id='...') — use returned workflow_def as format reference; for extraction_columns fetch ema://rules/extraction-column-format " +
|
|
115
|
+
"2) LLM modifies the workflow_def JSON (text/JSON edits) " +
|
|
116
|
+
"3) Deploy: workflow(mode='deploy', persona_id='...', base_fingerprint='<fingerprint>', workflow_def={...}) " +
|
|
117
|
+
"(or workflow_def_path='/path/to/wf.json' for large payloads)",
|
|
118
|
+
dont: "Try to use incremental update operations - they don't exist. Do not compare local JSON files in Python/scripts; use MCP get as format reference. Always deploy complete workflow_def.",
|
|
114
119
|
example: `// The ONLY working pattern for workflow modifications:
|
|
115
120
|
const data = await workflow({ mode: "get", persona_id: "abc" });
|
|
116
121
|
const wf = data.workflow_def;
|
|
@@ -120,7 +125,7 @@ wf.actions.push(newNode);
|
|
|
120
125
|
wf.actions[2].inputs.instructions.inline.wellKnown.stringValue = "new prompt";
|
|
121
126
|
|
|
122
127
|
// Deploy the complete modified workflow
|
|
123
|
-
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: wf });`,
|
|
128
|
+
await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: wf });`,
|
|
124
129
|
antiExample: `// WRONG: These patterns DO NOT WORK
|
|
125
130
|
persona({ method: "update", id: "abc", input: "add HITL" }) // input param ignored
|
|
126
131
|
persona({ method: "update", id: "abc", operations: [...] }) // operations param ignored`,
|
|
@@ -136,7 +141,7 @@ persona({ method: "update", id: "abc", operations: [...] }) // operations param
|
|
|
136
141
|
title: "workflow_spec via persona update has limitations",
|
|
137
142
|
applies: "When using persona(method='update', workflow_spec={...})",
|
|
138
143
|
description: "persona(method='update', workflow_spec={...}) can ONLY remove/rewire nodes, NOT add new ones. " +
|
|
139
|
-
"For adding nodes, use workflow(mode='deploy', workflow_def={...}) with full workflow.",
|
|
144
|
+
"For adding nodes, use workflow(mode='deploy', base_fingerprint='<fingerprint>', workflow_def={...}) with full workflow.",
|
|
140
145
|
do: "Use workflow_spec for simple rewiring. Use workflow(mode='deploy') for adding nodes.",
|
|
141
146
|
dont: "Try to add new nodes via workflow_spec - it will throw an error.",
|
|
142
147
|
example: `// workflow_spec: good for rewiring/removing
|
|
@@ -152,7 +157,7 @@ await persona({
|
|
|
152
157
|
// For adding nodes: use workflow(mode="deploy")
|
|
153
158
|
const data = await workflow({ mode: "get", persona_id: "abc" });
|
|
154
159
|
data.workflow_def.actions.push(newNode);
|
|
155
|
-
await workflow({ mode: "deploy", persona_id: "abc", workflow_def: data.workflow_def });`,
|
|
160
|
+
await workflow({ mode: "deploy", persona_id: "abc", base_fingerprint: data.fingerprint, workflow_def: data.workflow_def });`,
|
|
156
161
|
antiExample: `// WRONG: workflow_spec cannot add new nodes
|
|
157
162
|
await persona({
|
|
158
163
|
method: "update",
|
|
@@ -402,7 +407,7 @@ All workflow modifications follow a 3-step flow:
|
|
|
402
407
|
|
|
403
408
|
1. Get: \`workflow(mode="get", persona_id="...")\` → returns workflow_def, schema
|
|
404
409
|
2. LLM modifies the workflow_def JSON
|
|
405
|
-
3. Deploy: \`workflow(mode="deploy", persona_id="...", workflow_def={...})\`
|
|
410
|
+
3. Deploy: \`workflow(mode="deploy", persona_id="...", base_fingerprint="<fingerprint>", workflow_def={...})\`
|
|
406
411
|
|
|
407
412
|
The LLM generates the full workflow_def. MCP provides data and executes.
|
|
408
413
|
|
|
@@ -16,40 +16,7 @@
|
|
|
16
16
|
* - Slower but allows data transformation
|
|
17
17
|
*/
|
|
18
18
|
import { errorResult } from "../types.js";
|
|
19
|
-
|
|
20
|
-
* Sanitization session for PII replacement.
|
|
21
|
-
* Simple implementation - can be enhanced with more patterns.
|
|
22
|
-
*/
|
|
23
|
-
class SanitizationSession {
|
|
24
|
-
replacements = new Map();
|
|
25
|
-
counters = {};
|
|
26
|
-
getOrCreateReplacement(value, type) {
|
|
27
|
-
const existing = this.replacements.get(value);
|
|
28
|
-
if (existing)
|
|
29
|
-
return existing;
|
|
30
|
-
this.counters[type] = (this.counters[type] ?? 0) + 1;
|
|
31
|
-
const replacement = `[${type.toUpperCase()}_${this.counters[type]}]`;
|
|
32
|
-
this.replacements.set(value, replacement);
|
|
33
|
-
return replacement;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Simple pattern-based PII detection.
|
|
38
|
-
*/
|
|
39
|
-
function detectWithPatterns(text) {
|
|
40
|
-
const detected = [];
|
|
41
|
-
// Email pattern
|
|
42
|
-
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
43
|
-
for (const match of text.matchAll(emailRegex)) {
|
|
44
|
-
detected.push({ value: match[0], type: "email" });
|
|
45
|
-
}
|
|
46
|
-
// Phone pattern (various formats)
|
|
47
|
-
const phoneRegex = /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g;
|
|
48
|
-
for (const match of text.matchAll(phoneRegex)) {
|
|
49
|
-
detected.push({ value: match[0], type: "phone" });
|
|
50
|
-
}
|
|
51
|
-
return detected;
|
|
52
|
-
}
|
|
19
|
+
import { SanitizationSession, columnValueToInputWithSanitization, sanitizeString, } from "../../domain/dashboard.js";
|
|
53
20
|
/**
|
|
54
21
|
* Infer MIME type from filename extension.
|
|
55
22
|
*/
|
|
@@ -103,26 +70,12 @@ async function processDocumentColumn(client, sourcePersonaId, rowId, inputCol, d
|
|
|
103
70
|
}
|
|
104
71
|
/**
|
|
105
72
|
* Process a string column with optional sanitization.
|
|
73
|
+
* Uses the domain sanitizeString function for PII removal.
|
|
106
74
|
*/
|
|
107
75
|
function processStringColumn(inputCol, value, sanitizationSession, sanitizeExamples) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const detected = detectWithPatterns(processedValue);
|
|
112
|
-
for (const entity of detected) {
|
|
113
|
-
const replacement = sanitizationSession.getOrCreateReplacement(entity.value, entity.type);
|
|
114
|
-
processedValue = processedValue.split(entity.value).join(replacement);
|
|
115
|
-
}
|
|
116
|
-
// User-provided examples
|
|
117
|
-
if (sanitizeExamples) {
|
|
118
|
-
for (const example of sanitizeExamples) {
|
|
119
|
-
if (processedValue.includes(example)) {
|
|
120
|
-
const replacement = sanitizationSession.getOrCreateReplacement(example, "unknown");
|
|
121
|
-
processedValue = processedValue.split(example).join(replacement);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
76
|
+
const processedValue = sanitizationSession
|
|
77
|
+
? sanitizeString(value, sanitizationSession, sanitizeExamples)
|
|
78
|
+
: value;
|
|
126
79
|
return {
|
|
127
80
|
name: inputCol.name,
|
|
128
81
|
string_value: processedValue,
|
|
@@ -281,11 +234,44 @@ export async function handleDashboardClone(args, client) {
|
|
|
281
234
|
}
|
|
282
235
|
}
|
|
283
236
|
else if (inputCol.columnType === "COLUMN_TYPE_ARRAY") {
|
|
237
|
+
// Handle full array - convert each element to DashboardInput using domain function
|
|
284
238
|
const arrayVals = colValue.value?.arrayValue?.arrayValues ?? [];
|
|
285
239
|
if (arrayVals.length > 0) {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
240
|
+
const arrayInputs = [];
|
|
241
|
+
for (const elem of arrayVals) {
|
|
242
|
+
// Cast to ColumnValueData - types are structurally compatible
|
|
243
|
+
const elemInput = columnValueToInputWithSanitization("element", elem, sanitizationSession, sanitizeExamples);
|
|
244
|
+
if (elemInput) {
|
|
245
|
+
arrayInputs.push(elemInput);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (arrayInputs.length > 0) {
|
|
249
|
+
inputs.push({
|
|
250
|
+
name: inputCol.name,
|
|
251
|
+
array_value: arrayInputs,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (inputCol.columnType === "COLUMN_TYPE_OBJECT") {
|
|
257
|
+
// Handle object columns - convert each sub-column using domain function
|
|
258
|
+
const objectVals = colValue.value?.objectValue?.objectValues ?? {};
|
|
259
|
+
const objectKeys = Object.keys(objectVals);
|
|
260
|
+
if (objectKeys.length > 0) {
|
|
261
|
+
const objectInput = {};
|
|
262
|
+
for (const key of objectKeys) {
|
|
263
|
+
const subValue = objectVals[key];
|
|
264
|
+
// Cast to ColumnValueData - types are structurally compatible
|
|
265
|
+
const subInput = columnValueToInputWithSanitization(key, subValue, sanitizationSession, sanitizeExamples);
|
|
266
|
+
if (subInput) {
|
|
267
|
+
objectInput[key] = subInput;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (Object.keys(objectInput).length > 0) {
|
|
271
|
+
inputs.push({
|
|
272
|
+
name: inputCol.name,
|
|
273
|
+
object_value: objectInput,
|
|
274
|
+
});
|
|
289
275
|
}
|
|
290
276
|
}
|
|
291
277
|
}
|