@enfyra/mcp-server 0.1.63 → 0.1.65

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 (48) hide show
  1. package/README.md +1 -0
  2. package/dist/lib/dynamic-endpoint-contract.js +3 -0
  3. package/dist/lib/dynamic-endpoint-contract.js.map +1 -1
  4. package/dist/lib/extension-search-tools.js +2 -1
  5. package/dist/lib/extension-search-tools.js.map +1 -1
  6. package/dist/lib/extension-sfc-analyzer.d.ts +4 -0
  7. package/dist/lib/extension-sfc-analyzer.js +137 -0
  8. package/dist/lib/extension-sfc-analyzer.js.map +1 -0
  9. package/dist/lib/mcp-examples.js +2 -2
  10. package/dist/lib/mcp-instructions.js +5 -15
  11. package/dist/lib/mcp-instructions.js.map +1 -1
  12. package/dist/lib/mcp-usage-telemetry.js +4 -3
  13. package/dist/lib/mcp-usage-telemetry.js.map +1 -1
  14. package/dist/lib/mutation-guards.d.ts +1 -0
  15. package/dist/lib/mutation-guards.js +28 -0
  16. package/dist/lib/mutation-guards.js.map +1 -1
  17. package/dist/lib/platform-operation-tools.d.ts +159 -3
  18. package/dist/lib/platform-operation-tools.js +361 -78
  19. package/dist/lib/platform-operation-tools.js.map +1 -1
  20. package/dist/lib/required-knowledge.d.ts +10 -3
  21. package/dist/lib/required-knowledge.js +53 -17
  22. package/dist/lib/required-knowledge.js.map +1 -1
  23. package/dist/lib/response-format.js +39 -25
  24. package/dist/lib/response-format.js.map +1 -1
  25. package/dist/lib/runtime-zone-tools.d.ts +14 -0
  26. package/dist/lib/runtime-zone-tools.js +54 -16
  27. package/dist/lib/runtime-zone-tools.js.map +1 -1
  28. package/dist/lib/session-safety.d.ts +9 -0
  29. package/dist/lib/session-safety.js +89 -0
  30. package/dist/lib/session-safety.js.map +1 -0
  31. package/dist/lib/source-artifacts.js +3 -1
  32. package/dist/lib/source-artifacts.js.map +1 -1
  33. package/dist/lib/table-tools.d.ts +5 -0
  34. package/dist/lib/table-tools.js +46 -5
  35. package/dist/lib/table-tools.js.map +1 -1
  36. package/dist/lib/tool-input-normalization.d.ts +4 -0
  37. package/dist/lib/tool-input-normalization.js +35 -0
  38. package/dist/lib/tool-input-normalization.js.map +1 -0
  39. package/dist/lib/tool-routing.d.ts +6 -2
  40. package/dist/lib/tool-routing.js +33 -8
  41. package/dist/lib/tool-routing.js.map +1 -1
  42. package/dist/lib/toolset-filter.d.ts +7 -3
  43. package/dist/lib/toolset-filter.js +144 -86
  44. package/dist/lib/toolset-filter.js.map +1 -1
  45. package/dist/lib/types.d.ts +20 -0
  46. package/dist/mcp-server-entry.js +52 -21
  47. package/dist/mcp-server-entry.js.map +1 -1
  48. package/package.json +4 -1
@@ -4,6 +4,9 @@ import { fetchAPI } from './fetch.js';
4
4
  import { fetchTableCatalog, fetchTableMetadata, fetchTableMetadataByRef, resolveTableCatalogEntry } from './metadata-client.js';
5
5
  import { assertCustomEndpointRoute, assertDynamicEndpointContract, extractExplicitRepositoryTableNames, reviewDynamicEndpointContract, } from './dynamic-endpoint-contract.js';
6
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';
7
10
  import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
8
11
  const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
9
12
  'CommonDrawer',
