@enfyra/mcp-server 0.1.31 → 0.1.33

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.
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { createHash } from 'node:crypto';
2
3
  import { fetchAPI } from './fetch.js';
3
4
  import { validatePortableScriptSource, validateScriptSourceIfPresent } from './mutation-guards.js';
4
5
  import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
@@ -646,6 +647,70 @@ async function updateExtensionCode(apiUrl, { id, name, code, description, isEnab
646
647
  validation,
647
648
  };
648
649
  }
650
+ function sha256Text(value) {
651
+ return createHash('sha256').update(String(value ?? '')).digest('hex');
652
+ }
653
+ async function patchExtensionCode(apiUrl, { id, name, search, replace, expectedSha256, apply, description, isEnabled, version, globalRulesAckKey, extensionKnowledgeAckKey, }) {
654
+ assertGlobalRulesAck(globalRulesAckKey);
655
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
656
+ if (!id && !name)
657
+ throw new Error('Provide id or name to patch an existing extension.');
658
+ if (!search)
659
+ throw new Error('search must be a non-empty exact code fragment.');
660
+ const existing = id
661
+ ? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id,code')
662
+ : await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id,code');
663
+ if (!existing)
664
+ throw new Error(`Extension not found: ${id || name}`);
665
+ const extensionId = getId(existing);
666
+ const currentCode = String(existing.code ?? '');
667
+ const currentSha256 = sha256Text(currentCode);
668
+ if (expectedSha256 && expectedSha256 !== currentSha256) {
669
+ throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before patching.`);
670
+ }
671
+ const occurrences = currentCode.split(search).length - 1;
672
+ if (occurrences !== 1) {
673
+ throw new Error(`Expected search fragment to occur exactly once; found ${occurrences}. Use a more specific fragment or update_extension_code for a full replacement.`);
674
+ }
675
+ const nextCode = currentCode.replace(search, replace);
676
+ const nextSha256 = sha256Text(nextCode);
677
+ const preview = {
678
+ action: apply ? 'extension_code_patch_applied' : 'extension_code_patch_previewed',
679
+ id: extensionId,
680
+ name: existing.name || name || null,
681
+ type: existing.type || null,
682
+ currentSha256,
683
+ nextSha256,
684
+ currentLength: currentCode.length,
685
+ nextLength: nextCode.length,
686
+ occurrences,
687
+ apply: Boolean(apply),
688
+ };
689
+ if (!apply) {
690
+ return {
691
+ ...preview,
692
+ nextStep: {
693
+ tool: 'patch_extension_code',
694
+ input: { id: extensionId, expectedSha256: currentSha256, search, replace, apply: true },
695
+ },
696
+ };
697
+ }
698
+ const result = await updateExtensionCode(apiUrl, {
699
+ id: extensionId,
700
+ name: undefined,
701
+ code: nextCode,
702
+ description,
703
+ isEnabled,
704
+ version,
705
+ globalRulesAckKey,
706
+ extensionKnowledgeAckKey,
707
+ });
708
+ return {
709
+ ...preview,
710
+ result,
711
+ validation: result.validation,
712
+ };
713
+ }
649
714
  function normalizeMetadataTables(metadata) {
650
715
  const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
651
716
  return Array.isArray(tables) ? tables : Object.values(tables || {});
@@ -948,6 +1013,137 @@ function chooseFlowStepTool(intent) {
948
1013
  return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'query');
949
1014
  return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
950
1015
  }
1016
+ function planFlowSteps(steps) {
1017
+ const items = Array.isArray(steps) ? steps : [];
1018
+ return items.map((step, index) => {
1019
+ const intent = typeof step === 'string' ? step : step?.intent;
1020
+ const key = typeof step === 'object' && step?.key ? String(step.key) : `step_${index + 1}`;
1021
+ const recommendation = chooseFlowStepTool(intent);
1022
+ return {
1023
+ order: index + 1,
1024
+ key,
1025
+ intent,
1026
+ tool: recommendation.tool,
1027
+ type: recommendation.type,
1028
+ suggestedInput: {
1029
+ key,
1030
+ name: typeof step === 'object' && step?.name ? step.name : key.replace(/_/g, ' '),
1031
+ order: index + 1,
1032
+ ...(recommendation.config ? { config: recommendation.config } : {}),
1033
+ ...(recommendation.sourceCode ? { sourceCode: recommendation.sourceCode } : {}),
1034
+ ...(recommendation.condition ? { condition: recommendation.condition } : {}),
1035
+ },
1036
+ reason: recommendation.when,
1037
+ };
1038
+ });
1039
+ }
1040
+ function normalizeFlowWorkflowStep(step, index) {
1041
+ const input = typeof step === 'string' ? { intent: step } : (step || {});
1042
+ const intent = String(input.intent || input.name || input.key || `Step ${index + 1}`);
1043
+ const recommended = chooseFlowStepTool(input.type || intent);
1044
+ const type = String(input.type || recommended.type || 'script');
1045
+ const guidance = FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === type);
1046
+ if (!guidance) {
1047
+ throw new Error(`steps[${index}].type must be one of ${FLOW_STEP_TOOL_GUIDANCE.map((item) => item.type).join(', ')}.`);
1048
+ }
1049
+ const key = String(input.key || intent)
1050
+ .trim()
1051
+ .toLowerCase()
1052
+ .replace(/[^a-z0-9]+/g, '_')
1053
+ .replace(/^_+|_+$/g, '')
1054
+ .slice(0, 64) || `step_${index + 1}`;
1055
+ return {
1056
+ index,
1057
+ key,
1058
+ name: input.name || intent,
1059
+ intent,
1060
+ type,
1061
+ order: input.order ?? index * 10,
1062
+ config: input.config ?? guidance.config ?? {},
1063
+ sourceCode: input.sourceCode ?? guidance.sourceCode,
1064
+ scriptLanguage: input.scriptLanguage || 'javascript',
1065
+ timeout: input.timeout,
1066
+ isEnabled: input.isEnabled ?? true,
1067
+ chosenByIntent: !input.type,
1068
+ recommendedTool: guidance.tool,
1069
+ };
1070
+ }
1071
+ async function runFlowWorkflow(apiUrl, opts) {
1072
+ const steps = parseJsonArrayArg('steps', opts.steps, []);
1073
+ const plan = steps.map(normalizeFlowWorkflowStep);
1074
+ const hasDynamicCode = plan.some((step) => ['script', 'condition'].includes(step.type) && step.sourceCode);
1075
+ const triggerType = opts.triggerType || 'manual';
1076
+ const flowInput = {
1077
+ name: opts.name,
1078
+ triggerType,
1079
+ triggerConfig: triggerType === 'schedule' ? opts.triggerConfig : (opts.triggerConfig ?? {}),
1080
+ timeout: opts.timeout,
1081
+ maxExecutions: opts.maxExecutions,
1082
+ isEnabled: opts.isEnabled,
1083
+ description: opts.description,
1084
+ globalRulesAckKey: opts.globalRulesAckKey,
1085
+ };
1086
+ if (!opts.apply) {
1087
+ return {
1088
+ action: 'flow_workflow_planned',
1089
+ flow: {
1090
+ name: opts.name,
1091
+ triggerType,
1092
+ },
1093
+ stepCount: plan.length,
1094
+ plan,
1095
+ requiredAckParams: ['globalRulesAckKey', ...(hasDynamicCode ? ['knowledgeAckKey'] : [])],
1096
+ nextSteps: [
1097
+ 'Review the plan. Prefer fixed step types; script is only for logic not covered by query/create/update/delete/http/sleep/trigger/log/condition.',
1098
+ 'Call flow_workflow again with apply=true and the required ack params to create/update the flow and steps sequentially.',
1099
+ 'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
1100
+ ],
1101
+ };
1102
+ }
1103
+ if (!opts.name)
1104
+ throw new Error('name is required.');
1105
+ assertGlobalRulesAck(opts.globalRulesAckKey);
1106
+ if (hasDynamicCode)
1107
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1108
+ const flowResult = await ensureFlow(apiUrl, flowInput);
1109
+ const flowId = flowResult.flow.id;
1110
+ const operations = [];
1111
+ for (const step of plan) {
1112
+ const result = await ensureFlowStep(apiUrl, {
1113
+ flowName: undefined,
1114
+ flowId,
1115
+ key: step.key,
1116
+ type: step.type,
1117
+ order: step.order,
1118
+ config: step.config,
1119
+ sourceCode: step.sourceCode,
1120
+ scriptLanguage: step.scriptLanguage,
1121
+ timeout: step.timeout,
1122
+ isEnabled: step.isEnabled,
1123
+ globalRulesAckKey: opts.globalRulesAckKey,
1124
+ knowledgeAckKey: opts.knowledgeAckKey,
1125
+ });
1126
+ operations.push({
1127
+ index: step.index,
1128
+ key: step.key,
1129
+ type: step.type,
1130
+ result,
1131
+ });
1132
+ }
1133
+ return {
1134
+ action: 'flow_workflow_applied',
1135
+ flow: flowResult.flow,
1136
+ flowResult,
1137
+ stepCount: plan.length,
1138
+ plan,
1139
+ operations,
1140
+ sequential: true,
1141
+ nextSteps: [
1142
+ 'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
1143
+ 'Use trigger_flow only after saved behavior is verified.',
1144
+ ],
1145
+ };
1146
+ }
951
1147
  function normalizeEndpointAccess(anonymousAccess, makePublic) {
952
1148
  if (makePublic !== undefined)
953
1149
  return makePublic ? 'public' : 'private';
@@ -1087,6 +1283,12 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
1087
1283
  }
1088
1284
  const firstRunnable = steps.find((item) => item.status === 'pending') || null;
1089
1285
  const blocked = steps.find((item) => item.status === 'blocked') || null;
1286
+ const pendingAckParams = firstRunnable
1287
+ ? [
1288
+ 'globalRulesAckKey',
1289
+ ...(firstRunnable.id === 'save_handler' ? ['knowledgeAckKey'] : []),
1290
+ ]
1291
+ : [];
1090
1292
  const nextSteps = blocked
1091
1293
  ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
1092
1294
  : firstRunnable
@@ -1094,7 +1296,10 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
1094
1296
  tool: 'api_endpoint_workflow',
1095
1297
  input: { path: normalizedPath, method: methodName, apply: true },
1096
1298
  stepId: firstRunnable.id,
1097
- requiresKnowledgeAck: firstRunnable.id === 'save_handler' ? 'dynamicCodeAckKey from get_enfyra_required_knowledge' : undefined,
1299
+ requiredAckParams: pendingAckParams,
1300
+ requiresKnowledgeAck: pendingAckParams.length
1301
+ ? `Pass ${pendingAckParams.join(' and ')} from get_enfyra_required_knowledge when applying this step.`
1302
+ : undefined,
1098
1303
  }]
1099
1304
  : [];
1100
1305
  return {
@@ -1501,6 +1706,23 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1501
1706
  globalRulesAckKey: globalRulesAckParam(z),
1502
1707
  extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
1503
1708
  }, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
1709
+ server.tool('patch_extension_code', [
1710
+ 'Focused operation: patch an existing Enfyra admin extension code by exact search/replace.',
1711
+ 'Use this for small UI fixes instead of rewriting the whole Vue SFC. It hash-checks the current code, validates with /enfyra_extension/preview, and saves only when apply=true.',
1712
+ 'Default apply=false returns a preview and nextStep input.',
1713
+ ].join(' '), {
1714
+ id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
1715
+ name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
1716
+ search: z.string().describe('Exact code fragment that must occur once.'),
1717
+ replace: z.string().describe('Replacement code fragment.'),
1718
+ expectedSha256: z.string().optional().describe('Optional SHA-256 of current extension code from a prior inspect/read. Rejects stale patches.'),
1719
+ apply: z.boolean().optional().default(false).describe('Preview by default. Set true to validate and save.'),
1720
+ description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
1721
+ isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
1722
+ version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
1723
+ globalRulesAckKey: globalRulesAckParam(z),
1724
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
1725
+ }, async (input) => jsonText(await patchExtensionCode(ENFYRA_API_URL, input)));
1504
1726
  server.tool('get_extension_theme_contract', 'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.', {}, async () => jsonText(getExtensionThemeContract()));
1505
1727
  server.tool('get_theme_class_reference', [
1506
1728
  'Return the authoritative Enfyra theme & color class reference: class -> CSS variable -> Nuxt UI semantic color -> intent.',
@@ -1529,8 +1751,8 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1529
1751
  description: z.string().optional().describe('Extension description.'),
1530
1752
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1531
1753
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
1532
- apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1533
- applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1754
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step. When true, always pass globalRulesAckKey; also pass knowledgeAckKey when saving handler sourceCode.'),
1755
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes. When true, always pass globalRulesAckKey and pass knowledgeAckKey if handler sourceCode may be saved.'),
1534
1756
  stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1535
1757
  globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1536
1758
  extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
@@ -1657,7 +1879,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1657
1879
  ].join(' '), {
1658
1880
  path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
1659
1881
  method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
1660
- sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER and #table_name. Do not send compiledCode. Do not use @REPOS.secure.<table>; use @REPOS.main for route main table or #table_name/@REPOS.table_name with explicit fields/auth checks.'),
1882
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER and #table_name. Repository calls are async: use `const result = await #table.find(...)` and read rows from `result.data || []`. Do not send compiledCode. Do not use @REPOS.secure.<table>; use @REPOS.main for route main table or #table_name/@REPOS.table_name with explicit fields/auth checks.'),
1661
1883
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1662
1884
  anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
1663
1885
  public: z.boolean().optional().describe('Compatibility alias for anonymousAccess. true means public, false means private.'),
@@ -1686,7 +1908,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1686
1908
  ].join(' '), {
1687
1909
  path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
1688
1910
  method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
1689
- sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER and #table_name. Do not send compiledCode. Do not use @REPOS.secure.<table>; use @REPOS.main for route main table or #table_name/@REPOS.table_name with explicit fields/auth checks.'),
1911
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER and #table_name. Repository calls are async: use `const result = await #table.find(...)` and read rows from `result.data || []`. Do not send compiledCode. Do not use @REPOS.secure.<table>; use @REPOS.main for route main table or #table_name/@REPOS.table_name with explicit fields/auth checks.'),
1690
1912
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1691
1913
  public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
1692
1914
  description: z.string().optional().describe('Route description.'),
@@ -1905,7 +2127,78 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1905
2127
  reload,
1906
2128
  });
