@enfyra/mcp-server 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -265,7 +265,7 @@ The MCP server includes safety guards for LLM callers:
265
265
  - Generated code should use relation property names such as `conversation`, `sender`, and `member` instead of physical FK fields such as `conversationId`, `senderId`, or `memberId`.
266
266
  - Custom route tools reject `mainTableId` unless the route is the canonical table route.
267
267
  - `discover_enfyra_workflows` maps task intent to workflow surfaces before the agent loads detailed examples or guesses between similar tools.
268
- - Platform operation tools such as `api_endpoint_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
268
+ - Platform operation tools such as `api_endpoint_workflow`, `extension_workflow`, `create_api_endpoint`, `enable_route`, `disable_route`, `delete_route`, `public_route_methods`, `add_route_methods`, `set_table_graphql`, `ensure_guard`, `ensure_field_permission`, `ensure_column_rule`, `ensure_websocket_event`, `choose_flow_step_tool`, fixed-type flow step tools, `ensure_menu`, `reorder_menus`, `ensure_page_extension`, `ensure_global_extension`, and `ensure_widget_extension` resolve metadata ids and validate code before saving.
269
269
  - Schema changes are serialized.
270
270
  - Destructive deletes return a preview before requiring `confirm=true`.
271
271
 
@@ -307,6 +307,8 @@ The MCP server exposes tools for workflow routing, metadata discovery, required
307
307
 
308
308
  Routes have two separate controls. `isEnabled` controls runtime registration: disabled routes return `404`. Use `enable_route` and `disable_route` for this lifecycle. `publicMethods` controls anonymous access for enabled routes; use `public_route_methods` and `private_route_methods` for that access boundary.
309
309
 
310
+ Use `reorder_menus` for menu order or parent changes. It calls the Enfyra 2.2.6 `/admin/menu/reorder` operation route so hierarchy validation and menu cache invalidation are handled by the server instead of PATCHing individual `enfyra_menu` records.
311
+
310
312
  Admin app page paths and API paths are different surfaces. A page extension path such as `/cloud/projects/:id` is a UI route unless an enabled Enfyra API route with that exact path exists. Use `test_rest_endpoint` only for actual API routes under `ENFYRA_API_URL`; verify page extensions through the app URL/browser or extension/menu metadata.
311
313
 
312
314
  For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by route path plus role/user. For production script edits, use `trace_metadata_usage`, `get_script_source`, and `patch_script_source` so changes are targeted, hash-checked, and validated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -673,6 +673,42 @@ async function ensureMenu(apiUrl, {
673
673
  };
674
674
  }
675
675
 
676
+ async function reorderMenus(apiUrl, { updates, globalRulesAckKey }) {
677
+ assertGlobalRulesAck(globalRulesAckKey);
678
+ const seen = new Set();
679
+ const normalizedUpdates = updates.map((item, index) => {
680
+ const id = item?.id;
681
+ if (id === null || id === undefined || String(id).trim() === '') {
682
+ throw new Error(`updates[${index}].id is required.`);
683
+ }
684
+ const key = String(id);
685
+ if (seen.has(key)) throw new Error(`Duplicate menu id in reorder payload: ${key}`);
686
+ seen.add(key);
687
+ const order = Number(item.order);
688
+ if (!Number.isInteger(order) || order < 0) {
689
+ throw new Error(`updates[${index}].order must be a non-negative integer.`);
690
+ }
691
+ const parent = item.parent === undefined || item.parent === null || String(item.parent).trim() === ''
692
+ ? null
693
+ : item.parent;
694
+ return { id, order, parent };
695
+ });
696
+ const result = await fetchAPI(apiUrl, '/admin/menu/reorder', {
697
+ method: 'POST',
698
+ body: JSON.stringify({ updates: normalizedUpdates }),
699
+ });
700
+ return {
701
+ action: 'menus_reordered',
702
+ updates: normalizedUpdates,
703
+ result,
704
+ reload: {
705
+ attempted: false,
706
+ succeeded: true,
707
+ reason: '/admin/menu/reorder persists order/parent updates and emits enfyra_menu cache invalidation.',
708
+ },
709
+ };
710
+ }
711
+
676
712
  async function ensureExtension(apiUrl, {
677
713
  name,
678
714
  type,
@@ -873,6 +909,18 @@ function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
873
909
  return true;
874
910
  }
875
911
 
912
+ function extensionMatches(existingExtension, opts, menuId) {
913
+ if (!existingExtension) return false;
914
+ if (String(existingExtension.type || '') !== String(opts.type || 'page')) return false;
915
+ if (String(existingExtension.code ?? '') !== String(opts.code ?? '')) return false;
916
+ if (opts.description !== undefined && String(existingExtension.description || '') !== String(opts.description || '')) return false;
917
+ if (opts.isEnabled !== undefined && Boolean(existingExtension.isEnabled) !== Boolean(opts.isEnabled)) return false;
918
+ if (opts.version !== undefined && String(existingExtension.version || '') !== String(opts.version)) return false;
919
+ if ((opts.type || 'page') === 'page' && menuId && String(refId(existingExtension.menu)) !== String(menuId)) return false;
920
+ if ((opts.type || 'page') !== 'page' && refId(existingExtension.menu)) return false;
921
+ return true;
922
+ }
923
+
876
924
  function step(status, id, title, detail = {}) {
877
925
  return { id, title, status, ...detail };
878
926
  }
@@ -1183,6 +1231,189 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
1183
1231
  };
1184
1232
  }
1185
1233
 
1234
+ async function resolveExtensionWorkflowState(apiUrl, opts) {
1235
+ const type = opts.type || 'page';
1236
+ if (type === 'page' && opts.menuId && (opts.menuLabel || opts.menuPath)) {
1237
+ throw new Error('Provide menuId or menuLabel/menuPath for page extension workflow, not both.');
1238
+ }
1239
+ if (type !== 'page' && (opts.menuId || opts.menuLabel || opts.menuPath)) {
1240
+ throw new Error('Menu fields are only valid for page extensions.');
1241
+ }
1242
+ const validation = await validateExtensionCode(apiUrl, opts.code, opts.name);
1243
+ const existingExtension = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: opts.name } }, 'id,_id,name,type,menu.id,description,isEnabled,version,code');
1244
+ let menu = null;
1245
+ if (type === 'page' && opts.menuId) {
1246
+ menu = await findRecord(apiUrl, 'enfyra_menu', { id: { _eq: opts.menuId } }, 'id,_id,label,path,type,order,isEnabled');
1247
+ if (!menu) throw new Error(`Menu not found: ${opts.menuId}`);
1248
+ } else if (type === 'page' && (opts.menuPath || opts.menuLabel)) {
1249
+ const normalizedPath = opts.menuPath ? normalizeRestPath(opts.menuPath) : undefined;
1250
+ menu = normalizedPath
1251
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,label,path,type,order,isEnabled')
1252
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: opts.menuLabel } }, 'id,_id,label,path,type,order,isEnabled');
1253
+ }
1254
+
1255
+ const menuId = opts.menuId || getId(menu);
1256
+ const steps = [];
1257
+ steps.push(step('completed', 'validate_extension', 'Validate extension code', { validation }));
1258
+ if (type === 'page') {
1259
+ if (menuId) {
1260
+ const menuNeedsUpdate = Boolean(menu && (
1261
+ (opts.menuLabel !== undefined && menu.label !== opts.menuLabel)
1262
+ || (opts.menuPath !== undefined && menu.path !== normalizeRestPath(opts.menuPath))
1263
+ || (opts.menuType !== undefined && menu.type !== opts.menuType)
1264
+ || (opts.menuOrder !== undefined && Number(menu.order || 0) !== Number(opts.menuOrder))
1265
+ || (opts.menuIsEnabled !== undefined && Boolean(menu.isEnabled) !== Boolean(opts.menuIsEnabled))
1266
+ ));
1267
+ steps.push(step(menuNeedsUpdate ? 'pending' : 'completed', 'ensure_menu', 'Ensure page menu', {
1268
+ menuId,
1269
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : { id: menuId },
1270
+ }));
1271
+ } else if (opts.menuLabel) {
1272
+ steps.push(step('pending', 'ensure_menu', 'Create page menu', {
1273
+ reason: 'No existing menu matched; ensure_menu will create it.',
1274
+ }));
1275
+ } else {
1276
+ steps.push(step('blocked', 'ensure_menu', 'Create or select page menu', {
1277
+ reason: 'Page extensions require menuId or menuLabel. Provide menuId for an existing menu or menuLabel/menuPath to create/update one.',
1278
+ }));
1279
+ }
1280
+ }
1281
+
1282
+ const effectiveMenuId = type === 'page' ? menuId : undefined;
1283
+ const saveStatus = steps.some((item) => ['blocked', 'waiting'].includes(item.status))
1284
+ ? 'waiting'
1285
+ : extensionMatches(existingExtension, { ...opts, type }, effectiveMenuId)
1286
+ ? 'completed'
1287
+ : 'pending';
1288
+ steps.push(step(saveStatus, 'save_extension', `Ensure ${type} extension`, {
1289
+ extensionId: getId(existingExtension),
1290
+ currentType: existingExtension?.type || null,
1291
+ desiredType: type,
1292
+ menuId: effectiveMenuId || null,
1293
+ reason: saveStatus === 'waiting' ? 'Menu must exist before saving page extension.' : undefined,
1294
+ }));
1295
+
1296
+ const firstRunnable = steps.find((item) => item.status === 'pending') || null;
1297
+ const blocked = steps.find((item) => item.status === 'blocked') || null;
1298
+ return {
1299
+ extension: {
1300
+ name: opts.name,
1301
+ type,
1302
+ id: getId(existingExtension),
1303
+ menuId: effectiveMenuId || null,
1304
+ },
1305
+ validation,
1306
+ existingExtension: existingExtension ? {
1307
+ id: getId(existingExtension),
1308
+ name: existingExtension.name,
1309
+ type: existingExtension.type,
1310
+ menuId: refId(existingExtension.menu) || null,
1311
+ } : null,
1312
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : null,
1313
+ steps,
1314
+ firstRunnable,
1315
+ blocked,
1316
+ nextSteps: blocked
1317
+ ? [{ tool: 'extension_workflow', input: { name: opts.name, type }, reason: blocked.reason }]
1318
+ : firstRunnable
1319
+ ? [{
1320
+ tool: 'extension_workflow',
1321
+ input: { name: opts.name, type, apply: true, stepId: firstRunnable.id },
1322
+ stepId: firstRunnable.id,
1323
+ requiresKnowledgeAck: 'globalRulesAckKey and extensionAckKey from get_enfyra_required_knowledge',
1324
+ }]
1325
+ : [],
1326
+ };
1327
+ }
1328
+
1329
+ async function applyExtensionWorkflowStep(apiUrl, state, opts, stepId) {
1330
+ const selectedStep = stepId
1331
+ ? state.steps.find((item) => item.id === stepId)
1332
+ : state.firstRunnable;
1333
+ if (!selectedStep) return { action: 'noop', reason: 'No runnable step remains.' };
1334
+ if (selectedStep.status !== 'pending') {
1335
+ throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
1336
+ }
1337
+
1338
+ const type = opts.type || 'page';
1339
+ if (selectedStep.id === 'ensure_menu') {
1340
+ if (type !== 'page') throw new Error('ensure_menu step is only valid for page extensions.');
1341
+ if (!opts.menuLabel && !opts.menuId) throw new Error('menuLabel or menuId is required for ensure_menu.');
1342
+ return {
1343
+ action: 'menu_ensured',
1344
+ menu: await ensureMenu(apiUrl, {
1345
+ label: opts.menuLabel || state.menu?.label || opts.name,
1346
+ path: opts.menuPath || state.menu?.path,
1347
+ icon: opts.menuIcon,
1348
+ type: opts.menuType,
1349
+ order: opts.menuOrder,
1350
+ permission: opts.menuPermission,
1351
+ description: opts.menuDescription,
1352
+ isEnabled: opts.menuIsEnabled,
1353
+ globalRulesAckKey: opts.globalRulesAckKey,
1354
+ }),
1355
+ };
1356
+ }
1357
+
1358
+ if (selectedStep.id === 'save_extension') {
1359
+ let menuId = opts.menuId || state.extension.menuId;
1360
+ if (type === 'page' && !menuId) {
1361
+ const freshState = await resolveExtensionWorkflowState(apiUrl, opts);
1362
+ menuId = freshState.extension.menuId;
1363
+ }
1364
+ if (type === 'page' && !menuId) throw new Error('Page extension menu is missing. Apply ensure_menu first.');
1365
+ return {
1366
+ action: `${type}_extension_ensured`,
1367
+ extension: await ensureExtension(apiUrl, {
1368
+ name: opts.name,
1369
+ type,
1370
+ code: opts.code,
1371
+ menuId,
1372
+ description: opts.description,
1373
+ isEnabled: opts.isEnabled,
1374
+ version: opts.version,
1375
+ globalRulesAckKey: opts.globalRulesAckKey,
1376
+ extensionKnowledgeAckKey: opts.extensionKnowledgeAckKey,
1377
+ }),
1378
+ };
1379
+ }
1380
+
1381
+ throw new Error(`Unsupported extension workflow step: ${selectedStep.id}`);
1382
+ }
1383
+
1384
+ async function runExtensionWorkflow(apiUrl, opts) {
1385
+ let state = await resolveExtensionWorkflowState(apiUrl, opts);
1386
+ const operations = [];
1387
+ if (opts.apply || opts.applyAll) {
1388
+ assertGlobalRulesAck(opts.globalRulesAckKey);
1389
+ assertExtensionKnowledgeAck(opts.extensionKnowledgeAckKey);
1390
+ const maxSteps = opts.applyAll ? 5 : 1;
1391
+ for (let i = 0; i < maxSteps; i += 1) {
1392
+ if (state.blocked || !state.firstRunnable) break;
1393
+ operations.push(await applyExtensionWorkflowStep(apiUrl, state, opts, opts.stepId));
1394
+ if (!opts.applyAll) break;
1395
+ state = await resolveExtensionWorkflowState(apiUrl, opts);
1396
+ }
1397
+ }
1398
+ const latestState = operations.length ? await resolveExtensionWorkflowState(apiUrl, opts) : state;
1399
+ return {
1400
+ action: operations.length ? 'extension_workflow_advanced' : 'extension_workflow_planned',
1401
+ extension: latestState.extension,
1402
+ validation: latestState.validation,
1403
+ menu: latestState.menu,
1404
+ existingExtension: latestState.existingExtension,
1405
+ steps: latestState.steps,
1406
+ operations,
1407
+ complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
1408
+ nextSteps: latestState.nextSteps,
1409
+ guidance: [
1410
+ 'Call get_extension_theme_contract before generating or reviewing extension UI.',
1411
+ 'For menu/account-panel notifications, use counts only when the signal source already owns an exact count; otherwise use a dot/chip for new attention.',
1412
+ 'Do not fetch destination domain lists solely to decorate the shell; destination pages own domain fetching after click.',
1413
+ ],
1414
+ };
1415
+ }
1416
+
1186
1417
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1187
1418
  server.tool(
1188
1419
  'validate_dynamic_script',
@@ -1236,6 +1467,40 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1236
1467
  async () => jsonText(getThemeClassReference()),
1237
1468
  );
1238
1469
 
1470
+ server.tool(
1471
+ 'extension_workflow',
1472
+ [
1473
+ 'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
1474
+ 'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',
1475
+ 'With apply=false it validates code, reads live menu/extension state, and returns pending steps.',
1476
+ 'With apply=true it applies exactly the next pending step. With applyAll=true it advances all currently safe pending steps.',
1477
+ 'Call get_extension_theme_contract before generating or reviewing UI.',
1478
+ ].join(' '),
1479
+ {
1480
+ name: z.string().describe('Extension unique name.'),
1481
+ type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
1482
+ code: z.string().describe('Vue SFC extension code.'),
1483
+ menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
1484
+ menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
1485
+ menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
1486
+ menuIcon: z.string().optional().describe('Optional menu icon name.'),
1487
+ menuType: z.enum(['Menu', 'Dropdown Menu']).optional().describe('Menu type. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1488
+ menuOrder: z.number().optional().describe('Menu display order. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1489
+ menuPermission: z.string().optional().describe('Optional menu permission JSON object.'),
1490
+ menuDescription: z.string().optional().describe('Optional menu admin note.'),
1491
+ menuIsEnabled: z.boolean().optional().describe('Enable the menu. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1492
+ description: z.string().optional().describe('Extension description.'),
1493
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1494
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
1495
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1496
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1497
+ stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1498
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1499
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1500
+ },
1501
+ async (input) => jsonText(await runExtensionWorkflow(ENFYRA_API_URL, input)),
1502
+ );
1503
+
1239
1504
  server.tool(
1240
1505
  'set_table_graphql',
1241
1506
  'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.',
@@ -2123,6 +2388,24 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2123
2388
  }),
2124
2389
  );
2125
2390
 
2391
+ server.tool(
2392
+ 'reorder_menus',
2393
+ [
2394
+ 'Business operation: reorder Enfyra admin menus and optionally move menus under a new parent.',
2395
+ 'Uses the server /admin/menu/reorder route introduced in Enfyra 2.2.6 instead of PATCHing each enfyra_menu record.',
2396
+ 'The server validates duplicate ids, non-negative integer order, dropdown-only parents, /data child restrictions, system menu parent locks, cycle prevention, persistence, and menu cache invalidation.',
2397
+ ].join(' '),
2398
+ {
2399
+ updates: z.array(z.object({
2400
+ id: z.union([z.string(), z.number()]).describe('Menu id to reorder.'),
2401
+ order: z.number().int().nonnegative().describe('Sibling order index. Must be a non-negative integer.'),
2402
+ parent: z.union([z.string(), z.number(), z.null()]).optional().describe('New parent menu id, or null for a root menu. Parent must be a Dropdown Menu.'),
2403
+ })).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
2404
+ globalRulesAckKey: globalRulesAckParam(z),
2405
+ },
2406
+ async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)),
2407
+ );
2408
+
2126
2409
  server.tool(
2127
2410
  'ensure_page_extension',
2128
2411
  '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.',
@@ -67,14 +67,14 @@ export const TOOL_WORKFLOWS = [
67
67
  firstTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'inspect_feature'],
68
68
  inspectTools: ['inspect_feature', 'trace_metadata_usage', 'get_script_source'],
69
69
  knowledgeTools: ['get_enfyra_required_knowledge', 'get_extension_theme_contract', 'get_theme_class_reference'],
70
- writeTools: ['ensure_menu', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
70
+ writeTools: ['extension_workflow', 'ensure_menu', 'reorder_menus', 'ensure_page_extension', 'ensure_global_extension', 'ensure_widget_extension'],
71
71
  verifyTools: ['validate_extension_code', 'inspect_feature'],
72
72
  avoidTools: [
73
73
  {
74
74
  tool: 'create_record/update_record on enfyra_extension',
75
75
  when: 'creating or changing extension code',
76
- useInstead: 'ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
77
- reason: 'Ensure tools validate extension code and preserve extension/menu contracts before saving.',
76
+ useInstead: 'extension_workflow, ensure_page_extension, ensure_global_extension, or ensure_widget_extension',
77
+ reason: 'Workflow and ensure tools validate extension code and preserve extension/menu contracts before saving.',
78
78
  },
79
79
  {
80
80
  tool: 'query_table on destination domain lists',
@@ -82,12 +82,20 @@ export const TOOL_WORKFLOWS = [
82
82
  useInstead: 'notification summary/realtime shell signal plus destination-page fetch on click',
83
83
  reason: 'Shell notifications should not fetch messages, tickets, orders, or jobs lists solely for a badge.',
84
84
  },
85
+ {
86
+ tool: 'update_record/PATCH enfyra_menu for order or parent changes',
87
+ when: 'drag-and-drop or programmatic menu ordering changes sibling order or parent',
88
+ useInstead: 'reorder_menus',
89
+ reason: 'The Enfyra 2.2.6 /admin/menu/reorder route validates menu hierarchy constraints and emits menu cache invalidation.',
90
+ },
85
91
  ],
86
92
  requiredAck: ['globalRulesAckKey', 'extensionAckKey when saving extension code'],
87
93
  exampleCategories: ['extensions'],
88
94
  nextStepTemplate: [
89
95
  'Call get_extension_theme_contract before writing or reviewing UI.',
90
96
  'Inspect the existing menu/extension/global shell registration.',
97
+ 'Use extension_workflow with apply=false when page/menu wiring or shell notification behavior needs multiple steps.',
98
+ 'Use reorder_menus for menu order/parent changes instead of patching individual enfyra_menu records.',
91
99
  'Choose count only when the source already owns an exact count; choose dot/chip for new-attention signals.',
92
100
  'Validate extension code or use an ensure_*_extension tool that validates before saving.',
93
101
  ],
@@ -173,6 +173,12 @@ const MCP_PERMISSION_REQUIREMENTS = [
173
173
  route: '/admin/reload/routes',
174
174
  methods: ['POST'],
175
175
  },
176
+ {
177
+ area: 'menu reorder',
178
+ tools: ['reorder_menus'],
179
+ route: '/admin/menu/reorder',
180
+ methods: ['POST'],
181
+ },
176
182
  {
177
183
  area: 'metadata cache reload',
178
184
  tools: ['reload_metadata'],