@@ -66,6 +69,26 @@ function sameId(a, b) {
66
69
  function firstDataRecord(result) {
67
70
  return Array.isArray(result?.data) ? result.data[0] : result;
68
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
+ }
69
92
  function normalizeRestPath(path) {
70
93
  if (!path)
71
94
  return '/';
@@ -625,12 +648,16 @@ export function buildExtensionResourceListSnippet(input) {
625
648
  const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
626
649
  const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
627
650
  const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
628
- const snippet = [
629
- '<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}`,
630
657
  ` :loading="${input.loadingExpression || 'pending'}"`,
631
658
  ` :has-items="${itemsExpression}.length > 0"`,
632
659
  ` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
633
- ` :items-per-page="${input.itemsPerPageExpression || '0'}"`,
660
+ ` :items-per-page="${itemsPerPageExpression}"`,
634
661
  ` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '&quot;')}"`,
635
662
  ` empty-description="${String(input.emptyDescription || '').replace(/"/g, '&quot;')}"`,
636
663
  ` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
@@ -646,6 +673,9 @@ export function buildExtensionResourceListSnippet(input) {
646
673
  ' />',
647
674
  '</CommonResourceListFrame>',
648
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');
649
679
  return {
650
680
  action: 'extension_resource_list_built',
651
681
  components: ['CommonResourceListFrame', 'CommonResourceListItem'],
@@ -654,6 +684,8 @@ export function buildExtensionResourceListSnippet(input) {
654
684
  'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
655
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.',
656
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.',
657
689
  'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
658
690
  ],
659
691
  };
@@ -1119,48 +1151,120 @@ export function buildExtensionConfirmSnippet(input = {}) {
1119
1151
  ],
1120
1152
  };
1121
1153
  }
1122
- export function reviewExtensionUiContract(code) {
1154
+ export function reviewExtensionUiContract(code, options = {}) {
1123
1155
  const source = String(code || '');
1156
+ const pattern = String(options.pattern || 'auto');
1124
1157
  const issues = [];
1125
1158
  const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
1126
- 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'))) {
1127
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.');
1128
1171
  }
1129
- if (/<(?:CommonModal|UModal)\b[^>]*(?:\s:title=|\stitle=)/.test(source)) {
1172
+ if (modals.some((element) => hasStaticOrBound(element, 'title'))) {
1130
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.');
1131
1174
  }
1132
- if (/<CommonDrawer\b/.test(source) && !/primary-action=/.test(source)) {
1175
+ if (drawers.some((element) => !hasStaticOrBound(element, 'primary-action') && !hasStaticOrBound(element, 'primaryAction'))) {
1133
1176
  push('warning', 'drawer-primary-action', 'CommonDrawer has no primaryAction.', 'Editing/create drawers should wire Save/Create through primaryAction.');
1134
1177
  }
1135
- if (/<CommonDrawer\b/.test(source) && !/cancel-action=/.test(source)) {
1178
+ if (drawers.some((element) => !hasStaticOrBound(element, 'cancel-action') && !hasStaticOrBound(element, 'cancelAction'))) {
1136
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.');
1137
1180
  }
1138
- 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'))) {
1139
1182
  push('warning', 'modal-danger-action', 'Destructive/confirmation modal has no dangerAction.', 'Wire the final destructive action through dangerAction.');
1140
1183
  }
1141
- const fieldPattern = /<(UInput|UTextarea|USelectMenu|USelect)(\s[^<>]*?)\/?>/g;
1142
- let fieldMatch;
1143
- while ((fieldMatch = fieldPattern.exec(source))) {
1144
- const [, tag, attrs] = fieldMatch;
1145
- const classMatch = attrs.match(/\bclass="([^"]*)"/);
1146
- if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
1147
- 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.');
1148
1189
  }
1149
1190
  }
1150
- const buttonPattern = /<button(\s[^>]*)?>/g;
1151
- let buttonMatch;
1152
- while ((buttonMatch = buttonPattern.exec(source))) {
1153
- if (!/\btype=/.test(buttonMatch[1] || '')) {
1191
+ for (const element of byTag('button')) {
1192
+ if (!hasStaticOrBound(element, 'type')) {
1154
1193
  push('warning', 'native-button-type', 'Native button is missing type="button".', 'Add type="button" unless the button intentionally submits a form.');
1155
1194
  }
1156
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
+ }
1157
1258
  return {
1158
1259
  action: 'extension_ui_contract_reviewed',
1260
+ pattern,
1159
1261
  valid: issues.every((issue) => issue.severity !== 'error'),
1160
1262
  issueCount: issues.length,
1161
1263
  issues,
1162
1264
  nextSteps: issues.length
1163
- ? ['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.']
1164
1268
  : ['Snippet matches the checked modal/drawer contract rules. Still validate the final SFC before saving.'],
1165
1269
  };
1166
1270
  }
@@ -1168,6 +1272,7 @@ function collectExtensionRuntimeIssues(code) {
1168
1272
  const source = String(code || '');
1169
1273
  const issues = [];
1170
1274
  const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
1275
+ const analysis = analyzeExtensionSfc(source);
1171
1276
  if (/(?:^|[>\n;])\s*import(?:\s.+?\sfrom\s+|\s*['"])/m.test(source)) {
1172
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.');
1173
1278
  }
@@ -1183,10 +1288,11 @@ function collectExtensionRuntimeIssues(code) {
1183
1288
  if (/\b(?:query|body|filter|deep|aggregate)\s*:\s*JSON\.stringify\s*\(/.test(source)) {
1184
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().');
1185
1290
  }
1186
- 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')))) {
1187
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.');
1188
1294
  }
1189
- if (/<CommonEmptyState\b/.test(source)) {
1295
+ if (analysis.elements.some((element) => element.tag === 'CommonEmptyState')) {
1190
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.');
1191
1297
  }
1192
1298
  const scriptBlocks = [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1]);
@@ -1467,7 +1573,7 @@ export function buildExtensionUiSnippet(kind, input = {}) {
1467
1573
  if (!input?.code) {
1468
1574
  throw new Error('build_extension_ui kind=review requires input.code.');
1469
1575
  }
1470
- const uiReview = reviewExtensionUiContract(input.code);
1576
+ const uiReview = reviewExtensionUiContract(input.code, { pattern: input.pattern });
1471
1577
  const themeReview = reviewExtensionThemeContract(input.code);
1472
1578
  const runtimeReview = reviewExtensionRuntimeContract(input.code);
1473
1579
  result = {
@@ -1744,39 +1850,44 @@ function findInvalidExtensionSortSyntax(code) {
1744
1850
  }
1745
1851
  return null;
1746
1852
  }
1747
- 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
+ }
1748
1858
  if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
1749
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.');
1750
1860
  }
1751
1861
  const invalidSortSyntax = findInvalidExtensionSortSyntax(code);
1752
1862
  if (invalidSortSyntax) {
1753
- 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".`);
1754
- }
1755
- const violations = [];
1756
- for (const template of readTemplateBlocks(code)) {
1757
- let index = 0;
1758
- while (index < template.length) {
1759
- const tagStart = template.indexOf('<', index);
1760
- if (tagStart === -1)
1761
- break;
1762
- const tagName = readTemplateTagName(template, tagStart);
1763
- if (tagName && tagName === tagName.toLowerCase() && !tagName.includes('-')) {
1764
- const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
1765
- if (expected)
1766
- violations.push({ tag: tagName, expected });
1767
- }
1768
- index = tagStart + 1;
1769
- }
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".`);
1770
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
+ });
1771
1872
  if (violations.length) {
1772
1873
  const first = violations[0];
1773
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.`);
1774
1875
  }
1775
- 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 }));
1776
1882
  if (missingFullWidthFields.length) {
1777
1883
  const first = missingFullWidthFields[0];
1778
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}`);
1779
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
+ }
1780
1891
  const themeReview = reviewExtensionThemeContract(code);
