@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.
Files changed (52) hide show
  1. package/README.md +1 -0
  2. package/dist/lib/dynamic-endpoint-contract.d.ts +12 -0
  3. package/dist/lib/dynamic-endpoint-contract.js +148 -0
  4. package/dist/lib/dynamic-endpoint-contract.js.map +1 -0
  5. package/dist/lib/dynamic-repository-builder.d.ts +7 -0
  6. package/dist/lib/dynamic-repository-builder.js +8 -0
  7. package/dist/lib/dynamic-repository-builder.js.map +1 -1
  8. package/dist/lib/extension-search-tools.js +2 -1
  9. package/dist/lib/extension-search-tools.js.map +1 -1
  10. package/dist/lib/extension-sfc-analyzer.d.ts +4 -0
  11. package/dist/lib/extension-sfc-analyzer.js +137 -0
  12. package/dist/lib/extension-sfc-analyzer.js.map +1 -0
  13. package/dist/lib/mcp-examples.js +117 -8
  14. package/dist/lib/mcp-examples.js.map +1 -1
  15. package/dist/lib/mcp-instructions.js +5 -15
  16. package/dist/lib/mcp-instructions.js.map +1 -1
  17. package/dist/lib/mcp-usage-telemetry.js +41 -2
  18. package/dist/lib/mcp-usage-telemetry.js.map +1 -1
  19. package/dist/lib/mutation-guards.d.ts +1 -0
  20. package/dist/lib/mutation-guards.js +42 -0
  21. package/dist/lib/mutation-guards.js.map +1 -1
  22. package/dist/lib/platform-operation-tools.d.ts +160 -3
  23. package/dist/lib/platform-operation-tools.js +454 -95
  24. package/dist/lib/platform-operation-tools.js.map +1 -1
  25. package/dist/lib/required-knowledge.d.ts +9 -2
  26. package/dist/lib/required-knowledge.js +60 -17
  27. package/dist/lib/required-knowledge.js.map +1 -1
  28. package/dist/lib/response-format.js +39 -25
  29. package/dist/lib/response-format.js.map +1 -1
  30. package/dist/lib/runtime-zone-tools.js +4 -3
  31. package/dist/lib/runtime-zone-tools.js.map +1 -1
  32. package/dist/lib/session-safety.d.ts +9 -0
  33. package/dist/lib/session-safety.js +89 -0
  34. package/dist/lib/session-safety.js.map +1 -0
  35. package/dist/lib/source-artifacts.js +3 -1
  36. package/dist/lib/source-artifacts.js.map +1 -1
  37. package/dist/lib/table-tools.d.ts +9 -1
  38. package/dist/lib/table-tools.js +69 -9
  39. package/dist/lib/table-tools.js.map +1 -1
  40. package/dist/lib/tool-input-normalization.d.ts +4 -0
  41. package/dist/lib/tool-input-normalization.js +35 -0
  42. package/dist/lib/tool-input-normalization.js.map +1 -0
  43. package/dist/lib/tool-routing.d.ts +6 -2
  44. package/dist/lib/tool-routing.js +52 -16
  45. package/dist/lib/tool-routing.js.map +1 -1
  46. package/dist/lib/toolset-filter.d.ts +7 -3
  47. package/dist/lib/toolset-filter.js +144 -86
  48. package/dist/lib/toolset-filter.js.map +1 -1
  49. package/dist/lib/types.d.ts +62 -0
  50. package/dist/mcp-server-entry.js +79 -23
  51. package/dist/mcp-server-entry.js.map +1 -1
  52. 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 snippet = [
628
- '<CommonResourceListFrame',
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="${input.itemsPerPageExpression || '0'}"`,
660
+ ` :items-per-page="${itemsPerPageExpression}"`,
633
661
  ` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '&quot;')}"`,
634
662
  ` empty-description="${String(input.emptyDescription || '').replace(/"/g, '&quot;')}"`,
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
- if (/<CommonDrawer\b[^>]*(?:\s:title=|\stitle=)/.test(source)) {
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 (/<(?:CommonModal|UModal)\b[^>]*(?:\s:title=|\stitle=)/.test(source)) {
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 (/<CommonDrawer\b/.test(source) && !/primary-action=/.test(source)) {
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 (/<CommonDrawer\b/.test(source) && !/cancel-action=/.test(source)) {
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 (/<(?:CommonModal|UModal)\b/.test(source) && /delete|remove|confirm|cannot be undone/i.test(source) && !/danger-action=/.test(source)) {
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 fieldPattern = /<(UInput|UTextarea|USelectMenu|USelect)(\s[^<>]*?)\/?>/g;
1141
- let fieldMatch;
1142
- while ((fieldMatch = fieldPattern.exec(source))) {
1143
- const [, tag, attrs] = fieldMatch;
1144
- const classMatch = attrs.match(/\bclass="([^"]*)"/);
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 buttonPattern = /<button(\s[^>]*)?>/g;
1150
- let buttonMatch;
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
- ? ['Use build_extension_ui with kind=drawer or kind=modal for replacement snippets, then apply with patch_extension_code/update_extension_code.']
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 (/<(?:CommonModal|UModal)\b[^>]*\bv-model\s*=/.test(source)) {
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 (/<CommonEmptyState\b/.test(source)) {
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 build_extension_api_usage with structured sort entries; Enfyra REST requires one comma-separated string such as "-isPinned,-updatedAt".`);
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 = findMissingFullWidthFieldControls(code);
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
- async function updateExtensionCode(apiUrl, { id, name, code, description, isEnabled, version, globalRulesAckKey, extensionKnowledgeAckKey, }) {
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 validation = await validateExtensionCode(apiUrl, code, name || existing.name || extensionId);
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
- result,
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
- async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMode, replaceAll, patches, expectedSha256, apply, description, isEnabled, version, globalRulesAckKey, extensionKnowledgeAckKey, }) {
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: operation.id || getId(existing),
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 in one table from static config or previous flow values.',
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 by id.',
2246
- config: { table: 'table_name', id: '@FLOW_PAYLOAD.id', data: { field: 'value' } },
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 by id.',
2252
- config: { table: 'table_name', id: '@FLOW_PAYLOAD.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
- return {
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; script is only for logic not covered by query/create/update/delete/http/sleep/trigger/log/condition.',
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
- operations,
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
- }, async ({ code, name }) => jsonText({
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 saves the code in the same call only when validation succeeds.',
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, and saves only when apply=true.',
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('Optional SHA-256 of current extension code from a prior inspect/read. Rejects stale patches.'),
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. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, and @USER. Repository calls are async and reads return result.data. Use @REPOS.main or #secure.table_name/@REPOS.secure.table_name for user-facing access; reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
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 means public, false means private.'),
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. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, and @USER. Repository calls are async and reads return result.data. Use @REPOS.main or #secure.table_name/@REPOS.secure.table_name for user-facing access; reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
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
- const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
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. For query/create/update/delete/http/sleep/trigger/log steps, prefer config over sourceCode.'),
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 extension code before save. Call get_extension_theme_contract first for UI work.', {
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 extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.', {
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 extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.', {
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.'),