1907
2129
  });
1908
- server.tool('ensure_guard', 'Business operation: create or update a request guard and optional guard rules. It resolves route/method ids and prevents pre_auth user-based rules.', {
2130
+ server.tool('ensure_route_rate_limit', 'Business operation: create or update a route rate-limit guard through the Enfyra guard engine. Prefer this over pre-hooks or raw guard JSON for request throttling.', {
2131
+ name: z.string().optional().describe('Optional guard name. Defaults to a stable name based on path, methods, and scope.'),
2132
+ routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
2133
+ path: z.string().optional().describe('Route path to protect, e.g. /newsletter_signup.'),
2134
+ methods: z.array(z.string()).default(['POST']).describe('HTTP method names to protect.'),
2135
+ scope: z.enum(['ip', 'user', 'route']).default('ip').describe('Rate-limit key scope. Use ip for public/pre-auth routes, user for authenticated users, route for a shared route-wide limit.'),
2136
+ maxRequests: z.number().int().positive().describe('Allowed request count per window.'),
2137
+ perSeconds: z.number().int().positive().describe('Window length in seconds.'),
2138
+ position: z.enum(['pre_auth', 'post_auth']).optional().describe('Optional override. Defaults to pre_auth for ip/route and post_auth for user.'),
2139
+ priority: z.number().optional().default(0).describe('Lower runs earlier.'),
2140
+ isEnabled: z.boolean().optional().default(true).describe('Enable the guard. Defaults true.'),
2141
+ description: z.string().optional().describe('Admin note.'),
2142
+ globalRulesAckKey: globalRulesAckParam(z),
2143
+ }, async ({ name, routeId, path, methods, scope, maxRequests, perSeconds, position, priority, isEnabled, description, globalRulesAckKey }) => {
2144
+ assertGlobalRulesAck(globalRulesAckKey);
2145
+ if (path && routeId)
2146
+ throw new Error('Provide path or routeId, not both.');
2147
+ const resolvedPosition = position || (scope === 'user' ? 'post_auth' : 'pre_auth');
2148
+ if (scope === 'user' && resolvedPosition === 'pre_auth') {
2149
+ throw new Error('User-scoped rate limits require post_auth because user identity is unavailable before auth.');
2150
+ }
2151
+ const { route } = await resolveRoute(ENFYRA_API_URL, { path, routeId });
2152
+ const { methodMap } = await getMethodContext(ENFYRA_API_URL);
2153
+ const methodNames = uniqueMethodNames(methods?.length ? methods : ['POST']);
2154
+ const ruleType = scope === 'user' ? 'rate_limit_by_user' : scope === 'route' ? 'rate_limit_by_route' : 'rate_limit_by_ip';
2155
+ const guardName = name || `Rate limit ${scope} ${route.path} ${methodNames.join('_')}`;
2156
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: guardName } }, 'id,_id,name');
2157
+ const guardBody = {
2158
+ name: guardName,
2159
+ position: resolvedPosition,
2160
+ combinator: 'and',
2161
+ priority,
2162
+ isGlobal: false,
2163
+ isEnabled,
2164
+ description: description || `Rate-limit ${methodNames.join(', ')} ${route.path} by ${scope}.`,
2165
+ route: { id: getId(route) },
2166
+ methods: resolveMethodRefs(methodMap, methodNames),
2167
+ };
2168
+ const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
2169
+ const guardId = guardOperation.id || getId(existing);
2170
+ const existingRules = await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: guardId } } }, 'id,_id,isEnabled');
2171
+ const disabledRules = [];
2172
+ for (const rule of existingRules) {
2173
+ disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
2174
+ method: 'PATCH',
2175
+ body: JSON.stringify({ isEnabled: false }),
2176
+ }));
2177
+ }
2178
+ const rule = await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
2179
+ method: 'POST',
2180
+ body: JSON.stringify({
2181
+ type: ruleType,
2182
+ config: { maxRequests, perSeconds },
2183
+ priority: 0,
2184
+ isEnabled: true,
2185
+ description: `${maxRequests} request${maxRequests === 1 ? '' : 's'} per ${perSeconds} seconds by ${scope}.`,
2186
+ guard: { id: guardId },
2187
+ }),
2188
+ });
2189
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
2190
+ return jsonText({
2191
+ action: 'route_rate_limit_ensured',
2192
+ route: { id: getId(route), path: route.path },
2193
+ methods: methodNames,
2194
+ guard: { id: guardId, name: guardName, position: resolvedPosition, isEnabled },
2195
+ rule: { type: ruleType, config: { maxRequests, perSeconds }, result: rule },
2196
+ disabledRuleCount: disabledRules.length,
2197
+ reload,
2198
+ next: 'Call inspect_route({ path }) to confirm the guard is attached, then test behavior through the actual REST route if doing so will not consume a production rate-limit bucket.',
2199
+ });
2200
+ });
2201
+ server.tool('ensure_guard', 'Advanced business operation: create or update a custom request guard tree and optional guard rules. For simple request throttling use ensure_route_rate_limit instead.', {
1909
2202
  name: z.string().describe('Guard name. Existing guard with this name is updated unless guardId is provided.'),
1910
2203
  guardId: z.union([z.string(), z.number()]).optional().describe('Optional existing guard id.'),
1911
2204
  position: z.enum(['pre_auth', 'post_auth']).optional().default('pre_auth').describe('Guard position.'),
@@ -2052,6 +2345,37 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2052
2345
  const reload = naturalPartialReload('Websocket event writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
2053
2346
  return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
2054
2347
  });
2348
+ server.tool('flow_workflow', [
2349
+ 'Workflow front door for creating or updating an Enfyra flow and its steps in one guided path.',
2350
+ 'Use apply=false first to plan step types from plain-language intents. Use apply=true only after reviewing the plan; the tool creates/updates the flow first, then steps sequentially.',
2351
+ 'Prefer this over choosing individual ensure_*_flow_step tools in guided mode.',
2352
+ ].join(' '), {
2353
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
2354
+ triggerType: z.enum(['manual', 'schedule']).optional().default('manual').describe('manual for API/admin/hook/child flow usage, schedule for cron/time-based flows.'),
2355
+ triggerConfig: z.union([z.record(z.any()), z.string()]).optional().describe('Trigger config object or JSON string. Required for scheduled flows.'),
2356
+ steps: z.array(z.union([
2357
+ z.string(),
2358
+ z.object({
2359
+ key: z.string().optional().describe('Stable step key. Generated from intent when omitted.'),
2360
+ name: z.string().optional().describe('Human label. Defaults from intent.'),
2361
+ intent: z.string().optional().describe('Plain-language step intent. Used to choose a fixed step type when type is omitted.'),
2362
+ type: z.enum(['query', 'create', 'update', 'delete', 'http', 'condition', 'sleep', 'trigger_flow', 'log', 'script']).optional().describe('Explicit step type. Omit to let the workflow choose from intent.'),
2363
+ config: z.union([z.record(z.any()), z.string()]).optional().describe('Step config object or JSON string. For query/create/update/delete/http/sleep/trigger/log steps, prefer config over sourceCode.'),
2364
+ sourceCode: z.string().optional().describe('Only for script or condition steps. Use fixed step types when possible.'),
2365
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript'),
2366
+ order: z.number().optional().describe('Step order. Defaults to index * 10.'),
2367
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2368
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2369
+ }),
2370
+ ])).min(1).max(30).describe('Ordered step intents/definitions. Keep one business operation per step.'),
2371
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
2372
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
2373
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
2374
+ description: z.string().optional().describe('Admin note.'),
2375
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies flow and steps sequentially.'),
2376
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
2377
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true and any script/condition step has sourceCode.'),
2378
+ }, async (input) => jsonText(await runFlowWorkflow(ENFYRA_API_URL, input)));
2055
2379
  server.tool('ensure_manual_flow', 'Business operation: create or update a manually triggered Enfyra flow. Use this when the flow is run by API, admin action, another flow, or hook.', {
2056
2380
  name: z.string().describe('Flow name. Existing flow with this name is updated.'),
2057
2381
  timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
@@ -2103,6 +2427,29 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2103
2427
  ],
2104
2428
  });