1781
1892
  const firstThemeError = themeReview.issues.find((issue) => issue.severity === 'error');
1782
1893
  if (firstThemeError) {
@@ -1787,10 +1898,10 @@ export function validateExtensionCodeLocally(code) {
1787
1898
  if (firstRuntimeError) {
1788
1899
  throw new Error(`Invalid extension runtime contract: ${firstRuntimeError.message} Rule: ${firstRuntimeError.rule}. ${firstRuntimeError.suggestion}`);
1789
1900
  }
1790
- return { componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
1901
+ return { vueSfcAst: 'passed', componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
1791
1902
  }
1792
- export async function validateExtensionCode(apiUrl, code, name) {
1793
- const localChecks = validateExtensionCodeLocally(code);
1903
+ export async function validateExtensionCode(apiUrl, code, name, options = {}) {
1904
+ const localChecks = validateExtensionCodeLocally(code, options);
1794
1905
  const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
1795
1906
  method: 'POST',
1796
1907
  body: JSON.stringify({ code, name }),
@@ -1805,18 +1916,95 @@ export async function validateExtensionCode(apiUrl, code, name) {
1805
1916
  compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
1806
1917
  };
1807
1918
  }
1808
- 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, }) {
1809
1992
  assertGlobalRulesAck(globalRulesAckKey);
1810
1993
  assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
1811
1994
  if (!id && !name)
1812
1995
  throw new Error('Provide id or name to update an existing extension.');
1813
1996
  const existing = id
1814
- ? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id')
1815
- : 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');
1816
1999
  if (!existing)
1817
2000
  throw new Error(`Extension not found: ${id || name}`);
1818
2001
  const extensionId = getId(existing);
1819
- 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 });
1820
2008
  const body = {
1821
2009
  code,
1822
2010
  ...(description !== undefined ? { description } : {}),
@@ -1827,13 +2015,34 @@ async function updateExtensionCode(apiUrl, { id, name, code, description, isEnab
1827
2015
  method: 'PATCH',
1828
2016
  body: JSON.stringify(body),
1829
2017
  });
2018
+ const nextSha256 = sha256Text(code);
2019
+ const verification = await verifyExtensionRuntime(apiUrl, {
2020
+ id: extensionId,
2021
+ name: undefined,
2022
+ uiPattern,
2023
+ expectedSha256: nextSha256,
2024
+ });
1830
2025
  return {
1831
2026
  action: 'extension_code_updated',
1832
2027
  id: extensionId,
1833
2028
  name: existing.name || name || null,
1834
2029
  type: existing.type || null,
1835
- 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
+ }),
1836
2039
  validation,
2040
+ contractReview: {
2041
+ valid: contractReview.valid,
2042
+ issueCount: contractReview.issueCount,
2043
+ pattern: contractReview.ui?.pattern,
2044
+ },
2045
+ verification,
1837
2046
  };
