@enfyra/mcp-server 0.1.62 → 0.1.64
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 +1 -0
- package/dist/lib/dynamic-endpoint-contract.d.ts +12 -0
- package/dist/lib/dynamic-endpoint-contract.js +148 -0
- package/dist/lib/dynamic-endpoint-contract.js.map +1 -0
- package/dist/lib/dynamic-repository-builder.d.ts +7 -0
- package/dist/lib/dynamic-repository-builder.js +8 -0
- package/dist/lib/dynamic-repository-builder.js.map +1 -1
- package/dist/lib/extension-search-tools.js +2 -1
- package/dist/lib/extension-search-tools.js.map +1 -1
- package/dist/lib/extension-sfc-analyzer.d.ts +4 -0
- package/dist/lib/extension-sfc-analyzer.js +137 -0
- package/dist/lib/extension-sfc-analyzer.js.map +1 -0
- package/dist/lib/mcp-examples.js +117 -8
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/mcp-instructions.js +5 -15
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/mcp-usage-telemetry.js +41 -2
- package/dist/lib/mcp-usage-telemetry.js.map +1 -1
- package/dist/lib/mutation-guards.d.ts +1 -0
- package/dist/lib/mutation-guards.js +42 -0
- package/dist/lib/mutation-guards.js.map +1 -1
- package/dist/lib/platform-operation-tools.d.ts +160 -3
- package/dist/lib/platform-operation-tools.js +454 -95
- package/dist/lib/platform-operation-tools.js.map +1 -1
- package/dist/lib/required-knowledge.d.ts +9 -2
- package/dist/lib/required-knowledge.js +60 -17
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/response-format.js +39 -25
- package/dist/lib/response-format.js.map +1 -1
- package/dist/lib/runtime-zone-tools.js +4 -3
- package/dist/lib/runtime-zone-tools.js.map +1 -1
- package/dist/lib/session-safety.d.ts +9 -0
- package/dist/lib/session-safety.js +89 -0
- package/dist/lib/session-safety.js.map +1 -0
- package/dist/lib/source-artifacts.js +3 -1
- package/dist/lib/source-artifacts.js.map +1 -1
- package/dist/lib/table-tools.d.ts +9 -1
- package/dist/lib/table-tools.js +69 -9
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-input-normalization.d.ts +4 -0
- package/dist/lib/tool-input-normalization.js +35 -0
- package/dist/lib/tool-input-normalization.js.map +1 -0
- package/dist/lib/tool-routing.d.ts +6 -2
- package/dist/lib/tool-routing.js +52 -16
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/lib/toolset-filter.d.ts +7 -3
- package/dist/lib/toolset-filter.js +144 -86
- package/dist/lib/toolset-filter.js.map +1 -1
- package/dist/lib/types.d.ts +62 -0
- package/dist/mcp-server-entry.js +79 -23
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +4 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { fetchAPI } from './fetch.js';
|
|
4
|
-
import { fetchTableCatalog, fetchTableMetadataByRef, resolveTableCatalogEntry } from './metadata-client.js';
|
|
4
|
+
import { fetchTableCatalog, fetchTableMetadata, fetchTableMetadataByRef, resolveTableCatalogEntry } from './metadata-client.js';
|
|
5
|
+
import { assertCustomEndpointRoute, assertDynamicEndpointContract, extractExplicitRepositoryTableNames, reviewDynamicEndpointContract, } from './dynamic-endpoint-contract.js';
|
|
5
6
|
import { validatePortableScriptSource, validateScriptSourceIfPresent } from './mutation-guards.js';
|
|
7
|
+
import { writeSourceArtifact } from './source-artifacts.js';
|
|
8
|
+
import { normalizeEscapedVueSource, normalizeStrictBoolean, } from './tool-input-normalization.js';
|
|
9
|
+
import { analyzeExtensionSfc, extensionElementAttributeValue, extensionElementHasAttribute, } from './extension-sfc-analyzer.js';
|
|
6
10
|
import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
|
|
7
11
|
const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
|
|
8
12
|
'CommonDrawer',
|
|
@@ -65,6 +69,26 @@ function sameId(a, b) {
|
|
|
65
69
|
function firstDataRecord(result) {
|
|
66
70
|
return Array.isArray(result?.data) ? result.data[0] : result;
|
|
67
71
|
}
|
|
72
|
+
export function summarizeWorkflowOperation(operation) {
|
|
73
|
+
const record = firstDataRecord(operation?.result) || {};
|
|
74
|
+
const selectedRecord = Object.fromEntries(['id', '_id', 'name', 'key', 'path', 'label', 'title', 'state', 'severity', 'type', 'isEnabled', 'version', 'jobId', 'flowId']
|
|
75
|
+
.filter((key) => record?.[key] !== undefined)
|
|
76
|
+
.map((key) => [key, record[key]]));
|
|
77
|
+
return {
|
|
78
|
+
action: operation?.action || null,
|
|
79
|
+
result: {
|
|
80
|
+
statusCode: operation?.result?.statusCode ?? null,
|
|
81
|
+
message: operation?.result?.message ?? null,
|
|
82
|
+
...(Object.keys(selectedRecord).length ? { record: selectedRecord } : {}),
|
|
83
|
+
},
|
|
84
|
+
...(operation?.routeReload ? {
|
|
85
|
+
routeReload: {
|
|
86
|
+
attempted: Boolean(operation.routeReload.attempted),
|
|
87
|
+
succeeded: operation.routeReload.succeeded === true,
|
|
88
|
+
},
|
|
89
|
+
} : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
68
92
|
function normalizeRestPath(path) {
|
|
69
93
|
if (!path)
|
|
70
94
|
return '/';
|
|
@@ -624,12 +648,16 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
624
648
|
const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
|
|
625
649
|
const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
|
|
626
650
|
const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
|
|
627
|
-
const
|
|
628
|
-
|
|
651
|
+
const itemsPerPageExpression = input.itemsPerPageExpression || '0';
|
|
652
|
+
const pageModel = String(itemsPerPageExpression) !== '0'
|
|
653
|
+
? `\n v-model:page="${input.pageExpression || 'page'}"`
|
|
654
|
+
: '';
|
|
655
|
+
const frame = [
|
|
656
|
+
`<CommonResourceListFrame${pageModel}`,
|
|
629
657
|
` :loading="${input.loadingExpression || 'pending'}"`,
|
|
630
658
|
` :has-items="${itemsExpression}.length > 0"`,
|
|
631
659
|
` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
|
|
632
|
-
` :items-per-page="${
|
|
660
|
+
` :items-per-page="${itemsPerPageExpression}"`,
|
|
633
661
|
` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '"')}"`,
|
|
634
662
|
` empty-description="${String(input.emptyDescription || '').replace(/"/g, '"')}"`,
|
|
635
663
|
` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
|
|
@@ -645,6 +673,9 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
645
673
|
' />',
|
|
646
674
|
'</CommonResourceListFrame>',
|
|
647
675
|
].join('\n');
|
|
676
|
+
const snippet = input.constrained === false
|
|
677
|
+
? frame
|
|
678
|
+
: ['<section class="eapp-page-constrained-wide space-y-4">', indentLines(frame, 2), '</section>'].join('\n');
|
|
648
679
|
return {
|
|
649
680
|
action: 'extension_resource_list_built',
|
|
650
681
|
components: ['CommonResourceListFrame', 'CommonResourceListItem'],
|
|
@@ -653,6 +684,8 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
653
684
|
'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
|
|
654
685
|
'CommonResourceListFrame supports extension default slots. It renders rows when loading is false and hasItems is true; inspect the source artifact, hasItems/items expressions, and API response shape before replacing it.',
|
|
655
686
|
'Keep first-load skeleton, empty state, and pagination owned by the frame.',
|
|
687
|
+
'Keep search and filter controls in a separate compact surface before the list; do not wrap filters and all rows in one oversized card.',
|
|
688
|
+
'Keep operational list pages constrained with eapp-page-constrained-wide unless the workflow intentionally owns a full-bleed canvas.',
|
|
656
689
|
'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
|
|
657
690
|
],
|
|
658
691
|
};
|
|
@@ -1118,48 +1151,120 @@ export function buildExtensionConfirmSnippet(input = {}) {
|
|
|
1118
1151
|
],
|
|
1119
1152
|
};
|
|
1120
1153
|
}
|
|
1121
|
-
export function reviewExtensionUiContract(code) {
|
|
1154
|
+
export function reviewExtensionUiContract(code, options = {}) {
|
|
1122
1155
|
const source = String(code || '');
|
|
1156
|
+
const pattern = String(options.pattern || 'auto');
|
|
1123
1157
|
const issues = [];
|
|
1124
1158
|
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1125
|
-
|
|
1159
|
+
const analysis = analyzeExtensionSfc(source);
|
|
1160
|
+
const elements = analysis.elements;
|
|
1161
|
+
const byTag = (tag) => elements.filter((element) => element.tag === tag);
|
|
1162
|
+
const hasStaticOrBound = (element, name) => (extensionElementHasAttribute(element, name, null) || extensionElementHasAttribute(element, name, 'bind'));
|
|
1163
|
+
const drawers = byTag('CommonDrawer');
|
|
1164
|
+
const modals = [...byTag('CommonModal'), ...byTag('UModal')];
|
|
1165
|
+
const allClasses = new Set(elements.flatMap((element) => element.classes));
|
|
1166
|
+
if (!analysis.valid) {
|
|
1167
|
+
push('error', 'vue-sfc-parse', `Vue SFC parsing failed: ${analysis.errors[0]}`, 'Fix the malformed SFC/template before reviewing UI policy.');
|
|
1168
|
+
}
|
|
1169
|
+
if (drawers.some((element) => hasStaticOrBound(element, 'title'))) {
|
|
1126
1170
|
push('error', 'common-drawer-slots', 'CommonDrawer should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
|
|
1127
1171
|
}
|
|
1128
|
-
if (
|
|
1172
|
+
if (modals.some((element) => hasStaticOrBound(element, 'title'))) {
|
|
1129
1173
|
push('error', 'common-modal-slots', 'CommonModal/UModal should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
|
|
1130
1174
|
}
|
|
1131
|
-
if (
|
|
1175
|
+
if (drawers.some((element) => !hasStaticOrBound(element, 'primary-action') && !hasStaticOrBound(element, 'primaryAction'))) {
|
|
1132
1176
|
push('warning', 'drawer-primary-action', 'CommonDrawer has no primaryAction.', 'Editing/create drawers should wire Save/Create through primaryAction.');
|
|
1133
1177
|
}
|
|
1134
|
-
if (
|
|
1178
|
+
if (drawers.some((element) => !hasStaticOrBound(element, 'cancel-action') && !hasStaticOrBound(element, 'cancelAction'))) {
|
|
1135
1179
|
push('warning', 'drawer-cancel-action', 'CommonDrawer has no cancelAction.', 'Use cancelAction for the ordinary Cancel footer button unless the workflow intentionally has no cancel.');
|
|
1136
1180
|
}
|
|
1137
|
-
if (
|
|
1181
|
+
if (modals.some((element) => /delete|remove|confirm|cannot be undone/i.test(element.text) && !hasStaticOrBound(element, 'danger-action') && !hasStaticOrBound(element, 'dangerAction'))) {
|
|
1138
1182
|
push('warning', 'modal-danger-action', 'Destructive/confirmation modal has no dangerAction.', 'Wire the final destructive action through dangerAction.');
|
|
1139
1183
|
}
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
|
|
1146
|
-
push('warning', 'modal-drawer-field-width', `${tag} is missing class="w-full".`, 'Use class="w-full" for form controls inside modal/drawer body forms unless intentionally inline.');
|
|
1184
|
+
for (const element of elements.filter((item) => ['UInput', 'UTextarea', 'USelectMenu', 'USelect'].includes(item.tag))) {
|
|
1185
|
+
const intentionallyCompact = extensionElementHasAttribute(element, 'data-compact', null)
|
|
1186
|
+
|| extensionElementHasAttribute(element, 'data-inline', null);
|
|
1187
|
+
if (!intentionallyCompact && !element.classes.includes('w-full')) {
|
|
1188
|
+
push('warning', 'modal-drawer-field-width', `${element.tag} is missing class="w-full".`, 'Use class="w-full" for form controls inside modal/drawer body forms unless intentionally inline.');
|
|
1147
1189
|
}
|
|
1148
1190
|
}
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
while ((buttonMatch = buttonPattern.exec(source))) {
|
|
1152
|
-
if (!/\btype=/.test(buttonMatch[1] || '')) {
|
|
1191
|
+
for (const element of byTag('button')) {
|
|
1192
|
+
if (!hasStaticOrBound(element, 'type')) {
|
|
1153
1193
|
push('warning', 'native-button-type', 'Native button is missing type="button".', 'Add type="button" unless the button intentionally submits a form.');
|
|
1154
1194
|
}
|
|
1155
1195
|
}
|
|
1196
|
+
if (pattern === 'resource_list') {
|
|
1197
|
+
const frames = byTag('CommonResourceListFrame');
|
|
1198
|
+
if (frames.length === 0) {
|
|
1199
|
+
push('error', 'resource-list-frame-required', 'Operational resource lists must use CommonResourceListFrame.', 'Use build_extension_ui kind=resource_list so loading, empty state, total, and pagination stay list-owned.');
|
|
1200
|
+
}
|
|
1201
|
+
if (byTag('CommonResourceListItem').length === 0) {
|
|
1202
|
+
push('error', 'resource-list-item-required', 'Operational resource rows must use CommonResourceListItem.', 'Move title, description, badge, stats, metadata, navigation, and row actions into CommonResourceListItem.');
|
|
1203
|
+
}
|
|
1204
|
+
if (elements.some((element) => ['UCard', 'article'].includes(element.tag) && extensionElementHasAttribute(element, 'for', 'for'))) {
|
|
1205
|
+
push('error', 'resource-list-ad-hoc-cards', 'A resource-list screen still renders inventory rows as repeated cards.', 'Use CommonResourceListItem for homogeneous operational rows; reserve resource_grid for workboards and catalogs.');
|
|
1206
|
+
}
|
|
1207
|
+
if (elements.some((element) => ['table', 'UTable'].includes(element.tag))) {
|
|
1208
|
+
push('error', 'resource-list-ad-hoc-table', 'A resource-list screen still renders its primary inventory as a table.', 'Use CommonResourceListItem so row metadata and actions remain responsive on narrow screens.');
|
|
1209
|
+
}
|
|
1210
|
+
const frame = frames[0];
|
|
1211
|
+
if (frame && !hasStaticOrBound(frame, 'loading')) {
|
|
1212
|
+
push('error', 'resource-list-loading-owned', 'CommonResourceListFrame is missing its loading contract.', 'Bind :loading to the first-load state.');
|
|
1213
|
+
}
|
|
1214
|
+
if (frame && !hasStaticOrBound(frame, 'has-items') && !hasStaticOrBound(frame, 'hasItems')) {
|
|
1215
|
+
push('error', 'resource-list-empty-owned', 'CommonResourceListFrame is missing its has-items contract.', 'Bind :has-items and keep the empty state owned by the frame.');
|
|
1216
|
+
}
|
|
1217
|
+
if (frame && !hasStaticOrBound(frame, 'empty-title') && !hasStaticOrBound(frame, 'emptyTitle')) {
|
|
1218
|
+
push('error', 'resource-list-empty-copy', 'CommonResourceListFrame is missing an empty-state title.', 'Provide concise empty-title and, when useful, empty-description and empty-icon.');
|
|
1219
|
+
}
|
|
1220
|
+
const itemsPerPage = frame
|
|
1221
|
+
? extensionElementAttributeValue(frame, 'items-per-page', 'bind') ?? extensionElementAttributeValue(frame, 'itemsPerPage', 'bind')
|
|
1222
|
+
: null;
|
|
1223
|
+
if (itemsPerPage && itemsPerPage !== '0' && !extensionElementHasAttribute(frame, 'page', 'model')) {
|
|
1224
|
+
push('error', 'resource-list-pagination-owned', 'A paginated CommonResourceListFrame is missing its page model.', 'Bind v-model:page on the frame so pagination state stays list-owned.');
|
|
1225
|
+
}
|
|
1226
|
+
if (!allClasses.has('eapp-page-constrained-wide')) {
|
|
1227
|
+
push('warning', 'resource-list-width', 'The operational list is not constrained for wide admin viewports.', 'Wrap the page inventory in eapp-page-constrained-wide unless this extension intentionally owns a full-bleed canvas.');
|
|
1228
|
+
}
|
|
1229
|
+
const frameIndex = frame ? elements.indexOf(frame) : elements.length;
|
|
1230
|
+
const beforeFrame = elements.slice(0, frameIndex);
|
|
1231
|
+
const hasSearchOrFilterControl = beforeFrame.some((element) => (['UInput', 'UInputMenu', 'USelect', 'USelectMenu'].includes(element.tag)
|
|
1232
|
+
&& element.attributes.some((attribute) => /search|filter/i.test(`${attribute.name} ${attribute.value || ''}`))));
|
|
1233
|
+
const hasFilterSurface = beforeFrame.some((element) => element.classes.some((name) => ['eapp-surface-card', 'eapp-surface-muted'].includes(name)));
|
|
1234
|
+
if (hasSearchOrFilterControl && !hasFilterSurface) {
|
|
1235
|
+
push('warning', 'resource-list-filter-surface', 'Search or filter controls are not in a separate compact surface before the list.', 'Place controls in a compact eapp-surface-card or eapp-surface-muted block, separate from CommonResourceListFrame.');
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (pattern === 'resource_grid') {
|
|
1239
|
+
const frame = byTag('CommonResourceListFrame')[0];
|
|
1240
|
+
if (!frame || extensionElementAttributeValue(frame, 'variant', null) !== 'plain') {
|
|
1241
|
+
push('error', 'resource-grid-frame', 'Resource grids must use CommonResourceListFrame variant="plain".', 'Use build_extension_ui kind=resource_grid so loading and empty state remain list-owned without duplicate contained chrome.');
|
|
1242
|
+
}
|
|
1243
|
+
if (!allClasses.has('md:grid-cols-2') || !allClasses.has('xl:grid-cols-3')) {
|
|
1244
|
+
push('error', 'resource-grid-breakpoints', 'Resource grids must use the admin-shell one/two/three-column breakpoints.', 'Use one column by default, md:grid-cols-2, and xl:grid-cols-3.');
|
|
1245
|
+
}
|
|
1246
|
+
if (!allClasses.has('eapp-page-constrained-wide')) {
|
|
1247
|
+
push('warning', 'resource-grid-width', 'The resource grid is not constrained for the admin shell.', 'Keep eapp-page-constrained-wide unless the workflow intentionally owns a full-bleed canvas.');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (pattern === 'auto'
|
|
1251
|
+
&& elements.some((element) => ['UCard', 'article'].includes(element.tag) && extensionElementHasAttribute(element, 'for', 'for'))
|
|
1252
|
+
&& byTag('CommonResourceListFrame').length === 0) {
|
|
1253
|
+
push('warning', 'ad-hoc-inventory', 'Repeated cards were found without a shared list frame.', 'Choose resource_list for dense operational rows or resource_grid for a workboard/catalog, then use the matching builder contract.');
|
|
1254
|
+
}
|
|
1255
|
+
if (byTag('UButton').some((element) => hasStaticOrBound(element, 'disabled') && /Already|Completed|Granted|Handled/i.test(element.text))) {
|
|
1256
|
+
push('warning', 'disabled-terminal-action', 'A terminal state appears as a disabled action button.', 'Render terminal state as a badge or metadata and omit the unavailable action.');
|
|
1257
|
+
}
|
|
1156
1258
|
return {
|
|
1157
1259
|
action: 'extension_ui_contract_reviewed',
|
|
1260
|
+
pattern,
|
|
1158
1261
|
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
1159
1262
|
issueCount: issues.length,
|
|
1160
1263
|
issues,
|
|
1161
1264
|
nextSteps: issues.length
|
|
1162
|
-
? [
|
|
1265
|
+
? [pattern === 'resource_list' || pattern === 'resource_grid'
|
|
1266
|
+
? `Use build_extension_ui kind=${pattern} for the canonical layout, then apply with patch_extension_code/update_extension_code.`
|
|
1267
|
+
: 'Use the matching build_extension_ui contract, then apply with patch_extension_code/update_extension_code.']
|
|
1163
1268
|
: ['Snippet matches the checked modal/drawer contract rules. Still validate the final SFC before saving.'],
|
|
1164
1269
|
};
|
|
1165
1270
|
}
|
|
@@ -1167,6 +1272,7 @@ function collectExtensionRuntimeIssues(code) {
|
|
|
1167
1272
|
const source = String(code || '');
|
|
1168
1273
|
const issues = [];
|
|
1169
1274
|
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1275
|
+
const analysis = analyzeExtensionSfc(source);
|
|
1170
1276
|
if (/(?:^|[>\n;])\s*import(?:\s.+?\sfrom\s+|\s*['"])/m.test(source)) {
|
|
1171
1277
|
push('error', 'static-import', 'Static import statements are not allowed in enfyra_extension.code.', 'Use injected globals/components directly, or load app packages with getPackages(["package-name"]) inside runtime code.');
|
|
1172
1278
|
}
|
|
@@ -1182,10 +1288,11 @@ function collectExtensionRuntimeIssues(code) {
|
|
|
1182
1288
|
if (/\b(?:query|body|filter|deep|aggregate)\s*:\s*JSON\.stringify\s*\(/.test(source)) {
|
|
1183
1289
|
push('error', 'use-api-json-stringify-options', 'useApi query/body/filter/deep/aggregate options must be plain objects or computed objects, not JSON strings.', 'Pass the object directly to useApi or execute().');
|
|
1184
1290
|
}
|
|
1185
|
-
if (
|
|
1291
|
+
if (analysis.elements.some((element) => (['CommonModal', 'UModal'].includes(element.tag)
|
|
1292
|
+
&& extensionElementHasAttribute(element, 'model', 'model')))) {
|
|
1186
1293
|
push('error', 'modal-open-model', 'CommonModal/UModal must bind v-model:open, not the default v-model contract.', 'Call build_extension_ui kind=modal and preserve its v-model:open binding.');
|
|
1187
1294
|
}
|
|
1188
|
-
if (
|
|
1295
|
+
if (analysis.elements.some((element) => element.tag === 'CommonEmptyState')) {
|
|
1189
1296
|
push('error', 'unavailable-common-empty-state', 'CommonEmptyState is not registered in the dynamic extension runtime.', 'Use the injected EmptyState alias or build_extension_ui kind=empty_state/resource_list/resource_grid.');
|
|
1190
1297
|
}
|
|
1191
1298
|
const scriptBlocks = [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1]);
|
|
@@ -1466,7 +1573,7 @@ export function buildExtensionUiSnippet(kind, input = {}) {
|
|
|
1466
1573
|
if (!input?.code) {
|
|
1467
1574
|
throw new Error('build_extension_ui kind=review requires input.code.');
|
|
1468
1575
|
}
|
|
1469
|
-
const uiReview = reviewExtensionUiContract(input.code);
|
|
1576
|
+
const uiReview = reviewExtensionUiContract(input.code, { pattern: input.pattern });
|
|
1470
1577
|
const themeReview = reviewExtensionThemeContract(input.code);
|
|
1471
1578
|
const runtimeReview = reviewExtensionRuntimeContract(input.code);
|
|
1472
1579
|
result = {
|
|
@@ -1743,39 +1850,44 @@ function findInvalidExtensionSortSyntax(code) {
|
|
|
1743
1850
|
}
|
|
1744
1851
|
return null;
|
|
1745
1852
|
}
|
|
1746
|
-
export function validateExtensionCodeLocally(code) {
|
|
1853
|
+
export function validateExtensionCodeLocally(code, options = {}) {
|
|
1854
|
+
const analysis = analyzeExtensionSfc(code);
|
|
1855
|
+
if (!analysis.valid) {
|
|
1856
|
+
throw new Error(`Invalid Vue SFC: ${analysis.errors[0] || 'template parsing failed.'}`);
|
|
1857
|
+
}
|
|
1747
1858
|
if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
|
|
1748
1859
|
throw new Error('Invalid extension component resolution: do not call resolveComponent() in Enfyra extensions. Use auto-injected components such as <UButton> directly in the template so the app/compiler resolves them correctly.');
|
|
1749
1860
|
}
|
|
1750
1861
|
const invalidSortSyntax = findInvalidExtensionSortSyntax(code);
|
|
1751
1862
|
if (invalidSortSyntax) {
|
|
1752
|
-
throw new Error(`Invalid extension sort contract: ${invalidSortSyntax} Use
|
|
1753
|
-
}
|
|
1754
|
-
const violations = [];
|
|
1755
|
-
for (const template of readTemplateBlocks(code)) {
|
|
1756
|
-
let index = 0;
|
|
1757
|
-
while (index < template.length) {
|
|
1758
|
-
const tagStart = template.indexOf('<', index);
|
|
1759
|
-
if (tagStart === -1)
|
|
1760
|
-
break;
|
|
1761
|
-
const tagName = readTemplateTagName(template, tagStart);
|
|
1762
|
-
if (tagName && tagName === tagName.toLowerCase() && !tagName.includes('-')) {
|
|
1763
|
-
const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
|
|
1764
|
-
if (expected)
|
|
1765
|
-
violations.push({ tag: tagName, expected });
|
|
1766
|
-
}
|
|
1767
|
-
index = tagStart + 1;
|
|
1768
|
-
}
|
|
1863
|
+
throw new Error(`Invalid extension sort contract: ${invalidSortSyntax} Use build_extension_ui kind=api_usage with structured sort entries; Enfyra REST requires one comma-separated string such as "-isPinned,-updatedAt".`);
|
|
1769
1864
|
}
|
|
1865
|
+
const violations = analysis.elements.flatMap((element) => {
|
|
1866
|
+
const tagName = element.tag;
|
|
1867
|
+
if (tagName !== tagName.toLowerCase() || tagName.includes('-'))
|
|
1868
|
+
return [];
|
|
1869
|
+
const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
|
|
1870
|
+
return expected ? [{ tag: tagName, expected }] : [];
|
|
1871
|
+
});
|
|
1770
1872
|
if (violations.length) {
|
|
1771
1873
|
const first = violations[0];
|
|
1772
1874
|
throw new Error(`Invalid extension component casing: use <${first.expected}> instead of <${first.tag}>. Enfyra/Nuxt UI auto-injected components must keep PascalCase in extension templates; lowercase tags render as unresolved DOM elements.`);
|
|
1773
1875
|
}
|
|
1774
|
-
const missingFullWidthFields =
|
|
1876
|
+
const missingFullWidthFields = analysis.elements
|
|
1877
|
+
.filter((element) => FULL_WIDTH_EXTENSION_FIELD_TAGS.includes(element.tag))
|
|
1878
|
+
.filter((element) => !extensionElementHasAttribute(element, 'data-compact', null))
|
|
1879
|
+
.filter((element) => !extensionElementHasAttribute(element, 'data-inline', null))
|
|
1880
|
+
.filter((element) => !element.classes.includes('w-full'))
|
|
1881
|
+
.map((element) => ({ tag: element.tag, snippet: element.source }));
|
|
1775
1882
|
if (missingFullWidthFields.length) {
|
|
1776
1883
|
const first = missingFullWidthFields[0];
|
|
1777
1884
|
throw new Error(`Invalid extension field width: <${first.tag}> must include class="w-full" in Enfyra extensions unless it is intentionally compact with data-compact or data-inline. First offending snippet: ${first.snippet}`);
|
|
1778
1885
|
}
|
|
1886
|
+
const uiReview = reviewExtensionUiContract(code, { pattern: options.uiPattern });
|
|
1887
|
+
const firstUiError = uiReview.issues.find((issue) => issue.severity === 'error');
|
|
1888
|
+
if (firstUiError) {
|
|
1889
|
+
throw new Error(`Invalid extension UI contract: ${firstUiError.message} Rule: ${firstUiError.rule}. ${firstUiError.suggestion}`);
|
|
1890
|
+
}
|
|
1779
1891
|
const themeReview = reviewExtensionThemeContract(code);
|
|
1780
1892
|
const firstThemeError = themeReview.issues.find((issue) => issue.severity === 'error');
|
|
1781
1893
|
if (firstThemeError) {
|
|
@@ -1786,10 +1898,10 @@ export function validateExtensionCodeLocally(code) {
|
|
|
1786
1898
|
if (firstRuntimeError) {
|
|
1787
1899
|
throw new Error(`Invalid extension runtime contract: ${firstRuntimeError.message} Rule: ${firstRuntimeError.rule}. ${firstRuntimeError.suggestion}`);
|
|
1788
1900
|
}
|
|
1789
|
-
return { componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
|
|
1901
|
+
return { vueSfcAst: 'passed', componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
|
|
1790
1902
|
}
|
|
1791
|
-
export async function validateExtensionCode(apiUrl, code, name) {
|
|
1792
|
-
const localChecks = validateExtensionCodeLocally(code);
|
|
1903
|
+
export async function validateExtensionCode(apiUrl, code, name, options = {}) {
|
|
1904
|
+
const localChecks = validateExtensionCodeLocally(code, options);
|
|
1793
1905
|
const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
|
|
1794
1906
|
method: 'POST',
|
|
1795
1907
|
body: JSON.stringify({ code, name }),
|
|
@@ -1804,18 +1916,95 @@ export async function validateExtensionCode(apiUrl, code, name) {
|
|
|
1804
1916
|
compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
|
|
1805
1917
|
};
|
|
1806
1918
|
}
|
|
1807
|
-
|
|
1919
|
+
function summarizeExtensionSaveResult(result, fallback = {}) {
|
|
1920
|
+
const record = unwrapData(result)[0] || (result?.data && !Array.isArray(result.data) ? result.data : null) || {};
|
|
1921
|
+
return {
|
|
1922
|
+
id: getId(record) ?? fallback.id ?? null,
|
|
1923
|
+
name: record.name ?? fallback.name ?? null,
|
|
1924
|
+
type: record.type ?? fallback.type ?? null,
|
|
1925
|
+
isEnabled: record.isEnabled ?? fallback.isEnabled ?? null,
|
|
1926
|
+
version: record.version ?? fallback.version ?? null,
|
|
1927
|
+
updatedAt: record.updatedAt ?? null,
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
export function buildExtensionRuntimeVerification({ extension, code, validation, uiPattern, expectedSha256 }) {
|
|
1931
|
+
const source = String(code || '');
|
|
1932
|
+
const currentSha256 = sha256Text(source);
|
|
1933
|
+
const review = buildExtensionUiSnippet('review', { code: source, pattern: uiPattern });
|
|
1934
|
+
const rawMenu = Array.isArray(extension?.menu) ? extension.menu[0] : extension?.menu;
|
|
1935
|
+
const isPage = String(extension?.type || '').toLowerCase() === 'page';
|
|
1936
|
+
const savedRecordPassed = getId(extension) !== null;
|
|
1937
|
+
const menuWiringPassed = !isPage || Boolean(getId(rawMenu) !== null && rawMenu?.path);
|
|
1938
|
+
const hashMatches = !expectedSha256 || expectedSha256 === currentSha256;
|
|
1939
|
+
const compilerPassed = validation?.valid === true;
|
|
1940
|
+
const valid = savedRecordPassed && compilerPassed && review.valid && menuWiringPassed && hashMatches;
|
|
1941
|
+
return {
|
|
1942
|
+
action: 'extension_runtime_verified',
|
|
1943
|
+
valid,
|
|
1944
|
+
extension: {
|
|
1945
|
+
id: getId(extension),
|
|
1946
|
+
name: extension?.name || null,
|
|
1947
|
+
type: extension?.type || null,
|
|
1948
|
+
isEnabled: extension?.isEnabled ?? null,
|
|
1949
|
+
version: extension?.version ?? null,
|
|
1950
|
+
sha256: currentSha256,
|
|
1951
|
+
length: source.length,
|
|
1952
|
+
},
|
|
1953
|
+
checks: {
|
|
1954
|
+
savedRecord: { status: savedRecordPassed ? 'passed' : 'failed' },
|
|
1955
|
+
expectedHash: { status: hashMatches ? 'passed' : 'failed', expectedSha256: expectedSha256 || null, currentSha256 },
|
|
1956
|
+
serverCompile: { status: compilerPassed ? 'passed' : 'failed', compiledLength: validation?.compiledLength ?? null },
|
|
1957
|
+
uiContract: { status: review.ui.valid ? 'passed' : 'failed', issueCount: review.ui.issueCount, pattern: review.ui.pattern },
|
|
1958
|
+
themeContract: { status: review.theme.valid ? 'passed' : 'failed', issueCount: review.theme.issueCount },
|
|
1959
|
+
runtimeContract: { status: review.runtime.valid ? 'passed' : 'failed', issueCount: review.runtime.issueCount },
|
|
1960
|
+
menuWiring: {
|
|
1961
|
+
status: menuWiringPassed ? 'passed' : 'failed',
|
|
1962
|
+
applicable: isPage,
|
|
1963
|
+
menu: rawMenu ? { id: getId(rawMenu), label: rawMenu.label || null, path: rawMenu.path || null } : null,
|
|
1964
|
+
},
|
|
1965
|
+
browserRender: {
|
|
1966
|
+
status: 'not_run',
|
|
1967
|
+
reason: 'MCP can verify saved metadata, server compilation, static runtime/UI/theme contracts, and page menu wiring. A signed-in browser is still required to prove component execution, API data shape, console errors, and responsive layout.',
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
contractReview: review.valid
|
|
1971
|
+
? { valid: true, issueCount: 0, pattern: review.ui.pattern }
|
|
1972
|
+
: review,
|
|
1973
|
+
coverage: {
|
|
1974
|
+
verified: ['saved metadata', 'expected source hash', 'server Vue compilation', 'static UI/theme/runtime contracts', ...(isPage ? ['page menu wiring'] : [])],
|
|
1975
|
+
browserRequiredForFullRuntimeProof: true,
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
export async function verifyExtensionRuntime(apiUrl, { id, name, uiPattern, expectedSha256 }) {
|
|
1980
|
+
if (!id && !name)
|
|
1981
|
+
throw new Error('Provide id or name to verify an existing extension.');
|
|
1982
|
+
const existing = id
|
|
1983
|
+
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,isEnabled,version,updatedAt,menu.id,menu.label,menu.path,code')
|
|
1984
|
+
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,isEnabled,version,updatedAt,menu.id,menu.label,menu.path,code');
|
|
1985
|
+
if (!existing)
|
|
1986
|
+
throw new Error(`Extension not found: ${id || name}`);
|
|
1987
|
+
const code = String(existing.code || '');
|
|
1988
|
+
const validation = await validateExtensionCode(apiUrl, code, existing.name || String(id || name), { uiPattern });
|
|
1989
|
+
return buildExtensionRuntimeVerification({ extension: existing, code, validation, uiPattern, expectedSha256 });
|
|
1990
|
+
}
|
|
1991
|
+
async function updateExtensionCode(apiUrl, { id, name, code, description, isEnabled, version, expectedSha256, uiPattern, globalRulesAckKey, extensionKnowledgeAckKey, }) {
|
|
1808
1992
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1809
1993
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
1810
1994
|
if (!id && !name)
|
|
1811
1995
|
throw new Error('Provide id or name to update an existing extension.');
|
|
1812
1996
|
const existing = id
|
|
1813
|
-
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id')
|
|
1814
|
-
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id');
|
|
1997
|
+
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,isEnabled,version,menu.id,code')
|
|
1998
|
+
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,isEnabled,version,menu.id,code');
|
|
1815
1999
|
if (!existing)
|
|
1816
2000
|
throw new Error(`Extension not found: ${id || name}`);
|
|
1817
2001
|
const extensionId = getId(existing);
|
|
1818
|
-
const
|
|
2002
|
+
const currentSha256 = sha256Text(existing.code || '');
|
|
2003
|
+
if (expectedSha256 && expectedSha256 !== currentSha256) {
|
|
2004
|
+
throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before replacing it.`);
|
|
2005
|
+
}
|
|
2006
|
+
const validation = await validateExtensionCode(apiUrl, code, name || existing.name || extensionId, { uiPattern });
|
|
2007
|
+
const contractReview = buildExtensionUiSnippet('review', { code, pattern: uiPattern });
|
|
1819
2008
|
const body = {
|
|
1820
2009
|
code,
|
|
1821
2010
|
...(description !== undefined ? { description } : {}),
|
|
@@ -1826,13 +2015,34 @@ async function updateExtensionCode(apiUrl, { id, name, code, description, isEnab
|
|
|
1826
2015
|
method: 'PATCH',
|
|
1827
2016
|
body: JSON.stringify(body),
|
|
1828
2017
|
});
|
|
2018
|
+
const nextSha256 = sha256Text(code);
|
|
2019
|
+
const verification = await verifyExtensionRuntime(apiUrl, {
|
|
2020
|
+
id: extensionId,
|
|
2021
|
+
name: undefined,
|
|
2022
|
+
uiPattern,
|
|
2023
|
+
expectedSha256: nextSha256,
|
|
2024
|
+
});
|
|
1829
2025
|
return {
|
|
1830
2026
|
action: 'extension_code_updated',
|
|
1831
2027
|
id: extensionId,
|
|
1832
2028
|
name: existing.name || name || null,
|
|
1833
2029
|
type: existing.type || null,
|
|
1834
|
-
|
|
2030
|
+
previousSha256: currentSha256,
|
|
2031
|
+
sha256: nextSha256,
|
|
2032
|
+
saved: summarizeExtensionSaveResult(result, {
|
|
2033
|
+
id: extensionId,
|
|
2034
|
+
name: existing.name || name,
|
|
2035
|
+
type: existing.type,
|
|
2036
|
+
isEnabled: isEnabled ?? existing.isEnabled,
|
|
2037
|
+
version: version ?? existing.version,
|
|
2038
|
+
}),
|
|
1835
2039
|
validation,
|
|
2040
|
+
contractReview: {
|
|
2041
|
+
valid: contractReview.valid,
|
|
2042
|
+
issueCount: contractReview.issueCount,
|
|
2043
|
+
pattern: contractReview.ui?.pattern,
|
|
2044
|
+
},
|
|
2045
|
+
verification,
|
|
1836
2046
|
};
|
|
1837
2047
|
}
|
|
1838
2048
|
function sha256Text(value) {
|
|
@@ -1949,7 +2159,30 @@ export function applyExtensionCodePatches(code, patches) {
|
|
|
1949
2159
|
});
|
|
1950
2160
|
return { code: nextCode, patches: normalizedPatches, results };
|
|
1951
2161
|
}
|
|
1952
|
-
|
|
2162
|
+
function patchDiffLines(value, prefix) {
|
|
2163
|
+
const lines = String(value ?? '').split('\n');
|
|
2164
|
+
return lines.map((line) => `${prefix}${line}`).join('\n');
|
|
2165
|
+
}
|
|
2166
|
+
export function buildExtensionPatchDiffArtifact({ id, name, currentSha256, nextSha256, patches }) {
|
|
2167
|
+
const hunks = (patches || []).map((patch, index) => [
|
|
2168
|
+
`@@ patch ${index + 1} (${patch.searchMode || 'exact'}${patch.replaceAll ? ', all matches' : ''}) @@`,
|
|
2169
|
+
patchDiffLines(patch.search, '-'),
|
|
2170
|
+
patchDiffLines(patch.replace, '+'),
|
|
2171
|
+
].join('\n'));
|
|
2172
|
+
const content = [
|
|
2173
|
+
`--- ${name || id || 'extension'}@${currentSha256 || 'unknown'}`,
|
|
2174
|
+
`+++ ${name || id || 'extension'}@${nextSha256 || 'unknown'}`,
|
|
2175
|
+
...hunks,
|
|
2176
|
+
'',
|
|
2177
|
+
].join('\n');
|
|
2178
|
+
return writeSourceArtifact({
|
|
2179
|
+
tableName: 'enfyra_extension',
|
|
2180
|
+
id: id || name || 'extension',
|
|
2181
|
+
fieldName: 'patch.diff',
|
|
2182
|
+
source: content,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMode, replaceAll, patches, expectedSha256, apply, description, isEnabled, version, uiPattern, globalRulesAckKey, extensionKnowledgeAckKey, }) {
|
|
1953
2186
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1954
2187
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
1955
2188
|
if (!id && !name)
|
|
@@ -1962,6 +2195,9 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1962
2195
|
const extensionId = getId(existing);
|
|
1963
2196
|
const currentCode = String(existing.code ?? '');
|
|
1964
2197
|
const currentSha256 = sha256Text(currentCode);
|
|
2198
|
+
if (apply && !expectedSha256) {
|
|
2199
|
+
throw new Error('expectedSha256 is required when apply=true. Preview the patch or inspect the extension first, then retry with the current code hash.');
|
|
2200
|
+
}
|
|
1965
2201
|
if (expectedSha256 && expectedSha256 !== currentSha256) {
|
|
1966
2202
|
throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before patching.`);
|
|
1967
2203
|
}
|
|
@@ -1969,6 +2205,13 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1969
2205
|
const nextCode = patchResult.code;
|
|
1970
2206
|
const nextSha256 = sha256Text(nextCode);
|
|
1971
2207
|
const occurrences = patchResult.results.reduce((total, item) => total + item.occurrences, 0);
|
|
2208
|
+
const diff = buildExtensionPatchDiffArtifact({
|
|
2209
|
+
id: extensionId,
|
|
2210
|
+
name: existing.name || name,
|
|
2211
|
+
currentSha256,
|
|
2212
|
+
nextSha256,
|
|
2213
|
+
patches: patchResult.patches,
|
|
2214
|
+
});
|
|
1972
2215
|
const nextStepPatchInput = patchResult.patches.length === 1
|
|
1973
2216
|
? {
|
|
1974
2217
|
search: patchResult.patches[0].search,
|
|
@@ -1988,6 +2231,7 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1988
2231
|
nextLength: nextCode.length,
|
|
1989
2232
|
occurrences,
|
|
1990
2233
|
patchResults: patchResult.results,
|
|
2234
|
+
diff,
|
|
1991
2235
|
atomic: patchResult.patches.length > 1,
|
|
1992
2236
|
apply: Boolean(apply),
|
|
1993
2237
|
};
|
|
@@ -2007,6 +2251,8 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
2007
2251
|
description,
|
|
2008
2252
|
isEnabled,
|
|
2009
2253
|
version,
|
|
2254
|
+
expectedSha256: currentSha256,
|
|
2255
|
+
uiPattern,
|
|
2010
2256
|
globalRulesAckKey,
|
|
2011
2257
|
extensionKnowledgeAckKey,
|
|
2012
2258
|
});
|
|
@@ -2169,13 +2415,21 @@ async function ensureExtension(apiUrl, { name, type, code, menuId, description,
|
|
|
2169
2415
|
isEnabled,
|
|
2170
2416
|
version,
|
|
2171
2417
|
});
|
|
2418
|
+
const extensionId = operation.id || getId(existing);
|
|
2419
|
+
const verification = await verifyExtensionRuntime(apiUrl, {
|
|
2420
|
+
id: extensionId,
|
|
2421
|
+
name: extensionId ? undefined : name,
|
|
2422
|
+
uiPattern: undefined,
|
|
2423
|
+
expectedSha256: sha256Text(code),
|
|
2424
|
+
});
|
|
2172
2425
|
return {
|
|
2173
|
-
id:
|
|
2426
|
+
id: extensionId,
|
|
2174
2427
|
name,
|
|
2175
2428
|
type,
|
|
2176
2429
|
action: operation.action,
|
|
2177
|
-
operation,
|
|
2430
|
+
operation: { action: operation.action, id: extensionId },
|
|
2178
2431
|
validation,
|
|
2432
|
+
verification,
|
|
2179
2433
|
};
|
|
2180
2434
|
}
|
|
2181
2435
|
async function ensureFlow(apiUrl, { name, triggerType = 'manual', triggerConfig, timeout, maxExecutions = 100, isEnabled = true, description, globalRulesAckKey, }) {
|
|
@@ -2236,20 +2490,20 @@ const FLOW_STEP_TOOL_GUIDANCE = [
|
|
|
2236
2490
|
{
|
|
2237
2491
|
tool: 'ensure_create_flow_step',
|
|
2238
2492
|
type: 'create',
|
|
2239
|
-
when: 'Create one record
|
|
2493
|
+
when: 'Create one record from static config only. Fixed step config is not template-transformed; use a script step when data comes from @FLOW_PAYLOAD, @FLOW_LAST, or @FLOW.',
|
|
2240
2494
|
config: { table: 'table_name', data: { field: 'value' } },
|
|
2241
2495
|
},
|
|
2242
2496
|
{
|
|
2243
2497
|
tool: 'ensure_update_flow_step',
|
|
2244
2498
|
type: 'update',
|
|
2245
|
-
when: 'Update one known record
|
|
2246
|
-
config: { table: 'table_name', id: '
|
|
2499
|
+
when: 'Update one statically known record. Use a script step when id or data comes from runtime flow values.',
|
|
2500
|
+
config: { table: 'table_name', id: '<static-id>', data: { field: 'value' } },
|
|
2247
2501
|
},
|
|
2248
2502
|
{
|
|
2249
2503
|
tool: 'ensure_delete_flow_step',
|
|
2250
2504
|
type: 'delete',
|
|
2251
|
-
when: 'Delete one known record
|
|
2252
|
-
config: { table: 'table_name', id: '
|
|
2505
|
+
when: 'Delete one statically known record. Use a script step when id comes from runtime flow values.',
|
|
2506
|
+
config: { table: 'table_name', id: '<static-id>' },
|
|
2253
2507
|
},
|
|
2254
2508
|
{
|
|
2255
2509
|
tool: 'ensure_http_flow_step',
|
|
@@ -2311,6 +2565,36 @@ function chooseFlowStepTool(intent) {
|
|
|
2311
2565
|
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'query');
|
|
2312
2566
|
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
|
|
2313
2567
|
}
|
|
2568
|
+
const FIXED_FLOW_STEP_TYPES = new Set(['query', 'create', 'update', 'delete', 'http', 'sleep', 'trigger_flow', 'log']);
|
|
2569
|
+
const FLOW_RUNTIME_MACRO_PATTERN = /@FLOW(?:_PAYLOAD|_LAST|_META)?\b/u;
|
|
2570
|
+
function findFlowRuntimeMacro(value) {
|
|
2571
|
+
if (typeof value === 'string')
|
|
2572
|
+
return FLOW_RUNTIME_MACRO_PATTERN.exec(value)?.[0] || null;
|
|
2573
|
+
if (Array.isArray(value)) {
|
|
2574
|
+
for (const item of value) {
|
|
2575
|
+
const match = findFlowRuntimeMacro(item);
|
|
2576
|
+
if (match)
|
|
2577
|
+
return match;
|
|
2578
|
+
}
|
|
2579
|
+
return null;
|
|
2580
|
+
}
|
|
2581
|
+
if (!value || typeof value !== 'object')
|
|
2582
|
+
return null;
|
|
2583
|
+
for (const item of Object.values(value)) {
|
|
2584
|
+
const match = findFlowRuntimeMacro(item);
|
|
2585
|
+
if (match)
|
|
2586
|
+
return match;
|
|
2587
|
+
}
|
|
2588
|
+
return null;
|
|
2589
|
+
}
|
|
2590
|
+
export function assertFixedFlowStepConfigIsStatic(type, config, index = 0) {
|
|
2591
|
+
if (!FIXED_FLOW_STEP_TYPES.has(String(type)))
|
|
2592
|
+
return;
|
|
2593
|
+
const macro = findFlowRuntimeMacro(config);
|
|
2594
|
+
if (!macro)
|
|
2595
|
+
return;
|
|
2596
|
+
throw new Error(`steps[${index}] uses ${macro} inside a ${type} config, but ESV fixed flow step configs are static and are not template-transformed. Use a script step for runtime payload/previous-step values, keep one business operation in that script, and call @LOGS(message, details?) for captured logs.`);
|
|
2597
|
+
}
|
|
2314
2598
|
function planFlowSteps(steps) {
|
|
2315
2599
|
const items = Array.isArray(steps) ? steps : [];
|
|
2316
2600
|
return items.map((step, index) => {
|
|
@@ -2350,7 +2634,7 @@ function normalizeFlowWorkflowStep(step, index) {
|
|
|
2350
2634
|
.replace(/[^a-z0-9]+/g, '_')
|
|
2351
2635
|
.replace(/^_+|_+$/g, '')
|
|
2352
2636
|
.slice(0, 64) || `step_${index + 1}`;
|
|
2353
|
-
|
|
2637
|
+
const normalized = {
|
|
2354
2638
|
index,
|
|
2355
2639
|
key,
|
|
2356
2640
|
name: input.name || intent,
|
|
@@ -2365,6 +2649,8 @@ function normalizeFlowWorkflowStep(step, index) {
|
|
|
2365
2649
|
chosenByIntent: !input.type,
|
|
2366
2650
|
recommendedTool: guidance.tool,
|
|
2367
2651
|
};
|
|
2652
|
+
assertFixedFlowStepConfigIsStatic(type, normalized.config, index);
|
|
2653
|
+
return normalized;
|
|
2368
2654
|
}
|
|
2369
2655
|
async function runFlowWorkflow(apiUrl, opts) {
|
|
2370
2656
|
const steps = parseJsonArrayArg('steps', opts.steps, []);
|
|
@@ -2392,7 +2678,7 @@ async function runFlowWorkflow(apiUrl, opts) {
|
|
|
2392
2678
|
plan,
|
|
2393
2679
|
requiredAckParams: ['globalRulesAckKey', ...(hasDynamicCode ? ['knowledgeAckKey'] : [])],
|
|
2394
2680
|
nextSteps: [
|
|
2395
|
-
'Review the plan. Prefer fixed step types
|
|
2681
|
+
'Review the plan. Prefer fixed step types only for static config; ESV does not interpolate @FLOW_PAYLOAD/@FLOW_LAST/@FLOW inside fixed-step config. Use one focused script step when runtime values are required.',
|
|
2396
2682
|
'Call flow_workflow again with apply=true and the required ack params to create/update the flow and steps sequentially.',
|
|
2397
2683
|
'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
|
|
2398
2684
|
],
|
|
@@ -2431,10 +2717,26 @@ async function runFlowWorkflow(apiUrl, opts) {
|
|
|
2431
2717
|
return {
|
|
2432
2718
|
action: 'flow_workflow_applied',
|
|
2433
2719
|
flow: flowResult.flow,
|
|
2434
|
-
flowResult
|
|
2720
|
+
flowResult: {
|
|
2721
|
+
action: flowResult.action,
|
|
2722
|
+
flow: flowResult.flow,
|
|
2723
|
+
reload: flowResult.reload,
|
|
2724
|
+
},
|
|
2435
2725
|
stepCount: plan.length,
|
|
2436
|
-
plan,
|
|
2437
|
-
|
|
2726
|
+
plan: plan.map(({ sourceCode, ...step }) => ({
|
|
2727
|
+
...step,
|
|
2728
|
+
...(sourceCode ? { source: { length: sourceCode.length, sha256: sha256Text(sourceCode) } } : {}),
|
|
2729
|
+
})),
|
|
2730
|
+
operations: operations.map((operation) => ({
|
|
2731
|
+
index: operation.index,
|
|
2732
|
+
key: operation.key,
|
|
2733
|
+
type: operation.type,
|
|
2734
|
+
action: operation.result.action,
|
|
2735
|
+
flow: operation.result.flow,
|
|
2736
|
+
step: operation.result.step,
|
|
2737
|
+
validation: operation.result.validation,
|
|
2738
|
+
reload: operation.result.reload,
|
|
2739
|
+
})),
|
|
2438
2740
|
sequential: true,
|
|
2439
2741
|
nextSteps: [
|
|
2440
2742
|
'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
|
|
@@ -2447,6 +2749,29 @@ function normalizeEndpointAccess(anonymousAccess, makePublic) {
|
|
|
2447
2749
|
return makePublic ? 'public' : 'private';
|
|
2448
2750
|
return anonymousAccess || 'private';
|
|
2449
2751
|
}
|
|
2752
|
+
async function reviewCustomEndpointSource(apiUrl, method, sourceCode) {
|
|
2753
|
+
const repositoryTables = extractExplicitRepositoryTableNames(sourceCode);
|
|
2754
|
+
const selectedTables = repositoryTables.slice(0, 5);
|
|
2755
|
+
const results = await Promise.allSettled(selectedTables.map(async (tableName) => [tableName, await fetchTableMetadata(apiUrl, tableName)]));
|
|
2756
|
+
const tableMetadata = {};
|
|
2757
|
+
const metadataUnavailable = [];
|
|
2758
|
+
for (const [index, result] of results.entries()) {
|
|
2759
|
+
if (result.status === 'fulfilled') {
|
|
2760
|
+
tableMetadata[result.value[0]] = result.value[1];
|
|
2761
|
+
}
|
|
2762
|
+
else {
|
|
2763
|
+
metadataUnavailable.push(selectedTables[index]);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
return reviewDynamicEndpointContract({
|
|
2767
|
+
routeKind: 'custom',
|
|
2768
|
+
method,
|
|
2769
|
+
sourceCode,
|
|
2770
|
+
tableMetadata,
|
|
2771
|
+
metadataUnavailable,
|
|
2772
|
+
metadataTruncated: repositoryTables.length > selectedTables.length,
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2450
2775
|
function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
|
|
2451
2776
|
if (!existingHandler)
|
|
2452
2777
|
return false;
|
|
@@ -2484,18 +2809,25 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
|
|
|
2484
2809
|
const normalizedPath = normalizeRestPath(opts.path);
|
|
2485
2810
|
const methodName = normalizeMethodName(opts.method);
|
|
2486
2811
|
const access = normalizeEndpointAccess(opts.anonymousAccess, opts.public);
|
|
2812
|
+
assertDynamicEndpointContract(reviewDynamicEndpointContract({
|
|
2813
|
+
routeKind: 'custom',
|
|
2814
|
+
method: methodName,
|
|
2815
|
+
sourceCode: opts.sourceCode,
|
|
2816
|
+
}));
|
|
2487
2817
|
const { methodMap, methodIdNameMap } = await getMethodContext(apiUrl);
|
|
2488
2818
|
const methodId = methodMap[methodName];
|
|
2489
2819
|
if (!methodId)
|
|
2490
2820
|
throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
2491
|
-
const [routes, scriptValidation] = await Promise.all([
|
|
2821
|
+
const [routes, scriptValidation, contractReview] = await Promise.all([
|
|
2492
2822
|
fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,description,availableMethods.*,publicMethods.*,mainTable.name'),
|
|
2493
2823
|
validateScriptSourceIfPresent(fetchAPI, apiUrl, 'enfyra_route_handler', {
|
|
2494
2824
|
sourceCode: opts.sourceCode,
|
|
2495
2825
|
scriptLanguage: opts.scriptLanguage || 'javascript',
|
|
2496
2826
|
}),
|
|
2827
|
+
reviewCustomEndpointSource(apiUrl, methodName, opts.sourceCode),
|
|
2497
2828
|
]);
|
|
2498
2829
|
const route = routes.find((item) => item.path === normalizedPath) || null;
|
|
2830
|
+
assertCustomEndpointRoute(route);
|
|
2499
2831
|
const routeId = getId(route);
|
|
2500
2832
|
const availableMethods = methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
|
|
2501
2833
|
const publicMethods = methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
|
|
@@ -2615,6 +2947,7 @@ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
|
|
|
2615
2947
|
handler,
|
|
2616
2948
|
role,
|
|
2617
2949
|
scriptValidation,
|
|
2950
|
+
contractReview,
|
|
2618
2951
|
steps,
|
|
2619
2952
|
firstRunnable,
|
|
2620
2953
|
blocked,
|
|
@@ -2771,8 +3104,9 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
|
|
|
2771
3104
|
action: operations.length ? 'api_endpoint_workflow_advanced' : 'api_endpoint_workflow_planned',
|
|
2772
3105
|
endpoint: latestState.endpoint,
|
|
2773
3106
|
scriptValidation: latestState.scriptValidation,
|
|
3107
|
+
contractReview: latestState.contractReview,
|
|
2774
3108
|
steps: latestSteps,
|
|
2775
|
-
operations,
|
|
3109
|
+
operations: operations.map(summarizeWorkflowOperation),
|
|
2776
3110
|
complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
|
|
2777
3111
|
nextSteps,
|
|
2778
3112
|
cleanupHints: latestState.endpoint.routeId
|
|
@@ -3012,24 +3346,37 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3012
3346
|
'This calls /enfyra_extension/preview and does not save anything.',
|
|
3013
3347
|
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3014
3348
|
].join(' '), {
|
|
3015
|
-
code: z.string().describe('Vue SFC or compiled extension bundle code.'),
|
|
3349
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC or compiled extension bundle code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3016
3350
|
name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
|
|
3017
|
-
|
|
3351
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. resource_list/resource_grid enable deterministic layout policy checks.'),
|
|
3352
|
+
}, async ({ code, name, uiPattern }) => jsonText({
|
|
3018
3353
|
action: 'extension_code_validated',
|
|
3019
|
-
validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
|
|
3354
|
+
validation: await validateExtensionCode(ENFYRA_API_URL, code, name, { uiPattern }),
|
|
3020
3355
|
}));
|
|
3356
|
+
server.tool('verify_extension_runtime', [
|
|
3357
|
+
'Verify one saved Enfyra extension through the strongest checks available inside MCP.',
|
|
3358
|
+
'It checks the saved record and expected hash, runs local UI/theme/runtime policy review, calls the server Vue compiler, and verifies page menu wiring.',
|
|
3359
|
+
'It explicitly reports browserRender=not_run because signed-in component execution, real API data shape, console errors, and responsive layout require browser automation outside this MCP server.',
|
|
3360
|
+
].join(' '), {
|
|
3361
|
+
id: z.union([z.string(), z.number()]).optional().describe('Saved extension id. Provide id or name.'),
|
|
3362
|
+
name: z.string().optional().describe('Saved extension unique name. Provide id or name.'),
|
|
3363
|
+
expectedSha256: z.string().optional().describe('Optional expected saved source hash from inspect/update/patch output.'),
|
|
3364
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern for deterministic layout policy checks.'),
|
|
3365
|
+
}, async (input) => jsonText(await verifyExtensionRuntime(ENFYRA_API_URL, input)));
|
|
3021
3366
|
server.tool('update_extension_code', [
|
|
3022
3367
|
'Business operation: update an existing Enfyra admin extension code by id or name.',
|
|
3023
|
-
'It runs local extension guards and /enfyra_extension/preview first, then
|
|
3368
|
+
'It runs local extension guards and /enfyra_extension/preview first, saves only when validation succeeds, then re-reads and verifies the exact saved source in the same call.',
|
|
3024
3369
|
'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
|
|
3025
3370
|
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3026
3371
|
].join(' '), {
|
|
3027
3372
|
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
3028
3373
|
name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
|
|
3029
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
3374
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3030
3375
|
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3031
3376
|
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
3032
3377
|
version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
|
|
3378
|
+
expectedSha256: z.string().optional().describe('Optional SHA-256 of current extension code. Rejects stale full replacements.'),
|
|
3379
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. Enforces deterministic layout policy before saving.'),
|
|
3033
3380
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
3034
3381
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3035
3382
|
}, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
|
|
@@ -3039,7 +3386,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3039
3386
|
'For edits that temporarily unbalance Vue tags or slots, pass patches=[{search,replace},...] so all patches are applied in memory, then the final SFC is validated and saved atomically when apply=true.',
|
|
3040
3387
|
'Default searchMode="exact"; use searchMode="whitespace" only when indentation/newline variation is the problem.',
|
|
3041
3388
|
'Default replaceAll=false requires exactly one match; set replaceAll=true only after preview confirms the match count.',
|
|
3042
|
-
'It hash-checks the current code, validates with /enfyra_extension/preview,
|
|
3389
|
+
'It hash-checks the current code, validates with /enfyra_extension/preview, saves only when apply=true, then re-reads and verifies the exact saved source.',
|
|
3043
3390
|
'Default apply=false returns a preview and nextStep input.',
|
|
3044
3391
|
].join(' '), {
|
|
3045
3392
|
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
@@ -3054,11 +3401,12 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3054
3401
|
searchMode: z.enum(['exact', 'whitespace']).optional().default('exact').describe('Patch matching mode. Use whitespace only for indentation/newline variation.'),
|
|
3055
3402
|
replaceAll: z.boolean().optional().default(false).describe('Patch replace-all mode. false requires exactly one match for this patch.'),
|
|
3056
3403
|
})).optional().describe('Atomic multi-patch list. Patches apply sequentially in memory and only the final SFC is validated/saved when apply=true. Use this for slot/tag pairs that would be invalid as intermediate states.'),
|
|
3057
|
-
expectedSha256: z.string().optional().describe('
|
|
3404
|
+
expectedSha256: z.string().optional().describe('SHA-256 of current extension code from preview/inspect. Required when apply=true and rejects stale patches.'),
|
|
3058
3405
|
apply: z.boolean().optional().default(false).describe('Preview by default. Set true to validate and save.'),
|
|
3059
3406
|
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3060
3407
|
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
3061
3408
|
version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
|
|
3409
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. Enforces deterministic layout policy before saving.'),
|
|
3062
3410
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
3063
3411
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3064
3412
|
}, async (input) => jsonText(await patchExtensionCode(ENFYRA_API_URL, input)));
|
|
@@ -3095,7 +3443,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3095
3443
|
'theme_review',
|
|
3096
3444
|
'review',
|
|
3097
3445
|
]).describe('Which extension UI contract builder/reviewer to run.'),
|
|
3098
|
-
input: z.record(z.any()).optional().default({}).describe('Builder input object. For kind=api_usage, pass { path, resource, method? }; for kind=confirm, pass { resource, executeName?, refreshName?, recordName?, idExpression? }; for kind=notify, pass { kind, title, description? }. For kind=theme_classes, pass { intent }. For kind=runtime_review/theme_review/review, pass { code }.'),
|
|
3446
|
+
input: z.record(z.any()).optional().default({}).describe('Builder input object. For kind=api_usage, pass { path, resource, method? }; for kind=confirm, pass { resource, executeName?, refreshName?, recordName?, idExpression? }; for kind=notify, pass { kind, title, description? }. For kind=theme_classes, pass { intent }. For kind=runtime_review/theme_review/review, pass { code, pattern? }, where pattern may be resource_list or resource_grid for deterministic layout policy.'),
|
|
3099
3447
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3100
3448
|
}, async ({ kind, input, extensionKnowledgeAckKey }) => {
|
|
3101
3449
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
@@ -3173,7 +3521,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3173
3521
|
'Use this before patching or saving generated extension UI when CommonDrawer, CommonModal, UModal, UInput, UTextarea, USelect, or native buttons are involved.',
|
|
3174
3522
|
'This is a static contract review, not a compiler validation; still validate the final SFC before saving.',
|
|
3175
3523
|
].join(' '), {
|
|
3176
|
-
code: z.string().describe('Vue SFC or template snippet to review.'),
|
|
3524
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC or template snippet to review.'),
|
|
3177
3525
|
}, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
|
|
3178
3526
|
server.tool('build_extension_page_shell', [
|
|
3179
3527
|
'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
|
|
@@ -3347,7 +3695,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3347
3695
|
].join(' '), {
|
|
3348
3696
|
name: z.string().describe('Extension unique name.'),
|
|
3349
3697
|
type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
|
|
3350
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
3698
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3351
3699
|
menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
|
|
3352
3700
|
menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
|
|
3353
3701
|
menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
|
|
@@ -3486,15 +3834,15 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3486
3834
|
server.tool('api_endpoint_workflow', [
|
|
3487
3835
|
'Step-by-step workflow for creating or updating a custom REST endpoint.',
|
|
3488
3836
|
'Use this when an LLM is building or changing endpoint behavior and should follow live nextSteps instead of guessing raw metadata mutations.',
|
|
3489
|
-
'With apply=false it validates sourceCode, reads live route/handler/access state, and returns pending steps.',
|
|
3837
|
+
'With apply=false it validates sourceCode, blocks canonical-route collisions, reviews explicit repository metadata/security boundaries, reads live route/handler/access state, and returns pending steps.',
|
|
3490
3838
|
'With apply=true it applies only the next pending step, then returns a fresh plan. With applyAll=true it advances all currently safe pending steps.',
|
|
3491
3839
|
].join(' '), {
|
|
3492
3840
|
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3493
3841
|
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
3494
|
-
sourceCode: z.string().describe('Handler sourceCode.
|
|
3842
|
+
sourceCode: z.string().describe('Handler body sourceCode for a custom route, which has no main table. Do not wrap it in export default/module.exports. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3495
3843
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
3496
3844
|
anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
|
|
3497
|
-
public: z.boolean().optional().describe('Compatibility alias for anonymousAccess. true
|
|
3845
|
+
public: z.preprocess(normalizeStrictBoolean, z.boolean()).optional().describe('Compatibility alias for anonymousAccess. Accepts boolean true/false and exact string "true"/"false"; false means private.'),
|
|
3498
3846
|
roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
|
|
3499
3847
|
roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
|
|
3500
3848
|
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
|
|
@@ -3520,9 +3868,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3520
3868
|
].join(' '), {
|
|
3521
3869
|
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3522
3870
|
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
3523
|
-
sourceCode: z.string().describe('Handler sourceCode.
|
|
3871
|
+
sourceCode: z.string().describe('Handler body sourceCode for a custom route, which has no main table. Do not wrap it in export default/module.exports. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3524
3872
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
3525
|
-
public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
|
|
3873
|
+
public: z.preprocess(normalizeStrictBoolean, z.boolean()).optional().default(false).describe('When true, the method is added to publicMethods for anonymous access. Exact string "true"/"false" is normalized for weak clients.'),
|
|
3526
3874
|
description: z.string().optional().describe('Route description.'),
|
|
3527
3875
|
timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
|
|
3528
3876
|
overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
|
|
@@ -3535,12 +3883,25 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3535
3883
|
assertDynamicCodeKnowledgeAck(knowledgeAckKey);
|
|
3536
3884
|
const normalizedPath = normalizeRestPath(path);
|
|
3537
3885
|
const methodName = normalizeMethodName(method);
|
|
3538
|
-
|
|
3886
|
+
assertDynamicEndpointContract(reviewDynamicEndpointContract({
|
|
3887
|
+
routeKind: 'custom',
|
|
3888
|
+
method: methodName,
|
|
3889
|
+
sourceCode,
|
|
3890
|
+
}));
|
|
3891
|
+
const [{ methodMap, methodIdNameMap }, routes, scriptValidation, contractReview] = await Promise.all([
|
|
3892
|
+
getMethodContext(ENFYRA_API_URL),
|
|
3893
|
+
fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name'),
|
|
3894
|
+
validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
|
|
3895
|
+
sourceCode,
|
|
3896
|
+
scriptLanguage,
|
|
3897
|
+
}),
|
|
3898
|
+
reviewCustomEndpointSource(ENFYRA_API_URL, methodName, sourceCode),
|
|
3899
|
+
]);
|
|
3539
3900
|
const methodId = methodMap[methodName];
|
|
3540
3901
|
if (!methodId)
|
|
3541
3902
|
throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
3542
|
-
const routes = await fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
|
|
3543
3903
|
let route = routes.find((item) => item.path === normalizedPath);
|
|
3904
|
+
assertCustomEndpointRoute(route);
|
|
3544
3905
|
let routeAction = 'existing';
|
|
3545
3906
|
if (!route) {
|
|
3546
3907
|
const createRouteResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
|
|
@@ -3573,10 +3934,6 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3573
3934
|
routeAction = 'updated';
|
|
3574
3935
|
}
|
|
3575
3936
|
const routeId = getId(route);
|
|
3576
|
-
const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
|
|
3577
|
-
sourceCode,
|
|
3578
|
-
scriptLanguage,
|
|
3579
|
-
});
|
|
3580
3937
|
const existingHandler = await findHandler(ENFYRA_API_URL, routeId, methodId);
|
|
3581
3938
|
let handlerResult;
|
|
3582
3939
|
let handlerAction;
|
|
@@ -3642,6 +3999,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3642
3999
|
routeAction,
|
|
3643
4000
|
handlerAction,
|
|
3644
4001
|
scriptValidation,
|
|
4002
|
+
contractReview,
|
|
3645
4003
|
routeReload,
|
|
3646
4004
|
smokeTest,
|
|
3647
4005
|
usage: {
|
|
@@ -3960,6 +4318,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3960
4318
|
'Workflow front door for creating or updating an Enfyra flow and its steps in one guided path.',
|
|
3961
4319
|
'For a fully specified, non-destructive flow, use apply=true to create/update the flow and all steps sequentially in one call. Use apply=false only when step types or risk need review.',
|
|
3962
4320
|
'Prefer this over choosing individual ensure_*_flow_step tools in guided mode.',
|
|
4321
|
+
'Fixed query/create/update/delete/http/sleep/trigger/log config is static in current ESV and does not interpolate @FLOW_PAYLOAD/@FLOW_LAST/@FLOW; use a focused script step for runtime values.',
|
|
3963
4322
|
].join(' '), {
|
|
3964
4323
|
name: z.string().describe('Flow name. Existing flow with this name is updated.'),
|
|
3965
4324
|
triggerType: z.enum(['manual', 'schedule']).optional().default('manual').describe('manual for API/admin/hook/child flow usage, schedule for cron/time-based flows.'),
|
|
@@ -3971,7 +4330,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3971
4330
|
name: z.string().optional().describe('Human label. Defaults from intent.'),
|
|
3972
4331
|
intent: z.string().optional().describe('Plain-language step intent. Used to choose a fixed step type when type is omitted.'),
|
|
3973
4332
|
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.'),
|
|
3974
|
-
config: z.union([z.record(z.any()), z.string()]).optional().describe('Step config object or JSON string.
|
|
4333
|
+
config: z.union([z.record(z.any()), z.string()]).optional().describe('Step config object or JSON string. Fixed-step config is static and cannot contain @FLOW_PAYLOAD/@FLOW_LAST/@FLOW. Use a focused script step for runtime values.'),
|
|
3975
4334
|
sourceCode: z.string().optional().describe('Only for script or condition steps. Use fixed step types when possible.'),
|
|
3976
4335
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript'),
|
|
3977
4336
|
order: z.number().optional().describe('Step order. Defaults to index * 10.'),
|
|
@@ -4223,9 +4582,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4223
4582
|
})).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
|
|
4224
4583
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
4225
4584
|
}, async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)));
|
|
4226
|
-
server.tool('ensure_page_extension', 'Business operation: create or update one page extension attached to an existing menu. Validates
|
|
4585
|
+
server.tool('ensure_page_extension', 'Business operation: create or update one page extension attached to an existing menu. Validates before save, then re-reads and verifies the exact saved source and menu wiring. Call get_extension_theme_contract first for UI work.', {
|
|
4227
4586
|
name: z.string().describe('Extension unique name.'),
|
|
4228
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4587
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4229
4588
|
menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
|
|
4230
4589
|
description: z.string().optional().describe('Extension description.'),
|
|
4231
4590
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
@@ -4236,9 +4595,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4236
4595
|
action: 'page_extension_ensured',
|
|
4237
4596
|
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
|
|
4238
4597
|
}));
|
|
4239
|
-
server.tool('ensure_global_extension', 'Business operation: create or update one global shell extension. Validates
|
|
4598
|
+
server.tool('ensure_global_extension', 'Business operation: create or update one global shell extension. Validates before save, rejects menu coupling, then re-reads and verifies the exact saved source. Call get_extension_theme_contract first for UI work.', {
|
|
4240
4599
|
name: z.string().describe('Extension unique name.'),
|
|
4241
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4600
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4242
4601
|
description: z.string().optional().describe('Extension description.'),
|
|
4243
4602
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4244
4603
|
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
@@ -4248,9 +4607,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4248
4607
|
action: 'global_extension_ensured',
|
|
4249
4608
|
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
|
|
4250
4609
|
}));
|
|
4251
|
-
server.tool('ensure_widget_extension', 'Business operation: create or update one widget extension. Validates
|
|
4610
|
+
server.tool('ensure_widget_extension', 'Business operation: create or update one widget extension. Validates before save, rejects menu coupling, then re-reads and verifies the exact saved source. Call get_extension_theme_contract first for UI work.', {
|
|
4252
4611
|
name: z.string().describe('Extension unique name.'),
|
|
4253
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4612
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4254
4613
|
description: z.string().optional().describe('Extension description.'),
|
|
4255
4614
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4256
4615
|
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|