2105
2429
  });
2430
+ server.tool('plan_flow_steps', 'Dry-run helper: choose the ordered Enfyra flow step tools for a whole flow plan before mutating flow metadata.', {
2431
+ steps: z.array(z.union([
2432
+ z.string(),
2433
+ z.object({
2434
+ key: z.string().optional().describe('Stable step key. Generated when omitted.'),
2435
+ name: z.string().optional().describe('Human label. Defaults from key.'),
2436
+ intent: z.string().describe('Plain-language description of this step.'),
2437
+ }),
2438
+ ])).min(1).max(30).describe('Ordered step intents. Use this before ensure_*_flow_step calls when a flow has multiple steps.'),
2439
+ }, async ({ steps }) => {
2440
+ const plan = planFlowSteps(steps);
2441
+ return jsonText({
2442
+ action: 'flow_steps_planned',
2443
+ stepCount: plan.length,
2444
+ plan,
2445
+ nextSteps: [
2446
+ 'Create or update the flow with ensure_manual_flow or ensure_scheduled_flow first.',
2447
+ 'Call each planned ensure_*_flow_step in order, adding flowName or flowId plus table/query/config details.',
2448
+ 'Use ensure_script_flow_step only for steps where the plan chose script because fixed step types are insufficient.',
2449
+ 'Use test_flow_step for script/condition/high-risk steps before triggering the full flow.',
2450
+ ],
2451
+ });
2452
+ });
2106
2453
  server.tool('ensure_script_flow_step', 'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.', {
2107
2454
  flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2108
2455
  flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),