1838
2047
  }
1839
2048
  function sha256Text(value) {
@@ -1950,7 +2159,30 @@ export function applyExtensionCodePatches(code, patches) {
1950
2159
  });
1951
2160
  return { code: nextCode, patches: normalizedPatches, results };
1952
2161
  }
1953
- 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, }) {
1954
2186
  assertGlobalRulesAck(globalRulesAckKey);
1955
2187
  assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
1956
2188
  if (!id && !name)
@@ -1963,6 +2195,9 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
1963
2195
  const extensionId = getId(existing);
1964
2196
  const currentCode = String(existing.code ?? '');
1965
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
+ }
1966
2201
  if (expectedSha256 && expectedSha256 !== currentSha256) {
1967
2202
  throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before patching.`);
1968
2203
  }
@@ -1970,6 +2205,13 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
1970
2205
  const nextCode = patchResult.code;
1971
2206
  const nextSha256 = sha256Text(nextCode);
1972
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
+ });
1973
2215
  const nextStepPatchInput = patchResult.patches.length === 1
1974
2216
  ? {
1975
2217
  search: patchResult.patches[0].search,
@@ -1989,6 +2231,7 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
1989
2231
  nextLength: nextCode.length,
1990
2232
  occurrences,
1991
2233
  patchResults: patchResult.results,
2234
+ diff,
1992
2235
  atomic: patchResult.patches.length > 1,
1993
2236
  apply: Boolean(apply),
1994
2237
  };
@@ -2008,6 +2251,8 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
2008
2251
  description,
2009
2252
  isEnabled,
2010
2253
  version,
2254
+ expectedSha256: currentSha256,
2255
+ uiPattern,
2011
2256
  globalRulesAckKey,
2012
2257
  extensionKnowledgeAckKey,
2013
2258
  });
@@ -2170,13 +2415,21 @@ async function ensureExtension(apiUrl, { name, type, code, menuId, description,
2170
2415
  isEnabled,
2171
2416
  version,
2172
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
+ });
2173
2425
  return {
2174
- id: operation.id || getId(existing),
2426
+ id: extensionId,
2175
2427
  name,
2176
2428
  type,
2177
2429
  action: operation.action,
2178
- operation,
2430
+ operation: { action: operation.action, id: extensionId },
2179
2431
  validation,
2432
+ verification,
2180
2433
  };
2181
2434
  }
2182
2435
  async function ensureFlow(apiUrl, { name, triggerType = 'manual', triggerConfig, timeout, maxExecutions = 100, isEnabled = true, description, globalRulesAckKey, }) {
@@ -2464,10 +2717,26 @@ async function runFlowWorkflow(apiUrl, opts) {
2464
2717
  return {
2465
2718
  action: 'flow_workflow_applied',
2466
2719
  flow: flowResult.flow,
2467
- flowResult,
2720
+ flowResult: {
2721
+ action: flowResult.action,
2722
+ flow: flowResult.flow,
2723
+ reload: flowResult.reload,
2724
+ },
2468
2725
  stepCount: plan.length,
2469
- plan,
2470
- 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
+ })),
2471
2740
  sequential: true,
2472
2741
  nextSteps: [
2473
2742
  'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
@@ -2837,7 +3106,7 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
2837
3106
  scriptValidation: latestState.scriptValidation,
2838
3107
  contractReview: latestState.contractReview,
2839
3108
  steps: latestSteps,
2840
- operations,
3109
+ operations: operations.map(summarizeWorkflowOperation),
2841
3110
  complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
2842
3111
  nextSteps,
2843
3112
  cleanupHints: latestState.endpoint.routeId
@@ -3077,24 +3346,37 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3077
3346
  'This calls /enfyra_extension/preview and does not save anything.',
3078
3347
  'Call get_extension_theme_contract first when generating or reviewing UI.',
3079
3348
  ].join(' '), {
3080
- 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.'),
3081
3350
  name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
3082
- }, 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({
3083
3353
  action: 'extension_code_validated',
3084
- validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
3354
+ validation: await validateExtensionCode(ENFYRA_API_URL, code, name, { uiPattern }),
3085
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)));
3086
3366
  server.tool('update_extension_code', [
3087
3367
  'Business operation: update an existing Enfyra admin extension code by id or name.',
3088
- '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.',
3089
3369
  'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
3090
3370
  'Call get_extension_theme_contract first when generating or reviewing UI.',
3091
3371
  ].join(' '), {
3092
3372
  id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
3093
3373
  name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
3094
- 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.'),
3095
3375
  description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
3096
3376
  isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
3097
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.'),
3098
3380
  globalRulesAckKey: globalRulesAckParam(z),
3099
3381
  extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
3100
3382
  }, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
@@ -3104,7 +3386,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3104
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.',
3105
3387
  'Default searchMode="exact"; use searchMode="whitespace" only when indentation/newline variation is the problem.',
3106
3388
  'Default replaceAll=false requires exactly one match; set replaceAll=true only after preview confirms the match count.',
3107
- '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.',
3108
3390
  'Default apply=false returns a preview and nextStep input.',
3109
3391
  ].join(' '), {
3110
3392
  id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
@@ -3119,11 +3401,12 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3119
3401
  searchMode: z.enum(['exact', 'whitespace']).optional().default('exact').describe('Patch matching mode. Use whitespace only for indentation/newline variation.'),
3120
3402
  replaceAll: z.boolean().optional().default(false).describe('Patch replace-all mode. false requires exactly one match for this patch.'),
3121
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.'),
3122
- 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.'),
3123
3405
  apply: z.boolean().optional().default(false).describe('Preview by default. Set true to validate and save.'),
3124
3406
  description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
3125
3407
  isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
3126
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.'),
3127
3410
  globalRulesAckKey: globalRulesAckParam(z),
3128
3411
  extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
3129
3412
  }, async (input) => jsonText(await patchExtensionCode(ENFYRA_API_URL, input)));
@@ -3160,7 +3443,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3160
3443
  'theme_review',
3161
3444
  'review',
3162
3445
  ]).describe('Which extension UI contract builder/reviewer to run.'),
3163
- 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.'),
3164
3447
  extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
3165
3448
  }, async ({ kind, input, extensionKnowledgeAckKey }) => {
3166
3449
  assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
@@ -3238,7 +3521,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3238
3521
  'Use this before patching or saving generated extension UI when CommonDrawer, CommonModal, UModal, UInput, UTextarea, USelect, or native buttons are involved.',
3239
3522
  'This is a static contract review, not a compiler validation; still validate the final SFC before saving.',
3240
3523
  ].join(' '), {
3241
- 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.'),
3242
3525
  }, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
3243
3526
  server.tool('build_extension_page_shell', [
3244
3527
  'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
@@ -3412,7 +3695,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3412
3695
  ].join(' '), {
3413
3696
  name: z.string().describe('Extension unique name.'),
3414
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.'),
3415
- 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.'),
3416
3699
  menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
3417
3700
  menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
3418
3701
  menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
@@ -3556,10 +3839,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3556
3839
  ].join(' '), {
3557
3840
  path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
3558
3841
  method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
3559
- sourceCode: z.string().describe('Handler sourceCode for a custom route, which has no main table. 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.'),
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.'),
3560
3843
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
3561
3844
  anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
3562
- 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.'),
3563
3846
  roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
3564
3847
  roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
3565
3848
  allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
@@ -3585,9 +3868,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3585
3868
  ].join(' '), {
3586
3869
  path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
3587
3870
  method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
3588
- sourceCode: z.string().describe('Handler sourceCode for a custom route, which has no main table. 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.'),
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.'),
3589
3872
  scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
3590
- 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.'),
3591
3874
  description: z.string().optional().describe('Route description.'),
3592
3875
  timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
3593
3876
  overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
@@ -4299,9 +4582,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
4299
4582
  })).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
4300
4583
  globalRulesAckKey: globalRulesAckParam(z),
4301
4584
  }, async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)));
4302
- 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.', {
4303
4586
  name: z.string().describe('Extension unique name.'),
4304
- 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.'),
4305
4588
  menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
4306
4589
  description: z.string().optional().describe('Extension description.'),
4307
4590
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
@@ -4312,9 +4595,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
4312
4595
  action: 'page_extension_ensured',
4313
4596
  extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
4314
4597
  }));
4315
- 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.', {
4316
4599
  name: z.string().describe('Extension unique name.'),
4317
- 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.'),
4318
4601
  description: z.string().optional().describe('Extension description.'),
4319
4602
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
4320
4603
  version: z.string().optional().default('1.0.0').describe('Extension version.'),
@@ -4324,9 +4607,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
4324
4607
  action: 'global_extension_ensured',
4325
4608
  extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
4326
4609
  }));
4327
- 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.', {
4328
4611
  name: z.string().describe('Extension unique name.'),
4329
- 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.'),
4330
4613
  description: z.string().optional().describe('Extension description.'),
4331
4614
  isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
4332
4615
  version: z.string().optional().default('1.0.0').describe('Extension version.'),