@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.
- package/README.md +4 -3
- package/dist/lib/mcp-examples.js +14 -27
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/mcp-instructions.d.ts +5 -1
- package/dist/lib/mcp-instructions.js +15 -12
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/mcp-usage-telemetry.d.ts +43 -0
- package/dist/lib/mcp-usage-telemetry.js +387 -0
- package/dist/lib/mcp-usage-telemetry.js.map +1 -0
- package/dist/lib/mutation-guards.js +12 -0
- package/dist/lib/mutation-guards.js.map +1 -1
- package/dist/lib/platform-operation-tools.js +353 -6
- package/dist/lib/platform-operation-tools.js.map +1 -1
- package/dist/lib/required-knowledge.js +5 -4
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/response-format.js +12 -2
- package/dist/lib/response-format.js.map +1 -1
- package/dist/lib/runtime-zone-tools.d.ts +44 -0
- package/dist/lib/runtime-zone-tools.js +196 -0
- package/dist/lib/runtime-zone-tools.js.map +1 -1
- package/dist/lib/table-tools.d.ts +2 -0
- package/dist/lib/table-tools.js +168 -20
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-routing.d.ts +1 -1
- package/dist/lib/tool-routing.js +54 -33
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/lib/toolset-filter.d.ts +9 -0
- package/dist/lib/toolset-filter.js +115 -0
- package/dist/lib/toolset-filter.js.map +1 -0
- package/dist/mcp-server-entry.js +63 -18
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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('
|
|
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.'),
|